Skip to content

Commit

Permalink
feat: WithdrawReward on StakingAccountHolder (WIP 3/3)
Browse files Browse the repository at this point in the history
 - test non-trivial delegations
 - todos for remaining work
 - factor out tryDecodeResponse, toJSON
 - test: withdrawReward on StakingAccountHolder.helpers facet
  • Loading branch information
dckc committed Apr 30, 2024
1 parent 66943ed commit adc4aaf
Show file tree
Hide file tree
Showing 2 changed files with 211 additions and 17 deletions.
92 changes: 75 additions & 17 deletions packages/orchestration/src/exos/stakingAccountKit.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
// @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';
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 { E } from '@endo/far';
import { Any } from '@agoric/cosmic-proto/google/protobuf/any';

/**
* @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';
Expand All @@ -39,6 +43,7 @@ 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()),
Expand All @@ -49,6 +54,30 @@ 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 toJSON = x => /** @type {AnyJson} */ (Any.toJSON(x));

/**
* @template T
* @param {string} ackStr
* @param {(m: Uint8Array) => T} decode
*/
export const tryDecodeResponse = (ackStr, decode) => {
try {
const decoded = decode(decodeBase64(ackStr));
return decoded;
} 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
Expand All @@ -63,6 +92,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,
Expand Down Expand Up @@ -112,25 +143,37 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
const delegatorAddress = this.state.chainAddress;

const result = await E(account).executeEncodedTx([
/** @type {AnyJson} */ (
Any.toJSON(
MsgDelegate.toProtoMsg({
delegatorAddress,
validatorAddress,
amount,
}),
)
toJSON(
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.decode);
},

/**
* @param {CosmosValidatorAddress} validator
* @returns {Promise<ChainAmount[]>}
*/
async withdrawReward(validator) {
const { chainAddress } = this.state;
const msg0 = MsgWithdrawDelegatorReward.toProtoMsg({
delegatorAddress: chainAddress,
validatorAddress: validator.address,
});
const account = this.facets.helper.owned();
const result = await E(account).executeEncodedTx([toJSON(msg0)]);
// @ts-expect-error type is wrong for MsgWithdrawDelegatorRewardResponse???
const { amount: coins } = tryDecodeResponse(
result,
MsgWithdrawDelegatorRewardResponse.decode,
);
return harden(coins.map(toChainAmount));
},
},
invitationMakers: {
Expand All @@ -140,6 +183,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();
},
Expand Down Expand Up @@ -180,6 +229,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');
},
Expand Down
136 changes: 136 additions & 0 deletions packages/orchestration/test/test-withdraw-reward.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// @ts-check
import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js';

import { encodeBase64 } from '@endo/base64';
import { E, Far } from '@endo/far';
import * as pbjs from 'protobufjs';

Check failure on line 6 in packages/orchestration/test/test-withdraw-reward.js

View workflow job for this annotation

GitHub Actions / lint-rest

'protobufjs' should be listed in the project's dependencies. Run 'npm i -S protobufjs' to add it
import {
MsgWithdrawDelegatorReward,
MsgWithdrawDelegatorRewardResponse,
} from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js';
import { makeScalarBigMapStore } from '@agoric/vat-data';
import { prepareStakingAccountKit } from '../src/exos/stakingAccountKit.js';

/** @import {ChainAccount} from '../src/types.js'; */

const test = anyTest;

const { Fail } = assert;

// XXX typescript doesn't realize .default is needed
/** @type {typeof import('protobufjs').Writer} */
const Writer = pbjs.default.Writer;

test('WithdrawDelegatorReward: protobuf encoding helper', 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('WithdrawDelegatorReward: bad inputs', t => {
/** @type {any[]} */
const badInputs = [{}, { delegatorAddress: 'abc' }, { delegatorAddress: 2 }];

for (const it of badInputs) {
t.throws(() => MsgWithdrawDelegatorReward.encode(it));
}
});

/**
* XXX defined in codegen/cosmos/base/v1beta1/coin.d.ts
* but that's not exported from comsmic-proto.
*
* @typedef {{ denom: string; amount: string; }} Coin
*/

/**
* @param {string} [addr]
* @param {Record<string, Coin>} [delegations]
*/
const mockAccount = (addr = 'agoric1234', delegations = {}) => {
const calls = [];

const typeUrl = '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward';

/** @type {ChainAccount} */
const account = Far('MockAccount', {
getAccountAddress: () => addr,
executeEncodedTx: async msgs => {
assert.equal(msgs.length, 1);
assert.equal(msgs[0].typeUrl, typeUrl);
await null;
calls.push({ msgs });
const wr = new Writer();
for (const v of Object.values(delegations)) {
// @ts-expect-error BinaryWriter is not exported
MsgWithdrawDelegatorRewardResponse.encode({ amount: [v] }, wr);
}
const bs = wr.finish();
return encodeBase64(bs);
},
executeTx: () => Fail`mock`,
close: () => Fail`mock`,
deposit: () => Fail`mock`,
getPurse: () => Fail`mock`,
prepareTransfer: () => Fail`mock`,
});
return { account, calls };
};

/** @returns {ZCF} */
const mockZCF = () =>
harden({
// @ts-expect-error mock
makeInvitation: async (handler, desc, c = undefined, patt = undefined) => {

Check failure on line 92 in packages/orchestration/test/test-withdraw-reward.js

View workflow job for this annotation

GitHub Actions / lint-rest

'c' is assigned a value but never used. Allowed unused vars must match /^_/u

Check failure on line 92 in packages/orchestration/test/test-withdraw-reward.js

View workflow job for this annotation

GitHub Actions / lint-rest

'patt' is assigned a value but never used. Allowed unused vars must match /^_/u
// const userSeat = harden({
// getOfferResult: () => {
// const zcfSeat = {};
// const r = handler(zcfSeat);
// return r;
// },
// });
/** @type {Invitation} */
// @ts-expect-error mock
const invitation = harden({});
return invitation;
},
});

test('withdraw rewards from staking account holder', async t => {
const makeRecorderKit = () => {
/** @type {any} */
const kit = harden({});
return kit;
};
const baggage = makeScalarBigMapStore('b1');
const zcf = mockZCF();
const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf);

const validator = { address: 'agoric1valoper234', addressEncoding: 'bech32' };
const delegations = {
[validator.address]: { denom: 'ustake', amount: '200' },
};
const { account } = mockAccount(undefined, delegations);
// const { rootNode } = makeFakeStorageKit('mockChainStorageRoot');
/** @type {StorageNode} */
// @ts-expect-error mock
const storageNode = Far('StorageNode', {});
const addr = 'agoric123';

// TODO: invitationMakers
const { helper } = make(account, storageNode, addr);
const actual = await E(helper).withdrawReward(validator);
t.deepEqual(actual, [{ denom: 'ustake', value: 200n }]);
});

test.todo(`delegate; undelegate; collect rewards`);
test.todo('undelegate uses a timer: begin; how long? wait; resolve');
test.todo('undelegate is cancellable - cosmos cancelUnbonding');

0 comments on commit adc4aaf

Please sign in to comment.