From 54d830fd53420d3395a5d9ca3bc11e8a55a2773b Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 9 Apr 2024 13:35:17 -0400 Subject: [PATCH] feat(orchestration): stakeAtom delegate --- .../test/bootstrapTests/test-orchestration.ts | 72 ++++++- packages/boot/test/tools/ibc/test-mocks.js | 4 +- .../src/contracts/stakeAtom.contract.js | 68 ++++++- .../src/contracts/stakingAccountHolder.js | 192 ++++++++++++++++++ .../src/proposals/start-stakeAtom.js | 27 ++- .../vm-config/decentral-devnet-config.json | 15 ++ 6 files changed, 360 insertions(+), 18 deletions(-) create mode 100644 packages/orchestration/src/contracts/stakingAccountHolder.js diff --git a/packages/boot/test/bootstrapTests/test-orchestration.ts b/packages/boot/test/bootstrapTests/test-orchestration.ts index 92ff7b65abf..a7d2cff9b55 100644 --- a/packages/boot/test/bootstrapTests/test-orchestration.ts +++ b/packages/boot/test/bootstrapTests/test-orchestration.ts @@ -3,6 +3,7 @@ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import type { TestFn } from 'ava'; import { Fail } from '@agoric/assert'; +import { AmountMath } from '@agoric/ertp'; import type { start as stakeBldStart } from '@agoric/orchestration/src/contracts/stakeBld.contract.js'; import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; import { M, matches } from '@endo/patterns'; @@ -60,7 +61,6 @@ test.serial('stakeBld', async t => { const current = await wd.getCurrentWalletRecord(); const latest = await wd.getLatestUpdateRecord(); - console.log({ current, latest }); t.like(current, { offerToPublicSubscriberPaths: [ // TODO publish something useful @@ -88,7 +88,7 @@ test.serial('stakeBld', async t => { }); }); -test.serial('stakeAtom', async t => { +test.serial('stakeAtom - repl-style', async t => { const { buildProposal, evalProposal, @@ -120,4 +120,72 @@ test.serial('stakeAtom', async t => { matches(account, M.remotable('ChainAccount')), 'account is a remotable', ); + + 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'); +}); + +test.serial('stakeAtom - smart wallet', async t => { + const { agoricNamesRemotes } = t.context; + + const wd = await t.context.walletFactoryDriver.provideSmartWallet( + 'agoric1testStakAtom', + ); + + await wd.executeOffer({ + id: 'request-account', + invitationSpec: { + source: 'agoricContract', + instancePath: ['stakeAtom'], + callPipe: [['makeCreateAccountInvitation']], + }, + proposal: {}, + }); + t.like(wd.getCurrentWalletRecord(), { + offerToPublicSubscriberPaths: [ + ['request-account', { account: 'published.stakeAtom' }], + ], + }); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'request-account', numWantsSatisfied: 1 }, + }); + + const { ATOM } = agoricNamesRemotes.brand; + ATOM || Fail`ATOM missing from agoricNames`; + + await t.notThrowsAsync( + wd.executeOffer({ + id: 'request-delegate-success', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-account', + invitationMakerName: 'Delegate', + invitationArgs: ['cosmosvaloper1test', { brand: ATOM, value: 10n }], + }, + proposal: {}, + }), + ); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'request-delegate-success', numWantsSatisfied: 1 }, + }); + + await t.throwsAsync( + wd.executeOffer({ + id: 'request-delegate-fail', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-account', + invitationMakerName: 'Delegate', + invitationArgs: ['cosmosvaloper1fail', { brand: ATOM, value: 10n }], + }, + proposal: {}, + }), + { + message: 'ABCI code: 5: error handling packet: see events for details', + }, + 'delegate fails with invalid validator', + ); }); diff --git a/packages/boot/test/tools/ibc/test-mocks.js b/packages/boot/test/tools/ibc/test-mocks.js index 4ab1c27186f..d2de9c5d501 100644 --- a/packages/boot/test/tools/ibc/test-mocks.js +++ b/packages/boot/test/tools/ibc/test-mocks.js @@ -1,7 +1,7 @@ // @ts-check import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; -import { addParamsToVersion } from '../../../tools/ibc/mocks.js'; +import { addParamsIfJsonVersion } from '../../../tools/ibc/mocks.js'; test('addParamsToVersion', t => { const params = { address: 'cosmos1234' }; @@ -26,6 +26,6 @@ test('addParamsToVersion', t => { ]; for (const { version, expected, message } of scenarios) { - t.is(addParamsToVersion(version, params), expected, message); + t.is(addParamsIfJsonVersion(version, params), expected, message); } }); diff --git a/packages/orchestration/src/contracts/stakeAtom.contract.js b/packages/orchestration/src/contracts/stakeAtom.contract.js index f730c54451b..e93b56385fc 100644 --- a/packages/orchestration/src/contracts/stakeAtom.contract.js +++ b/packages/orchestration/src/contracts/stakeAtom.contract.js @@ -2,20 +2,24 @@ /** * @file Example contract that uses orchestration */ - +import { makeTracer } from '@agoric/internal'; import { makeDurableZone } from '@agoric/zone/durable.js'; import { V as E } from '@agoric/vat-data/vow.js'; import { M } from '@endo/patterns'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport'; +import { prepareStakingAccountHolder } from './stakingAccountHolder.js'; +const trace = makeTracer('StakeAtom'); /** - * @import * as orchestration from '../types' - * @import * as vatData from '@agoric/vat-data' + * @import { Orchestration } from '../types.js'; + * @import { Baggage } from '@agoric/vat-data'; + * @import { IBCConnectionID } from '@agoric/vats'; */ /** * @typedef {{ - * hostConnectionId: orchestration.ConnectionId; - * controllerConnectionId: orchestration.ConnectionId; + * hostConnectionId: IBCConnectionID; + * controllerConnectionId: IBCConnectionID; * }} StakeAtomTerms */ @@ -23,26 +27,66 @@ import { M } from '@endo/patterns'; * * @param {ZCF} zcf * @param {{ - * orchestration: orchestration.Orchestration; + * orchestration: Orchestration; + * storageNode: StorageNode; + * marshaller: Marshaller; * }} privateArgs - * @param {vatData.Baggage} baggage + * @param {Baggage} baggage */ export const start = async (zcf, privateArgs, baggage) => { const { hostConnectionId, controllerConnectionId } = zcf.getTerms(); - const { orchestration } = privateArgs; + const { orchestration, marshaller, storageNode } = privateArgs; const zone = makeDurableZone(baggage); + const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); + + const makeStakingAccountHolder = prepareStakingAccountHolder( + baggage, + makeRecorderKit, + zcf, + ); + + async function createAccount() { + const account = await E(orchestration).createAccount( + hostConnectionId, + controllerConnectionId, + ); + const accountAddress = await E(account).getAccountAddress(); + trace('account address', accountAddress); + const { holder, invitationMakers } = makeStakingAccountHolder( + account, + storageNode, + accountAddress, + ); + return { + publicSubscribers: holder.getPublicTopics(), + invitationMakers, + account: holder, + }; + } + const publicFacet = zone.exo( 'StakeAtom', M.interface('StakeAtomI', { createAccount: M.callWhen().returns(M.remotable('ChainAccount')), + makeCreateAccountInvitation: M.call().returns(M.promise()), }), { async createAccount() { - return E(orchestration).createAccount( - hostConnectionId, - controllerConnectionId, + trace('createAccount'); + return createAccount().then(({ account }) => account); + }, + makeCreateAccountInvitation() { + trace('makeCreateAccountInvitation'); + return zcf.makeInvitation( + async seat => { + seat.exit(); + return createAccount(); + }, + 'wantStakingAccount', + undefined, + undefined, ); }, }, @@ -50,3 +94,5 @@ export const start = async (zcf, privateArgs, baggage) => { return { publicFacet }; }; + +/** @typedef {typeof start} StakeAtomSF */ diff --git a/packages/orchestration/src/contracts/stakingAccountHolder.js b/packages/orchestration/src/contracts/stakingAccountHolder.js new file mode 100644 index 00000000000..a44dd74f1ec --- /dev/null +++ b/packages/orchestration/src/contracts/stakingAccountHolder.js @@ -0,0 +1,192 @@ +// @ts-check +/** @file Use-object for the owner of a staking account */ +import { + MsgDelegate, + MsgDelegateResponse, +} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.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 { txToBase64 } from '../utils/tx.js'; + +/** + * @import { ChainAccount, ChainAddress } from '../types.js'; + * @import { RecorderKit, MakeRecorderKit } from '@agoric/zoe/src/contractSupport/recorder.js'; + * @import { Baggage } from '@agoric/swingset-liveslots'; + */ + +const trace = makeTracer('StakingAccountHolder'); + +const { Fail } = assert; +/** + * @typedef {object} StakingAccountNotification + * @property {string} address + */ + +/** + * @typedef {{ + * topicKit: RecorderKit; + * account: ChainAccount; + * chainAddress: ChainAddress; + * }} State + */ + +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()), +}); + +/** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */ +const PUBLIC_TOPICS = { + account: ['Staking Account holder status', M.any()], +}; + +/** + * @param {Baggage} baggage + * @param {MakeRecorderKit} makeRecorderKit + * @param {ZCF} zcf + */ +export const prepareStakingAccountHolder = (baggage, makeRecorderKit, zcf) => { + const makeAccountHolderKit = prepareExoClassKit( + baggage, + 'Staking Account Holder', + { + helper: UnguardedHelperI, + holder: HolderI, + invitationMakers: M.interface('invitationMakers', { + Delegate: HolderI.payload.methodGuards.makeDelegateInvitation, + CloseAccount: HolderI.payload.methodGuards.makeCloseAccountInvitation, + TransferAccount: + HolderI.payload.methodGuards.makeTransferAccountInvitation, + }), + }, + /** + * @param {ChainAccount} account + * @param {StorageNode} storageNode + * @param {ChainAddress} chainAddress + * @returns {State} + */ + (account, storageNode, chainAddress) => { + // must be the fully synchronous maker because the kit is held in durable state + const topicKit = makeRecorderKit(storageNode, PUBLIC_TOPICS.account[1]); + + return { account, chainAddress, topicKit }; + }, + { + helper: { + /** @throws if this holder no longer owns the account */ + owned() { + const { account } = this.state; + if (!account) { + throw Fail`Using account holder after transfer`; + } + return account; + }, + getUpdater() { + return this.state.topicKit.recorder; + }, + /** + * _Assumes users has already sent funds to their ICA, until #9193 + * @param {string} validatorAddress + * @param {Amount<'nat'>} ertpAmount + */ + 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; + + const result = await E(account).executeEncodedTx([ + txToBase64( + 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}`; + } + }, + }, + invitationMakers: { + Delegate(validatorAddress, amount) { + return this.facets.holder.makeDelegateInvitation( + validatorAddress, + amount, + ); + }, + CloseAccount() { + return this.facets.holder.makeCloseAccountInvitation(); + }, + TransferAccount() { + return this.facets.holder.makeTransferAccountInvitation(); + }, + }, + holder: { + getPublicTopics() { + const { topicKit } = this.state; + return harden({ + account: { + description: PUBLIC_TOPICS.account[0], + subscriber: topicKit.subscriber, + storagePath: topicKit.recorder.getStoragePath(), + }, + }); + }, + /** + * + * @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'); + }, + /** + * Starting a transfer revokes the account holder. The associated updater + * will get a special notification that the account is being transferred. + */ + makeTransferAccountInvitation() { + throw Error('not yet implemented'); + }, + }, + }, + ); + return makeAccountHolderKit; +}; diff --git a/packages/orchestration/src/proposals/start-stakeAtom.js b/packages/orchestration/src/proposals/start-stakeAtom.js index 46dbb9c37ee..c41e07b486d 100644 --- a/packages/orchestration/src/proposals/start-stakeAtom.js +++ b/packages/orchestration/src/proposals/start-stakeAtom.js @@ -1,16 +1,25 @@ // @ts-check import { makeTracer } from '@agoric/internal'; +import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; import { E } from '@endo/far'; +/** @import { StakeAtomSF, StakeAtomTerms} from '../contracts/stakeAtom.contract' */ + const trace = makeTracer('StartStakeAtom', true); /** * @param {BootstrapPowers & { installation: {consume: {stakeAtom: Installation}}}} powers - * @param {{options: import('../contracts/stakeAtom.contract.js').StakeAtomTerms}} options + * @param {{options: StakeAtomTerms }} options */ export const startStakeAtom = async ( { - consume: { orchestration, startUpgradable }, + consume: { + agoricNames, + board, + chainStorage, + orchestration, + startUpgradable, + }, installation: { consume: { stakeAtom }, }, @@ -20,19 +29,28 @@ export const startStakeAtom = async ( }, { options: { hostConnectionId, controllerConnectionId } }, ) => { + const VSTORAGE_PATH = 'stakeAtom'; trace('startStakeAtom', { hostConnectionId, controllerConnectionId }); await null; - /** @type {StartUpgradableOpts} */ + const storageNode = await makeStorageNodeChild(chainStorage, VSTORAGE_PATH); + const marshaller = await E(board).getPublishingMarshaller(); + const atomIssuer = await E(agoricNames).lookup('issuer', 'ATOM'); + trace('ATOM Issuer', atomIssuer); + + /** @type {StartUpgradableOpts} */ const startOpts = { label: 'stakeAtom', installation: stakeAtom, + issuerKeywordRecord: harden({ ATOM: atomIssuer }), terms: { hostConnectionId, controllerConnectionId, }, privateArgs: { orchestration: await orchestration, + storageNode, + marshaller, }, }; @@ -49,6 +67,9 @@ export const getManifestForStakeAtom = ( manifest: { [startStakeAtom.name]: { consume: { + agoricNames: true, + board: true, + chainStorage: true, orchestration: true, startUpgradable: true, }, diff --git a/packages/vm-config/decentral-devnet-config.json b/packages/vm-config/decentral-devnet-config.json index dc6da6992a7..ad99fc4b421 100644 --- a/packages/vm-config/decentral-devnet-config.json +++ b/packages/vm-config/decentral-devnet-config.json @@ -11,6 +11,9 @@ "@agoric/builders/scripts/vats/init-network.js", "@agoric/builders/scripts/vats/init-localchain.js" ], + [ + "@agoric/builders/scripts/vats/init-orchestration.js" + ], [ { "module": "@agoric/builders/scripts/inter-protocol/init-core.js", @@ -161,6 +164,18 @@ } ] } + ], + [ + { + "module": "@agoric/builders/scripts/orchestration/init-stakeAtom.js", + "entrypoint": "defaultProposalBuilder", + "args": [ + { + "hostConnectionId": "connection-1", + "controllerConnectionId": "connection-0" + } + ] + } ] ] },