diff --git a/packages/boot/test/bootstrapTests/test-orchestration.ts b/packages/boot/test/bootstrapTests/test-orchestration.ts index c8e0d2e8325..fa1721afd41 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,21 @@ test.serial('stakeAtom - repl-style', async t => { const atomBrand = await EV(agoricNames).lookup('brand', 'ATOM'); const atomAmount = AmountMath.make(atomBrand, 10n); - await t.notThrowsAsync( - EV(account).delegate('cosmosvaloper1test', atomAmount), + const validatorAddress: CosmosValidatorAddress = { + address: 'cosmosvaloper1test', + chainId: 'gaiatest', + addressEncoding: 'bech32', + }; + await t.notThrowsAsync(EV(account).delegate(validatorAddress, atomAmount)); + + const queryRes = await EV(account).getBalance(); + t.deepEqual(queryRes, { value: 0n, denom: 'uatom' }); + + const queryUnknownDenom = await EV(account).getBalance('some-invalid-denom'); + t.deepEqual( + queryUnknownDenom, + { value: 0n, denom: 'some-invalid-denom' }, + 'getBalance for unknown denom returns value: 0n', ); }); @@ -156,6 +170,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({ @@ -164,7 +183,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: {}, }), @@ -173,6 +192,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', @@ -180,7 +205,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 9d96b937d01..3ab10b627e2 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/cosmic-proto/src/helpers.ts b/packages/cosmic-proto/src/helpers.ts index 6aafb38320d..070081bed8e 100644 --- a/packages/cosmic-proto/src/helpers.ts +++ b/packages/cosmic-proto/src/helpers.ts @@ -73,16 +73,17 @@ export type JsonSafe = { */ export type RequestQueryJson = JsonSafe; -const QUERY_REQ_TYPEURL_RE = /^\/(\w+(?:\.\w+)*)\.Query(\w+)Request$/; +const QUERY_REQ_TYPEURL_RE = + /^\/(?\w+(?:\.\w+)*)\.Query(?\w+)Request$/; export const typeUrlToGrpcPath = (typeUrl: Any['typeUrl']) => { const match = typeUrl.match(QUERY_REQ_TYPEURL_RE); - if (!match) { + if (!(match && match.groups)) { throw new TypeError( `Invalid typeUrl: ${typeUrl}. Must be a Query Request.`, ); } - const [, serviceName, methodName] = match; + const { serviceName, methodName } = match.groups; return `/${serviceName}.Query/${methodName}`; }; diff --git a/packages/cosmic-proto/test/snapshots/test-exports.js.md b/packages/cosmic-proto/test/snapshots/test-exports.js.md index 1c607d72e86..1c3710bfc2b 100644 --- a/packages/cosmic-proto/test/snapshots/test-exports.js.md +++ b/packages/cosmic-proto/test/snapshots/test-exports.js.md @@ -27,6 +27,8 @@ Generated by [AVA](https://avajs.dev). 'readInt32', 'readUInt32', 'tendermint', + 'toRequestQueryJson', + 'typeUrlToGrpcPath', 'typedJson', 'uInt64ToString', 'utf8Length', diff --git a/packages/cosmic-proto/test/snapshots/test-exports.js.snap b/packages/cosmic-proto/test/snapshots/test-exports.js.snap index 9f20ab80b82..64464ddbafe 100644 Binary files a/packages/cosmic-proto/test/snapshots/test-exports.js.snap and b/packages/cosmic-proto/test/snapshots/test-exports.js.snap differ diff --git a/packages/orchestration/src/examples/stakeAtom.contract.js b/packages/orchestration/src/examples/stakeAtom.contract.js index ca885cb12a0..3239452932d 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 */ @@ -34,7 +35,9 @@ const trace = makeTracer('StakeAtom'); * @param {Baggage} baggage */ export const start = async (zcf, privateArgs, baggage) => { - const { hostConnectionId, controllerConnectionId } = zcf.getTerms(); + // TODO #9063 this roughly matches what we'll get from Chain.getChainInfo() + const { hostConnectionId, controllerConnectionId, bondDenom } = + zcf.getTerms(); const { orchestration, marshaller, storageNode } = privateArgs; const zone = makeDurableZone(baggage); @@ -52,12 +55,19 @@ export const start = async (zcf, privateArgs, baggage) => { hostConnectionId, controllerConnectionId, ); - const address = await E(account).getAddress(); - trace('chain address', address); + // #9212 TODO do not fail if host does not have `async-icq` module; + // communicate to OrchestrationAccount that it can't send queries + const icqConnection = await E(orchestration).provideICQConnection( + controllerConnectionId, + ); + 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/icqConnectionKit.js b/packages/orchestration/src/exos/icqConnectionKit.js index 58eea647967..79025061988 100644 --- a/packages/orchestration/src/exos/icqConnectionKit.js +++ b/packages/orchestration/src/exos/icqConnectionKit.js @@ -39,7 +39,20 @@ export const ICQConnectionI = M.interface('ICQConnection', { * }} ICQConnectionKitState */ -/** @param {Zone} zone */ +/** + * Prepares an ICQ Connection Kit based on the {@link https://github.com/cosmos/ibc-apps/blob/e9b46e4bf0ad0a66cf6bc53b5e5496f6e2b4b02b/modules/async-icq/README.md | `icq/v1` IBC application protocol}. + * + * `icq/v1`, also referred to as `async-icq`, is a protocol for asynchronous queries + * between IBC-enabled chains. It allows a chain to send queries to another chain + * and receive responses asynchronously. + * + * The ICQ connection kit provides the necessary functionality to establish and manage + * an ICQ connection between two chains. It includes methods for retrieving the local + * and remote addresses of the connection, as well as sending queries and handling + * connection events. + * + * @param {Zone} zone + */ export const prepareICQConnectionKit = zone => zone.exoClassKit( 'ICQConnectionKit', diff --git a/packages/orchestration/src/exos/stakingAccountKit.js b/packages/orchestration/src/exos/stakingAccountKit.js index 0083a6bf717..ba0096e496a 100644 --- a/packages/orchestration/src/exos/stakingAccountKit.js +++ b/packages/orchestration/src/exos/stakingAccountKit.js @@ -8,6 +8,10 @@ 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 { AmountShape } from '@agoric/ertp'; import { makeTracer } from '@agoric/internal'; @@ -16,11 +20,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 { toRequestQueryJson } from '@agoric/cosmic-proto'; +import { ChainAddressShape, CoinShape } from '../typeGuards.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 {ChainAccount, ChainAddress, ChainAmount, 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'; */ @@ -37,13 +43,17 @@ const { Fail } = assert; * topicKit: RecorderKit; * account: ChainAccount; * chainAddress: ChainAddress; + * icqConnection: ICQConnection; + * bondDenom: string; * }} State */ -const HolderI = M.interface('holder', { +export const ChainAccountHolderI = M.interface('ChainAccountHolder', { getPublicTopics: M.call().returns(TopicsRecordShape), - delegate: M.callWhen(M.string(), AmountShape).returns(M.record()), - withdrawReward: M.callWhen(M.string()).returns(M.array()), + 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)), }); /** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */ @@ -89,10 +99,10 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { 'Staking Account Holder', { helper: UnguardedHelperI, - holder: HolderI, + holder: ChainAccountHolderI, invitationMakers: M.interface('invitationMakers', { - Delegate: M.call(M.string(), AmountShape).returns(M.promise()), - WithdrawReward: M.call(M.string()).returns(M.promise()), + 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()), }), @@ -101,13 +111,15 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { * @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: { @@ -126,24 +138,24 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { invitationMakers: { /** * - * @param {string} validatorAddress + * @param {CosmosValidatorAddress} validator * @param {Amount<'nat'>} amount */ - Delegate(validatorAddress, amount) { - trace('Delegate', validatorAddress, amount); + Delegate(validator, amount) { + trace('Delegate', validator, amount); return zcf.makeInvitation(async seat => { seat.exit(); - return this.facets.holder.delegate(validatorAddress, amount); + return this.facets.holder.delegate(validator, amount); }, 'Delegate'); }, - /** @param {string} validatorAddress */ - WithdrawReward(validatorAddress) { - trace('WithdrawReward', validatorAddress); + /** @param {CosmosValidatorAddress} validator */ + WithdrawReward(validator) { + trace('WithdrawReward', validator); return zcf.makeInvitation(async seat => { seat.exit(); - return this.facets.holder.withdrawReward(validatorAddress); + return this.facets.holder.withdrawReward(validator); }, 'WithdrawReward'); }, CloseAccount() { @@ -170,20 +182,23 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { }, // TODO move this beneath the Orchestration abstraction, // to the OrchestrationAccount provided by makeAccount() + /** @returns {ChainAddress} */ + getAddress() { + return this.state.chainAddress; + }, /** * _Assumes users has already sent funds to their ICA, until #9193 - * @param {string} validatorAddress + * @param {CosmosValidatorAddress} validator * @param {Amount<'nat'>} ertpAmount */ - async delegate(validatorAddress, ertpAmount) { - trace('delegate', validatorAddress, ertpAmount); + async delegate(validator, ertpAmount) { + trace('delegate', validator, ertpAmount); - // FIXME get values from proposal or args - // FIXME brand handling and amount scaling + // FIXME brand handling and amount scaling #9211 trace('TODO: handle brand', ertpAmount); const amount = { amount: String(ertpAmount.value), - denom: 'uatom', + denom: this.state.bondDenom, }; const account = this.facets.helper.owned(); @@ -193,7 +208,7 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { toAnyJSON( MsgDelegate.toProtoMsg({ delegatorAddress, - validatorAddress, + validatorAddress: validator.address, amount, }), ), @@ -204,15 +219,15 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { }, /** - * @param {string} validatorAddress + * @param {CosmosValidatorAddress} validator * @returns {Promise} */ - async withdrawReward(validatorAddress) { + async withdrawReward(validator) { const { chainAddress } = this.state; - assert.typeof(validatorAddress, 'string'); + assert.typeof(validator.address, 'string'); const msg = MsgWithdrawDelegatorReward.toProtoMsg({ delegatorAddress: chainAddress.address, - validatorAddress, + validatorAddress: validator.address, }); const account = this.facets.helper.owned(); const result = await E(account).executeEncodedTx([toAnyJSON(msg)]); @@ -222,9 +237,35 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { ); return harden(coins.map(toChainAmount)); }, + /** + * @param {ChainAmount['denom']} [denom] - defaults to bondDenom + * @returns {Promise} + */ + async getBalance(denom) { + const { chainAddress, icqConnection, bondDenom } = this.state; + denom ||= bondDenom; + assert.typeof(denom, 'string'); + + const [result] = await E(icqConnection).query([ + toRequestQueryJson( + QueryBalanceRequest.toProtoMsg({ + address: chainAddress.address, + denom, + }), + ), + ]); + 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}`; + return harden(toChainAmount(balance)); + }, }, }, ); return makeStakingAccountKit; }; + /** @typedef {ReturnType>} StakingAccountKit */ +/** @typedef {StakingAccountKit['holder']} StakingAccounHolder */ diff --git a/packages/orchestration/src/proposals/orchestration-proposal.js b/packages/orchestration/src/proposals/orchestration-proposal.js index b064a167a19..1887e2c26e1 100644 --- a/packages/orchestration/src/proposals/orchestration-proposal.js +++ b/packages/orchestration/src/proposals/orchestration-proposal.js @@ -2,9 +2,9 @@ import { V as E } from '@agoric/vat-data/vow.js'; /** - * @import {Connection, Port, PortAllocator} from '@agoric/network'; - * @import { OrchestrationService } from '../service.js' - * @import { OrchestrationVat } from '../vat-orchestration.js' + * @import {PortAllocator} from '@agoric/network'; + * @import {OrchestrationService} from '../service.js' + * @import {OrchestrationVat} from '../vat-orchestration.js' */ /** @@ -19,8 +19,7 @@ import { V as E } from '@agoric/vat-data/vow.js'; * orchestrationVat: Producer; * }; * }} powers - * @param {object} options - * @param {{ orchestrationRef: VatSourceRef }} options.options + * @param {{ options: { orchestrationRef: VatSourceRef }}} options * * @typedef {{ * orchestration: ERef; @@ -84,8 +83,8 @@ export const getManifestForOrchestration = (_powers, { orchestrationRef }) => ({ }, produce: { orchestration: 'orchestration', - orchestrationKit: 'orchestration', - orchestrationVat: 'orchestration', + orchestrationKit: 'orchestrationKit', + orchestrationVat: 'orchestrationVat', }, }, }, diff --git a/packages/orchestration/src/proposals/start-stakeAtom.js b/packages/orchestration/src/proposals/start-stakeAtom.js index 13aa78b1580..888765fd18e 100644 --- a/packages/orchestration/src/proposals/start-stakeAtom.js +++ b/packages/orchestration/src/proposals/start-stakeAtom.js @@ -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); @@ -46,6 +50,7 @@ export const startStakeAtom = async ( terms: { hostConnectionId, controllerConnectionId, + bondDenom, }, privateArgs: { orchestration: await orchestration, diff --git a/packages/orchestration/src/service.js b/packages/orchestration/src/service.js index 5503b7dd6f2..ef832a43b63 100644 --- a/packages/orchestration/src/service.js +++ b/packages/orchestration/src/service.js @@ -145,6 +145,7 @@ const prepareOrchestrationKit = ( .connection; } // allocate a new Port for every Connection + // TODO #9317 optimize ICQ port allocation const port = await this.facets.self.allocateICQControllerPort(); const remoteConnAddr = makeICQChannelAddress(controllerConnectionId); const icqConnectionKit = makeICQConnectionKit(port); diff --git a/packages/vm-config/decentral-devnet-config.json b/packages/vm-config/decentral-devnet-config.json index ad99fc4b421..2bf94fdd6f4 100644 --- a/packages/vm-config/decentral-devnet-config.json +++ b/packages/vm-config/decentral-devnet-config.json @@ -172,7 +172,8 @@ "args": [ { "hostConnectionId": "connection-1", - "controllerConnectionId": "connection-0" + "controllerConnectionId": "connection-0", + "bondDenom": "uatom" } ] }