Skip to content

Commit

Permalink
feat: WithdrawReward on StakingAccountHolder
Browse files Browse the repository at this point in the history
 - test: withdrawReward on StakingAccountHolder.helpers facet
 - test tx encoding layers
 - test non-trivial delegations
 - factor out tryDecodeResponse, toJSON
 - todos for remaining work
  • Loading branch information
dckc committed May 2, 2024
1 parent 7f44cd5 commit a550852
Show file tree
Hide file tree
Showing 3 changed files with 380 additions and 17 deletions.
100 changes: 83 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,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 toJSON = 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
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -112,25 +147,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.fromProtoMsg);
},

/**
* @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.fromProtoMsg,
);
return harden(coins.map(toChainAmount));
},
},
invitationMakers: {
Expand All @@ -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();
},
Expand Down Expand Up @@ -180,6 +233,19 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
return this.facets.helper.delegate(validatorAddress, ertpAmount);
}, 'Delegate');
},
/** @param {string} validatorAddress */
makeWithdrawRewardInvitation(validatorAddress) {
trace('makeWithdrawRewardInvitation', validatorAddress);

const validator = {
address: validatorAddress,
addressEncoding: 'bech32',
};
return zcf.makeInvitation(async seat => {
seat.exit();
return this.facets.helper.withdrawReward(validator);
}, 'WithdrawReward');
},
makeCloseAccountInvitation() {
throw Error('not yet implemented');
},
Expand Down
98 changes: 98 additions & 0 deletions packages/orchestration/test/test-tx-encoding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// @ts-check
import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js';

import { decodeBase64, encodeBase64 } from '@endo/base64';
import {
MsgWithdrawDelegatorReward,
MsgWithdrawDelegatorRewardResponse,
} from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js';
import { MsgDelegateResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx';
import { Any } from '@agoric/cosmic-proto/google/protobuf/any';
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} */
// @ts-expect-error type says coins: but code says amount:
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=',
);
});
Loading

0 comments on commit a550852

Please sign in to comment.