Skip to content

Commit

Permalink
feat: withdrawReward on StakingAccountHolder (#9307)
Browse files Browse the repository at this point in the history
refs: #9071

I'm not confident this closes #9071; see that issue for questions about
scope.

## Description

Adds `WithdrawReward` on `invitationMakers` of `StakingAccountHolder`,
following the pattern set by `Delegate`.

### Security Considerations

I'm a bit uneasy about lack of guards for the helper facet (`helper:
UnguardedHelperI`). It seems to impose a non-local review burden: we
have to be sure every call to the helper facet passes only args that
have already been guarded. I tripped on it, once: I passed the wrong
type of thing to a helper method and didn't get a helpful guard
violation message.

### Scaling Considerations

While proportionally, the amount of work done doesn't outgrow the
message size (presuming an IBC transaction is O(1)), in absolute terms,
it seems unlikely that the fee for a `WithdrawReward` `MsgSpendAction`
compensates for the cost of the IBC transaction.

### Documentation Considerations

It's not entirely clear to me what the status of `StakingAccountHolder`
is. Is it an example? If so, maybe this is `docs` rather than `feat`?

### Testing Considerations

Based on internal discussions, the tests here are unit tests that mock
the rest of the system.

### Upgrade Considerations

This presumably gets deployed with the rest of orchestration in an
upcoming chain-halting upgrade.
  • Loading branch information
mergify[bot] authored May 3, 2024
2 parents 2e2466e + aa79cd5 commit dcca603
Show file tree
Hide file tree
Showing 5 changed files with 429 additions and 76 deletions.
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 });
}
};

/** @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

0 comments on commit dcca603

Please sign in to comment.