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: redelegate, undelegate on StakingAccountKit #9331

Merged
merged 5 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
10 changes: 7 additions & 3 deletions packages/orchestration/src/examples/stakeAtom.contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@ export const start = async (zcf, privateArgs, baggage) => {
const accountAddress = await E(account).getAddress();
trace('account address', accountAddress);
const { holder, invitationMakers } = makeStakingAccountKit(
account,
storageNode,
accountAddress,
icqConnection,
bondDenom,
{
account,
storageNode,
icqConnection,
// @ts-expect-error only for undelegate, which we do not use
timer: harden({}),
Copy link
Member

Choose a reason for hiding this comment

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

should it be optional then?

Copy link
Member

Choose a reason for hiding this comment

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

Seems worthwhile to include ChainTimerService in start-stakeAtom.js? makeAccount in StakeAtom returns a StakingAccountKit, so it does seem to be used.

Copy link
Member Author

Choose a reason for hiding this comment

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

oops... missed this one; tending to it now...

},
);
return {
publicSubscribers: holder.getPublicTopics(),
Expand Down
244 changes: 207 additions & 37 deletions packages/orchestration/src/exos/stakingAccountKit.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import {
MsgWithdrawDelegatorRewardResponse,
} from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js';
import {
MsgBeginRedelegate,
MsgDelegate,
MsgDelegateResponse,
MsgUndelegate,
MsgUndelegateResponse,
} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js';
import {
QueryBalanceRequest,
Expand All @@ -15,19 +18,31 @@ import {
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 { InvitationShape } from '@agoric/zoe/src/typeGuards.js';
import { decodeBase64, encodeBase64 } from '@endo/base64';
import { E } from '@endo/far';
import { toRequestQueryJson } from '@agoric/cosmic-proto';
import { ChainAddressShape, CoinShape } from '../typeGuards.js';
import {
AmountArgShape,
ChainAddressShape,
ChainAmountShape,
CoinShape,
DelegationShape,
} from '../typeGuards.js';

/** maximum clock skew, in seconds, for unbonding time reported from other chain */
export const maxClockSkew = 10n * 60n;

/**
* @import {ChainAccount, ChainAddress, ChainAmount, CosmosValidatorAddress, ICQConnection} from '../types.js';
* @import {AmountArg, ChainAccount, ChainAddress, ChainAmount, CosmosValidatorAddress, ICQConnection, StakingAccountActions} 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';
* @import { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js';
* @import { Delegation } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js';
* @import {TimerService} from '@agoric/time';
*/

const trace = makeTracer('StakingAccountHolder');
Expand All @@ -45,15 +60,25 @@ const { Fail } = assert;
* chainAddress: ChainAddress;
* icqConnection: ICQConnection;
* bondDenom: string;
* timer: TimerService;
* }} State
*/

export const ChainAccountHolderI = M.interface('ChainAccountHolder', {
getPublicTopics: M.call().returns(TopicsRecordShape),
getAddress: M.call().returns(ChainAddressShape),
getBalance: M.callWhen().optional(M.string()).returns(CoinShape),
delegate: M.callWhen(ChainAddressShape, AmountShape).returns(M.record()),
withdrawReward: M.callWhen(ChainAddressShape).returns(M.arrayOf(CoinShape)),
delegate: M.callWhen(ChainAddressShape, AmountShape).returns(M.undefined()),
redelegate: M.callWhen(
ChainAddressShape,
ChainAddressShape,
AmountShape,
).returns(M.undefined()),
withdrawReward: M.callWhen(ChainAddressShape).returns(
M.arrayOf(ChainAmountShape),
),
withdrawRewards: M.callWhen().returns(M.arrayOf(ChainAmountShape)),
undelegate: M.callWhen(M.arrayOf(DelegationShape)).returns(M.undefined()),
});

/** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */
Expand All @@ -68,6 +93,25 @@ const PUBLIC_TOPICS = {
*/
const toAnyJSON = x => /** @type {AnyJson} */ (Any.toJSON(x));

export const encodeTxResponse = (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;
};

export const trivialDelegateResponse = encodeTxResponse(
{},
MsgDelegateResponse.toProtoMsg,
);

const expect = (actual, expected, message) => {
if (actual !== expected) {
console.log(message, { actual, expected });
Copy link
Member

Choose a reason for hiding this comment

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

Why not throw an error here?

}
};

/**
* @template T
* @param {string} ackStr
Expand Down Expand Up @@ -98,29 +142,46 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
baggage,
'Staking Account Holder',
{
helper: UnguardedHelperI,
helper: M.interface('helper', {
owned: M.call().returns(M.remotable()),
getUpdater: M.call().returns(M.remotable()),
amountToCoin: M.call(AmountShape).returns(M.record()),
}),
holder: ChainAccountHolderI,
invitationMakers: M.interface('invitationMakers', {
Delegate: M.call(ChainAddressShape, AmountShape).returns(M.promise()),
WithdrawReward: M.call(ChainAddressShape).returns(M.promise()),
CloseAccount: M.call().returns(M.promise()),
TransferAccount: M.call().returns(M.promise()),
Delegate: M.callWhen(ChainAddressShape, AmountShape).returns(
InvitationShape,
),
Redelegate: M.callWhen(
ChainAddressShape,
ChainAddressShape,
AmountArgShape,
).returns(InvitationShape),
WithdrawReward: M.callWhen(ChainAddressShape).returns(InvitationShape),
Undelegate: M.callWhen(M.arrayOf(DelegationShape)).returns(
InvitationShape,
),
CloseAccount: M.callWhen().returns(InvitationShape),
TransferAccount: M.callWhen().returns(InvitationShape),
}),
},
/**
* @param {ChainAccount} account
* @param {StorageNode} storageNode
* @param {ChainAddress} chainAddress
* @param {ICQConnection} icqConnection
* @param {string} bondDenom e.g. 'uatom'
* @param {object} io
* @param {ChainAccount} io.account
* @param {StorageNode} io.storageNode
* @param {ICQConnection} io.icqConnection
* @param {TimerService} io.timer
* @returns {State}
*/
(account, storageNode, chainAddress, icqConnection, bondDenom) => {
(chainAddress, bondDenom, io) => {
const { storageNode, ...rest } = io;
// must be the fully synchronous maker because the kit is held in durable state
// @ts-expect-error XXX Patterns
const topicKit = makeRecorderKit(storageNode, PUBLIC_TOPICS.account[1]);

return { account, chainAddress, topicKit, icqConnection, bondDenom };
return { chainAddress, bondDenom, topicKit, ...rest };
},
{
helper: {
Expand All @@ -135,6 +196,23 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
getUpdater() {
return this.state.topicKit.recorder;
},
/**
* @param {AmountArg} amount
* @returns {Coin}
*/
amountToCoin(amount) {
Copy link
Member

Choose a reason for hiding this comment

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

good enough for now

const { bondDenom } = this.state;
if ('denom' in amount) {
assert.equal(amount.denom, bondDenom);
} else {
trace('TODO: handle brand', amount);
// FIXME(#9211) brand handling
}
return harden({
denom: bondDenom,
amount: String(amount.value),
});
},
},
invitationMakers: {
/**
Expand All @@ -150,6 +228,23 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
return this.facets.holder.delegate(validator, amount);
}, 'Delegate');
},
/**
* @param {CosmosValidatorAddress} srcValidator
* @param {CosmosValidatorAddress} dstValidator
* @param {AmountArg} amount
*/
Redelegate(srcValidator, dstValidator, amount) {
Copy link
Member

Choose a reason for hiding this comment

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

since all these makers just map the methods from the holder, I wonder if we should have some method on the holder to create invitation makers given a zcf

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not clear on what you're suggesting.

Making another exoKit seems awkward.

trace('Redelegate', srcValidator, dstValidator, amount);

return zcf.makeInvitation(async seat => {
seat.exit();
return this.facets.holder.redelegate(
srcValidator,
dstValidator,
amount,
);
}, 'Redelegate');
},
/** @param {CosmosValidatorAddress} validator */
WithdrawReward(validator) {
trace('WithdrawReward', validator);
Expand All @@ -159,6 +254,17 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
return this.facets.holder.withdrawReward(validator);
}, 'WithdrawReward');
},
/**
* @param {Delegation[]} delegations
*/
Undelegate(delegations) {
trace('Undelegate', delegations);

return zcf.makeInvitation(async seat => {
seat.exit();
return this.facets.holder.undelegate(delegations);
}, 'Undelegate');
},
CloseAccount() {
throw Error('not yet implemented');
},
Expand All @@ -181,6 +287,7 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
},
});
},

// TODO move this beneath the Orchestration abstraction,
// to the OrchestrationAccount provided by makeAccount()
/** @returns {ChainAddress} */
Expand All @@ -190,52 +297,69 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
/**
* _Assumes users has already sent funds to their ICA, until #9193
* @param {CosmosValidatorAddress} validator
* @param {Amount<'nat'>} ertpAmount
* @param {AmountArg} amount
*/
async delegate(validator, ertpAmount) {
trace('delegate', validator, ertpAmount);

// FIXME brand handling and amount scaling #9211
trace('TODO: handle brand', ertpAmount);
const amount = {
amount: String(ertpAmount.value),
denom: this.state.bondDenom,
};

const account = this.facets.helper.owned();
const delegatorAddress = this.state.chainAddress.address;
async delegate(validator, amount) {
trace('delegate', validator, amount);
const { helper } = this.facets;
const { chainAddress } = this.state;

const result = await E(account).executeEncodedTx([
const result = await E(helper.owned()).executeEncodedTx([
Copy link
Member

Choose a reason for hiding this comment

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

👍

toAnyJSON(
MsgDelegate.toProtoMsg({
delegatorAddress,
delegatorAddress: chainAddress.address,
validatorAddress: validator.address,
amount,
amount: helper.amountToCoin(amount),
}),
),
]);

if (!result) throw Fail`Failed to delegate.`;
return tryDecodeResponse(result, MsgDelegateResponse.fromProtoMsg);
expect(result, trivialDelegateResponse, 'MsgDelegateResponse');
},
/**
* _Assumes users has already sent funds to their ICA, until #9193
* @param {CosmosValidatorAddress} srcValidator
* @param {CosmosValidatorAddress} dstValidator
* @param {AmountArg} amount
*/
async redelegate(srcValidator, dstValidator, amount) {
trace('redelegate', srcValidator, dstValidator, amount);
const { helper } = this.facets;
const { chainAddress } = this.state;

// NOTE: response, including completionTime, is currently discarded.
await E(helper.owned()).executeEncodedTx([
toAnyJSON(
MsgBeginRedelegate.toProtoMsg({
delegatorAddress: chainAddress.address,
validatorSrcAddress: srcValidator.address,
validatorDstAddress: dstValidator.address,
amount: helper.amountToCoin(amount),
}),
),
]);
},

/**
* @param {CosmosValidatorAddress} validator
* @returns {Promise<ChainAmount[]>}
*/
async withdrawReward(validator) {
trace('withdrawReward', validator);
const { helper } = this.facets;
const { chainAddress } = this.state;
assert.typeof(validator.address, 'string');
const msg = MsgWithdrawDelegatorReward.toProtoMsg({
delegatorAddress: chainAddress.address,
validatorAddress: validator.address,
});
const account = this.facets.helper.owned();
const account = helper.owned();
const result = await E(account).executeEncodedTx([toAnyJSON(msg)]);
const { amount: coins } = tryDecodeResponse(
const response = tryDecodeResponse(
result,
MsgWithdrawDelegatorRewardResponse.fromProtoMsg,
);
trace('withdrawReward response', response);
const { amount: coins } = response;
return harden(coins.map(toChainAmount));
},
/**
Expand All @@ -262,9 +386,55 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => {
if (!balance) throw Fail`Result lacked balance key: ${result}`;
dckc marked this conversation as resolved.
Show resolved Hide resolved
return harden(toChainAmount(balance));
},

withdrawRewards() {
throw assert.error('Not implemented');
},

/**
* @param {Delegation[]} delegations
*/
async undelegate(delegations) {
trace('undelegate', delegations);
const { helper } = this.facets;
const { chainAddress, bondDenom, timer } = this.state;

const result = await E(helper.owned()).executeEncodedTx(
delegations.map(d =>
toAnyJSON(
MsgUndelegate.toProtoMsg({
delegatorAddress: chainAddress.address,
validatorAddress: d.validatorAddress,
amount: { denom: bondDenom, amount: d.shares },
}),
),
),
);

const response = tryDecodeResponse(
result,
MsgUndelegateResponse.fromProtoMsg,
);
trace('undelegate response', response);
const { completionTime } = response;
const endTime = BigInt(completionTime.getTime() / 1000);

await E(timer).wakeAt(endTime + maxClockSkew);
},
},
},
);

/** check holder facet against StakingAccountActions interface. */
// eslint-disable-next-line no-unused-vars
const typeCheck = () => {
/** @type {any} */
const arg = null;
/** @satisfies { StakingAccountActions } */
// eslint-disable-next-line no-unused-vars
const kit = makeStakingAccountKit(arg, arg, arg).holder;
};

return makeStakingAccountKit;
};

Expand Down
Loading
Loading