diff --git a/packages/orchestration/src/examples/restake.contract.js b/packages/orchestration/src/examples/restake.contract.js new file mode 100644 index 00000000000..0f231669bc0 --- /dev/null +++ b/packages/orchestration/src/examples/restake.contract.js @@ -0,0 +1,212 @@ +import { + EmptyProposalShape, + InvitationShape, +} from '@agoric/zoe/src/typeGuards.js'; +import { M } from '@endo/patterns'; +import { E } from '@endo/far'; +import { makeTracer } from '@agoric/internal'; +import { TimestampRecordShape } from '@agoric/time'; +import { Fail } from '@endo/errors'; +import { withOrchestration } from '../utils/start-helper.js'; +import * as flows from './restake.flows.js'; +import { prepareCombineInvitationMakers } from '../exos/combine-invitation-makers.js'; +import { CosmosOrchestrationInvitationMakersInterface } from '../exos/cosmos-orchestration-account.js'; +import { ChainAddressShape } from '../typeGuards.js'; + +/** + * @import {GuestInterface} from '@agoric/async-flow'; + * @import {TimerRepeater, TimestampRecord} from '@agoric/time'; + * @import {Zone} from '@agoric/zone'; + * @import {OrchestrationPowers} from '../utils/start-helper.js'; + * @import {OrchestrationTools} from '../utils/start-helper.js'; + * @import {CosmosOrchestrationAccount} from '../exos/cosmos-orchestration-account.js'; + * @import {CosmosValidatorAddress} from '../types.js'; + */ + +const trace = makeTracer('RestakeContract'); + +/** + * XXX consider moving to privateArgs / creatorFacet, as terms are immutable + * + * @typedef {{ + * minimumDelay: bigint; + * minimumInterval: bigint; + * }} RestakeContractTerms + */ + +/** + * TODO use RelativeTimeRecord + * + * @typedef {{ + * delay: bigint; + * interval: bigint; + * }} RepeaterOpts + */ + +const RepeaterOptsShape = { + delay: M.nat(), + interval: M.nat(), +}; +harden(RepeaterOptsShape); + +/** + * 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 }) => { + const RestakeInvitationMakersI = M.interface('RestakeInvitationMakers', { + Restake: M.call(ChainAddressShape, RepeaterOptsShape).returns(M.promise()), + CancelRestake: M.call().returns(M.promise()), + }); + + /** + * A durable-ready exo that's returned to callers of `makeRestakeAccount`. + * Each caller will have their own instance with isolated state. + * + * It serves three main purposes: + * + * - persist state for our custom invitationMakers + * - provide invitationMakers and offer handlers for our restaking actions + * - provide a TimerWaker handler for the repeater logic + * + * The invitationMaker offer handlers interface with `TimerService` using `E` + * and remote calls. Since calls to `chainTimerService` are not cross-chain, + * it's safe to write them using Promises. + * + * When we need to perform cross-chain actions, like in the `wake` handler, we + * should use resumable code. Since our restake logic performs cross-chain + * actions via `.withdrawReward()` and `delegate()`, `orchFns.wakerHandler()` + * is written as an async-flow in {@link file://./restake.flows.js} + */ + const makeRestakeKit = zone.exoClassKit( + 'RestakeExtraInvitationMaker', + { + invitationMakers: RestakeInvitationMakersI, + waker: M.interface('Waker', { + wake: M.call(TimestampRecordShape).returns(M.any()), + }), + }, + /** + * @param {GuestInterface} account + */ + account => + /** + * @type {{ + * account: GuestInterface; + * repeater: TimerRepeater | undefined; + * validator: CosmosValidatorAddress | undefined; + * }} + */ ({ account, repeater: undefined, validator: undefined }), + { + invitationMakers: { + /** + * @param {CosmosValidatorAddress} validator + * @param {RepeaterOpts} opts + * @returns {Promise} + */ + Restake(validator, opts) { + trace('Restake', validator); + const { delay, interval } = opts; + const { minimumDelay, minimumInterval } = zcf.getTerms(); + // TODO use AmounthMath + delay >= minimumDelay || Fail`delay must be at least ${minimumDelay}`; + interval >= minimumInterval || + Fail`interval must be at least ${minimumInterval}`; + + return zcf.makeInvitation( + async () => { + await null; + this.state.validator = validator; + if (this.state.repeater) { + await E(this.state.repeater).disable(); + this.state.repeater = undefined; + } + const repeater = await E(timerService).makeRepeater( + delay, + interval, + ); + this.state.repeater = repeater; + await E(repeater).schedule(this.facets.waker); + }, + 'Restake', + undefined, + EmptyProposalShape, + ); + }, + CancelRestake() { + trace('Cancel Restake'); + return zcf.makeInvitation( + async () => { + const { repeater } = this.state; + if (!repeater) throw Fail`No active repeater.`; + await E(repeater).disable(); + this.state.repeater = undefined; + }, + 'Cancel Restake', + undefined, + EmptyProposalShape, + ); + }, + }, + waker: { + /** @param {TimestampRecord} timestampRecord */ + wake(timestampRecord) { + trace('Waker Fired', timestampRecord); + const { account, validator } = this.state; + try { + if (!account) throw Fail`No account`; + if (!validator) throw Fail`No validator`; + // eslint-disable-next-line no-use-before-define -- defined by orchestrateAll, necessarily after this + orchFns.wakerHandler(account, validator, timestampRecord); + } catch (e) { + trace('Wake handler failed', e); + } + }, + }, + }, + ); + + /** @type {any} XXX async membrane */ + const makeCombineInvitationMakers = prepareCombineInvitationMakers( + zone, + CosmosOrchestrationInvitationMakersInterface, + RestakeInvitationMakersI, + ); + + const orchFns = orchestrateAll(flows, { + makeCombineInvitationMakers, + makeRestakeKit, + }); + + 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); +harden(start); + +/** @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..e1a78c6a751 --- /dev/null +++ b/packages/orchestration/src/examples/restake.flows.js @@ -0,0 +1,92 @@ +/** + * @file Example contract that allows users to do different things with rewards + */ +import { M, mustMatch } from '@endo/patterns'; +import { Fail } from '@endo/errors'; +import { makeTracer } from '@agoric/internal'; + +const trace = makeTracer('RestakeFlows'); + +/** + * @import {CosmosValidatorAddress, OrchestrationAccount, OrchestrationFlow, Orchestrator, StakingAccountActions} from '@agoric/orchestration'; + * @import {TimestampRecord} from '@agoric/time'; + * @import {GuestInterface} from '@agoric/async-flow'; + * @import {ContinuingOfferResult, InvitationMakers} from '@agoric/smart-wallet/src/types.js'; + * @import {MakeCombineInvitationMakers} from '../exos/combine-invitation-makers.js'; + * @import {CosmosOrchestrationAccount} from '../exos/cosmos-orchestration-account.js'; + */ + +/** + * Create an OrchestrationAccount for a specific chain and return a + * {@link ContinuingOfferResult} that combines the invitationMakers from the orch + * account (`Delegate`, `WithdrawRewards`, `Transfer`, etc.) with our custom + * invitationMakers (`Restake`, `CancelRestake`) from `RestakeKit`. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {{ + * makeCombineInvitationMakers: MakeCombineInvitationMakers; + * makeRestakeKit: ( + * account: OrchestrationAccount & StakingAccountActions, + * ) => unknown; + * }} ctx + * @param {ZCFSeat} seat + * @param {{ chainName: string }} offerArgs + */ +export const makeRestakeAccount = async ( + orch, + { makeRestakeKit, makeCombineInvitationMakers }, + seat, + { chainName }, +) => { + trace('MakeRestakeAccount', chainName); + seat.exit(); + const remoteChain = await orch.getChain(chainName); + const account = + /** @type {OrchestrationAccount & StakingAccountActions} */ ( + await remoteChain.makeAccount() + ); + const restakeKit = /** @type {{ invitationMakers: InvitationMakers }} */ ( + makeRestakeKit(account) + ); + const { publicSubscribers, invitationMakers } = + await account.asContinuingOffer(); + + return /** @type {ContinuingOfferResult} */ ({ + publicSubscribers, + invitationMakers: makeCombineInvitationMakers( + restakeKit.invitationMakers, + invitationMakers, + ), + }); +}; +harden(makeRestakeAccount); + +/** + * A resumable async-flow that's provided to the `TimerWaker` waker handler in + * `RestakeKit`. It withdraws rewards from a validator and delegates them. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} _orch + * @param {object} _ctx + * @param {GuestInterface} account + * @param {CosmosValidatorAddress} validator + * @param {TimestampRecord} timestampRecord + */ +export const wakerHandler = async ( + _orch, + _ctx, + account, + validator, + timestampRecord, +) => { + trace('Restake Waker Fired', timestampRecord); + const amounts = await account.withdrawReward(validator); + if (amounts.length !== 1) { + throw Fail`Received ${amounts.length} amounts, only expected one.`; + } + if (!amounts[0].value) return; + + return account.delegate(validator, amounts[0]); +}; +harden(wakerHandler); diff --git a/packages/orchestration/src/utils/orchestrationAccount.js b/packages/orchestration/src/utils/orchestrationAccount.js index 242482bce60..6c74a591733 100644 --- a/packages/orchestration/src/utils/orchestrationAccount.js +++ b/packages/orchestration/src/utils/orchestrationAccount.js @@ -6,6 +6,7 @@ import { M } from '@endo/patterns'; import { AmountArgShape, ChainAddressShape, + DelegationShape, DenomAmountShape, IBCTransferOptionsShape, } from '../typeGuards.js'; @@ -38,3 +39,20 @@ export const orchestrationAccountMethods = { ), getPublicTopics: M.call().returns(Vow$(TopicsRecordShape)), }; + +export const orchestrationAccountInvitationMakers = { + Delegate: M.call(ChainAddressShape, AmountArgShape).returns(M.promise()), + Redelegate: M.call( + ChainAddressShape, + ChainAddressShape, + AmountArgShape, + ).returns(M.promise()), + WithdrawReward: M.call(ChainAddressShape).returns(M.promise()), + Undelegate: M.call(M.arrayOf(DelegationShape)).returns(M.promise()), + DeactivateAccount: M.call().returns(M.promise()), + ReactivateAccount: M.call().returns(M.promise()), + TransferAccount: M.call().returns(M.promise()), + Send: M.call().returns(M.promise()), + SendAll: M.call().returns(M.promise()), + Transfer: M.call().returns(M.promise()), +}; 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..863d1fa399f --- /dev/null +++ b/packages/orchestration/test/examples/restake.contract.test.ts @@ -0,0 +1,201 @@ +/* eslint-disable jsdoc/require-param */ +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 { ContinuingOfferResult } from '@agoric/smart-wallet/src/types.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: 10n, + minimumInterval: 20n, // 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 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(), + )) as ContinuingOfferResult; + 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()); + + /** verify WithdrawReward and Delegate acknowledgements */ + const verifyWithdrawAndDelegate = ( + events: IBCEvent[], + label: string, + ) => { + const withdrawRewardAck = events.at( + -2, + ) as IBCEvent<'acknowledgementPacket'>; + console.log('withdrawRewardAck', withdrawRewardAck); + t.is( + withdrawRewardAck.acknowledgement, + buildMsgResponseString(MsgWithdrawDelegatorRewardResponse, { + amount: [{ denom: 'uatom', amount: '10' }], + }), + `Account withdrew rewards - ${label}`, + ); + + const delegateAck = events.at(-1) as IBCEvent<'acknowledgementPacket'>; + t.is( + delegateAck.acknowledgement, + buildMsgResponseString(MsgDelegateResponse, {}), + `Account delegated the claimed rewards - ${label} `, + ); + }; + + { + t.log('Advance timer past first wake'); + timer.advanceTo(32n); + await eventLoopIteration(); + const { bridgeEvents } = await inspectDibcBridge(); + // ChannelOpen, Delegate, WithdrawReward, Delegate + t.is(bridgeEvents.length, 4); + verifyWithdrawAndDelegate(bridgeEvents, 'first iteration'); + } + + { + // repeater continues to fire + timer.advanceTo(64n); + await eventLoopIteration(); + const { bridgeEvents } = await inspectDibcBridge(); + t.is(bridgeEvents.length, 6); + verifyWithdrawAndDelegate(bridgeEvents, 'second iteration'); + } + + t.log('Cancel the restake repeater'); + const cancelInv = await E(invitationMakers).CancelRestake(); + const cancelSeat = await E(zoe).offer(cancelInv); + await vt.when(E(cancelSeat).getOfferResult()); + + { + timer.advanceTo(96n); + await eventLoopIteration(); + const { bridgeEvents } = await inspectDibcBridge(); + t.is(bridgeEvents.length, 6, 'no more events after cancel'); + } + + t.log('contract enforces minimum intervals'); + await t.throwsAsync( + E(invitationMakers).Restake(defaultValidator, { + delay: 1n, + interval: 2n, + }), + { message: 'delay must be at least "[10n]"' }, + ); + await t.throwsAsync( + E(invitationMakers).Restake(defaultValidator, { + delay: 10n, + interval: 2n, + }), + { message: 'interval must be at least "[20n]"' }, + ); +}); + +test.todo('if wake handler fails, repeater still continues'); +test.todo('contract restart behavior');