diff --git a/packages/orchestration/src/examples/restake.contract.js b/packages/orchestration/src/examples/restake.contract.js new file mode 100644 index 00000000000..118f91b7528 --- /dev/null +++ b/packages/orchestration/src/examples/restake.contract.js @@ -0,0 +1,94 @@ +import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; +import { M } from '@endo/patterns'; +import { withOrchestration } from '../utils/start-helper.js'; +import * as flows from './restake.flows.js'; +import { + prepareRestakeHolderKit, + prepareRestakeWaker, + restakeInvitaitonGuardShape, +} from './restake.kit.js'; +import { prepareCombineInvitationMakers } from '../exos/combine-invitation-makers.js'; + +/** + * @import {Zone} from '@agoric/zone'; + * @import {OrchestrationPowers} from '../utils/start-helper.js'; + * @import {OrchestrationTools} from '../utils/start-helper.js'; + */ + +/** + * XXX consider moving to privateArgs / creatorFacet, as terms are immutable + * + * @typedef {{ + * minimumDelay: bigint; + * minimumInterval: bigint; + * }} RestakeContractTerms + */ + +/** + * A contract that allows calls to make an OrchestrationAccount, and + * subsequently schedule a restake (claim and stake rewards) on an interval. + * + * Leverages existing invitation makers like Delegate and combines them with + * Restake and CancelRestake. + * + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * }} privateArgs + * @param {Zone} zone + * @param {OrchestrationTools} tools + */ +const contract = async ( + zcf, + { timerService }, + zone, + { orchestrateAll, vowTools }, +) => { + const makeRestakeWaker = prepareRestakeWaker( + zone.subZone('restakeWaker'), + vowTools, + ); + const makeCombineInvitationMakers = prepareCombineInvitationMakers( + zone, + restakeInvitaitonGuardShape, + ); + + const { minimumDelay, minimumInterval } = zcf.getTerms(); + + const makeRestakeHolderKit = prepareRestakeHolderKit( + zone.subZone('restakeHolder'), + { + vowTools, + zcf, + timer: timerService, + makeRestakeWaker, + params: harden({ minimumDelay, minimumInterval }), + }, + ); + + const orchFns = orchestrateAll(flows, { + makeRestakeHolderKit, + makeCombineInvitationMakers, + }); + + const publicFacet = zone.exo( + 'Restake Public Facet', + M.interface('Restake PF', { + makeRestakeAccountInvitation: M.callWhen().returns(InvitationShape), + }), + { + makeRestakeAccountInvitation() { + return zcf.makeInvitation( + orchFns.makeRestakeAccount, + 'Make a Restake Account', + ); + }, + }, + ); + + return { publicFacet }; +}; + +export const start = withOrchestration(contract); + +/** @typedef {typeof start} RestakeSF */ diff --git a/packages/orchestration/src/examples/restake.flows.js b/packages/orchestration/src/examples/restake.flows.js new file mode 100644 index 00000000000..44d9be027bb --- /dev/null +++ b/packages/orchestration/src/examples/restake.flows.js @@ -0,0 +1,53 @@ +/** + * @file Example contract that allows users to do different things with rewards + */ +import { M, mustMatch } from '@endo/patterns'; + +/** + * @import {OrchestrationAccount, OrchestrationFlow, Orchestrator, StakingAccountActions} from '@agoric/orchestration'; + * @import {MakeRestakeHolderKit} from './restake.kit.js'; + * @import {MakeCombineInvitationMakers} from '../exos/combine-invitation-makers.js'; + */ + +/** + * Create an OrchestrationAccount for a specific chain and return a continuing + * offer with invitations makers for Delegate, WithdrawRewards, Transfer, etc. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {{ + * makeRestakeHolderKit: MakeRestakeHolderKit; + * makeCombineInvitationMakers: MakeCombineInvitationMakers; + * }} ctx + * @param {ZCFSeat} seat + * @param {{ chainName: string }} offerArgs + */ +export const makeRestakeAccount = async ( + orch, + { makeRestakeHolderKit, makeCombineInvitationMakers }, + seat, + { chainName }, +) => { + seat.exit(); // no funds exchanged + mustMatch(chainName, M.string()); + const remoteChain = await orch.getChain(chainName); + const orchAccount = + /** @type {OrchestrationAccount & StakingAccountActions} */ ( + await remoteChain.makeAccount() + ); + const restakeHolderKit = makeRestakeHolderKit(orchAccount); + const { invitationMakers: orchInvitationMakers, publicSubscribers } = + await orchAccount.asContinuingOffer(); + + const combinedInvitationMakers = makeCombineInvitationMakers( + // `orchInvitationMakers` currently lying about its type + orchInvitationMakers, + // @ts-expect-error update `makeCombineInvitationMakers` to accept Guarded... + restakeHolderKit.invitationMakers, + ); + + return harden({ + invitationMakers: combinedInvitationMakers, + publicSubscribers, + }); +}; diff --git a/packages/orchestration/src/examples/restake.kit.js b/packages/orchestration/src/examples/restake.kit.js new file mode 100644 index 00000000000..3b75707335d --- /dev/null +++ b/packages/orchestration/src/examples/restake.kit.js @@ -0,0 +1,260 @@ +import { M, mustMatch } from '@endo/patterns'; +import { E } from '@endo/far'; +import { Fail } from '@endo/errors'; +import { TimestampRecordShape } from '@agoric/time'; +import { VowShape } from '@agoric/vow'; +import { makeTracer } from '@agoric/internal'; +import { EmptyProposalShape } from '@agoric/zoe/src/typeGuards.js'; +import { ChainAddressShape, DenomAmountShape } from '../typeGuards.js'; + +const trace = makeTracer('RestakeKit'); + +/** + * @import {Vow, VowTools} from '@agoric/vow'; + * @import {Zone} from '@agoric/zone'; + * @import {CosmosValidatorAddress, DenomAmount, OrchestrationAccount, StakingAccountActions} from '@agoric/orchestration'; + * @import {Remote} from '@agoric/internal'; + * @import {TimestampRecord, TimerService, TimerRepeater} from '@agoric/time'; + */ + +/** + * @typedef {{ + * orchAccount: OrchestrationAccount & StakingAccountActions; + * validator: CosmosValidatorAddress; + * }} RestakeWakerState + */ + +/** + * @param {Zone} zone + * @param {VowTools} vowTools + */ +const prepareRestakeWakerKit = (zone, { watch }) => { + return zone.exoClassKit( + 'RestakeWakerKit', + { + waker: M.interface('RestakeWaker', { + wake: M.call(TimestampRecordShape).returns(M.any()), + }), + withdrawRewardsHandler: M.interface('WithdrawRewardsHandle', { + onFulfilled: M.call(M.arrayOf(DenomAmountShape)) + .optional(M.arrayOf(M.undefined())) + .returns(VowShape), + }), + }, + /** + * @param {OrchestrationAccount & StakingAccountActions} orchAccount + * @param {CosmosValidatorAddress} validator + * @returns {RestakeWakerState} + */ + (orchAccount, validator) => harden({ orchAccount, validator }), + { + waker: { + /** @param {TimestampRecord} timestampRecord */ + wake(timestampRecord) { + trace('Wake Received', timestampRecord); + const { orchAccount, validator } = this.state; + return watch( + E(orchAccount).withdrawReward(validator), + this.facets.withdrawRewardsHandler, + ); + }, + }, + withdrawRewardsHandler: { + /** @param {DenomAmount[]} amounts */ + onFulfilled(amounts) { + trace('Withdrew Rewards', amounts); + if (amounts.length !== 1) { + // XXX post to vstorage, else this effectively dropped on the ground + throw Fail`Received ${amounts.length} amounts, only expected one.`; + } + if (!amounts[0].value) return; + const { orchAccount, validator } = this.state; + return watch(E(orchAccount).delegate(validator, amounts[0])); + }, + }, + }, + { + stateShape: { + orchAccount: M.remotable('OrchestrationAccount'), + validator: ChainAddressShape, + }, + }, + ); +}; + +/** + * @param {Zone} zone + * @param {VowTools} vowTools + * @returns {( + * ...args: Parameters> + * ) => ReturnType>['waker']} + */ +export const prepareRestakeWaker = (zone, vowTools) => { + const makeKit = prepareRestakeWakerKit(zone, vowTools); + return (...args) => makeKit(...args).waker; +}; + +/** + * @typedef {{ + * orchAccount: OrchestrationAccount & StakingAccountActions; + * restakeRepeater: TimerRepeater | undefined; + * }} RestakeHolderState + */ + +const RepeaterStateShape = { + orchAccount: M.remotable('OrchestrationAccount'), + restakeRepeater: M.or(M.remotable('TimerRepeater'), M.undefined()), +}; + +/** + * @typedef {{ + * delay: bigint; + * interval: bigint; + * }} RepeaterOpts + */ + +const RepeaterOptsShape = { + delay: M.nat(), + interval: M.nat(), +}; + +/** + * @typedef {{ + * minimumDelay: bigint; + * minimumInterval: bigint; + * }} RestakeParams + */ + +export const restakeInvitaitonGuardShape = { + Restake: M.call(ChainAddressShape, RepeaterOptsShape).returns(M.promise()), + CancelRestake: M.call().returns(M.promise()), +}; + +/** + * @param {Zone} zone + * @param {object} opts + * @param {VowTools} opts.vowTools + * @param {ZCF} opts.zcf + * @param {Remote} opts.timer + * @param {ReturnType} opts.makeRestakeWaker + * @param {RestakeParams} opts.params + */ +export const prepareRestakeHolderKit = ( + zone, + { + vowTools: { watch, asVow }, + zcf, + timer, + makeRestakeWaker, + params: { minimumDelay, minimumInterval }, + }, +) => { + return zone.exoClassKit( + 'RestakeHolderKit', + { + invitationMakers: M.interface('InvitationMakers', { + ...restakeInvitaitonGuardShape, + }), + holder: M.interface('Holder', { + restake: M.call(ChainAddressShape, RepeaterOptsShape).returns(VowShape), + cancelRestake: M.call().returns(VowShape), + }), + }, + /** + * @param {OrchestrationAccount & StakingAccountActions} orchAccount + */ + orchAccount => { + return /** @type {RestakeHolderState} */ ( + harden({ + orchAccount, + restakeRepeater: undefined, + }) + ); + }, + { + invitationMakers: { + /** + * @param {CosmosValidatorAddress} validator + * @param {RepeaterOpts} opts + * @returns {Promise} + */ + Restake(validator, opts) { + trace('Restake', validator, opts); + return zcf.makeInvitation( + seat => { + seat.exit(); + return watch(this.facets.holder.restake(validator, opts)); + }, + 'Restake', + undefined, + EmptyProposalShape, + ); + }, + CancelRestake() { + trace('Cancel Restake'); + return zcf.makeInvitation( + seat => { + seat.exit(); + return watch(this.facets.holder.cancelRestake()); + }, + 'Cancel Restake', + undefined, + EmptyProposalShape, + ); + }, + }, + holder: { + /** + * @param {CosmosValidatorAddress} validator + * @param {RepeaterOpts} opts + * @returns {Vow} + */ + restake(validator, opts) { + trace('restake', validator, opts); + return asVow(() => { + mustMatch( + validator, + ChainAddressShape, + 'invalid validator address', + ); + mustMatch(opts, RepeaterOptsShape, 'invalid repeater options'); + const { delay, interval } = opts; + delay >= minimumDelay || + Fail`delay must be at least ${minimumDelay}`; + interval >= minimumInterval || + Fail`interval must be at least ${minimumInterval}`; + if (this.state.restakeRepeater) { + // TODO block on this + E(this.state.restakeRepeater.disable()); + } + const restakeWaker = makeRestakeWaker( + this.state.orchAccount, + validator, + ); + return watch( + E.when(E(timer).makeRepeater(delay, interval), repeater => { + this.state.restakeRepeater = repeater; + console.debug('new repeater', repeater); + return E(repeater).schedule(restakeWaker); + // XXX on success, post to vstorage + // XXX orch-acct needs to expose its recorder kit writer + }), + ); + }); + }, + cancelRestake() { + if (!this.state.restakeRepeater) { + throw Fail`Restake not active.`; + } + return watch(E(this.state.restakeRepeater).disable()); + }, + }, + }, + { + stateShape: RepeaterStateShape, + }, + ); +}; + +/** @typedef {ReturnType} MakeRestakeHolderKit */ +/** @typedef {ReturnType} RestakeHolderKit */ diff --git a/packages/orchestration/test/examples/restake.contract.test.ts b/packages/orchestration/test/examples/restake.contract.test.ts new file mode 100644 index 00000000000..d48627f40a1 --- /dev/null +++ b/packages/orchestration/test/examples/restake.contract.test.ts @@ -0,0 +1,170 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import type { TestFn } from 'ava'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; +import { E } from '@endo/far'; +import path from 'path'; +import { + MsgWithdrawDelegatorReward, + MsgWithdrawDelegatorRewardResponse, +} from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; +import { IBCEvent } from '@agoric/vats'; +import { MsgDelegateResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { commonSetup } from '../supports.js'; +import { + buildMsgResponseString, + buildTxPacketString, +} from '../../tools/ibc-mocks.js'; +import { CosmosValidatorAddress } from '../../src/cosmos-api.js'; +import { defaultMockAckMap } from '../ibc-mocks.js'; + +const dirname = path.dirname(new URL(import.meta.url).pathname); + +const contractName = 'restake'; +const contractFile = `${dirname}/../../src/examples/${contractName}.contract.js`; +type StartFn = typeof import('../../src/examples/restake.contract.js').start; + +type TestContext = Awaited> & { + zoe: ZoeService; + instance: Instance; +}; + +const test = anyTest as TestFn; + +test.before(async t => { + const setupContext = await commonSetup(t); + const { + bootstrap: { storage }, + commonPrivateArgs, + mocks: { ibcBridge }, + } = setupContext; + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + t.log('contract coreEval', contractName); + const installation = await bundleAndInstall(contractFile); + + const storageNode = await E(storage.rootNode).makeChildNode(contractName); + const { instance } = await E(zoe).startInstance( + installation, + undefined, + { + minimumDelay: 1n, + minimumInterval: 5n, // for testing only + }, + { ...commonPrivateArgs, storageNode }, + ); + + const buildMocks = () => { + const withdrawReward = buildTxPacketString([ + MsgWithdrawDelegatorReward.toProtoMsg({ + delegatorAddress: 'cosmos1test', + validatorAddress: 'cosmosvaloper1test', + }), + ]); + const wdRewardResp = buildMsgResponseString( + MsgWithdrawDelegatorRewardResponse, + { + amount: [{ denom: 'uatom', amount: '10' }], + }, + ); + return { ...defaultMockAckMap, [withdrawReward]: wdRewardResp }; + }; + await E(ibcBridge).setMockAck(buildMocks()); + + t.context = { + ...setupContext, + zoe, + instance, + }; +}); + +const chainConfigs = { + agoric: { addressPrefix: 'agoric1fakeLCAAddress' }, + cosmoshub: { addressPrefix: 'cosmos1test' }, +}; + +const defaultValidator: CosmosValidatorAddress = { + value: 'cosmosvaloper1test', + chainId: 'cosmoshub-test', + encoding: 'bech32', +}; + +test('restake contract schedules claimRewards() and delegate() on an interval', async t => { + const { + bootstrap: { vowTools: vt, timer }, + zoe, + instance, + utils: { inspectDibcBridge }, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(instance); + const inv = E(publicFacet).makeRestakeAccountInvitation(); + + const userSeat = E(zoe).offer(inv, {}, undefined, { chainName: 'cosmoshub' }); + const { invitationMakers, publicSubscribers } = await vt.when( + E(userSeat).getOfferResult(), + ); + t.is( + publicSubscribers.account.storagePath, + 'mockChainStorageRoot.restake.cosmos1test', + ); + + // Delegate, so we have some rewards to claim + const delegateInv = await E(invitationMakers).Delegate(defaultValidator, { + denom: 'uatom', + value: 10n, + }); + const delegateSeat = E(zoe).offer(delegateInv, {}); + await vt.when(E(delegateSeat).getOfferResult()); + + const restakeInv = await E(invitationMakers).Restake(defaultValidator, { + delay: 10n, + interval: 20n, + }); + const restakeSeat = await E(zoe).offer(restakeInv); + await vt.when(E(restakeSeat).getOfferResult()); + + timer.advanceTo(32n); + + await eventLoopIteration(); + const messages = await inspectDibcBridge(); + + // ChannelOpen, Delegate, WithdrawReward, Delegate + t.is(messages.length, 4); + + const verifyWithdrawAndDelegate = (msgs, label) => { + const withdrawRewardAck = msgs.at(-2) as IBCEvent<'acknowledgementPacket'>; + t.is( + withdrawRewardAck.acknowledgement, + buildMsgResponseString(MsgWithdrawDelegatorRewardResponse, { + amount: [{ denom: 'uatom', amount: '10' }], + }), + `Account withdrew rewards - ${label}`, + ); + + const delegateAck = msgs.at(-1) as IBCEvent<'acknowledgementPacket'>; + t.is( + delegateAck.acknowledgement, + buildMsgResponseString(MsgDelegateResponse, {}), + `Account delegated the claimed rewards - ${label} `, + ); + }; + + verifyWithdrawAndDelegate(messages, 'first iteration'); + + // repeater continues to fire + timer.advanceTo(64n); + await eventLoopIteration(); + const newMessages = await inspectDibcBridge(); + t.is(newMessages.length, 6); + verifyWithdrawAndDelegate(newMessages, 'second iteration'); +}); + +test.todo('CancelRestake stops the repeater'); +test.todo( + 'Restake with an active repeater stops the old one and starts a new one', +); +test.todo( + 'interval interval and delay need to be greater than minimumDelay and minimumInterval', +);