From 8cbe1b60de03aeeffe8ffef433e4e35e4f900911 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 29 Apr 2024 19:38:45 -0500 Subject: [PATCH 1/3] feat: WithdrawReward on StakingAccountHolder - test: withdrawReward on StakingAccountHolder.helpers facet - test tx encoding layers - test non-trivial delegations - factor out tryDecodeResponse, toJSON - todos for remaining work --- .../test/bootstrapTests/test-orchestration.ts | 5 +- .../src/exos/stakingAccountKit.js | 98 ++++++-- .../orchestration/test/test-tx-encoding.js | 97 ++++++++ .../test/test-withdraw-reward.js | 219 ++++++++++++++++++ 4 files changed, 399 insertions(+), 20 deletions(-) create mode 100644 packages/orchestration/test/test-tx-encoding.js create mode 100644 packages/orchestration/test/test-withdraw-reward.js diff --git a/packages/boot/test/bootstrapTests/test-orchestration.ts b/packages/boot/test/bootstrapTests/test-orchestration.ts index f1ab0b27b20..c8e0d2e8325 100644 --- a/packages/boot/test/bootstrapTests/test-orchestration.ts +++ b/packages/boot/test/bootstrapTests/test-orchestration.ts @@ -124,8 +124,9 @@ test.serial('stakeAtom - repl-style', async t => { const atomBrand = await EV(agoricNames).lookup('brand', 'ATOM'); const atomAmount = AmountMath.make(atomBrand, 10n); - const res = await EV(account).delegate('cosmosvaloper1test', atomAmount); - t.is(res, 'Success', 'delegate returns Success'); + await t.notThrowsAsync( + EV(account).delegate('cosmosvaloper1test', atomAmount), + ); }); test.serial('stakeAtom - smart wallet', async t => { diff --git a/packages/orchestration/src/exos/stakingAccountKit.js b/packages/orchestration/src/exos/stakingAccountKit.js index 4b2a687a9c8..02416c0c48d 100644 --- a/packages/orchestration/src/exos/stakingAccountKit.js +++ b/packages/orchestration/src/exos/stakingAccountKit.js @@ -1,9 +1,14 @@ // @ts-check /** @file Use-object for the owner of a staking account */ +import { + MsgWithdrawDelegatorReward, + MsgWithdrawDelegatorRewardResponse, +} from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; import { MsgDelegate, MsgDelegateResponse, } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; +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'; @@ -11,10 +16,9 @@ import { M, prepareExoClassKit } from '@agoric/vat-data'; import { TopicsRecordShape } from '@agoric/zoe/src/contractSupport/index.js'; import { decodeBase64 } from '@endo/base64'; import { E } from '@endo/far'; -import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; /** - * @import { ChainAccount, ChainAddress } from '../types.js'; + * @import { ChainAccount, ChainAddress, ChainAmount, CosmosValidatorAddress } 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'; @@ -39,9 +43,10 @@ const { Fail } = assert; const HolderI = M.interface('holder', { getPublicTopics: M.call().returns(TopicsRecordShape), makeDelegateInvitation: M.call(M.string(), AmountShape).returns(M.promise()), + makeWithdrawRewardInvitation: M.call(M.string()).returns(M.promise()), makeCloseAccountInvitation: M.call().returns(M.promise()), makeTransferAccountInvitation: M.call().returns(M.promise()), - delegate: M.callWhen(M.string(), AmountShape).returns(M.string()), + delegate: M.callWhen(M.string(), AmountShape).returns(M.record()), }); /** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */ @@ -49,6 +54,33 @@ const PUBLIC_TOPICS = { account: ['Staking Account holder status', M.any()], }; +// UNTIL https://github.com/cosmology-tech/telescope/issues/605 +/** + * @param {Any} x + * @returns {AnyJson} + */ +const toAnyJSON = x => /** @type {AnyJson} */ (Any.toJSON(x)); + +/** + * @template T + * @param {string} ackStr + * @param {(p: {typeUrl: string, value: Uint8Array}) => T} fromProtoMsg + */ +export const tryDecodeResponse = (ackStr, fromProtoMsg) => { + try { + const any = Any.decode(decodeBase64(ackStr)); + const protoMsg = Any.decode(any.value); + + const msg = fromProtoMsg(protoMsg); + return msg; + } catch (cause) { + throw assert.error(`bad response: ${ackStr}`, undefined, { cause }); + } +}; + +/** @type {(c: { denom: string, amount: string }) => ChainAmount} */ +const toChainAmount = c => ({ denom: c.denom, value: BigInt(c.amount) }); + /** * @param {Baggage} baggage * @param {MakeRecorderKit} makeRecorderKit @@ -63,6 +95,8 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { holder: HolderI, invitationMakers: M.interface('invitationMakers', { Delegate: HolderI.payload.methodGuards.makeDelegateInvitation, + WithdrawReward: + HolderI.payload.methodGuards.makeWithdrawRewardInvitation, CloseAccount: HolderI.payload.methodGuards.makeCloseAccountInvitation, TransferAccount: HolderI.payload.methodGuards.makeTransferAccountInvitation, @@ -103,6 +137,7 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { async delegate(validatorAddress, ertpAmount) { // FIXME get values from proposal or args // FIXME brand handling and amount scaling + trace('TODO: handle brand', ertpAmount); const amount = { amount: String(ertpAmount.value), denom: 'uatom', @@ -112,25 +147,37 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { const delegatorAddress = this.state.chainAddress.address; const result = await E(account).executeEncodedTx([ - /** @type {AnyJson} */ ( - Any.toJSON( - MsgDelegate.toProtoMsg({ - delegatorAddress, - validatorAddress, - amount, - }), - ) + toAnyJSON( + MsgDelegate.toProtoMsg({ + delegatorAddress, + validatorAddress, + amount, + }), ), ]); if (!result) throw Fail`Failed to delegate.`; - try { - const decoded = MsgDelegateResponse.decode(decodeBase64(result)); - if (JSON.stringify(decoded) === '{}') return 'Success'; - throw Fail`Unexpected response: ${result}`; - } catch (e) { - throw Fail`Unable to decode result: ${result}`; - } + return tryDecodeResponse(result, MsgDelegateResponse.fromProtoMsg); + }, + + /** + * @param {string} validatorAddress + * @returns {Promise} + */ + async withdrawReward(validatorAddress) { + const { chainAddress } = this.state; + assert.typeof(validatorAddress, 'string'); + const msg = MsgWithdrawDelegatorReward.toProtoMsg({ + delegatorAddress: chainAddress.address, + validatorAddress, + }); + const account = this.facets.helper.owned(); + const result = await E(account).executeEncodedTx([toAnyJSON(msg)]); + const { amount: coins } = tryDecodeResponse( + result, + MsgWithdrawDelegatorRewardResponse.fromProtoMsg, + ); + return harden(coins.map(toChainAmount)); }, }, invitationMakers: { @@ -140,6 +187,12 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { amount, ); }, + /** @param {string} validatorAddress */ + WithdrawReward(validatorAddress) { + return this.facets.holder.makeWithdrawRewardInvitation( + validatorAddress, + ); + }, CloseAccount() { return this.facets.holder.makeCloseAccountInvitation(); }, @@ -180,6 +233,15 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { return this.facets.helper.delegate(validatorAddress, ertpAmount); }, 'Delegate'); }, + /** @param {string} validatorAddress */ + makeWithdrawRewardInvitation(validatorAddress) { + trace('makeWithdrawRewardInvitation', validatorAddress); + + return zcf.makeInvitation(async seat => { + seat.exit(); + return this.facets.helper.withdrawReward(validatorAddress); + }, 'WithdrawReward'); + }, makeCloseAccountInvitation() { throw Error('not yet implemented'); }, diff --git a/packages/orchestration/test/test-tx-encoding.js b/packages/orchestration/test/test-tx-encoding.js new file mode 100644 index 00000000000..d25dede87f2 --- /dev/null +++ b/packages/orchestration/test/test-tx-encoding.js @@ -0,0 +1,97 @@ +// @ts-check +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { + MsgWithdrawDelegatorReward, + 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 { decodeBase64, encodeBase64 } from '@endo/base64'; +import { tryDecodeResponse } from '../src/exos/stakingAccountKit.js'; + +const test = anyTest; + +const scenario1 = { + acct1: { + address: 'agoric1spy36ltduehs5dmszfrp792f0k2emcntrql3nx', + }, + validator: { address: 'agoric1valoper234', addressEncoding: 'bech32' }, + delegations: { + agoric1valoper234: { denom: 'uatom', amount: '200' }, + }, +}; + +test('MsgWithdrawDelegatorReward: protobuf encoding reminder', t => { + const actual = MsgWithdrawDelegatorReward.toProtoMsg({ + delegatorAddress: 'abc', + validatorAddress: 'def', + }); + + const abc = [0x03, 0x61, 0x62, 0x63]; // wire type 3, a, b, c + const def = [0x03, 0x64, 0x65, 0x66]; + t.deepEqual(actual, { + typeUrl: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', + value: Uint8Array.from([0x0a, ...abc, 0x12, ...def]), + }); +}); + +test('DelegateResponse decoding', t => { + // executeEncodedTx() returns "acknowledge string" + const ackStr = + 'Ei0KKy9jb3Ntb3Muc3Rha2luZy52MWJldGExLk1zZ0RlbGVnYXRlUmVzcG9uc2U='; + // That's base64 protobuf of an Any + const any = Any.decode(decodeBase64(ackStr)); + + t.like(any, { $typeUrl: '/google.protobuf.Any', typeUrl: '' }); + t.true(any.value instanceof Uint8Array); + + /** @import {MsgDelegateResponseProtoMsg} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; */ + /** @type {MsgDelegateResponseProtoMsg} */ + // @ts-expect-error we can tell this is the type from tye typeUrl + const protoMsg = Any.decode(any.value); + t.like(protoMsg, { + $typeUrl: '/google.protobuf.Any', + typeUrl: '/cosmos.staking.v1beta1.MsgDelegateResponse', + }); + t.true(protoMsg.value instanceof Uint8Array); + + const msgD = MsgDelegateResponse.fromProtoMsg(protoMsg); + t.deepEqual(msgD, {}); +}); + +test('tryDecodeResponse from withdraw', t => { + const ackStr = + 'ElIKPy9jb3Ntb3MuZGlzdHJpYnV0aW9uLnYxYmV0YTEuTXNnV2l0aGR' + + 'yYXdEZWxlZ2F0b3JSZXdhcmRSZXNwb25zZRIPCg0KBnVzdGFrZRIDMjAw'; + const msg = tryDecodeResponse( + ackStr, + MsgWithdrawDelegatorRewardResponse.fromProtoMsg, + ); + t.deepEqual(msg, { amount: [{ amount: '200', denom: 'ustake' }] }); +}); + +test('MsgWithdrawDelegatorRewardResponse encoding', t => { + const { delegations } = scenario1; + /** @type {MsgWithdrawDelegatorRewardResponse} */ + const response = { amount: Object.values(delegations) }; + const protoMsg = MsgWithdrawDelegatorRewardResponse.toProtoMsg(response); + + const typeUrl = + '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorRewardResponse'; + t.like(protoMsg, { typeUrl }); + t.true(protoMsg.value instanceof Uint8Array); + + const any1 = Any.fromPartial(protoMsg); + const any2 = Any.fromPartial({ value: Any.encode(any1).finish() }); + t.like(any2, { $typeUrl: '/google.protobuf.Any', typeUrl: '' }); + t.true(any2.value instanceof Uint8Array); + + const ackStr = encodeBase64(Any.encode(any2).finish()); + t.is(typeof ackStr, 'string'); + t.is( + ackStr, + 'ElEKPy9jb3Ntb3MuZGlzdHJpYnV0aW9uLnYxYmV0YTEuTXNnV2l0aGRy' + + 'YXdEZWxlZ2F0b3JSZXdhcmRSZXNwb25zZRIOCgwKBXVhdG9tEgMyMDA=', + ); +}); diff --git a/packages/orchestration/test/test-withdraw-reward.js b/packages/orchestration/test/test-withdraw-reward.js new file mode 100644 index 00000000000..2cfb69e9953 --- /dev/null +++ b/packages/orchestration/test/test-withdraw-reward.js @@ -0,0 +1,219 @@ +// @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} 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', + }, + validator: { address: 'agoric1valoper234', addressEncoding: 'bech32' }, + 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', {}); + + return { + baggage, + makeRecorderKit, + ...mockAccount(undefined, delegations), + storageNode, + ...mockZCF(), + }; +}; + +test('withdraw rewards from staking account holder', async t => { + const s = makeScenario(); + const { account, calls } = s; + const { baggage, makeRecorderKit, storageNode, zcf } = s; + const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf); + + // `helper` is a somewhat internal API; + // Higher fidelity tests below use invitationMakers. + const { helper } = make(account, storageNode, account.getAddress()); + const { validator } = scenario1; + const actual = await E(helper).withdrawReward(validator.address); + 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 } = s; + const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf); + + const { invitationMakers } = make(account, storageNode, account.getAddress()); + + const { validator, delegations } = scenario1; + { + const value = BigInt(Object.values(delegations)[0].amount); + const anAmount = { brand: Far('Token'), value }; + const toDelegate = await E(invitationMakers).Delegate( + validator.address, + 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.address, + ); + 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'); From 17c293261d3b218eaf83f34ec2ae042ec7d1b1e2 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 2 May 2024 16:30:03 -0500 Subject: [PATCH 2/3] feat(cosmic-proto): export distribution UNTIL endojs/endo#2265 --- packages/cosmic-proto/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cosmic-proto/package.json b/packages/cosmic-proto/package.json index e65d9364974..7e239f9fa79 100644 --- a/packages/cosmic-proto/package.json +++ b/packages/cosmic-proto/package.json @@ -40,6 +40,10 @@ "types": "./dist/codegen/cosmos/staking/v1beta1/tx.d.ts", "default": "./dist/codegen/cosmos/staking/v1beta1/tx.js" }, + "./cosmos/distribution/v1beta1/tx.js": { + "types": "./dist/codegen/cosmos/distribution/v1beta1/tx.d.ts", + "default": "./dist/codegen/cosmos/distribution/v1beta1/tx.js" + }, "./google/*.js": { "types": "./dist/codegen/google/*.d.ts", "default": "./dist/codegen/google/*.js" From aa79cd5e8eb808a6efa503f19783e64397fbbc03 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 2 May 2024 19:10:01 -0500 Subject: [PATCH 3/3] refactor: refine StakingAccountHolder facets - implement delegate() etc. directly on holder; - avoids unguarded helper methods - since these methods don't have multiple callers, it makes little sense to put them on the helper facet anyway - call zcf.makeInvitation() directly from invitationMaker facet methods; prune makeXInvitation methods on holder --- .../src/exos/stakingAccountKit.js | 136 +++++++----------- .../test/test-withdraw-reward.js | 6 +- 2 files changed, 56 insertions(+), 86 deletions(-) diff --git a/packages/orchestration/src/exos/stakingAccountKit.js b/packages/orchestration/src/exos/stakingAccountKit.js index 02416c0c48d..0083a6bf717 100644 --- a/packages/orchestration/src/exos/stakingAccountKit.js +++ b/packages/orchestration/src/exos/stakingAccountKit.js @@ -42,11 +42,8 @@ const { Fail } = assert; const HolderI = M.interface('holder', { getPublicTopics: M.call().returns(TopicsRecordShape), - makeDelegateInvitation: M.call(M.string(), AmountShape).returns(M.promise()), - makeWithdrawRewardInvitation: M.call(M.string()).returns(M.promise()), - makeCloseAccountInvitation: M.call().returns(M.promise()), - makeTransferAccountInvitation: M.call().returns(M.promise()), delegate: M.callWhen(M.string(), AmountShape).returns(M.record()), + withdrawReward: M.callWhen(M.string()).returns(M.array()), }); /** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */ @@ -94,12 +91,10 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { helper: UnguardedHelperI, holder: HolderI, invitationMakers: M.interface('invitationMakers', { - Delegate: HolderI.payload.methodGuards.makeDelegateInvitation, - WithdrawReward: - HolderI.payload.methodGuards.makeWithdrawRewardInvitation, - CloseAccount: HolderI.payload.methodGuards.makeCloseAccountInvitation, - TransferAccount: - HolderI.payload.methodGuards.makeTransferAccountInvitation, + Delegate: M.call(M.string(), AmountShape).returns(M.promise()), + WithdrawReward: M.call(M.string()).returns(M.promise()), + CloseAccount: M.call().returns(M.promise()), + TransferAccount: M.call().returns(M.promise()), }), }, /** @@ -127,6 +122,52 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { getUpdater() { return this.state.topicKit.recorder; }, + }, + invitationMakers: { + /** + * + * @param {string} validatorAddress + * @param {Amount<'nat'>} amount + */ + Delegate(validatorAddress, amount) { + trace('Delegate', validatorAddress, amount); + + return zcf.makeInvitation(async seat => { + seat.exit(); + return this.facets.holder.delegate(validatorAddress, amount); + }, 'Delegate'); + }, + /** @param {string} validatorAddress */ + WithdrawReward(validatorAddress) { + trace('WithdrawReward', validatorAddress); + + return zcf.makeInvitation(async seat => { + seat.exit(); + return this.facets.holder.withdrawReward(validatorAddress); + }, 'WithdrawReward'); + }, + CloseAccount() { + throw Error('not yet implemented'); + }, + /** + * Starting a transfer revokes the account holder. The associated updater + * will get a special notification that the account is being transferred. + */ + TransferAccount() { + throw Error('not yet implemented'); + }, + }, + holder: { + getPublicTopics() { + const { topicKit } = this.state; + return harden({ + account: { + description: PUBLIC_TOPICS.account[0], + subscriber: topicKit.subscriber, + storagePath: topicKit.recorder.getStoragePath(), + }, + }); + }, // TODO move this beneath the Orchestration abstraction, // to the OrchestrationAccount provided by makeAccount() /** @@ -135,6 +176,8 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { * @param {Amount<'nat'>} ertpAmount */ async delegate(validatorAddress, ertpAmount) { + trace('delegate', validatorAddress, ertpAmount); + // FIXME get values from proposal or args // FIXME brand handling and amount scaling trace('TODO: handle brand', ertpAmount); @@ -180,79 +223,6 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { return harden(coins.map(toChainAmount)); }, }, - invitationMakers: { - Delegate(validatorAddress, amount) { - return this.facets.holder.makeDelegateInvitation( - validatorAddress, - amount, - ); - }, - /** @param {string} validatorAddress */ - WithdrawReward(validatorAddress) { - return this.facets.holder.makeWithdrawRewardInvitation( - validatorAddress, - ); - }, - CloseAccount() { - return this.facets.holder.makeCloseAccountInvitation(); - }, - TransferAccount() { - return this.facets.holder.makeTransferAccountInvitation(); - }, - }, - holder: { - getPublicTopics() { - const { topicKit } = this.state; - return harden({ - account: { - description: PUBLIC_TOPICS.account[0], - subscriber: topicKit.subscriber, - storagePath: topicKit.recorder.getStoragePath(), - }, - }); - }, - /** - * - * @param {string} validatorAddress - * @param {Amount<'nat'>} ertpAmount - */ - async delegate(validatorAddress, ertpAmount) { - trace('delegate', validatorAddress, ertpAmount); - return this.facets.helper.delegate(validatorAddress, ertpAmount); - }, - /** - * - * @param {string} validatorAddress - * @param {Amount<'nat'>} ertpAmount - */ - makeDelegateInvitation(validatorAddress, ertpAmount) { - trace('makeDelegateInvitation', validatorAddress, ertpAmount); - - return zcf.makeInvitation(async seat => { - seat.exit(); - return this.facets.helper.delegate(validatorAddress, ertpAmount); - }, 'Delegate'); - }, - /** @param {string} validatorAddress */ - makeWithdrawRewardInvitation(validatorAddress) { - trace('makeWithdrawRewardInvitation', validatorAddress); - - return zcf.makeInvitation(async seat => { - seat.exit(); - return this.facets.helper.withdrawReward(validatorAddress); - }, 'WithdrawReward'); - }, - makeCloseAccountInvitation() { - throw Error('not yet implemented'); - }, - /** - * Starting a transfer revokes the account holder. The associated updater - * will get a special notification that the account is being transferred. - */ - makeTransferAccountInvitation() { - throw Error('not yet implemented'); - }, - }, }, ); return makeStakingAccountKit; diff --git a/packages/orchestration/test/test-withdraw-reward.js b/packages/orchestration/test/test-withdraw-reward.js index 2cfb69e9953..5c77a686829 100644 --- a/packages/orchestration/test/test-withdraw-reward.js +++ b/packages/orchestration/test/test-withdraw-reward.js @@ -157,11 +157,10 @@ test('withdraw rewards from staking account holder', async t => { const { baggage, makeRecorderKit, storageNode, zcf } = s; const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf); - // `helper` is a somewhat internal API; // Higher fidelity tests below use invitationMakers. - const { helper } = make(account, storageNode, account.getAddress()); + const { holder } = make(account, storageNode, account.getAddress()); const { validator } = scenario1; - const actual = await E(helper).withdrawReward(validator.address); + const actual = await E(holder).withdrawReward(validator.address); t.deepEqual(actual, [{ denom: 'uatom', value: 2n }]); const msg = { typeUrl: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', @@ -181,6 +180,7 @@ test(`delegate; withdraw rewards`, async t => { 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.address,