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/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" diff --git a/packages/orchestration/src/exos/stakingAccountKit.js b/packages/orchestration/src/exos/stakingAccountKit.js index 4b2a687a9c8..0083a6bf717 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'; @@ -38,10 +42,8 @@ const { Fail } = assert; const HolderI = M.interface('holder', { getPublicTopics: M.call().returns(TopicsRecordShape), - makeDelegateInvitation: M.call(M.string(), AmountShape).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()), + withdrawReward: M.callWhen(M.string()).returns(M.array()), }); /** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */ @@ -49,6 +51,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 @@ -62,10 +91,10 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { helper: UnguardedHelperI, holder: HolderI, invitationMakers: M.interface('invitationMakers', { - Delegate: HolderI.payload.methodGuards.makeDelegateInvitation, - 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()), }), }, /** @@ -93,58 +122,39 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { getUpdater() { return this.state.topicKit.recorder; }, - // TODO move this beneath the Orchestration abstraction, - // to the OrchestrationAccount provided by makeAccount() + }, + invitationMakers: { /** - * _Assumes users has already sent funds to their ICA, until #9193 + * * @param {string} validatorAddress - * @param {Amount<'nat'>} ertpAmount + * @param {Amount<'nat'>} amount */ - async delegate(validatorAddress, ertpAmount) { - // FIXME get values from proposal or args - // FIXME brand handling and amount scaling - const amount = { - amount: String(ertpAmount.value), - denom: 'uatom', - }; - - const account = this.facets.helper.owned(); - const delegatorAddress = this.state.chainAddress.address; - - const result = await E(account).executeEncodedTx([ - /** @type {AnyJson} */ ( - Any.toJSON( - MsgDelegate.toProtoMsg({ - delegatorAddress, - validatorAddress, - amount, - }), - ) - ), - ]); + Delegate(validatorAddress, amount) { + trace('Delegate', 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 zcf.makeInvitation(async seat => { + seat.exit(); + return this.facets.holder.delegate(validatorAddress, amount); + }, 'Delegate'); }, - }, - invitationMakers: { - Delegate(validatorAddress, amount) { - return this.facets.holder.makeDelegateInvitation( - validatorAddress, - amount, - ); + /** @param {string} validatorAddress */ + WithdrawReward(validatorAddress) { + trace('WithdrawReward', validatorAddress); + + return zcf.makeInvitation(async seat => { + seat.exit(); + return this.facets.holder.withdrawReward(validatorAddress); + }, 'WithdrawReward'); }, CloseAccount() { - return this.facets.holder.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. + */ TransferAccount() { - return this.facets.holder.makeTransferAccountInvitation(); + throw Error('not yet implemented'); }, }, holder: { @@ -158,37 +168,59 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { }, }); }, + // TODO move this beneath the Orchestration abstraction, + // to the OrchestrationAccount provided by makeAccount() /** - * + * _Assumes users has already sent funds to their ICA, until #9193 * @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'); - }, - makeCloseAccountInvitation() { - throw Error('not yet implemented'); + // 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', + }; + + const account = this.facets.helper.owned(); + const delegatorAddress = this.state.chainAddress.address; + + const result = await E(account).executeEncodedTx([ + toAnyJSON( + MsgDelegate.toProtoMsg({ + delegatorAddress, + validatorAddress, + amount, + }), + ), + ]); + + if (!result) throw Fail`Failed to delegate.`; + return tryDecodeResponse(result, MsgDelegateResponse.fromProtoMsg); }, + /** - * Starting a transfer revokes the account holder. The associated updater - * will get a special notification that the account is being transferred. + * @param {string} validatorAddress + * @returns {Promise} */ - makeTransferAccountInvitation() { - throw Error('not yet implemented'); + 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)); }, }, }, 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..5c77a686829 --- /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); + + // Higher fidelity tests below use invitationMakers. + const { holder } = make(account, storageNode, account.getAddress()); + const { validator } = scenario1; + const actual = await E(holder).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); + /** @type {Amount<'nat'>} */ + 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');