diff --git a/multichain-testing/test/account-balance-queries.test.ts b/multichain-testing/test/account-balance-queries.test.ts new file mode 100644 index 00000000000..7b2f5d2dd36 --- /dev/null +++ b/multichain-testing/test/account-balance-queries.test.ts @@ -0,0 +1,190 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import type { CosmosChainInfo } from '@agoric/orchestration'; +import { + commonSetup, + SetupContextWithWallets, + chainConfig, +} from './support.js'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import chainInfo from '../starship-chain-info.js'; +import { MAKE_ACCOUNT_AND_QUERY_BALANCE_TIMEOUT } from './config.js'; + +const test = anyTest as TestFn; + +const accounts = ['osmosis', 'cosmoshub', 'agoric']; + +const contractName = 'queryFlows'; +const contractBuilder = + '../packages/builders/scripts/testing/start-query-flows.js'; + +test.before(async t => { + const { deleteTestKeys, setupTestKeys, ...rest } = await commonSetup(t); + deleteTestKeys(accounts).catch(); + const wallets = await setupTestKeys(accounts); + t.context = { ...rest, wallets, deleteTestKeys }; + const { startContract } = rest; + await startContract(contractName, contractBuilder); +}); + +test.after(async t => { + const { deleteTestKeys } = t.context; + deleteTestKeys(accounts); +}); + +const queryAccountBalances = test.macro({ + title: (_, chainName: string) => `Query Account Balances on ${chainName}`, + exec: async (t, chainName: string) => { + const config = chainConfig[chainName]; + if (!config) return t.fail(`Unknown chain: ${chainName}`); + const { + wallets, + provisionSmartWallet, + vstorageClient, + retryUntilCondition, + } = t.context; + + const agoricAddr = wallets[chainName]; + const wdUser1 = await provisionSmartWallet(agoricAddr, { + BLD: 100n, + IST: 100n, + }); + t.log(`provisioning agoric smart wallet for ${agoricAddr}`); + + const doOffer = makeDoOffer(wdUser1); + t.log(`${chainName} makeAccountAndGetBalancesQuery offer`); + const offerId = `${chainName}-makeAccountAndGetBalancesQuery-${Date.now()}`; + + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeAccountAndGetBalancesQueryInvitation']], + }, + offerArgs: { chainName }, + proposal: {}, + }); + + const offerResult = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}`), + ({ status }) => status.id === offerId && (status.result || status.error), + `${offerId} offer result is in vstorage`, + MAKE_ACCOUNT_AND_QUERY_BALANCE_TIMEOUT, + ); + t.log('Account Balances Query Offer Result', offerResult); + + const { icqEnabled } = (chainInfo as Record)[ + chainName + ]; + t.log( + icqEnabled + ? 'ICQ Enabled expecting offer result.' + : 'ICQ Disabled expecting offer error', + ); + + const { + status: { result, error }, + } = offerResult; + if (icqEnabled) { + t.is(error, undefined, 'No error observed for supported chain'); + const balances = JSON.parse(result); + t.truthy(balances, 'Result is parsed successfully'); + t.true(Array.isArray(balances), 'Balances is an array'); + t.is(balances.length, 0, 'Balances are empty'); + } else { + t.truthy(error, 'Error observed for unsupported chain'); + t.regex( + error, + /Queries not available for chain/i, + 'Correct error message for unsupported chain', + ); + } + }, +}); + +const queryAccountBalance = test.macro({ + title: (_, chainName: string) => `Query Account Balance on ${chainName}`, + exec: async (t, chainName: string) => { + const config = chainConfig[chainName]; + if (!config) return t.fail(`Unknown chain: ${chainName}`); + const { + wallets, + provisionSmartWallet, + vstorageClient, + retryUntilCondition, + useChain, + } = t.context; + + const { + chainInfo: { + chain: { staking }, + }, + } = useChain(chainName); + const denom = staking?.staking_tokens?.[0].denom; + if (!denom) throw Error(`no denom for ${chainName}`); + + const agoricAddr = wallets[chainName]; + const wdUser1 = await provisionSmartWallet(agoricAddr, { + BLD: 100n, + IST: 100n, + }); + t.log(`provisioning agoric smart wallet for ${agoricAddr}`); + + const doOffer = makeDoOffer(wdUser1); + t.log(`${chainName} makeAccountAndGetBalanceQuery offer`); + const offerId = `${chainName}-makeAccountAndGetBalanceQuery-${Date.now()}`; + + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeAccountAndGetBalanceQueryInvitation']], + }, + offerArgs: { chainName, denom }, + proposal: {}, + }); + + const offerResult = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}`), + ({ status }) => status.id === offerId && (status.result || status.error), + `${offerId} offer result is in vstorage`, + MAKE_ACCOUNT_AND_QUERY_BALANCE_TIMEOUT, + ); + t.log('Account Balance Query Offer Result', offerResult); + const { icqEnabled } = (chainInfo as Record)[ + chainName + ]; + t.log( + icqEnabled + ? 'ICQ Enabled, expecting offer result.' + : 'ICQ Disabled, expecting offer error', + ); + + const { + status: { result, error }, + } = offerResult; + if (icqEnabled) { + t.is(error, undefined, 'No error observed for supported chain'); + const parsedBalance = JSON.parse(result); + t.truthy(parsedBalance, 'Result is parsed successfully'); + + t.truthy(parsedBalance, 'Balance object exists'); + t.is(parsedBalance.denom, denom, 'Correct denom in balance'); + t.is(parsedBalance.value, '[0n]', 'Balance amount is 0n'); + } else { + t.truthy(error, 'Error observed for unsupported chain'); + t.regex( + error, + /Queries not available for chain/i, + 'Correct error message for unsupported chain', + ); + } + }, +}); + +test.serial(queryAccountBalances, 'osmosis'); +test.serial(queryAccountBalances, 'cosmoshub'); +test.serial(queryAccountBalance, 'osmosis'); +test.serial(queryAccountBalance, 'cosmoshub'); diff --git a/multichain-testing/test/chain-queries.test.ts b/multichain-testing/test/chain-queries.test.ts index 2b4543a4059..70d5e73bbf4 100644 --- a/multichain-testing/test/chain-queries.test.ts +++ b/multichain-testing/test/chain-queries.test.ts @@ -22,9 +22,9 @@ const test = anyTest as TestFn; const accounts = ['osmosis', 'cosmoshub', 'agoric']; -const contractName = 'basicFlows'; +const contractName = 'queryFlows'; const contractBuilder = - '../packages/builders/scripts/orchestration/init-basic-flows.js'; + '../packages/builders/scripts/testing/start-query-flows.js'; test.before(async t => { const { deleteTestKeys, setupTestKeys, ...rest } = await commonSetup(t); @@ -110,7 +110,7 @@ const queryICQChain = test.macro({ const offerResult = await retryUntilCondition( () => vstorageClient.queryData(`published.wallet.${agoricAddr}`), ({ status }) => status.id === offerId && (status.result || status.error), - `${offerId} continuing invitation is in vstorage`, + `${offerId} offer result is in vstorage`, { maxRetries: 15, }, diff --git a/multichain-testing/test/config.ts b/multichain-testing/test/config.ts index a8619398f73..77a07b3396d 100644 --- a/multichain-testing/test/config.ts +++ b/multichain-testing/test/config.ts @@ -30,3 +30,13 @@ export const AUTO_STAKE_IT_DELEGATIONS_TIMEOUT: RetryOptions = { retryIntervalMs: 5000, maxRetries: 24, }; + +/** + * Wait about 90s to ensure: + * - ICA Account is created + * - ICQ Connection is established (in some instances) + * - Query is executed (sometimes local, sometimes via ICQ) + */ +export const MAKE_ACCOUNT_AND_QUERY_BALANCE_TIMEOUT: RetryOptions = { + maxRetries: 25, +}; diff --git a/packages/builders/scripts/testing/start-query-flows.js b/packages/builders/scripts/testing/start-query-flows.js new file mode 100644 index 00000000000..fc7e947bb7e --- /dev/null +++ b/packages/builders/scripts/testing/start-query-flows.js @@ -0,0 +1,134 @@ +/** + * @file A proposal to start the query-flows contract. + * + * QueryFlows is a testing fixture that publishes query results to vstorage. + * It's purpose is to support E2E testing. + */ +import { deeplyFulfilledObject, makeTracer } from '@agoric/internal'; +import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; +import { E } from '@endo/far'; + +/** + * @import {QueryFlowsSF as StartFn} from '@agoric/orchestration/src/fixtures/query-flows.contract.js'; + */ + +const contractName = 'queryFlows'; +const trace = makeTracer(contractName, true); + +/** + * See `@agoric/builders/builders/scripts/orchestration/init-query-flows.js` for + * the accompanying proposal builder. Run `agoric run + * packages/builders/scripts/orchestration/init-query-flows.js` to build the + * contract and proposal files. + * + * @param {BootstrapPowers & { + * installation: { + * consume: { + * queryFlows: Installation; + * }; + * }; + * }} powers + */ +export const startQueryFlows = async ({ + consume: { + agoricNames, + board, + chainStorage, + chainTimerService, + cosmosInterchainService, + localchain, + startUpgradable, + }, + installation: { + consume: { [contractName]: installation }, + }, + instance: { + // @ts-expect-error unknown instance + produce: { [contractName]: produceInstance }, + }, +}) => { + trace(`start ${contractName}`); + + const storageNode = await makeStorageNodeChild(chainStorage, contractName); + const marshaller = await E(board).getPublishingMarshaller(); + + /** @type {StartUpgradableOpts} */ + const startOpts = { + label: 'queryFlows', + installation, + terms: undefined, + privateArgs: await deeplyFulfilledObject( + harden({ + agoricNames, + orchestrationService: cosmosInterchainService, + localchain, + storageNode, + marshaller, + timerService: chainTimerService, + }), + ), + }; + + const { instance } = await E(startUpgradable)(startOpts); + produceInstance.resolve(instance); +}; +harden(startQueryFlows); + +export const getManifestForContract = ( + { restoreRef }, + { installKeys, ...options }, +) => { + return { + manifest: { + [startQueryFlows.name]: { + consume: { + agoricNames: true, + board: true, + chainStorage: true, + chainTimerService: true, + cosmosInterchainService: true, + localchain: true, + startUpgradable: true, + }, + installation: { + consume: { [contractName]: true }, + }, + instance: { + produce: { [contractName]: true }, + }, + }, + }, + installations: { + [contractName]: restoreRef(installKeys[contractName]), + }, + options, + }; +}; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').CoreEvalBuilder} */ +export const defaultProposalBuilder = async ({ publishRef, install }) => { + return harden({ + // Somewhat unorthodox, source the exports from this builder module + sourceSpec: '@agoric/builders/scripts/testing/start-query-flows.js', + getManifestCall: [ + 'getManifestForContract', + { + installKeys: { + queryFlows: publishRef( + install( + '@agoric/orchestration/src/fixtures/query-flows.contract.js', + ), + ), + }, + }, + ], + }); +}; + +export default async (homeP, endowments) => { + // import dynamically so the module can work in CoreEval environment + const dspModule = await import('@agoric/deploy-script-support'); + const { makeHelpers } = dspModule; + const { writeCoreEval } = await makeHelpers(homeP, endowments); + await writeCoreEval(startQueryFlows.name, defaultProposalBuilder); +}; diff --git a/packages/orchestration/src/examples/basic-flows.contract.js b/packages/orchestration/src/examples/basic-flows.contract.js index e52cbb59542..b1ff6b8613b 100644 --- a/packages/orchestration/src/examples/basic-flows.contract.js +++ b/packages/orchestration/src/examples/basic-flows.contract.js @@ -40,10 +40,6 @@ const contract = async ( M.interface('Basic Flows PF', { makeOrchAccountInvitation: M.callWhen().returns(InvitationShape), makePortfolioAccountInvitation: M.callWhen().returns(InvitationShape), - makeSendICQQueryInvitation: M.callWhen().returns(InvitationShape), - makeAccountAndSendBalanceQueryInvitation: - M.callWhen().returns(InvitationShape), - makeSendLocalQueryInvitation: M.callWhen().returns(InvitationShape), }), { makeOrchAccountInvitation() { @@ -58,24 +54,6 @@ const contract = async ( 'Make an Orchestration Account', ); }, - makeSendICQQueryInvitation() { - return zcf.makeInvitation( - orchFns.sendICQQuery, - 'Submit a query to a remote chain', - ); - }, - makeAccountAndSendBalanceQueryInvitation() { - return zcf.makeInvitation( - orchFns.makeAccountAndSendBalanceQuery, - 'Make an account and submit a balance query', - ); - }, - makeSendLocalQueryInvitation() { - return zcf.makeInvitation( - orchFns.sendLocalQuery, - 'Submit a query to the local chain', - ); - }, }, ); diff --git a/packages/orchestration/src/examples/basic-flows.flows.js b/packages/orchestration/src/examples/basic-flows.flows.js index b0d1c62822a..2948d3c6c49 100644 --- a/packages/orchestration/src/examples/basic-flows.flows.js +++ b/packages/orchestration/src/examples/basic-flows.flows.js @@ -1,22 +1,17 @@ /** - * @file Primarily a testing fixture, but also serves as an example of how to - * leverage basic functionality of the Orchestration API with async-flow. + * @file An example of how to leverage basic functionality of the Orchestration + * API with async-flow. Each offer returning a ContinuingOfferResult, with + * invitationMakers for Delegate, WithdrawRewards, Transfer, etc. */ import { makeTracer } from '@agoric/internal'; -import { Fail, q } from '@endo/errors'; import { M, mustMatch } from '@endo/patterns'; const trace = makeTracer('BasicFlows'); /** - * @import {Chain, DenomArg, OrchestrationAccount, OrchestrationFlow, Orchestrator, KnownChains, OrchestrationAccountI, ICQQueryFunction, CosmosChainInfo} from '@agoric/orchestration'; + * @import {OrchestrationAccount, OrchestrationFlow, Orchestrator} from '@agoric/orchestration'; * @import {ResolvedPublicTopic} from '@agoric/zoe/src/contractSupport/topics.js'; - * @import {JsonSafe} from '@agoric/cosmic-proto'; - * @import {QueryManyFn} from '@agoric/vats/src/localchain.js'; - * @import {RequestQuery} from '@agoric/cosmic-proto/tendermint/abci/types.js'; - * @import {OrchestrationPowers} from '../utils/start-helper.js'; * @import {MakePortfolioHolder} from '../exos/portfolio-holder-kit.js'; - * @import {OrchestrationTools} from '../utils/start-helper.js'; */ /** @@ -30,6 +25,7 @@ const trace = makeTracer('BasicFlows'); * @param {{ chainName: string }} offerArgs */ export const makeOrchAccount = async (orch, _ctx, seat, { chainName }) => { + trace('makeOrchAccount', chainName); seat.exit(); // no funds exchanged mustMatch(chainName, M.string()); const remoteChain = await orch.getChain(chainName); @@ -57,6 +53,7 @@ export const makePortfolioAccount = async ( seat, { chainNames }, ) => { + trace('makePortfolioAccount', chainNames); seat.exit(); // no funds exchanged mustMatch(chainNames, M.arrayOf(M.string())); const allChains = await Promise.all(chainNames.map(n => orch.getChain(n))); @@ -85,84 +82,3 @@ export const makePortfolioAccount = async ( return portfolioHolder.asContinuingOffer(); }; harden(makePortfolioAccount); - -/** - * Send a query to a remote chain and get the response back in an offer result. - * This invitation is for testing only. In a real scenario it's better to use an - * RPC or API client and vstorage to retrieve data for a frontend. Queries - * should only be leveraged if contract logic requires it. - * - * @satisfies {OrchestrationFlow} - * @param {Orchestrator} orch - * @param {any} _ctx - * @param {ZCFSeat} seat - * @param {{ chainName: string; msgs: Parameters[0] }} offerArgs - */ -export const sendICQQuery = async (orch, _ctx, seat, { chainName, msgs }) => { - seat.exit(); // no funds exchanged - mustMatch(chainName, M.string()); - if (chainName === 'agoric') throw Fail`ICQ not supported on local chain`; - const remoteChain = - /** @type {Chain} */ ( - await orch.getChain(chainName) - ); - const queryResponse = await remoteChain.query(msgs); - trace('SendICQQuery response:', queryResponse); - // `quote` to ensure offerResult (array) is visible in smart-wallet - return q(queryResponse).toString(); -}; -harden(sendICQQuery); - -/** - * Create an account and send a query and get the response back in an offer - * result. Like `sendQuery`, this invitation is for testing only. In a real - * scenario it doesn't make much sense to send a query immediately after the - * account is created - it won't have any funds. - * - * @satisfies {OrchestrationFlow} - * @param {Orchestrator} orch - * @param {any} _ctx - * @param {ZCFSeat} seat - * @param {{ chainName: string; denom: DenomArg }} offerArgs - */ -export const makeAccountAndSendBalanceQuery = async ( - orch, - _ctx, - seat, - { chainName, denom }, -) => { - seat.exit(); // no funds exchanged - mustMatch(chainName, M.string()); - if (chainName === 'agoric') throw Fail`ICQ not supported on local chain`; - const remoteChain = await orch.getChain(chainName); - const orchAccount = await remoteChain.makeAccount(); - const queryResponse = await orchAccount.getBalance(denom); - trace('ICQ Balance Query response:', queryResponse); - // `quote` to ensure offerResult (record) is visible in smart-wallet - return q(queryResponse).toString(); -}; -harden(makeAccountAndSendBalanceQuery); - -/** - * Send a query to the local chain and get the response back in an offer result. - * This invitation is for testing only. In a real scenario it's better to use an - * RPC or API client and vstorage to retrieve data for a frontend. Queries - * should only be leveraged if contract logic requires it. - * - * @satisfies {OrchestrationFlow} - * @param {Orchestrator} orch - * @param {any} _ctx - * @param {ZCFSeat} seat - * @param {{ - * msgs: Parameters[0]; - * }} offerArgs - */ -export const sendLocalQuery = async (orch, _ctx, seat, { msgs }) => { - seat.exit(); // no funds exchanged - const remoteChain = await orch.getChain('agoric'); - const queryResponse = await remoteChain.query(msgs); - trace('Local Query response:', queryResponse); - // `quote` to ensure offerResult (array) is visible in smart-wallet - return q(queryResponse).toString(); -}; -harden(sendLocalQuery); diff --git a/packages/orchestration/src/exos/README.md b/packages/orchestration/src/exos/README.md index b49eeddc8e3..b7858878804 100644 --- a/packages/orchestration/src/exos/README.md +++ b/packages/orchestration/src/exos/README.md @@ -1,6 +1,6 @@ # Exo structure -Last verified 2024-07-31 +Last verified 2024-08-30 ```mermaid classDiagram @@ -117,6 +117,7 @@ classDiagram executeEncodedTx() getAddress() getBalance() + getBalances() getPublicTopics() redelegate() send() diff --git a/packages/orchestration/src/exos/cosmos-orchestration-account.js b/packages/orchestration/src/exos/cosmos-orchestration-account.js index 47d4f792d45..576532eaf8b 100644 --- a/packages/orchestration/src/exos/cosmos-orchestration-account.js +++ b/packages/orchestration/src/exos/cosmos-orchestration-account.js @@ -3,6 +3,8 @@ import { toRequestQueryJson } from '@agoric/cosmic-proto'; import { QueryBalanceRequest, QueryBalanceResponse, + QueryAllBalancesRequest, + QueryAllBalancesResponse, } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; import { MsgSend } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/tx.js'; import { @@ -22,7 +24,7 @@ import { Shape as NetworkShape } from '@agoric/network'; import { M } from '@agoric/vat-data'; import { VowShape } from '@agoric/vow'; import { decodeBase64 } from '@endo/base64'; -import { Fail } from '@endo/errors'; +import { Fail, makeError, q } from '@endo/errors'; import { E } from '@endo/far'; import { AmountArgShape, @@ -146,6 +148,11 @@ export const prepareCosmosOrchestrationAccountKit = ( .optional(M.arrayOf(M.undefined())) // empty context .returns(M.or(M.record(), M.undefined())), }), + allBalancesQueryWatcher: M.interface('allBalancesQueryWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())).returns( + M.arrayOf(M.record()), + ), + }), undelegateWatcher: M.interface('undelegateWatcher', { onFulfilled: M.call(M.string()) .optional(M.arrayOf(M.undefined())) // empty context @@ -259,6 +266,29 @@ export const prepareCosmosOrchestrationAccountKit = ( return harden(toDenomAmount(balance)); }, }, + allBalancesQueryWatcher: { + /** + * @param {JsonSafe[]} results + */ + onFulfilled([result]) { + let response; + try { + response = QueryAllBalancesResponse.decode( + // note: an empty string for result.key is a valid result + decodeBase64(result.key), + ); + } catch (cause) { + throw makeError( + `Error parsing QueryAllBalances result ${q(result)}`, + undefined, + { cause }, + ); + } + const { balances } = response; + if (!balances) throw Fail`Result lacked balances key: ${q(result)}`; + return harden(balances.map(coin => toDenomAmount(coin))); + }, + }, undelegateWatcher: { /** * @param {string} result @@ -514,11 +544,7 @@ export const prepareCosmosOrchestrationAccountKit = ( return watch(results, this.facets.returnVoidWatcher); }); }, - /** @type {HostOf} */ - getBalances() { - // TODO https://github.com/Agoric/agoric-sdk/issues/9610 - return asVow(() => Fail`not yet implemented`); - }, + /** @type {HostOf} */ redelegate(srcValidator, dstValidator, amount) { return asVow(() => { @@ -565,7 +591,7 @@ export const prepareCosmosOrchestrationAccountKit = ( return asVow(() => { const { chainAddress, icqConnection } = this.state; if (!icqConnection) { - throw Fail`Queries not available for chain ${chainAddress.chainId}`; + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; } const results = E(icqConnection).query([ toRequestQueryJson( @@ -579,6 +605,24 @@ export const prepareCosmosOrchestrationAccountKit = ( }); }, + /** @type {HostOf} */ + getBalances() { + return asVow(() => { + const { chainAddress, icqConnection } = this.state; + if (!icqConnection) { + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; + } + const results = E(icqConnection).query([ + toRequestQueryJson( + QueryAllBalancesRequest.toProtoMsg({ + address: chainAddress.value, + }), + ), + ]); + return watch(results, this.facets.allBalancesQueryWatcher); + }); + }, + /** @type {HostOf} */ send(toAccount, amount) { return asVow(() => { diff --git a/packages/orchestration/src/fixtures/README.md b/packages/orchestration/src/fixtures/README.md new file mode 100644 index 00000000000..cd45ba85cd9 --- /dev/null +++ b/packages/orchestration/src/fixtures/README.md @@ -0,0 +1,3 @@ +# Fixtures + +This directory contains contracts used for testing. They are NOT representative of patterns we'd like to use in production, and only exist to facilitate testing. diff --git a/packages/orchestration/src/fixtures/query-flows.contract.js b/packages/orchestration/src/fixtures/query-flows.contract.js new file mode 100644 index 00000000000..d8e1c8eb271 --- /dev/null +++ b/packages/orchestration/src/fixtures/query-flows.contract.js @@ -0,0 +1,70 @@ +/** + * @file Testing fixture for Local and Interchain Queries + */ +import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; +import { M } from '@endo/patterns'; +import { withOrchestration } from '../utils/start-helper.js'; +import * as flows from './query-flows.flows.js'; + +/** + * @import {Zone} from '@agoric/zone'; + * @import {OrchestrationPowers} from '..//utils/start-helper.js'; + * @import {OrchestrationTools} from '../utils/start-helper.js'; + */ + +/** + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * }} _privateArgs + * @param {Zone} zone + * @param {OrchestrationTools} tools + */ +const contract = async (zcf, _privateArgs, zone, { orchestrateAll }) => { + const orchFns = orchestrateAll(flows, {}); + + const publicFacet = zone.exo( + 'Query Flows Public Facet', + M.interface('Query Flows PF', { + makeSendICQQueryInvitation: M.callWhen().returns(InvitationShape), + makeAccountAndGetBalanceQueryInvitation: + M.callWhen().returns(InvitationShape), + makeAccountAndGetBalancesQueryInvitation: + M.callWhen().returns(InvitationShape), + makeSendLocalQueryInvitation: M.callWhen().returns(InvitationShape), + }), + { + makeSendICQQueryInvitation() { + return zcf.makeInvitation( + orchFns.sendICQQuery, + 'Submit a query to a remote chain', + ); + }, + makeAccountAndGetBalanceQueryInvitation() { + return zcf.makeInvitation( + orchFns.makeAccountAndGetBalanceQuery, + 'Make an account and submit a balance query', + ); + }, + makeAccountAndGetBalancesQueryInvitation() { + return zcf.makeInvitation( + orchFns.makeAccountAndGetBalancesQuery, + 'Make an account and submit a balance query', + ); + }, + makeSendLocalQueryInvitation() { + return zcf.makeInvitation( + orchFns.sendLocalQuery, + 'Submit a query to the local chain', + ); + }, + }, + ); + + return { publicFacet }; +}; + +export const start = withOrchestration(contract); +harden(start); + +/** @typedef {typeof start} QueryFlowsSF */ diff --git a/packages/orchestration/src/fixtures/query-flows.flows.js b/packages/orchestration/src/fixtures/query-flows.flows.js new file mode 100644 index 00000000000..99226aa537a --- /dev/null +++ b/packages/orchestration/src/fixtures/query-flows.flows.js @@ -0,0 +1,124 @@ +/** + * @file Testing fixture for Local and Interchain Queries + */ +import { makeTracer } from '@agoric/internal'; +import { Fail, q } from '@endo/errors'; +import { M, mustMatch } from '@endo/patterns'; + +const trace = makeTracer('BasicFlows'); + +/** + * @import {Chain, DenomArg, OrchestrationFlow, Orchestrator, ICQQueryFunction, CosmosChainInfo} from '@agoric/orchestration'; + * @import {QueryManyFn} from '@agoric/vats/src/localchain.js'; + */ + +/** + * Send a query to a remote chain and get the response back in an offer result. + * This invitation is for testing only. In a real scenario it's better to use an + * RPC or API client and vstorage to retrieve data for a frontend. Queries + * should only be leveraged if contract logic requires it. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {any} _ctx + * @param {ZCFSeat} seat + * @param {{ chainName: string; msgs: Parameters[0] }} offerArgs + */ +export const sendICQQuery = async (orch, _ctx, seat, { chainName, msgs }) => { + seat.exit(); // no funds exchanged + mustMatch(chainName, M.string()); + if (chainName === 'agoric') throw Fail`ICQ not supported on local chain`; + const remoteChain = + /** @type {Chain} */ ( + await orch.getChain(chainName) + ); + const queryResponse = await remoteChain.query(msgs); + trace('SendICQQuery response:', queryResponse); + // `quote` to ensure offerResult (array) is visible in smart-wallet + return q(queryResponse).toString(); +}; +harden(sendICQQuery); + +/** + * Create an account, send a balance query, and get the response back in an + * offer result. Like `sendQuery`, this invitation is for testing only. In a + * real scenario it doesn't make much sense to send a query immediately after + * the account is created - it won't have any funds. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {any} _ctx + * @param {ZCFSeat} seat + * @param {{ chainName: string; denom: DenomArg }} offerArgs + */ +export const makeAccountAndGetBalanceQuery = async ( + orch, + _ctx, + seat, + { chainName, denom }, +) => { + seat.exit(); // no funds exchanged + mustMatch(chainName, M.string()); + if (chainName === 'agoric') throw Fail`ICQ not supported on local chain`; + const remoteChain = await orch.getChain(chainName); + const orchAccount = await remoteChain.makeAccount(); + const queryResponse = await orchAccount.getBalance(denom); + trace('ICQ Balance Query response:', queryResponse); + // `quote` to ensure offerResult (record) is visible in smart-wallet + return q(queryResponse).toString(); +}; +harden(makeAccountAndGetBalanceQuery); + +/** + * Create an account, send an all balances query, and get the response back in + * an offer result. Like `sendQuery`, this invitation is for testing only. In a + * real scenario it doesn't make much sense to send a query immediately after + * the account is created - it won't have any funds. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {any} _ctx + * @param {ZCFSeat} seat + * @param {{ chainName: string; denom: DenomArg }} offerArgs + */ +export const makeAccountAndGetBalancesQuery = async ( + orch, + _ctx, + seat, + { chainName }, +) => { + seat.exit(); // no funds exchanged + mustMatch(chainName, M.string()); + if (chainName === 'agoric') throw Fail`ICQ not supported on local chain`; + const remoteChain = await orch.getChain(chainName); + const orchAccount = await remoteChain.makeAccount(); + const queryResponse = await orchAccount.getBalances(); + trace('ICQ All Balances Query response:', queryResponse); + // `quote` to ensure offerResult (record) is visible in smart-wallet + return q(queryResponse).toString(); +}; +harden(makeAccountAndGetBalancesQuery); + +/** + * Send a query to the local chain and get the response back in an offer result. + * This invitation is for testing only. In a real scenario it's better to use an + * RPC or API client and vstorage to retrieve data for a frontend. Queries + * should only be leveraged if contract logic requires it. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {any} _ctx + * @param {ZCFSeat} seat + * @param {{ + * msgs: Parameters[0]; + * }} offerArgs + */ +export const sendLocalQuery = async (orch, _ctx, seat, { msgs }) => { + seat.exit(); // no funds exchanged + const remoteChain = await orch.getChain('agoric'); + const queryResponse = await remoteChain.query(msgs); + trace('Local Query response:', queryResponse); + // `quote` to ensure offerResult (array) is visible in smart-wallet + return q(queryResponse).toString(); +}; +harden(sendLocalQuery); diff --git a/packages/orchestration/test/examples/basic-flows.contract.test.ts b/packages/orchestration/test/examples/basic-flows.contract.test.ts index c71374130c8..037e8de3494 100644 --- a/packages/orchestration/test/examples/basic-flows.contract.test.ts +++ b/packages/orchestration/test/examples/basic-flows.contract.test.ts @@ -4,14 +4,6 @@ import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; import { E, getInterfaceOf } from '@endo/far'; import path from 'path'; -import { JsonSafe, toRequestQueryJson, typedJson } from '@agoric/cosmic-proto'; -import { - QueryBalanceRequest, - QueryBalanceResponse, -} from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; -import type { ResponseQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; -import { decodeBase64 } from '@endo/base64'; -import { LOCALCHAIN_DEFAULT_ADDRESS } from '@agoric/vats/tools/fake-bridge.js'; import { commonSetup } from '../supports.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); @@ -28,9 +20,6 @@ type TestContext = Awaited> & { const test = anyTest as TestFn; -const decodeBalanceQueryResponse = (results: JsonSafe[]) => - results.map(({ key }) => QueryBalanceResponse.decode(decodeBase64(key))); - test.beforeEach(async t => { const setupContext = await commonSetup(t); const { @@ -99,182 +88,3 @@ const orchestrationAccountScenario = test.macro({ test(orchestrationAccountScenario, 'agoric'); test(orchestrationAccountScenario, 'cosmoshub'); - -test('send query from chain object', async t => { - const { - bootstrap: { vowTools: vt }, - zoe, - instance, - utils: { inspectDibcBridge }, - } = t.context; - const publicFacet = await E(zoe).getPublicFacet(instance); - const balanceQuery = toRequestQueryJson( - QueryBalanceRequest.toProtoMsg({ - address: 'cosmos1test', - denom: 'uatom', - }), - ); - { - t.log('send query on chain with icqEnabled: true'); - const inv = E(publicFacet).makeSendICQQueryInvitation(); - const userSeat = E(zoe).offer(inv, {}, undefined, { - chainName: 'osmosis', - msgs: [balanceQuery], - }); - const offerResultString = await vt.when(E(userSeat).getOfferResult()); - const offerResult = JSON.parse(offerResultString); - t.log(offerResult); - t.assert(offerResult[0].key, 'base64 encoded response returned'); - const decodedResponse = decodeBalanceQueryResponse(offerResult); - t.deepEqual(decodedResponse, [ - { - balance: { - amount: '0', - denom: 'uatom', - }, - }, - ]); - } - { - t.log('send query on chain with icqEnabled: false'); - const inv = E(publicFacet).makeSendICQQueryInvitation(); - const userSeat = E(zoe).offer(inv, {}, undefined, { - chainName: 'cosmoshub', - msgs: [balanceQuery], - }); - await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { - message: 'Queries not available for chain "cosmoshub-4"', - }); - } - { - t.log('sending subsequent queries should not result in new ICQ channels'); - const inv = E(publicFacet).makeSendICQQueryInvitation(); - const userSeat = E(zoe).offer(inv, {}, undefined, { - chainName: 'osmosis', - msgs: [balanceQuery], - }); - await vt.when(E(userSeat).getOfferResult()); - const { bridgeDowncalls } = await inspectDibcBridge(); - - const portBindings = bridgeDowncalls.filter(x => x.method === 'bindPort'); - t.is(portBindings.length, 1, 'only one port bound'); - t.regex(portBindings?.[0]?.packet.source_port, /icqcontroller-/); - const icqChannelInits = bridgeDowncalls.filter( - x => x.method === 'startChannelOpenInit' && x.version === 'icq-1', - ); - t.is(icqChannelInits.length, 1, 'only one ICQ channel opened'); - const sendPacketCalls = bridgeDowncalls.filter( - x => - x.method === 'sendPacket' && x.packet.source_port === 'icqcontroller-1', - ); - t.is(sendPacketCalls.length, 2, 'sent two queries'); - } - const proto3JsonQuery = typedJson( - '/cosmos.bank.v1beta1.QueryAllBalancesRequest', - { - address: LOCALCHAIN_DEFAULT_ADDRESS, - }, - ); - { - t.log('send a query from the localchain'); - const inv = E(publicFacet).makeSendLocalQueryInvitation(); - const userSeat = E(zoe).offer(inv, {}, undefined, { - msgs: [proto3JsonQuery], - }); - const offerResultString = await vt.when(E(userSeat).getOfferResult()); - const offerResult = JSON.parse(offerResultString); - t.log(offerResult); - t.deepEqual( - offerResult, - [ - { - error: '', - height: '1', - reply: { - '@type': '/cosmos.bank.v1beta1.QueryAllBalancesResponse', - balances: [ - { denom: 'ubld', amount: '10' }, - { denom: 'uist', amount: '10' }, - ], - pagination: { - nextKey: null, - total: '2', - }, - }, - }, - ], - 'balances returned', - ); - } - { - t.log('remote chain facade guards offer with M.arrayOf(ICQMsgShape)'); - const inv = E(publicFacet).makeSendICQQueryInvitation(); - const userSeat = E(zoe).offer(inv, {}, undefined, { - chainName: 'osmosis', - // @ts-expect-error intentional error - msgs: [proto3JsonQuery], - }); - await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { - message: /.*Must have missing properties \["path","data"\]/, - }); - } -}); - -test('send query from orch account in an async-flow', async t => { - const { - bootstrap: { vowTools: vt }, - zoe, - instance, - utils: { inspectDibcBridge }, - } = t.context; - const publicFacet = await E(zoe).getPublicFacet(instance); - - { - t.log('send query from orchAccount on chain with icqEnabled: true'); - const inv = E(publicFacet).makeAccountAndSendBalanceQueryInvitation(); - const userSeat = E(zoe).offer(inv, {}, undefined, { - chainName: 'osmosis', - denom: 'uatom', - }); - const offerResultString = await vt.when(E(userSeat).getOfferResult()); - const offerResult = JSON.parse(offerResultString); - t.deepEqual(offerResult, { - value: '[0n]', - denom: 'uatom', - }); - } - { - t.log('send query from orchAccount that times out'); - const inv = E(publicFacet).makeAccountAndSendBalanceQueryInvitation(); - const userSeat = E(zoe).offer(inv, {}, undefined, { - chainName: 'osmosis', - denom: 'notarealdenom', - }); - await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { - message: 'ABCI code: 5: error handling packet: see events for details', - }); - } - { - t.log('send query from orchAccount on chain with icqEnabled: false'); - const inv = E(publicFacet).makeAccountAndSendBalanceQueryInvitation(); - const userSeat = E(zoe).offer(inv, {}, undefined, { - chainName: 'cosmoshub', - denom: 'uatom,', - }); - await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { - message: 'Queries not available for chain "cosmoshub-4"', - }); - } - - t.log("creating add'l account should not result in new ICQ channels"); - const { bridgeDowncalls } = await inspectDibcBridge(); - const icqPortBindings = bridgeDowncalls.filter( - x => - x.method === 'bindPort' && x.packet.source_port.includes('icqcontroller'), - ); - t.is(icqPortBindings.length, 1, 'only one icq port bound'); - const icqChannelInits = bridgeDowncalls.filter( - x => x.method === 'startChannelOpenInit' && x.version === 'icq-1', - ); - t.is(icqChannelInits.length, 1, 'only one ICQ channel opened'); -}); diff --git a/packages/orchestration/test/examples/snapshots/stake-ica.contract.test.ts.md b/packages/orchestration/test/examples/snapshots/stake-ica.contract.test.ts.md index e93a0cae30a..e878c3d19ff 100644 --- a/packages/orchestration/test/examples/snapshots/stake-ica.contract.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/stake-ica.contract.test.ts.md @@ -4,7 +4,7 @@ The actual snapshot is saved in `stake-ica.contract.test.ts.snap`. Generated by [AVA](https://avajs.dev). -## makeAccount, getAddress, getBalances, getBalance +## makeAccount, getAddress > accounts in vstorage diff --git a/packages/orchestration/test/examples/snapshots/stake-ica.contract.test.ts.snap b/packages/orchestration/test/examples/snapshots/stake-ica.contract.test.ts.snap index 96291e4fd79..171d95509f7 100644 Binary files a/packages/orchestration/test/examples/snapshots/stake-ica.contract.test.ts.snap and b/packages/orchestration/test/examples/snapshots/stake-ica.contract.test.ts.snap differ diff --git a/packages/orchestration/test/examples/stake-ica.contract.test.ts b/packages/orchestration/test/examples/stake-ica.contract.test.ts index ffcf19973cb..fcbe90cc0ab 100644 --- a/packages/orchestration/test/examples/stake-ica.contract.test.ts +++ b/packages/orchestration/test/examples/stake-ica.contract.test.ts @@ -52,7 +52,6 @@ const startContract = async ({ timer, marshaller, storage, - vowTools, issuerKeywordRecord = undefined, terms = getChainTerms('cosmoshub'), storagePath = 'stakeAtom', @@ -76,7 +75,7 @@ const startContract = async ({ return { publicFacet, zoe }; }; -test('makeAccount, getAddress, getBalances, getBalance', async t => { +test('makeAccount, getAddress', async t => { const { bootstrap, mocks } = await commonSetup(t); { // stakeAtom @@ -89,14 +88,6 @@ test('makeAccount, getAddress, getBalances, getBalance', async t => { t.regex(chainAddress.value, /cosmos1/); t.like(chainAddress, { chainId: 'cosmoshub-4', encoding: 'bech32' }); - await t.throwsAsync(E(account).getBalances(), { - message: 'not yet implemented', - }); - - await t.throwsAsync(E(account).getBalance('uatom'), { - message: 'Queries not available for chain "cosmoshub-4"', - }); - const accountP = E(publicFacet).makeAccount(); const { value: address2 } = await E(accountP).getAddress(); t.regex(address2, /cosmos1/); @@ -117,23 +108,6 @@ test('makeAccount, getAddress, getBalances, getBalance', async t => { const chainAddress = await E(account).getAddress(); t.regex(chainAddress.value, /osmo1/); t.like(chainAddress, { chainId: 'osmosis-1' }); - - const buildMocks = () => { - const balanceReq = buildQueryPacketString([ - QueryBalanceRequest.toProtoMsg({ - address: chainAddress.value, - denom: 'uosmo', - }), - ]); - const balanceResp = buildQueryResponseString(QueryBalanceResponse, { - balance: { amount: '0', denom: 'uosmo' }, - }); - return { [balanceReq]: balanceResp }; - }; - await E(ibcBridge).setMockAck(buildMocks()); - - const balance = await E(account).getBalance('uosmo'); - t.deepEqual(balance, { denom: 'uosmo', value: 0n }); } t.snapshot(bootstrap.storage.data.entries(), 'accounts in vstorage'); diff --git a/packages/orchestration/test/exos/cosmos-orchestration-account.test.ts b/packages/orchestration/test/exos/cosmos-orchestration-account.test.ts index 02f6d32066f..17858cc9387 100644 --- a/packages/orchestration/test/exos/cosmos-orchestration-account.test.ts +++ b/packages/orchestration/test/exos/cosmos-orchestration-account.test.ts @@ -8,15 +8,27 @@ import { MsgTransferResponse, } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/tx.js'; import { SIMULATED_ERRORS } from '@agoric/vats/tools/fake-bridge.js'; +import { + QueryAllBalancesRequest, + QueryAllBalancesResponse, + QueryBalanceRequest, + QueryBalanceResponse, +} from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; import { commonSetup } from '../supports.js'; -import type { AmountArg, ChainAddress } from '../../src/orchestration-api.js'; +import type { + AmountArg, + ChainAddress, + Denom, +} from '../../src/orchestration-api.js'; import { prepareMakeTestCOAKit } from './make-test-coa-kit.js'; import { buildMsgResponseString, + buildQueryPacketString, + buildQueryResponseString, buildTxPacketString, parseOutgoingTxPacket, } from '../../tools/ibc-mocks.js'; -import { defaultMockAckMap } from '../ibc-mocks.js'; type TestContext = Awaited>; @@ -29,7 +41,6 @@ test.beforeEach(async t => { test('send (to addr on same chain)', async t => { const { brands: { ist }, - facadeServices: { chainHub }, utils: { inspectDibcBridge }, } = t.context; const makeTestCOAKit = prepareMakeTestCOAKit(t, t.context); @@ -301,14 +312,115 @@ test('transfer', async t => { ); }); +test('getBalance and getBalances', async t => { + const { + mocks: { ibcBridge }, + } = t.context; + const makeTestCOAKit = prepareMakeTestCOAKit(t, t.context); + + const buildMocks = () => { + const makeBalanceReq = ( + address: ChainAddress['value'] = 'osmo1test', + denom: Denom = 'uosmo', + ) => + buildQueryPacketString([ + QueryBalanceRequest.toProtoMsg({ + address, + denom, + }), + ]); + const makeAllBalanceReq = (address: ChainAddress['value'] = 'osmo1test') => + buildQueryPacketString([ + QueryAllBalancesRequest.toProtoMsg({ + address, + }), + ]); + const makeBalanceResp = ( + balance: Coin = { denom: 'usomo', amount: '10' }, + ) => + buildQueryResponseString(QueryBalanceResponse, { + balance, + }); + const makeAllBalanceResp = (balances: Coin[] = []) => + buildQueryResponseString(QueryAllBalancesResponse, { + balances, + }); + + return { + [makeBalanceReq()]: makeBalanceResp({ + denom: 'uosmo', + amount: '10', + }), + [makeAllBalanceReq()]: makeAllBalanceResp([ + { + denom: 'uosmo', + amount: '10', + }, + ]), + [makeBalanceReq('osmo1test1')]: makeBalanceResp({ + denom: 'uosmo', + amount: '0', + }), + [makeAllBalanceReq('osmo1test1')]: makeAllBalanceResp(), + }; + }; + t.log('setting mockAckMap for osmosis balance queries'); + ibcBridge.setMockAck(buildMocks()); + t.log('set address prefix to osmo'); + ibcBridge.setAddressPrefix('osmo'); + + { + t.log('osmo1test mocked to have a 10 uosmo balance'); + const account = await makeTestCOAKit({ + bondDenom: 'uosmo', + chainId: 'osmosis-1', + icqEnabled: true, + }); + t.assert(account, 'account is returned'); + + t.deepEqual(await E(account).getBalance('uosmo'), { + denom: 'uosmo', + value: 10n, + }); + t.deepEqual(await E(account).getBalances(), [ + { denom: 'uosmo', value: 10n }, + ]); + } + + { + t.log('osmo1test1 mocked to have no balances'); + const account = await makeTestCOAKit({ + bondDenom: 'uosmo', + chainId: 'osmosis-1', + icqEnabled: true, + }); + t.assert(account, 'account is returned'); + t.deepEqual(await E(account).getBalance('uosmo'), { + denom: 'uosmo', + value: 0n, + }); + t.deepEqual(await E(account).getBalances(), []); + } + + { + t.log('cosmoshub does not support balance queries'); + t.log('set address prefix to cosmos'); + ibcBridge.setAddressPrefix('cosmos'); + const account = await makeTestCOAKit(); + await t.throwsAsync(E(account).getBalance('uatom'), { + message: 'Queries not available for chain "cosmoshub-4"', + }); + await t.throwsAsync(E(account).getBalances(), { + message: 'Queries not available for chain "cosmoshub-4"', + }); + } +}); + test('not yet implemented', async t => { const makeTestCOAKit = prepareMakeTestCOAKit(t, t.context); const account = await makeTestCOAKit(); const mockAmountArg: AmountArg = { value: 10n, denom: 'uatom' }; - await t.throwsAsync(E(account).getBalances(), { - message: 'not yet implemented', - }); await t.throwsAsync(E(account).transferSteps(mockAmountArg, null as any), { message: 'not yet implemented', }); diff --git a/packages/orchestration/test/exos/make-test-coa-kit.ts b/packages/orchestration/test/exos/make-test-coa-kit.ts index 5817490ca8d..1f6335a1cb8 100644 --- a/packages/orchestration/test/exos/make-test-coa-kit.ts +++ b/packages/orchestration/test/exos/make-test-coa-kit.ts @@ -5,6 +5,7 @@ import { Far } from '@endo/far'; import type { ExecutionContext } from 'ava'; import { prepareCosmosOrchestrationAccount } from '../../src/exos/cosmos-orchestration-account.js'; import { commonSetup } from '../supports.js'; +import type { ICQConnection } from '../../src/exos/icq-connection-kit.js'; /** * A testing utility that creates a (Cosmos)ChainAccount and makes a @@ -19,6 +20,7 @@ export const prepareMakeTestCOAKit = ( { bootstrap, facadeServices, utils }: Awaited>, { zcf = Far('MockZCF', {}) } = {}, ) => { + t.log('exo setup - prepareCosmosOrchestrationAccount'); const { cosmosInterchainService, marshaller, rootZone, timer, vowTools } = bootstrap; @@ -45,9 +47,8 @@ export const prepareMakeTestCOAKit = ( hostConnectionId = 'connection-0' as const, controllerConnectionId = 'connection-1' as const, bondDenom = 'uatom', + icqEnabled = false, } = {}) => { - t.log('exo setup - prepareCosmosOrchestrationAccount'); - t.log('request account from orchestration service'); const cosmosOrchAccount = await E(cosmosInterchainService).makeAccount( chainId, @@ -55,6 +56,14 @@ export const prepareMakeTestCOAKit = ( controllerConnectionId, ); + let icqConnection: ICQConnection | undefined; + if (icqEnabled) { + t.log('requesting icq connection from orchestration service'); + icqConnection = await E(cosmosInterchainService).provideICQConnection( + controllerConnectionId, + ); + } + const [chainAddress, localAddress, remoteAddress] = await Promise.all([ E(cosmosOrchAccount).getAddress(), E(cosmosOrchAccount).getLocalAddress(), @@ -67,7 +76,7 @@ export const prepareMakeTestCOAKit = ( { account: cosmosOrchAccount, storageNode: storageNode.makeChildNode(chainAddress.value), - icqConnection: undefined, + icqConnection, timer, }, ); diff --git a/packages/orchestration/test/fixtures/query-flows.contract.test.ts b/packages/orchestration/test/fixtures/query-flows.contract.test.ts new file mode 100644 index 00000000000..d4182e954c0 --- /dev/null +++ b/packages/orchestration/test/fixtures/query-flows.contract.test.ts @@ -0,0 +1,238 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import type { TestFn } from 'ava'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; +import { E } from '@endo/far'; +import path from 'path'; +import { JsonSafe, toRequestQueryJson, typedJson } from '@agoric/cosmic-proto'; +import { + QueryBalanceRequest, + QueryBalanceResponse, +} from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import type { ResponseQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; +import { decodeBase64 } from '@endo/base64'; +import { LOCALCHAIN_DEFAULT_ADDRESS } from '@agoric/vats/tools/fake-bridge.js'; +import { commonSetup } from '../supports.js'; + +const dirname = path.dirname(new URL(import.meta.url).pathname); + +const contractName = 'query-flows'; +const contractFile = `${dirname}/../../src/fixtures/query-flows.contract.js`; +type StartFn = + typeof import('../../src/fixtures/query-flows.contract.js').start; + +type TestContext = Awaited> & { + zoe: ZoeService; + instance: Instance; +}; + +const test = anyTest as TestFn; + +const decodeBalanceQueryResponse = (results: JsonSafe[]) => + results.map(({ key }) => QueryBalanceResponse.decode(decodeBase64(key))); + +test.beforeEach(async t => { + const setupContext = await commonSetup(t); + const { + bootstrap: { storage }, + commonPrivateArgs, + } = setupContext; + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + t.log('contract coreEval', contractName); + const installation = await bundleAndInstall(contractFile); + + const storageNode = await E(storage.rootNode).makeChildNode(contractName); + const { instance } = await E(zoe).startInstance( + installation, + undefined, + {}, + { ...commonPrivateArgs, storageNode }, + ); + + t.context = { + ...setupContext, + zoe, + instance, + }; +}); + +test('send query from chain object', async t => { + const { + bootstrap: { vowTools: vt }, + zoe, + instance, + utils: { inspectDibcBridge }, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(instance); + const balanceQuery = toRequestQueryJson( + QueryBalanceRequest.toProtoMsg({ + address: 'cosmos1test', + denom: 'uatom', + }), + ); + { + t.log('send query on chain with icqEnabled: true'); + const inv = E(publicFacet).makeSendICQQueryInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'osmosis', + msgs: [balanceQuery], + }); + const offerResultString = await vt.when(E(userSeat).getOfferResult()); + const offerResult = JSON.parse(offerResultString); + t.log(offerResult); + t.assert(offerResult[0].key, 'base64 encoded response returned'); + const decodedResponse = decodeBalanceQueryResponse(offerResult); + t.deepEqual(decodedResponse, [ + { + balance: { + amount: '0', + denom: 'uatom', + }, + }, + ]); + } + { + t.log('send query on chain with icqEnabled: false'); + const inv = E(publicFacet).makeSendICQQueryInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'cosmoshub', + msgs: [balanceQuery], + }); + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: 'Queries not available for chain "cosmoshub-4"', + }); + } + { + t.log('sending subsequent queries should not result in new ICQ channels'); + const inv = E(publicFacet).makeSendICQQueryInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'osmosis', + msgs: [balanceQuery], + }); + await vt.when(E(userSeat).getOfferResult()); + const { bridgeDowncalls } = await inspectDibcBridge(); + + const portBindings = bridgeDowncalls.filter(x => x.method === 'bindPort'); + t.is(portBindings.length, 1, 'only one port bound'); + t.regex(portBindings?.[0]?.packet.source_port, /icqcontroller-/); + const icqChannelInits = bridgeDowncalls.filter( + x => x.method === 'startChannelOpenInit' && x.version === 'icq-1', + ); + t.is(icqChannelInits.length, 1, 'only one ICQ channel opened'); + const sendPacketCalls = bridgeDowncalls.filter( + x => + x.method === 'sendPacket' && x.packet.source_port === 'icqcontroller-1', + ); + t.is(sendPacketCalls.length, 2, 'sent two queries'); + } + const proto3JsonQuery = typedJson( + '/cosmos.bank.v1beta1.QueryAllBalancesRequest', + { + address: LOCALCHAIN_DEFAULT_ADDRESS, + }, + ); + { + t.log('send a query from the localchain'); + const inv = E(publicFacet).makeSendLocalQueryInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + msgs: [proto3JsonQuery], + }); + const offerResultString = await vt.when(E(userSeat).getOfferResult()); + const offerResult = JSON.parse(offerResultString); + t.log(offerResult); + t.deepEqual( + offerResult, + [ + { + error: '', + height: '1', + reply: { + '@type': '/cosmos.bank.v1beta1.QueryAllBalancesResponse', + balances: [ + { denom: 'ubld', amount: '10' }, + { denom: 'uist', amount: '10' }, + ], + pagination: { + nextKey: null, + total: '2', + }, + }, + }, + ], + 'balances returned', + ); + } + { + t.log('remote chain facade guards offer with M.arrayOf(ICQMsgShape)'); + const inv = E(publicFacet).makeSendICQQueryInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'osmosis', + // @ts-expect-error intentional error + msgs: [proto3JsonQuery], + }); + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: /.*Must have missing properties \["path","data"\]/, + }); + } +}); + +test('send query from orch account in an async-flow', async t => { + const { + bootstrap: { vowTools: vt }, + zoe, + instance, + utils: { inspectDibcBridge }, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(instance); + + { + t.log('send query from orchAccount on chain with icqEnabled: true'); + const inv = E(publicFacet).makeAccountAndGetBalanceQueryInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'osmosis', + denom: 'uatom', + }); + const offerResultString = await vt.when(E(userSeat).getOfferResult()); + const offerResult = JSON.parse(offerResultString); + t.deepEqual(offerResult, { + value: '[0n]', + denom: 'uatom', + }); + } + { + t.log('send query from orchAccount that times out'); + const inv = E(publicFacet).makeAccountAndGetBalanceQueryInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'osmosis', + denom: 'notarealdenom', + }); + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: 'ABCI code: 5: error handling packet: see events for details', + }); + } + { + t.log('send query from orchAccount on chain with icqEnabled: false'); + const inv = E(publicFacet).makeAccountAndGetBalanceQueryInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'cosmoshub', + denom: 'uatom,', + }); + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: 'Queries not available for chain "cosmoshub-4"', + }); + } + + t.log("creating add'l account should not result in new ICQ channels"); + const { bridgeDowncalls } = await inspectDibcBridge(); + const icqPortBindings = bridgeDowncalls.filter( + x => + x.method === 'bindPort' && x.packet.source_port.includes('icqcontroller'), + ); + t.is(icqPortBindings.length, 1, 'only one icq port bound'); + const icqChannelInits = bridgeDowncalls.filter( + x => x.method === 'startChannelOpenInit' && x.version === 'icq-1', + ); + t.is(icqChannelInits.length, 1, 'only one ICQ channel opened'); +});