diff --git a/packages/orchestration/src/examples/stakeAtom.contract.js b/packages/orchestration/src/examples/stakeAtom.contract.js index d6cfd8dd94d..0c6f59b3e78 100644 --- a/packages/orchestration/src/examples/stakeAtom.contract.js +++ b/packages/orchestration/src/examples/stakeAtom.contract.js @@ -2,7 +2,7 @@ /** * @file Example contract that uses orchestration */ -import { makeTracer } from '@agoric/internal'; +import { makeTracer, StorageNodeShape } from '@agoric/internal'; import { makeDurableZone } from '@agoric/zone/durable.js'; import { V as E } from '@agoric/vow/vat.js'; import { M } from '@endo/patterns'; @@ -13,9 +13,20 @@ const trace = makeTracer('StakeAtom'); /** * @import { Baggage } from '@agoric/vat-data'; * @import { IBCConnectionID } from '@agoric/vats'; + * @import { TimerService } from '@agoric/time'; * @import { ICQConnection, OrchestrationService } from '../types.js'; */ +export const meta = harden({ + privateArgsShape: { + orchestration: M.remotable('orchestration'), + storageNode: StorageNodeShape, + marshaller: M.remotable('Marshaller'), + timer: M.remotable('TimerService'), + }, +}); +export const privateArgsShape = meta.privateArgsShape; + /** * @typedef {{ * hostConnectionId: IBCConnectionID; @@ -31,6 +42,7 @@ const trace = makeTracer('StakeAtom'); * orchestration: OrchestrationService; * storageNode: StorageNode; * marshaller: Marshaller; + * timer: TimerService; * }} privateArgs * @param {Baggage} baggage */ @@ -38,7 +50,7 @@ export const start = async (zcf, privateArgs, baggage) => { // TODO #9063 this roughly matches what we'll get from Chain.getChainInfo() const { hostConnectionId, controllerConnectionId, bondDenom } = zcf.getTerms(); - const { orchestration, marshaller, storageNode } = privateArgs; + const { orchestration, marshaller, storageNode, timer } = privateArgs; const zone = makeDurableZone(baggage); @@ -63,11 +75,14 @@ export const start = async (zcf, privateArgs, baggage) => { const accountAddress = await E(account).getAddress(); trace('account address', accountAddress); const { holder, invitationMakers } = makeStakingAccountKit( - account, - storageNode, accountAddress, - icqConnection, bondDenom, + { + account, + storageNode, + icqConnection, + timer, + }, ); return { publicSubscribers: holder.getPublicTopics(), diff --git a/packages/orchestration/src/exos/stakingAccountKit.js b/packages/orchestration/src/exos/stakingAccountKit.js index c32f492fcc3..703109fb040 100644 --- a/packages/orchestration/src/exos/stakingAccountKit.js +++ b/packages/orchestration/src/exos/stakingAccountKit.js @@ -5,8 +5,11 @@ import { MsgWithdrawDelegatorRewardResponse, } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; import { + MsgBeginRedelegate, MsgDelegate, MsgDelegateResponse, + MsgUndelegate, + MsgUndelegateResponse, } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; import { QueryBalanceRequest, @@ -15,19 +18,31 @@ import { import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; import { AmountShape } from '@agoric/ertp'; import { makeTracer } from '@agoric/internal'; -import { UnguardedHelperI } from '@agoric/internal/src/typeGuards.js'; import { M, prepareExoClassKit } from '@agoric/vat-data'; import { TopicsRecordShape } from '@agoric/zoe/src/contractSupport/index.js'; -import { decodeBase64 } from '@endo/base64'; +import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; +import { decodeBase64, encodeBase64 } from '@endo/base64'; import { E } from '@endo/far'; import { toRequestQueryJson } from '@agoric/cosmic-proto'; -import { ChainAddressShape, CoinShape } from '../typeGuards.js'; +import { + AmountArgShape, + ChainAddressShape, + ChainAmountShape, + CoinShape, + DelegationShape, +} from '../typeGuards.js'; + +/** maximum clock skew, in seconds, for unbonding time reported from other chain */ +export const maxClockSkew = 10n * 60n; /** - * @import {ChainAccount, ChainAddress, ChainAmount, CosmosValidatorAddress, ICQConnection} from '../types.js'; + * @import {AmountArg, ChainAccount, ChainAddress, ChainAmount, CosmosValidatorAddress, ICQConnection, StakingAccountActions} from '../types.js'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'; * @import {Baggage} from '@agoric/swingset-liveslots'; * @import {AnyJson} from '@agoric/cosmic-proto'; + * @import { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; + * @import { Delegation } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js'; + * @import {TimerService} from '@agoric/time'; */ const trace = makeTracer('StakingAccountHolder'); @@ -45,6 +60,7 @@ const { Fail } = assert; * chainAddress: ChainAddress; * icqConnection: ICQConnection; * bondDenom: string; + * timer: TimerService; * }} State */ @@ -52,8 +68,17 @@ export const ChainAccountHolderI = M.interface('ChainAccountHolder', { getPublicTopics: M.call().returns(TopicsRecordShape), getAddress: M.call().returns(ChainAddressShape), getBalance: M.callWhen().optional(M.string()).returns(CoinShape), - delegate: M.callWhen(ChainAddressShape, AmountShape).returns(M.record()), - withdrawReward: M.callWhen(ChainAddressShape).returns(M.arrayOf(CoinShape)), + delegate: M.callWhen(ChainAddressShape, AmountShape).returns(M.undefined()), + redelegate: M.callWhen( + ChainAddressShape, + ChainAddressShape, + AmountShape, + ).returns(M.undefined()), + withdrawReward: M.callWhen(ChainAddressShape).returns( + M.arrayOf(ChainAmountShape), + ), + withdrawRewards: M.callWhen().returns(M.arrayOf(ChainAmountShape)), + undelegate: M.callWhen(M.arrayOf(DelegationShape)).returns(M.undefined()), }); /** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */ @@ -68,6 +93,25 @@ const PUBLIC_TOPICS = { */ const toAnyJSON = x => /** @type {AnyJson} */ (Any.toJSON(x)); +export const encodeTxResponse = (response, toProtoMsg) => { + const protoMsg = toProtoMsg(response); + const any1 = Any.fromPartial(protoMsg); + const any2 = Any.fromPartial({ value: Any.encode(any1).finish() }); + const ackStr = encodeBase64(Any.encode(any2).finish()); + return ackStr; +}; + +export const trivialDelegateResponse = encodeTxResponse( + {}, + MsgDelegateResponse.toProtoMsg, +); + +const expect = (actual, expected, message) => { + if (actual !== expected) { + console.log(message, { actual, expected }); + } +}; + /** * @template T * @param {string} ackStr @@ -98,29 +142,46 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { baggage, 'Staking Account Holder', { - helper: UnguardedHelperI, + helper: M.interface('helper', { + owned: M.call().returns(M.remotable()), + getUpdater: M.call().returns(M.remotable()), + amountToCoin: M.call(AmountShape).returns(M.record()), + }), holder: ChainAccountHolderI, invitationMakers: M.interface('invitationMakers', { - Delegate: M.call(ChainAddressShape, AmountShape).returns(M.promise()), - WithdrawReward: M.call(ChainAddressShape).returns(M.promise()), - CloseAccount: M.call().returns(M.promise()), - TransferAccount: M.call().returns(M.promise()), + Delegate: M.callWhen(ChainAddressShape, AmountShape).returns( + InvitationShape, + ), + Redelegate: M.callWhen( + ChainAddressShape, + ChainAddressShape, + AmountArgShape, + ).returns(InvitationShape), + WithdrawReward: M.callWhen(ChainAddressShape).returns(InvitationShape), + Undelegate: M.callWhen(M.arrayOf(DelegationShape)).returns( + InvitationShape, + ), + CloseAccount: M.callWhen().returns(InvitationShape), + TransferAccount: M.callWhen().returns(InvitationShape), }), }, /** - * @param {ChainAccount} account - * @param {StorageNode} storageNode * @param {ChainAddress} chainAddress - * @param {ICQConnection} icqConnection * @param {string} bondDenom e.g. 'uatom' + * @param {object} io + * @param {ChainAccount} io.account + * @param {StorageNode} io.storageNode + * @param {ICQConnection} io.icqConnection + * @param {TimerService} io.timer * @returns {State} */ - (account, storageNode, chainAddress, icqConnection, bondDenom) => { + (chainAddress, bondDenom, io) => { + const { storageNode, ...rest } = io; // must be the fully synchronous maker because the kit is held in durable state // @ts-expect-error XXX Patterns const topicKit = makeRecorderKit(storageNode, PUBLIC_TOPICS.account[1]); - return { account, chainAddress, topicKit, icqConnection, bondDenom }; + return { chainAddress, bondDenom, topicKit, ...rest }; }, { helper: { @@ -135,6 +196,23 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { getUpdater() { return this.state.topicKit.recorder; }, + /** + * @param {AmountArg} amount + * @returns {Coin} + */ + amountToCoin(amount) { + const { bondDenom } = this.state; + if ('denom' in amount) { + assert.equal(amount.denom, bondDenom); + } else { + trace('TODO: handle brand', amount); + // FIXME(#9211) brand handling + } + return harden({ + denom: bondDenom, + amount: String(amount.value), + }); + }, }, invitationMakers: { /** @@ -150,6 +228,23 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { return this.facets.holder.delegate(validator, amount); }, 'Delegate'); }, + /** + * @param {CosmosValidatorAddress} srcValidator + * @param {CosmosValidatorAddress} dstValidator + * @param {AmountArg} amount + */ + Redelegate(srcValidator, dstValidator, amount) { + trace('Redelegate', srcValidator, dstValidator, amount); + + return zcf.makeInvitation(async seat => { + seat.exit(); + return this.facets.holder.redelegate( + srcValidator, + dstValidator, + amount, + ); + }, 'Redelegate'); + }, /** @param {CosmosValidatorAddress} validator */ WithdrawReward(validator) { trace('WithdrawReward', validator); @@ -159,6 +254,17 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { return this.facets.holder.withdrawReward(validator); }, 'WithdrawReward'); }, + /** + * @param {Delegation[]} delegations + */ + Undelegate(delegations) { + trace('Undelegate', delegations); + + return zcf.makeInvitation(async seat => { + seat.exit(); + return this.facets.holder.undelegate(delegations); + }, 'Undelegate'); + }, CloseAccount() { throw Error('not yet implemented'); }, @@ -181,6 +287,7 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { }, }); }, + // TODO move this beneath the Orchestration abstraction, // to the OrchestrationAccount provided by makeAccount() /** @returns {ChainAddress} */ @@ -190,33 +297,47 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { /** * _Assumes users has already sent funds to their ICA, until #9193 * @param {CosmosValidatorAddress} validator - * @param {Amount<'nat'>} ertpAmount + * @param {AmountArg} amount */ - async delegate(validator, ertpAmount) { - trace('delegate', validator, ertpAmount); - - // FIXME brand handling and amount scaling #9211 - trace('TODO: handle brand', ertpAmount); - const amount = { - amount: String(ertpAmount.value), - denom: this.state.bondDenom, - }; - - const account = this.facets.helper.owned(); - const delegatorAddress = this.state.chainAddress.address; + async delegate(validator, amount) { + trace('delegate', validator, amount); + const { helper } = this.facets; + const { chainAddress } = this.state; - const result = await E(account).executeEncodedTx([ + const result = await E(helper.owned()).executeEncodedTx([ toAnyJSON( MsgDelegate.toProtoMsg({ - delegatorAddress, + delegatorAddress: chainAddress.address, validatorAddress: validator.address, - amount, + amount: helper.amountToCoin(amount), }), ), ]); - if (!result) throw Fail`Failed to delegate.`; - return tryDecodeResponse(result, MsgDelegateResponse.fromProtoMsg); + expect(result, trivialDelegateResponse, 'MsgDelegateResponse'); + }, + /** + * _Assumes users has already sent funds to their ICA, until #9193 + * @param {CosmosValidatorAddress} srcValidator + * @param {CosmosValidatorAddress} dstValidator + * @param {AmountArg} amount + */ + async redelegate(srcValidator, dstValidator, amount) { + trace('redelegate', srcValidator, dstValidator, amount); + const { helper } = this.facets; + const { chainAddress } = this.state; + + // NOTE: response, including completionTime, is currently discarded. + await E(helper.owned()).executeEncodedTx([ + toAnyJSON( + MsgBeginRedelegate.toProtoMsg({ + delegatorAddress: chainAddress.address, + validatorSrcAddress: srcValidator.address, + validatorDstAddress: dstValidator.address, + amount: helper.amountToCoin(amount), + }), + ), + ]); }, /** @@ -224,18 +345,21 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { * @returns {Promise} */ async withdrawReward(validator) { + trace('withdrawReward', validator); + const { helper } = this.facets; const { chainAddress } = this.state; - assert.typeof(validator.address, 'string'); const msg = MsgWithdrawDelegatorReward.toProtoMsg({ delegatorAddress: chainAddress.address, validatorAddress: validator.address, }); - const account = this.facets.helper.owned(); + const account = helper.owned(); const result = await E(account).executeEncodedTx([toAnyJSON(msg)]); - const { amount: coins } = tryDecodeResponse( + const response = tryDecodeResponse( result, MsgWithdrawDelegatorRewardResponse.fromProtoMsg, ); + trace('withdrawReward response', response); + const { amount: coins } = response; return harden(coins.map(toChainAmount)); }, /** @@ -262,9 +386,55 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { if (!balance) throw Fail`Result lacked balance key: ${result}`; return harden(toChainAmount(balance)); }, + + withdrawRewards() { + throw assert.error('Not implemented'); + }, + + /** + * @param {Delegation[]} delegations + */ + async undelegate(delegations) { + trace('undelegate', delegations); + const { helper } = this.facets; + const { chainAddress, bondDenom, timer } = this.state; + + const result = await E(helper.owned()).executeEncodedTx( + delegations.map(d => + toAnyJSON( + MsgUndelegate.toProtoMsg({ + delegatorAddress: chainAddress.address, + validatorAddress: d.validatorAddress, + amount: { denom: bondDenom, amount: d.shares }, + }), + ), + ), + ); + + const response = tryDecodeResponse( + result, + MsgUndelegateResponse.fromProtoMsg, + ); + trace('undelegate response', response); + const { completionTime } = response; + const endTime = BigInt(completionTime.getTime() / 1000); + + await E(timer).wakeAt(endTime + maxClockSkew); + }, }, }, ); + + /** check holder facet against StakingAccountActions interface. */ + // eslint-disable-next-line no-unused-vars + const typeCheck = () => { + /** @type {any} */ + const arg = null; + /** @satisfies { StakingAccountActions } */ + // eslint-disable-next-line no-unused-vars + const kit = makeStakingAccountKit(arg, arg, arg).holder; + }; + return makeStakingAccountKit; }; diff --git a/packages/orchestration/src/proposals/start-stakeAtom.js b/packages/orchestration/src/proposals/start-stakeAtom.js index 888765fd18e..d5147f1e4e3 100644 --- a/packages/orchestration/src/proposals/start-stakeAtom.js +++ b/packages/orchestration/src/proposals/start-stakeAtom.js @@ -17,6 +17,7 @@ export const startStakeAtom = async ( agoricNames, board, chainStorage, + chainTimerService, orchestration, startUpgradable, }, @@ -56,6 +57,7 @@ export const startStakeAtom = async ( orchestration: await orchestration, storageNode, marshaller, + timer: await chainTimerService, }, }; @@ -75,6 +77,7 @@ export const getManifestForStakeAtom = ( agoricNames: true, board: true, chainStorage: true, + chainTimerService: true, orchestration: true, startUpgradable: true, }, diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js index bab1629a032..d84928dcf31 100644 --- a/packages/orchestration/src/typeGuards.js +++ b/packages/orchestration/src/typeGuards.js @@ -1,3 +1,5 @@ +// @ts-check +import { AmountShape } from '@agoric/ertp'; import { M } from '@endo/patterns'; export const ConnectionHandlerI = M.interface('ConnectionHandler', { @@ -18,3 +20,9 @@ export const Proto3Shape = { }; export const CoinShape = { value: M.bigint(), denom: M.string() }; + +export const ChainAmountShape = harden({ denom: M.string(), value: M.nat() }); + +export const AmountArgShape = M.or(AmountShape, ChainAmountShape); + +export const DelegationShape = M.record(); // TODO: DelegationShape fields diff --git a/packages/orchestration/src/types.d.ts b/packages/orchestration/src/types.d.ts index 13e457f29d3..910404ae18c 100644 --- a/packages/orchestration/src/types.d.ts +++ b/packages/orchestration/src/types.d.ts @@ -3,10 +3,6 @@ import type { Timestamp } from '@agoric/time'; import type { Invitation } from '@agoric/zoe/exported.js'; import type { Any } from '@agoric/cosmic-proto/google/protobuf/any'; import type { AnyJson } from '@agoric/cosmic-proto'; -import type { - MsgBeginRedelegateResponse, - MsgUndelegateResponse, -} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; import type { Delegation, Redelegation, @@ -283,28 +279,7 @@ export interface ChainAccount { getPort: () => Port; } -/** - * An object that supports high-level operations for an account on a remote chain. - */ -export interface BaseOrchestrationAccount { - /** @returns the underlying low-level operation object. */ - getChainAcccount: () => Promise; - - /** - * @returns the address of the account on the remote chain - */ - getAddress: () => ChainAddress; - - /** @returns an array of amounts for every balance in the account. */ - getBalances: () => Promise; - - /** @returns the balance of a specific denom for the account. */ - getBalance: (denom: DenomArg) => Promise; - - getDenomTrace: ( - denom: string, - ) => Promise<{ path: string; base_denom: string }>; - +export interface StakingAccountQueries { /** * @returns all active delegations from the account to any validator (or [] if none) */ @@ -347,15 +322,8 @@ export interface BaseOrchestrationAccount { * @returns the amount of the account's rewards pending from a specific validator */ getReward: (validator: CosmosValidatorAddress) => Promise; - - /** - * Transfer amount to another account on the same chain. The promise settles when the transfer is complete. - * @param toAccount - the account to send the amount to. MUST be on the same chain - * @param amount - the amount to send - * @returns void - */ - send: (toAccount: ChainAddress, amount: AmountArg) => Promise; - +} +export interface StakingAccountActions { /** * Delegate an amount to a validator. The promise settles when the delegation is complete. * @param validator - the validator to delegate to @@ -369,7 +337,7 @@ export interface BaseOrchestrationAccount { /** * Redelegate from one delegator to another. - * Settles when teh redelegation is established, not 21 days later. + * Settles when the redelegation is established, not 21 days later. * @param srcValidator - the current validator for the delegation. * @param dstValidator - the validator that will receive the delegation. * @param amount - how much to redelegate. @@ -379,14 +347,15 @@ export interface BaseOrchestrationAccount { srcValidator: CosmosValidatorAddress, dstValidator: CosmosValidatorAddress, amount: AmountArg, - ) => Promise; + ) => Promise; /** * Undelegate multiple delegations (concurrently). To delegate independently, pass an array with one item. * Resolves when the undelegation is complete and the tokens are no longer bonded. Note it may take weeks. + * The unbonding time is padded by 10 minutes to account for clock skew. * @param {Delegation[]} delegations - the delegation to undelegate */ - undelegate: (delegations: Delegation[]) => Promise; + undelegate: (delegations: Delegation[]) => Promise; /** * Withdraw rewards from all validators. The promise settles when the rewards are withdrawn. @@ -400,6 +369,39 @@ export interface BaseOrchestrationAccount { * @returns */ withdrawReward: (validator: CosmosValidatorAddress) => Promise; +} + +/** + * An object that supports high-level operations for an account on a remote chain. + */ +export interface BaseOrchestrationAccount + extends StakingAccountQueries, + StakingAccountActions { + /** @returns the underlying low-level operation object. */ + getChainAcccount: () => Promise; + + /** + * @returns the address of the account on the remote chain + */ + getAddress: () => ChainAddress; + + /** @returns an array of amounts for every balance in the account. */ + getBalances: () => Promise; + + /** @returns the balance of a specific denom for the account. */ + getBalance: (denom: DenomArg) => Promise; + + getDenomTrace: ( + denom: string, + ) => Promise<{ path: string; base_denom: string }>; + + /** + * Transfer amount to another account on the same chain. The promise settles when the transfer is complete. + * @param toAccount - the account to send the amount to. MUST be on the same chain + * @param amount - the amount to send + * @returns void + */ + send: (toAccount: ChainAddress, amount: AmountArg) => Promise; /** * Transfer an amount to another account, typically on another chain. diff --git a/packages/orchestration/test/staking-ops.test.js b/packages/orchestration/test/staking-ops.test.js new file mode 100644 index 00000000000..a178d008ca8 --- /dev/null +++ b/packages/orchestration/test/staking-ops.test.js @@ -0,0 +1,402 @@ +// @ts-check +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { MsgWithdrawDelegatorRewardResponse } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; +import { + MsgBeginRedelegateResponse, + MsgDelegate, + MsgDelegateResponse, + MsgUndelegateResponse, +} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { decodeBase64 } from '@endo/base64'; +import { E, Far } from '@endo/far'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { + prepareStakingAccountKit, + encodeTxResponse, + trivialDelegateResponse, +} from '../src/exos/stakingAccountKit.js'; + +/** + * @import {ChainAccount, ChainAddress, ICQConnection} from '../src/types.js'; + * @import { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; + */ + +const { Fail } = assert; + +test('MsgDelegateResponse trivial response', t => { + t.is( + trivialDelegateResponse, + 'Ei0KKy9jb3Ntb3Muc3Rha2luZy52MWJldGExLk1zZ0RlbGVnYXRlUmVzcG9uc2U=', + ); +}); + +const configStaking = /** @type {const} */ ({ + acct1: { + address: 'agoric1spy36ltduehs5dmszfrp792f0k2emcntrql3nx', + }, + validator: { + address: 'agoric1valoper234', + addressEncoding: 'bech32', + chainId: 'agoriclocal', + }, + delegations: { + agoric1valoper234: { denom: 'uatom', amount: '200' }, + }, + startTime: '2024-06-01T00:00Z', + completionTime: '2024-06-22T00:00Z', +}); + +const configRedelegate = /** @type {const} */ ({ + validator: { + address: 'agoric1valoper444', + addressEncoding: 'bech32', + chainId: 'atom-test', + }, + delegations: { + agoric1valoper234: { denom: 'uatom', amount: '50' }, + }, +}); + +const TICK = 5n * 60n; +const DAY = (60n * 60n * 24n) / TICK; +const DAYf = Number(DAY); + +const time = { + /** + * @param {string} dateString in YYYY-MM-DDTHH:mm:ss.sssZ format + * @returns {import('@agoric/time').Timestamp} + */ + parse: dateString => BigInt(Date.parse(dateString) / 1000), + + /** @param {import('@agoric/time').TimestampRecord} ts */ + format: ts => new Date(Number(ts.absValue) * 1000).toISOString(), +}; + +const makeScenario = () => { + /** + * @param {string} [addr] + * @param {Record} [delegations] + */ + const mockAccount = (addr = 'agoric1234', delegations = {}) => { + const calls = []; + + const simulate = { + '/cosmos.staking.v1beta1.MsgDelegate': _m => { + const response = MsgDelegateResponse.fromPartial({}); + return encodeTxResponse(response, MsgDelegateResponse.toProtoMsg); + }, + + '/cosmos.staking.v1beta1.MsgBeginRedelegate': _m => { + const response = MsgBeginRedelegateResponse.fromPartial({ + completionTime: new Date('2025-12-17T03:24:00Z'), + }); + return encodeTxResponse( + response, + MsgBeginRedelegateResponse.toProtoMsg, + ); + }, + + '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward': m => { + console.log('simulate withdraw', m); + const rewards = Object.values(delegations).map(({ denom, amount }) => ({ + denom, + amount: `${Number(amount) / 100}`, + })); + /** @type {MsgWithdrawDelegatorRewardResponse} */ + const response = { amount: rewards }; + + return encodeTxResponse( + response, + MsgWithdrawDelegatorRewardResponse.toProtoMsg, + ); + }, + + '/cosmos.staking.v1beta1.MsgUndelegate': _m => { + const { completionTime } = configStaking; + const response = MsgUndelegateResponse.fromPartial({ + completionTime: new Date(completionTime), + }); + return encodeTxResponse(response, MsgUndelegateResponse.toProtoMsg); + }, + }; + + /** @type {ChainAddress} */ + const chainAddress = harden({ + address: addr, + addressEncoding: 'bech32', + chainId: 'FIXME', + }); + + /** @type {ChainAccount} */ + const account = Far('MockAccount', { + getAddress: () => chainAddress, + executeEncodedTx: async msgs => { + assert.equal(msgs.length, 1); + const { typeUrl } = msgs[0]; + const doMessage = simulate[typeUrl]; + assert(doMessage, `unknown ${typeUrl}`); + await null; + calls.push({ msgs }); + return doMessage(msgs[0]); + }, + executeTx: () => Fail`mock`, + close: () => Fail`mock`, + deposit: () => Fail`mock`, + getPurse: () => Fail`mock`, + prepareTransfer: () => Fail`mock`, + getLocalAddress: () => Fail`mock`, + getRemoteAddress: () => Fail`mock`, + getPort: () => Fail`mock`, + }); + return { account, calls }; + }; + + const mockZCF = () => { + const toHandler = new Map(); + /** @type {ZCF} */ + const zcf = harden({ + // @ts-expect-error mock + makeInvitation: async (handler, _desc, _c, _patt) => { + /** @type {Invitation} */ + // @ts-expect-error mock + const invitation = Far('Invitation', {}); + toHandler.set(invitation, handler); + return invitation; + }, + }); + const zoe = harden({ + offer(invitation) { + const handler = toHandler.get(invitation); + const zcfSeat = harden({ + exit() {}, + }); + const result = Promise.resolve(null).then(() => handler(zcfSeat)); + const userSeat = harden({ + getOfferResult: () => result, + }); + return userSeat; + }, + }); + return { zcf, zoe }; + }; + + const makeRecorderKit = () => { + /** @type {any} */ + const kit = harden({}); + return kit; + }; + const baggage = makeScalarBigMapStore('b1'); + + const { delegations, startTime } = configStaking; + + // TODO: when we write to chainStorage, test it. + // const { rootNode } = makeFakeStorageKit('mockChainStorageRoot'); + + /** @type {StorageNode} */ + // @ts-expect-error mock + const storageNode = Far('StorageNode', {}); + + /** @type {ICQConnection} */ + // @ts-expect-error mock + const icqConnection = Far('ICQConnection', {}); + + const timer = buildManualTimer(undefined, time.parse(startTime), { + timeStep: TICK, + eventLoopIteration, + }); + return { + baggage, + makeRecorderKit, + ...mockAccount(undefined, delegations), + storageNode, + timer, + icqConnection, + ...mockZCF(), + }; +}; + +test('withdrawRewards() on StakingAccountHolder formats message correctly', async t => { + const s = makeScenario(); + const { account, calls, timer } = s; + const { baggage, makeRecorderKit, storageNode, zcf, icqConnection } = s; + const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf); + + // Higher fidelity tests below use invitationMakers. + const { holder } = make(account.getAddress(), 'uatom', { + account, + storageNode, + icqConnection, + timer, + }); + const { validator } = configStaking; + const actual = await E(holder).withdrawReward(validator); + t.deepEqual(actual, [{ denom: 'uatom', value: 2n }]); + const msg = { + typeUrl: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', + value: 'CgphZ29yaWMxMjM0EhFhZ29yaWMxdmFsb3BlcjIzNA==', + }; + t.deepEqual(calls, [{ msgs: [msg] }]); +}); + +test(`delegate; redelegate using invitationMakers`, async t => { + const s = makeScenario(); + const { account, calls, timer } = s; + const { baggage, makeRecorderKit, storageNode, zcf, zoe, icqConnection } = s; + /** @type {Brand<'nat'>} */ + const aBrand = Far('Token'); + const makeAccountKit = prepareStakingAccountKit( + baggage, + makeRecorderKit, + zcf, + ); + + const { invitationMakers } = makeAccountKit(account.getAddress(), 'uatom', { + account, + storageNode, + icqConnection, + timer, + }); + + const { validator, delegations } = configStaking; + { + const value = BigInt(Object.values(delegations)[0].amount); + const anAmount = { brand: aBrand, value }; + const toDelegate = await E(invitationMakers).Delegate(validator, anAmount); + const seat = E(zoe).offer(toDelegate); + const result = await E(seat).getOfferResult(); + + t.deepEqual(result, undefined); + const msg = { + typeUrl: '/cosmos.staking.v1beta1.MsgDelegate', + value: 'CgphZ29yaWMxMjM0EhFhZ29yaWMxdmFsb3BlcjIzNBoMCgV1YXRvbRIDMjAw', + }; + t.deepEqual(calls, [{ msgs: [msg] }]); + + // That msg.value looked odd in a protobuf tool. Let's double-check. + t.deepEqual(MsgDelegate.decode(decodeBase64(msg.value)), { + amount: { + amount: '200', + denom: 'uatom', + }, + delegatorAddress: 'agoric1234', + validatorAddress: 'agoric1valoper234', + }); + t.is(msg.typeUrl, MsgDelegate.typeUrl); + + // clear calls + calls.splice(0, calls.length); + } + + { + const { validator: dst } = configRedelegate; + const value = BigInt(Object.values(configRedelegate.delegations)[0].amount); + const anAmount = { brand: aBrand, value }; + const toRedelegate = await E(invitationMakers).Redelegate( + validator, + dst, + anAmount, + ); + const seat = E(zoe).offer(toRedelegate); + const result = await E(seat).getOfferResult(); + + t.deepEqual(result, undefined); + const msg = { + typeUrl: '/cosmos.staking.v1beta1.MsgBeginRedelegate', + value: + 'CgphZ29yaWMxMjM0EhFhZ29yaWMxdmFsb3BlcjIzNBoRYWdvcmljMXZhbG9wZXI0NDQiCwoFdWF0b20SAjUw', + }; + t.deepEqual(calls, [{ msgs: [msg] }]); + } +}); + +test(`withdraw rewards using invitationMakers`, async t => { + const s = makeScenario(); + const { account, calls, timer } = s; + const { baggage, makeRecorderKit, storageNode, zcf, zoe, icqConnection } = s; + const makeAccountKit = prepareStakingAccountKit( + baggage, + makeRecorderKit, + zcf, + ); + + const { invitationMakers } = makeAccountKit(account.getAddress(), 'uatom', { + account, + storageNode, + icqConnection, + timer, + }); + + const { validator } = configStaking; + const toWithdraw = await E(invitationMakers).WithdrawReward(validator); + const seat = E(zoe).offer(toWithdraw); + const result = await E(seat).getOfferResult(); + + t.deepEqual(result, [{ denom: 'uatom', value: 2n }]); + const msg = { + typeUrl: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', + value: 'CgphZ29yaWMxMjM0EhFhZ29yaWMxdmFsb3BlcjIzNA==', + }; + t.deepEqual(calls, [{ msgs: [msg] }]); +}); + +test(`undelegate waits for unbonding period`, async t => { + const s = makeScenario(); + const { account, calls, timer } = s; + const { baggage, makeRecorderKit, storageNode, zcf, zoe, icqConnection } = s; + const makeAccountKit = prepareStakingAccountKit( + baggage, + makeRecorderKit, + zcf, + ); + + const { invitationMakers } = makeAccountKit(account.getAddress(), 'uatom', { + account, + storageNode, + icqConnection, + timer, + }); + + const { validator, delegations } = configStaking; + + const value = BigInt(Object.values(delegations)[0].amount); + /** @type {Amount<'nat'>} */ + const anAmount = { brand: Far('Token'), value }; + const delegation = { + delegatorAddress: account.getAddress().address, + shares: `${anAmount.value}`, + validatorAddress: validator.address, + }; + const toUndelegate = await E(invitationMakers).Undelegate([delegation]); + const current = () => E(timer).getCurrentTimestamp().then(time.format); + t.log(await current(), 'undelegate', delegation.shares); + const seat = E(zoe).offer(toUndelegate); + + const beforeDone = E(timer) + .tickN(15 * DAYf) + .then(() => 15); + const afterDone = beforeDone.then(() => + E(timer) + .tickN(10 * DAYf) + .then(() => 25), + ); + const resultP = E(seat).getOfferResult(); + const notTooSoon = await Promise.race([beforeDone, resultP]); + t.log(await current(), 'not too soon?', notTooSoon === 15); + t.is(notTooSoon, 15); + const result = await Promise.race([resultP, afterDone]); + t.log(await current(), 'in time?', result === undefined); + t.deepEqual(result, undefined); + + const msg = { + typeUrl: '/cosmos.staking.v1beta1.MsgUndelegate', + value: 'CgphZ29yaWMxMjM0EhFhZ29yaWMxdmFsb3BlcjIzNBoMCgV1YXRvbRIDMjAw', + }; + t.deepEqual(calls, [{ msgs: [msg] }]); +}); + +test.todo(`delegate; undelegate; collect rewards`); +test.todo('undelegate uses a timer: begin; how long? wait; resolve'); +test.todo('undelegate is cancellable - cosmos cancelUnbonding'); diff --git a/packages/orchestration/test/withdraw-reward.test.js b/packages/orchestration/test/withdraw-reward.test.js deleted file mode 100644 index 656f624f76f..00000000000 --- a/packages/orchestration/test/withdraw-reward.test.js +++ /dev/null @@ -1,236 +0,0 @@ -// @ts-check -import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; - -import { MsgWithdrawDelegatorRewardResponse } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; -import { MsgDelegateResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; -import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; -import { makeScalarBigMapStore } from '@agoric/vat-data'; -import { encodeBase64 } from '@endo/base64'; -import { E, Far } from '@endo/far'; -import { prepareStakingAccountKit } from '../src/exos/stakingAccountKit.js'; - -/** - * @import {ChainAccount, ChainAddress, CosmosValidatorAddress, ICQConnection} from '../src/types.js'; - * @import { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; - */ - -const test = anyTest; - -const { Fail } = assert; - -const scenario1 = { - acct1: { - address: 'agoric1spy36ltduehs5dmszfrp792f0k2emcntrql3nx', - }, - /** @type {CosmosValidatorAddress} */ - validator: { - address: 'agoric1valoper234', - addressEncoding: 'bech32', - chainId: 'agoriclocal', - }, - delegations: { - agoric1valoper234: { denom: 'uatom', amount: '200' }, - }, -}; - -const makeScenario = () => { - const txEncode = (response, toProtoMsg) => { - const protoMsg = toProtoMsg(response); - const any1 = Any.fromPartial(protoMsg); - const any2 = Any.fromPartial({ value: Any.encode(any1).finish() }); - const ackStr = encodeBase64(Any.encode(any2).finish()); - return ackStr; - }; - - /** - * @param {string} [addr] - * @param {Record} [delegations] - */ - const mockAccount = (addr = 'agoric1234', delegations = {}) => { - const calls = []; - - const simulate = { - '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward': m => { - console.log('simulate withdraw', m); - const rewards = Object.values(delegations).map(({ denom, amount }) => ({ - denom, - amount: `${Number(amount) / 100}`, - })); - /** @type {MsgWithdrawDelegatorRewardResponse} */ - const response = { amount: rewards }; - - return txEncode( - response, - MsgWithdrawDelegatorRewardResponse.toProtoMsg, - ); - }, - - '/cosmos.staking.v1beta1.MsgDelegate': _m => { - const response = MsgDelegateResponse.fromPartial({}); - return txEncode(response, MsgDelegateResponse.toProtoMsg); - }, - }; - - /** @type {ChainAddress} */ - const chainAddress = harden({ - address: addr, - addressEncoding: 'bech32', - chainId: 'FIXME', - }); - - /** @type {ChainAccount} */ - const account = Far('MockAccount', { - getAddress: () => chainAddress, - executeEncodedTx: async msgs => { - assert.equal(msgs.length, 1); - const { typeUrl } = msgs[0]; - const doMessage = simulate[typeUrl]; - assert(doMessage, `unknown ${typeUrl}`); - await null; - calls.push({ msgs }); - return doMessage(msgs[0]); - }, - executeTx: () => Fail`mock`, - close: () => Fail`mock`, - deposit: () => Fail`mock`, - getPurse: () => Fail`mock`, - prepareTransfer: () => Fail`mock`, - getLocalAddress: () => Fail`mock`, - getRemoteAddress: () => Fail`mock`, - getPort: () => Fail`mock`, - }); - return { account, calls }; - }; - - const mockZCF = () => { - const toHandler = new Map(); - /** @type {ZCF} */ - const zcf = harden({ - // @ts-expect-error mock - makeInvitation: async (handler, _desc, _c, _patt) => { - /** @type {Invitation} */ - // @ts-expect-error mock - const invitation = harden({}); - toHandler.set(invitation, handler); - return invitation; - }, - }); - const zoe = harden({ - offer(invitation) { - const handler = toHandler.get(invitation); - const zcfSeat = harden({ - exit() {}, - }); - const result = Promise.resolve(null).then(() => handler(zcfSeat)); - const userSeat = harden({ - getOfferResult: () => result, - }); - return userSeat; - }, - }); - return { zcf, zoe }; - }; - - const makeRecorderKit = () => { - /** @type {any} */ - const kit = harden({}); - return kit; - }; - const baggage = makeScalarBigMapStore('b1'); - - const { delegations } = scenario1; - - // TODO: when we write to chainStorage, test it. - // const { rootNode } = makeFakeStorageKit('mockChainStorageRoot'); - - /** @type {StorageNode} */ - // @ts-expect-error mock - const storageNode = Far('StorageNode', {}); - - /** @type {ICQConnection} */ - // @ts-expect-error mock - const icqConnection = Far('ICQConnection', {}); - - return { - baggage, - makeRecorderKit, - ...mockAccount(undefined, delegations), - storageNode, - icqConnection, - ...mockZCF(), - }; -}; - -test('withdraw rewards from staking account holder', async t => { - const s = makeScenario(); - const { account, calls } = s; - const { baggage, makeRecorderKit, storageNode, zcf, icqConnection } = s; - const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf); - - // Higher fidelity tests below use invitationMakers. - const { holder } = make( - account, - storageNode, - account.getAddress(), - icqConnection, - 'uatom', - ); - const { validator } = scenario1; - const actual = await E(holder).withdrawReward(validator); - t.deepEqual(actual, [{ denom: 'uatom', value: 2n }]); - const msg = { - typeUrl: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', - value: 'CgphZ29yaWMxMjM0EhFhZ29yaWMxdmFsb3BlcjIzNA==', - }; - t.deepEqual(calls, [{ msgs: [msg] }]); -}); - -test(`delegate; withdraw rewards`, async t => { - const s = makeScenario(); - const { account, calls } = s; - const { baggage, makeRecorderKit, storageNode, zcf, zoe, icqConnection } = s; - const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf); - - const { invitationMakers } = make( - account, - storageNode, - account.getAddress(), - icqConnection, - 'uatom', - ); - - const { validator, delegations } = scenario1; - { - const value = BigInt(Object.values(delegations)[0].amount); - /** @type {Amount<'nat'>} */ - const anAmount = { brand: Far('Token'), value }; - const toDelegate = await E(invitationMakers).Delegate(validator, anAmount); - const seat = E(zoe).offer(toDelegate); - const result = await E(seat).getOfferResult(); - - t.deepEqual(result, {}); - const msg = { - typeUrl: '/cosmos.staking.v1beta1.MsgDelegate', - value: 'CgphZ29yaWMxMjM0EhFhZ29yaWMxdmFsb3BlcjIzNBoMCgV1YXRvbRIDMjAw', - }; - t.deepEqual(calls, [{ msgs: [msg] }]); - calls.splice(0, calls.length); - } - - { - const toWithdraw = await E(invitationMakers).WithdrawReward(validator); - const seat = E(zoe).offer(toWithdraw); - const result = await E(seat).getOfferResult(); - - t.deepEqual(result, [{ denom: 'uatom', value: 2n }]); - const msg = { - typeUrl: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', - value: 'CgphZ29yaWMxMjM0EhFhZ29yaWMxdmFsb3BlcjIzNA==', - }; - t.deepEqual(calls, [{ msgs: [msg] }]); - } -}); - -test.todo(`delegate; undelegate; collect rewards`); -test.todo('undelegate uses a timer: begin; how long? wait; resolve'); -test.todo('undelegate is cancellable - cosmos cancelUnbonding');