Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: withdrawReward on StakingAccountHolder #9307

Merged
merged 3 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/boot/test/bootstrapTests/test-orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
4 changes: 4 additions & 0 deletions packages/cosmic-proto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
180 changes: 106 additions & 74 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.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 { 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';
Expand All @@ -38,17 +42,42 @@ 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] }} */
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 });
}
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nb: would be nice to see this pulled out of this file as it seems reusable. This seems like it would be consumer facing for folks implementing custom messages.


/** @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 @@ -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()),
}),
},
/**
Expand Down Expand Up @@ -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: {
Expand All @@ -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<ChainAmount[]>}
*/
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));
},
},
},
Expand Down
97 changes: 97 additions & 0 deletions packages/orchestration/test/test-tx-encoding.js
Original file line number Diff line number Diff line change
@@ -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=',
);
});
Loading
Loading