diff --git a/packages/boot/test/bootstrapTests/test-orchestration.ts b/packages/boot/test/bootstrapTests/test-orchestration.ts index f1ab0b27b209..d5620c8b5026 100644 --- a/packages/boot/test/bootstrapTests/test-orchestration.ts +++ b/packages/boot/test/bootstrapTests/test-orchestration.ts @@ -7,6 +7,7 @@ import { AmountMath } from '@agoric/ertp'; import type { start as stakeBldStart } from '@agoric/orchestration/src/examples/stakeBld.contract.js'; import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; import { M, matches } from '@endo/patterns'; +import type { CosmosValidatorAddress } from '@agoric/orchestration'; import { makeWalletFactoryContext } from './walletFactory.ts'; type DefaultTestContext = Awaited>; @@ -124,8 +125,24 @@ 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); + const validatorAddress: CosmosValidatorAddress = { + address: 'cosmosvaloper1test', + chainId: 'gaiatest', + addressEncoding: 'bech32', + }; + const res = await EV(account).delegate(validatorAddress, atomAmount); t.is(res, 'Success', 'delegate returns Success'); + + const queryRes = await EV(account).queryBalance(); + t.deepEqual(queryRes, { amount: '0', denom: 'uatom' }); + + const queryUnknownDenom = + await EV(account).queryBalance('some-invalid-denom'); + t.deepEqual( + queryUnknownDenom, + { amount: '0', denom: 'some-invalid-denom' }, + 'queryBalance for unknown denom returns amount: 0', + ); }); test.serial('stakeAtom - smart wallet', async t => { @@ -155,6 +172,11 @@ test.serial('stakeAtom - smart wallet', async t => { const { ATOM } = agoricNamesRemotes.brand; ATOM || Fail`ATOM missing from agoricNames`; + const validatorAddress: CosmosValidatorAddress = { + address: 'cosmosvaloper1test', + chainId: 'gaiatest', + addressEncoding: 'bech32', + }; await t.notThrowsAsync( wd.executeOffer({ @@ -163,7 +185,7 @@ test.serial('stakeAtom - smart wallet', async t => { source: 'continuing', previousOffer: 'request-account', invitationMakerName: 'Delegate', - invitationArgs: ['cosmosvaloper1test', { brand: ATOM, value: 10n }], + invitationArgs: [validatorAddress, { brand: ATOM, value: 10n }], }, proposal: {}, }), @@ -172,6 +194,12 @@ test.serial('stakeAtom - smart wallet', async t => { status: { id: 'request-delegate-success', numWantsSatisfied: 1 }, }); + const validatorAddressFail: CosmosValidatorAddress = { + address: 'cosmosvaloper1fail', + chainId: 'gaiatest', + addressEncoding: 'bech32', + }; + await t.throwsAsync( wd.executeOffer({ id: 'request-delegate-fail', @@ -179,7 +207,7 @@ test.serial('stakeAtom - smart wallet', async t => { source: 'continuing', previousOffer: 'request-account', invitationMakerName: 'Delegate', - invitationArgs: ['cosmosvaloper1fail', { brand: ATOM, value: 10n }], + invitationArgs: [validatorAddressFail, { brand: ATOM, value: 10n }], }, proposal: {}, }), diff --git a/packages/builders/scripts/orchestration/init-stakeAtom.js b/packages/builders/scripts/orchestration/init-stakeAtom.js index 9d96b937d01b..3ab10b627e21 100644 --- a/packages/builders/scripts/orchestration/init-stakeAtom.js +++ b/packages/builders/scripts/orchestration/init-stakeAtom.js @@ -8,6 +8,7 @@ export const defaultProposalBuilder = async ( const { hostConnectionId = 'connection-1', controllerConnectionId = 'connection-0', + bondDenom = 'uatom', } = options; return harden({ sourceSpec: '@agoric/orchestration/src/proposals/start-stakeAtom.js', @@ -21,6 +22,7 @@ export const defaultProposalBuilder = async ( }, hostConnectionId, controllerConnectionId, + bondDenom, }, ], }); diff --git a/packages/orchestration/src/examples/stakeAtom.contract.js b/packages/orchestration/src/examples/stakeAtom.contract.js index ca885cb12a09..63339efb347b 100644 --- a/packages/orchestration/src/examples/stakeAtom.contract.js +++ b/packages/orchestration/src/examples/stakeAtom.contract.js @@ -11,15 +11,16 @@ import { prepareStakingAccountKit } from '../exos/stakingAccountKit.js'; const trace = makeTracer('StakeAtom'); /** - * @import { OrchestrationService } from '../service.js' * @import { Baggage } from '@agoric/vat-data'; * @import { IBCConnectionID } from '@agoric/vats'; + * @import { ICQConnection, OrchestrationService } from '../types.js'; */ /** * @typedef {{ * hostConnectionId: IBCConnectionID; * controllerConnectionId: IBCConnectionID; + * bondDenom: string; * }} StakeAtomTerms */ @@ -30,13 +31,17 @@ const trace = makeTracer('StakeAtom'); * orchestration: OrchestrationService; * storageNode: StorageNode; * marshaller: Marshaller; + * icqConnection: ICQConnection * }} privateArgs * @param {Baggage} baggage */ export const start = async (zcf, privateArgs, baggage) => { - const { hostConnectionId, controllerConnectionId } = zcf.getTerms(); - const { orchestration, marshaller, storageNode } = privateArgs; + // TODO #9063 this roughly matches what we'll get from Chain.getChainInfo() + const { hostConnectionId, controllerConnectionId, bondDenom } = + zcf.getTerms(); + const { orchestration, marshaller, storageNode, icqConnection } = privateArgs; + trace('@@@ICQCONN', icqConnection); const zone = makeDurableZone(baggage); const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); @@ -52,12 +57,14 @@ export const start = async (zcf, privateArgs, baggage) => { hostConnectionId, controllerConnectionId, ); - const address = await E(account).getAddress(); - trace('chain address', address); + const accountAddress = await E(account).getAddress(); + trace('account address', accountAddress); const { holder, invitationMakers } = makeStakingAccountKit( account, storageNode, - address, + accountAddress, + icqConnection, + bondDenom, ); return { publicSubscribers: holder.getPublicTopics(), diff --git a/packages/orchestration/src/exos/stakingAccountKit.js b/packages/orchestration/src/exos/stakingAccountKit.js index 4b2a687a9c8b..9c21c9e0ee1b 100644 --- a/packages/orchestration/src/exos/stakingAccountKit.js +++ b/packages/orchestration/src/exos/stakingAccountKit.js @@ -4,6 +4,12 @@ import { MsgDelegate, MsgDelegateResponse, } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; +import { + QueryBalanceRequest, + QueryBalanceResponse, +} from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; +import { RequestQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; import { AmountShape } from '@agoric/ertp'; import { makeTracer } from '@agoric/internal'; import { UnguardedHelperI } from '@agoric/internal/src/typeGuards.js'; @@ -11,13 +17,13 @@ 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 { ChainAddressShape } from '../typeGuards.js'; /** - * @import { ChainAccount, ChainAddress } from '../types.js'; + * @import { ChainAccount, ChainAddress, CosmosValidatorAddress, ICQConnection } 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 { AnyJson, RequestQueryJson } from '@agoric/cosmic-proto'; */ const trace = makeTracer('StakingAccountHolder'); @@ -33,15 +39,21 @@ const { Fail } = assert; * topicKit: RecorderKit; * account: ChainAccount; * chainAddress: ChainAddress; + * icqConnection: ICQConnection; + * bondDenom: string; * }} State */ -const HolderI = M.interface('holder', { +export const BalanceShape = { amount: M.string(), denom: M.string() }; + +export const ChainAccountHolderI = M.interface('ChainAccountHolder', { 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(ChainAddressShape, AmountShape).returns(M.string()), + queryBalance: M.callWhen().optional(M.string()).returns(BalanceShape), + getAddress: M.call().returns(ChainAddressShape), }); /** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */ @@ -60,25 +72,30 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { 'Staking Account Holder', { helper: UnguardedHelperI, - holder: HolderI, + holder: ChainAccountHolderI, invitationMakers: M.interface('invitationMakers', { - Delegate: HolderI.payload.methodGuards.makeDelegateInvitation, - CloseAccount: HolderI.payload.methodGuards.makeCloseAccountInvitation, + Delegate: + ChainAccountHolderI.payload.methodGuards.makeDelegateInvitation, + CloseAccount: + ChainAccountHolderI.payload.methodGuards.makeCloseAccountInvitation, TransferAccount: - HolderI.payload.methodGuards.makeTransferAccountInvitation, + ChainAccountHolderI.payload.methodGuards + .makeTransferAccountInvitation, }), }, /** * @param {ChainAccount} account * @param {StorageNode} storageNode * @param {ChainAddress} chainAddress + * @param {ICQConnection} icqConnection + * @param {string} bondDenom e.g. 'uatom' * @returns {State} */ - (account, storageNode, chainAddress) => { + (account, storageNode, chainAddress, icqConnection, bondDenom) => { // 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 }; + return { account, chainAddress, topicKit, icqConnection, bondDenom }; }, { helper: { @@ -95,17 +112,49 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { }, // TODO move this beneath the Orchestration abstraction, // to the OrchestrationAccount provided by makeAccount() + /** + * @param {string} [denom] - defaults to bondDenom + * @returns {Promise<{ amount: string; denom: string; }>} + */ + async queryBalance(denom) { + const { chainAddress, icqConnection, bondDenom } = this.state; + + denom ||= bondDenom; + + const [result] = await E(icqConnection).query([ + /** @type {RequestQueryJson} */ ( + RequestQuery.toJSON( + RequestQuery.fromPartial({ + path: '/cosmos.bank.v1beta1.Query/Balance', + data: QueryBalanceRequest.encode( + QueryBalanceRequest.fromPartial({ + address: chainAddress.address, + denom, + }), + ).finish(), + }), + ) + ), + ]); + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { balance } = QueryBalanceResponse.decode( + decodeBase64(result.key), + ); + if (!balance) throw Fail`Result lacked balance key: ${result}`; + // TODO, return Amount? cast amount to bigint? #9211 + return balance; + }, /** * _Assumes users has already sent funds to their ICA, until #9193 - * @param {string} validatorAddress + * @param {CosmosValidatorAddress} cosmosValidatorAddress * @param {Amount<'nat'>} ertpAmount */ - async delegate(validatorAddress, ertpAmount) { - // FIXME get values from proposal or args + async delegate(cosmosValidatorAddress, ertpAmount) { + // FIXME get values from proposal or args #9211 // FIXME brand handling and amount scaling const amount = { amount: String(ertpAmount.value), - denom: 'uatom', + denom: this.state.bondDenom, // TODO use ertpAmount.brand #9211 }; const account = this.facets.helper.owned(); @@ -116,7 +165,7 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { Any.toJSON( MsgDelegate.toProtoMsg({ delegatorAddress, - validatorAddress, + validatorAddress: cosmosValidatorAddress.address, amount, }), ) @@ -134,6 +183,10 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { }, }, invitationMakers: { + /** + * @param {CosmosValidatorAddress} validatorAddress + * @param {Amount<'nat'>} amount + */ Delegate(validatorAddress, amount) { return this.facets.holder.makeDelegateInvitation( validatorAddress, @@ -160,16 +213,28 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { }, /** * - * @param {string} validatorAddress + * @param {CosmosValidatorAddress} validatorAddress * @param {Amount<'nat'>} ertpAmount */ async delegate(validatorAddress, ertpAmount) { trace('delegate', validatorAddress, ertpAmount); return this.facets.helper.delegate(validatorAddress, ertpAmount); }, + getAddress() { + return this.state.chainAddress; + }, + /** + * @param {string} [denom] - defaults to bondDenom + * @returns {Promise<{ amount: string; denom: string; }>} + */ + async queryBalance(denom) { + denom ||= this.state.bondDenom; + trace('queryBalance', this.state.chainAddress.address, denom); + return this.facets.helper.queryBalance(denom); + }, /** * - * @param {string} validatorAddress + * @param {CosmosValidatorAddress} validatorAddress * @param {Amount<'nat'>} ertpAmount */ makeDelegateInvitation(validatorAddress, ertpAmount) { @@ -195,4 +260,5 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { ); return makeStakingAccountKit; }; + /** @typedef {ReturnType>} StakingAccountKit */ diff --git a/packages/orchestration/src/proposals/start-stakeAtom.js b/packages/orchestration/src/proposals/start-stakeAtom.js index 13aa78b15801..7557ecb56318 100644 --- a/packages/orchestration/src/proposals/start-stakeAtom.js +++ b/packages/orchestration/src/proposals/start-stakeAtom.js @@ -17,7 +17,7 @@ export const startStakeAtom = async ( agoricNames, board, chainStorage, - orchestration, + orchestration: orchestrationP, startUpgradable, }, installation: { @@ -27,10 +27,14 @@ export const startStakeAtom = async ( produce: { stakeAtom: produceInstance }, }, }, - { options: { hostConnectionId, controllerConnectionId } }, + { options: { hostConnectionId, controllerConnectionId, bondDenom } }, ) => { const VSTORAGE_PATH = 'stakeAtom'; - trace('startStakeAtom', { hostConnectionId, controllerConnectionId }); + trace('startStakeAtom', { + hostConnectionId, + controllerConnectionId, + bondDenom, + }); await null; const storageNode = await makeStorageNodeChild(chainStorage, VSTORAGE_PATH); @@ -38,6 +42,13 @@ export const startStakeAtom = async ( const atomIssuer = await E(agoricNames).lookup('issuer', 'ATOM'); trace('ATOM Issuer', atomIssuer); + const orchestration = await orchestrationP; + const icqConnection = await E(orchestration).provideICQConnection( + controllerConnectionId, + ); + + trace('@@@ICQCONN', icqConnection); + /** @type {StartUpgradableOpts} */ const startOpts = { label: 'stakeAtom', @@ -46,9 +57,11 @@ export const startStakeAtom = async ( terms: { hostConnectionId, controllerConnectionId, + bondDenom, }, privateArgs: { - orchestration: await orchestration, + orchestration, + icqConnection, storageNode, marshaller, },