diff --git a/multichain-testing/config.yaml b/multichain-testing/config.yaml index 32027465055..8bb75beeeb4 100644 --- a/multichain-testing/config.yaml +++ b/multichain-testing/config.yaml @@ -44,7 +44,9 @@ chains: host_port: 'icqhost' params: host_enabled: true - allow_queries: ['*'] + allow_queries: + - /cosmos.bank.v1beta1.Query/Balance + - /cosmos.bank.v1beta1.Query/AllBalances faucet: enabled: true type: starship diff --git a/multichain-testing/test/chain-queries.test.ts b/multichain-testing/test/chain-queries.test.ts new file mode 100644 index 00000000000..2b4543a4059 --- /dev/null +++ b/multichain-testing/test/chain-queries.test.ts @@ -0,0 +1,300 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { + QueryBalanceRequest, + QueryBalanceResponse, + QueryAllBalancesRequest, + QueryAllBalancesResponse, +} from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import { toRequestQueryJson, typedJson } from '@agoric/cosmic-proto'; +import { decodeBase64 } from '@endo/base64'; +import { + commonSetup, + SetupContextWithWallets, + chainConfig, + FAUCET_POUR, +} from './support.js'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import { createWallet } from '../tools/wallet.js'; +import { makeQueryClient } from '../tools/query.js'; + +const test = anyTest as TestFn; + +const accounts = ['osmosis', 'cosmoshub', 'agoric']; + +const contractName = 'basicFlows'; +const contractBuilder = + '../packages/builders/scripts/orchestration/init-basic-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 queryICQChain = test.macro({ + title: (_, chainName: string) => `Send ICQ Query 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 { creditFromFaucet, chainInfo, getRestEndpoint } = + useChain(chainName); + const { staking, bech32_prefix } = chainInfo.chain; + const denom = staking?.staking_tokens?.[0].denom; + if (!denom) throw Error(`no denom for ${chainName}`); + + t.log( + 'Set up wallet with tokens so we have a wallet with balance to query', + ); + const wallet = await createWallet(bech32_prefix); + const { address } = (await wallet.getAccounts())[0]; + await creditFromFaucet(address); + + const remoteQueryClient = makeQueryClient(await getRestEndpoint()); + await retryUntilCondition( + () => remoteQueryClient.queryBalances(address), + ({ balances }) => Number(balances?.[0]?.amount) > 0, + `Faucet balances found for ${address}`, + ); + + 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} sendICQQuery offer`); + const offerId = `${chainName}-sendICQQuery-${Date.now()}`; + + const balanceQuery = toRequestQueryJson( + QueryBalanceRequest.toProtoMsg({ + address, + denom, + }), + ); + const allBalanceQuery = toRequestQueryJson( + QueryAllBalancesRequest.toProtoMsg({ + address, + }), + ); + + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeSendICQQueryInvitation']], + }, + offerArgs: { chainName, msgs: [balanceQuery, allBalanceQuery] }, + proposal: {}, + }); + + const offerResult = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}`), + ({ status }) => status.id === offerId && (status.result || status.error), + `${offerId} continuing invitation is in vstorage`, + { + maxRetries: 15, + }, + ); + t.log('ICQ Query Offer Result', offerResult); + const { + status: { result, error }, + } = offerResult; + t.is(error, undefined, 'No error observed'); + + const [balanceQueryResult, allBalanceQueryResult] = JSON.parse(result); + + t.is(balanceQueryResult.code, 0, 'balance query was successful'); + const balanceQueryResultDecoded = QueryBalanceResponse.decode( + decodeBase64(balanceQueryResult.key), + ); + t.log('balanceQueryResult', balanceQueryResultDecoded); + t.deepEqual(balanceQueryResultDecoded, { + balance: { + amount: String(FAUCET_POUR), + denom, + }, + }); + + t.is(allBalanceQueryResult.code, 0, 'allBalances query was successful'); + const allBalanceQueryResultDecoded = QueryAllBalancesResponse.decode( + decodeBase64(allBalanceQueryResult.key), + ); + t.log('allBalanceQueryResult', allBalanceQueryResultDecoded); + t.like(allBalanceQueryResultDecoded, { + balances: [ + { + amount: String(FAUCET_POUR), + denom, + }, + ], + }); + }, +}); + +const queryChainWithoutICQ = test.macro({ + title: (_, chainName: string) => + `Attempt Query on chain with ICQ ${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 } = useChain(chainName); + const { staking, chain_id } = chainInfo.chain; + 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} sendICQQuery offer (unsupported)`); + const offerId = `${chainName}-sendICQQuery-${Date.now()}`; + + const balanceQuery = toRequestQueryJson( + QueryBalanceRequest.toProtoMsg({ + address: 'cosmos1234', + denom, + }), + ); + + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeSendICQQueryInvitation']], + }, + offerArgs: { chainName, msgs: [balanceQuery] }, + proposal: {}, + }); + + const offerResult = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}`), + ({ status }) => status.id === offerId && (status.result || status.error), + `${offerId} continuing invitation is in vstorage`, + { + maxRetries: 10, + }, + ); + t.is( + offerResult.status.error, + `Error: Queries not available for chain "${chain_id}"`, + 'Queries not available error returned', + ); + }, +}); + +test.serial('Send Local Query from chain object', async t => { + const { wallets, provisionSmartWallet, vstorageClient, retryUntilCondition } = + t.context; + + const agoricAddr = wallets['agoric']; + const wdUser1 = await provisionSmartWallet(agoricAddr, { + BLD: 100n, + IST: 100n, + }); + const expectedBalances = [ + { + denom: 'ubld', + amount: '90000000', // 100n * (10n ** 6n) - smart wallet provision + }, + { + denom: 'uist', + amount: '100250000', // 100n * (10n ** 6n) + smart wallet credit + }, + ]; + t.log(`provisioning agoric smart wallet for ${agoricAddr}`); + + const doOffer = makeDoOffer(wdUser1); + t.log('sendLocalQuery offer'); + const offerId = `agoric-sendLocalQuery-${Date.now()}`; + + const allBalancesProto3JsonQuery = typedJson( + '/cosmos.bank.v1beta1.QueryAllBalancesRequest', + { + address: agoricAddr, + }, + ); + const balanceProto3JsonQuery = typedJson( + '/cosmos.bank.v1beta1.QueryBalanceRequest', + { + address: agoricAddr, + denom: 'ubld', + }, + ); + + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeSendLocalQueryInvitation']], + }, + offerArgs: { + msgs: [allBalancesProto3JsonQuery, balanceProto3JsonQuery], + }, + proposal: {}, + }); + + const offerResult = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}`), + ({ status }) => status.id === offerId && (status.result || status.error), + `${offerId} continuing invitation is in vstorage`, + { + maxRetries: 10, + }, + ); + + const parsedResults = JSON.parse(offerResult.status.result); + t.truthy(parsedResults[0].height, 'query height is returned'); + t.is(parsedResults[0].error, '', 'error is empty'); + t.like( + parsedResults[0].reply, + { + balances: expectedBalances, + }, + 'QueryAllBalances result is returned', + ); + t.deepEqual( + parsedResults[1].reply, + { + '@type': '/cosmos.bank.v1beta1.QueryBalanceResponse', + balance: expectedBalances[0], + }, + 'QueryBalance result is returned', + ); +}); + +test.serial(queryICQChain, 'osmosis'); +test.serial(queryChainWithoutICQ, 'cosmoshub'); diff --git a/multichain-testing/test/stake-ica.test.ts b/multichain-testing/test/stake-ica.test.ts index bbdc74cbb79..01cbeabcc7d 100644 --- a/multichain-testing/test/stake-ica.test.ts +++ b/multichain-testing/test/stake-ica.test.ts @@ -1,6 +1,10 @@ import anyTest from '@endo/ses-ava/prepare-endo.js'; import type { TestFn } from 'ava'; -import { commonSetup, SetupContextWithWallets } from './support.js'; +import { + commonSetup, + SetupContextWithWallets, + FAUCET_POUR, +} from './support.js'; import { makeDoOffer } from '../tools/e2e-tools.js'; import { makeQueryClient } from '../tools/query.js'; import { sleep, type RetryOptions } from '../tools/sleep.js'; @@ -35,8 +39,6 @@ test.before(async t => { t.context = { ...rest, wallets, deleteTestKeys }; }); -const FAUCET_POUR = 10000000000n; - test.after(async t => { const { deleteTestKeys } = t.context; deleteTestKeys(accounts); diff --git a/multichain-testing/test/support.ts b/multichain-testing/test/support.ts index 065c656c995..2267b813436 100644 --- a/multichain-testing/test/support.ts +++ b/multichain-testing/test/support.ts @@ -11,6 +11,8 @@ import { makeRetryUntilCondition } from '../tools/sleep.js'; import { makeDeployBuilder } from '../tools/deploy.js'; import { makeHermes } from '../tools/hermes-tools.js'; +export const FAUCET_POUR = 10_000n * 1_000_000n; + const setupRegistry = makeSetupRegistry(makeGetFile({ dirname, join })); // XXX consider including bech32Prefix in `ChainInfo` diff --git a/packages/cosmic-proto/src/helpers.ts b/packages/cosmic-proto/src/helpers.ts index f25f093c68a..e1023dbd16d 100644 --- a/packages/cosmic-proto/src/helpers.ts +++ b/packages/cosmic-proto/src/helpers.ts @@ -7,7 +7,9 @@ import type { import type { QueryAllBalancesRequest, QueryAllBalancesResponse, + QueryBalanceRequest, QueryBalanceRequestProtoMsg, + QueryBalanceResponse, } from './codegen/cosmos/bank/v1beta1/query.js'; import type { MsgSend, @@ -38,6 +40,8 @@ export type Proto3Shape = { '/cosmos.bank.v1beta1.MsgSendResponse': MsgSendResponse; '/cosmos.bank.v1beta1.QueryAllBalancesRequest': QueryAllBalancesRequest; '/cosmos.bank.v1beta1.QueryAllBalancesResponse': QueryAllBalancesResponse; + '/cosmos.bank.v1beta1.QueryBalanceRequest': QueryBalanceRequest; + '/cosmos.bank.v1beta1.QueryBalanceResponse': QueryBalanceResponse; '/cosmos.staking.v1beta1.MsgDelegate': MsgDelegate; '/cosmos.staking.v1beta1.MsgDelegateResponse': MsgDelegateResponse; '/cosmos.staking.v1beta1.MsgUndelegate': MsgUndelegate; diff --git a/packages/orchestration/src/examples/basic-flows.contract.js b/packages/orchestration/src/examples/basic-flows.contract.js index 010247fd231..e52cbb59542 100644 --- a/packages/orchestration/src/examples/basic-flows.contract.js +++ b/packages/orchestration/src/examples/basic-flows.contract.js @@ -43,6 +43,7 @@ const contract = async ( makeSendICQQueryInvitation: M.callWhen().returns(InvitationShape), makeAccountAndSendBalanceQueryInvitation: M.callWhen().returns(InvitationShape), + makeSendLocalQueryInvitation: M.callWhen().returns(InvitationShape), }), { makeOrchAccountInvitation() { @@ -59,7 +60,7 @@ const contract = async ( }, makeSendICQQueryInvitation() { return zcf.makeInvitation( - orchFns.sendQuery, + orchFns.sendICQQuery, 'Submit a query to a remote chain', ); }, @@ -69,6 +70,12 @@ const contract = async ( '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 f3664bfcc15..b0d1c62822a 100644 --- a/packages/orchestration/src/examples/basic-flows.flows.js +++ b/packages/orchestration/src/examples/basic-flows.flows.js @@ -2,13 +2,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. */ -import { Fail } from '@endo/errors'; +import { makeTracer } from '@agoric/internal'; +import { Fail, q } from '@endo/errors'; import { M, mustMatch } from '@endo/patterns'; +const trace = makeTracer('BasicFlows'); + /** - * @import {DenomArg, OrchestrationAccount, OrchestrationFlow, Orchestrator} from '@agoric/orchestration'; + * @import {Chain, DenomArg, OrchestrationAccount, OrchestrationFlow, Orchestrator, KnownChains, OrchestrationAccountI, ICQQueryFunction, CosmosChainInfo} 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'; @@ -83,27 +87,31 @@ export const makePortfolioAccount = async ( harden(makePortfolioAccount); /** - * Send a query 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. + * 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: JsonSafe[] }} offerArgs + * @param {{ chainName: string; msgs: Parameters[0] }} offerArgs */ -export const sendQuery = async (orch, _ctx, seat, { chainName, msgs }) => { +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 = await orch.getChain(chainName); + const remoteChain = + /** @type {Chain} */ ( + await orch.getChain(chainName) + ); const queryResponse = await remoteChain.query(msgs); - console.debug('sendQuery response:', queryResponse); - return queryResponse; + trace('SendICQQuery response:', queryResponse); + // `quote` to ensure offerResult (array) is visible in smart-wallet + return q(queryResponse).toString(); }; -harden(sendQuery); +harden(sendICQQuery); /** * Create an account and send a query and get the response back in an offer @@ -129,7 +137,32 @@ export const makeAccountAndSendBalanceQuery = async ( const remoteChain = await orch.getChain(chainName); const orchAccount = await remoteChain.makeAccount(); const queryResponse = await orchAccount.getBalance(denom); - console.debug('getBalance response:', queryResponse); - return queryResponse; + 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/local-chain-facade.js b/packages/orchestration/src/exos/local-chain-facade.js index 65bff5c71bc..62ca0cf71ba 100644 --- a/packages/orchestration/src/exos/local-chain-facade.js +++ b/packages/orchestration/src/exos/local-chain-facade.js @@ -6,21 +6,20 @@ import { M } from '@endo/patterns'; import { pickFacet } from '@agoric/vat-data'; import { VowShape } from '@agoric/vow'; -import { Fail } from '@endo/errors'; -import { chainFacadeMethods } from '../typeGuards.js'; +import { chainFacadeMethods, TypedJsonShape } from '../typeGuards.js'; /** * @import {HostOf} from '@agoric/async-flow'; * @import {Zone} from '@agoric/base-zone'; * @import {TimerService} from '@agoric/time'; * @import {Remote} from '@agoric/internal'; - * @import {LocalChain, LocalChainAccount} from '@agoric/vats/src/localchain.js'; + * @import {LocalChain, LocalChainAccount, QueryManyFn} from '@agoric/vats/src/localchain.js'; * @import {AssetInfo} from '@agoric/vats/src/vat-bank.js'; * @import {NameHub} from '@agoric/vats'; * @import {Vow, VowTools} from '@agoric/vow'; * @import {CosmosInterchainService} from './cosmos-interchain-service.js'; * @import {LocalOrchestrationAccountKit, MakeLocalOrchestrationAccountKit} from './local-orchestration-account.js'; - * @import {ChainAddress, ChainInfo, CosmosChainInfo, IBCConnectionInfo, OrchestrationAccount} from '../types.js'; + * @import {Chain, ChainAddress, ChainInfo, CosmosChainInfo, IBCConnectionInfo, OrchestrationAccount} from '../types.js'; */ /** @@ -64,6 +63,7 @@ const prepareLocalChainFacadeKit = ( { public: M.interface('LocalChainFacade', { ...chainFacadeMethods, + query: M.call(M.arrayOf(TypedJsonShape)).returns(VowShape), getVBankAssetInfo: M.call().optional(M.boolean()).returns(VowShape), }), vbankAssetValuesWatcher: M.interface('vbankAssetValuesWatcher', { @@ -108,9 +108,9 @@ const prepareLocalChainFacadeKit = ( this.facets.makeAccountWatcher, ); }, - query() { - // TODO https://github.com/Agoric/agoric-sdk/pull/9935 - return asVow(() => Fail`not yet implemented`); + /** @type {HostOf['query']>} */ + query(requests) { + return watch(E(localchain).queryMany(requests)); }, /** @type {HostOf} */ getVBankAssetInfo() { diff --git a/packages/orchestration/src/exos/remote-chain-facade.js b/packages/orchestration/src/exos/remote-chain-facade.js index db427289980..76b78d15a9d 100644 --- a/packages/orchestration/src/exos/remote-chain-facade.js +++ b/packages/orchestration/src/exos/remote-chain-facade.js @@ -1,10 +1,15 @@ /** @file Remote Chain Facade exo */ import { makeTracer } from '@agoric/internal'; import { E } from '@endo/far'; +import { Fail, q } from '@endo/errors'; import { M } from '@endo/patterns'; import { pickFacet } from '@agoric/vat-data'; import { VowShape } from '@agoric/vow'; -import { ChainAddressShape, ChainFacadeI, ICQMsgShape } from '../typeGuards.js'; +import { + ChainAddressShape, + chainFacadeMethods, + ICQMsgShape, +} from '../typeGuards.js'; /** * @import {HostOf} from '@agoric/async-flow'; @@ -20,7 +25,6 @@ import { ChainAddressShape, ChainFacadeI, ICQMsgShape } from '../typeGuards.js'; * @import {CosmosChainInfo, IBCConnectionInfo, ChainAddress, IcaAccount, Chain, ICQConnection} from '../types.js'; */ -const { Fail } = assert; const trace = makeTracer('RemoteChainFacade'); /** @@ -62,7 +66,10 @@ const prepareRemoteChainFacadeKit = ( zone.exoClassKit( 'RemoteChainFacade', { - public: ChainFacadeI, + public: M.interface('RemoteChainFacade', { + ...chainFacadeMethods, + query: M.call(M.arrayOf(ICQMsgShape)).returns(VowShape), + }), makeICQConnectionQueryWatcher: M.interface( 'makeICQConnectionQueryWatcher', { @@ -140,7 +147,11 @@ const prepareRemoteChainFacadeKit = ( ); }); }, - /** @type {ICQConnection['query']} */ + /** + * @type {HostOf< + * Chain['query'] + * >} + */ query(msgs) { return asVow(() => { const { @@ -148,7 +159,7 @@ const prepareRemoteChainFacadeKit = ( connectionInfo, } = this.state; if (!icqEnabled) { - throw Fail`Queries not available for chain ${chainId}`; + throw Fail`Queries not available for chain ${q(chainId)}`; } // if none exists, make one and still send the query in the handler if (!this.state.icqConnection) { diff --git a/packages/orchestration/src/orchestration-api.ts b/packages/orchestration/src/orchestration-api.ts index 8bc5aa2abec..6fa1be523fe 100644 --- a/packages/orchestration/src/orchestration-api.ts +++ b/packages/orchestration/src/orchestration-api.ts @@ -7,7 +7,10 @@ import type { Amount, Brand, NatAmount } from '@agoric/ertp/src/types.js'; import type { CurrentWalletRecord } from '@agoric/smart-wallet/src/smartWallet.js'; import type { Timestamp } from '@agoric/time'; -import type { LocalChainAccount } from '@agoric/vats/src/localchain.js'; +import type { + LocalChainAccount, + QueryManyFn, +} from '@agoric/vats/src/localchain.js'; import type { ResolvedPublicTopic } from '@agoric/zoe/src/contractSupport/topics.js'; import type { Passable } from '@endo/marshal'; import type { @@ -89,7 +92,11 @@ export interface Chain { makeAccount: () => Promise>; // FUTURE supply optional port object; also fetch port object - query: CI extends { icqEnabled: true } ? ICQQueryFunction : never; + query: CI extends { icqEnabled: true } + ? ICQQueryFunction + : CI['chainId'] extends `agoric${string}` + ? QueryManyFn + : never; // TODO provide a way to get the local denom/brand/whatever for this chain } diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js index 004ded2e4e5..fad82ee03e9 100644 --- a/packages/orchestration/src/typeGuards.js +++ b/packages/orchestration/src/typeGuards.js @@ -7,6 +7,7 @@ import { M } from '@endo/patterns'; * @import {ChainAddress, CosmosAssetInfo, ChainInfo, CosmosChainInfo, DenomAmount} from './types.js'; * @import {Delegation} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js'; * @import {TxBody} from '@agoric/cosmic-proto/cosmos/tx/v1beta1/tx.js'; + * @import {TypedJson} from '@agoric/cosmic-proto'; */ /** @@ -123,15 +124,15 @@ export const ICQMsgShape = M.splitRecord( { height: M.string(), prove: M.boolean() }, ); +/** @type {TypedPattern} */ +export const TypedJsonShape = M.splitRecord({ '@type': M.string() }); + +/** @see {Chain} */ export const chainFacadeMethods = harden({ getChainInfo: M.call().returns(VowShape), makeAccount: M.call().returns(VowShape), - query: M.call(M.arrayOf(ICQMsgShape)).returns(VowShape), }); -/** @see {Chain} */ -export const ChainFacadeI = M.interface('ChainFacade', chainFacadeMethods); - /** * for google/protobuf/timestamp.proto, not to be confused with TimestampShape * from `@agoric/time` diff --git a/packages/orchestration/test/examples/basic-flows.contract.test.ts b/packages/orchestration/test/examples/basic-flows.contract.test.ts index 3096e13df65..c71374130c8 100644 --- a/packages/orchestration/test/examples/basic-flows.contract.test.ts +++ b/packages/orchestration/test/examples/basic-flows.contract.test.ts @@ -4,13 +4,14 @@ 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 } from '@agoric/cosmic-proto'; +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); @@ -120,7 +121,8 @@ test('send query from chain object', async t => { chainName: 'osmosis', msgs: [balanceQuery], }); - const offerResult = await vt.when(E(userSeat).getOfferResult()); + 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); @@ -167,6 +169,55 @@ test('send query from chain object', async t => { ); 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 => { @@ -185,9 +236,10 @@ test('send query from orch account in an async-flow', async t => { chainName: 'osmosis', denom: 'uatom', }); - const offerResult = await vt.when(E(userSeat).getOfferResult()); + const offerResultString = await vt.when(E(userSeat).getOfferResult()); + const offerResult = JSON.parse(offerResultString); t.deepEqual(offerResult, { - value: 0n, + value: '[0n]', denom: 'uatom', }); } @@ -226,6 +278,3 @@ test('send query from orch account in an async-flow', async t => { ); t.is(icqChannelInits.length, 1, 'only one ICQ channel opened'); }); - -// needs design? -test.todo('send query LocalChainFacade'); diff --git a/packages/orchestration/test/types.test-d.ts b/packages/orchestration/test/types.test-d.ts index 6a0db165743..d6d0c539da2 100644 --- a/packages/orchestration/test/types.test-d.ts +++ b/packages/orchestration/test/types.test-d.ts @@ -3,12 +3,16 @@ */ import { expectNotType, expectType } from 'tsd'; -import { typedJson } from '@agoric/cosmic-proto'; +import { JsonSafe, typedJson } from '@agoric/cosmic-proto'; import type { MsgDelegateResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; -import type { QueryAllBalancesResponse } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import type { + QueryAllBalancesResponse, + QueryBalanceResponse, +} from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; import type { Vow, VowTools } from '@agoric/vow'; import type { GuestAsyncFunc, HostInterface, HostOf } from '@agoric/async-flow'; import type { ResolvedPublicTopic } from '@agoric/zoe/src/contractSupport/topics.js'; +import type { ResponseQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; import type { ChainAddress, CosmosValidatorAddress, @@ -17,6 +21,7 @@ import type { Orchestrator, Chain, ChainInfo, + CosmosChainInfo, } from '../src/types.js'; import type { LocalOrchestrationAccountKit } from '../src/exos/local-orchestration-account.js'; import { prepareCosmosOrchestrationAccount } from '../src/exos/cosmos-orchestration-account.js'; @@ -183,3 +188,64 @@ expectNotType(chainAddr); expectType>(h()); } } + +// Test LocalChain.query() +{ + type ChainFacade = Chain<{ chainId: 'agoriclocal' }>; + const localChain: ChainFacade = null as any; + const results = localChain.query([ + typedJson('/cosmos.bank.v1beta1.QueryBalanceRequest', { + address: 'agoric1pleab', + denom: 'ubld', + }), + typedJson('/cosmos.bank.v1beta1.QueryAllBalancesRequest', { + address: 'agoric1pleab', + }), + ] as const); + + expectType>(results); + expectType<{ reply: JsonSafe }>(results[0]); + expectType<{ reply: JsonSafe }>(results[1]); +} + +// Test RemoteCosmosChain.query() (icqEnabled: true) +{ + type ChainFacade = Chain; + const remoteChain: ChainFacade = null as any; + const results = remoteChain.query([ + { + path: '/cosmos.staking.v1beta1.Query/Delegation', + data: 'base64bytes=', + height: '1', + prove: true, + }, + { + path: '/cosmos.bank.v1beta1.Query/Balance', + data: 'base64bytes=', + height: '1', + prove: true, + }, + ] as const); + + expectType>(results); + expectType>(results[0]); + expectType>(results[1]); +} + +// Test RemoteCosmosChain.query() (icqEnabled: false) +{ + type ChainFacade = Chain; + const remoteChain: ChainFacade = null as any; + + expectType(remoteChain.query); + + // @ts-expect-error query will throw an error + const results = remoteChain.query([ + { + path: '/cosmos.bank.v1beta1.Query/Balance', + data: 'base64bytes=', + height: '1', + prove: true, + }, + ] as const); +} diff --git a/packages/vats/src/localchain.js b/packages/vats/src/localchain.js index bec7cf407da..0f7543b59ec 100644 --- a/packages/vats/src/localchain.js +++ b/packages/vats/src/localchain.js @@ -34,6 +34,21 @@ const { Vow$ } = NetworkShape; * '[Symbol.iterator]()' method that returns an iterator. */ +/** + * Send a batch of query requests to the local chain. Unless there is a system + * error, will return all results to indicate their success or failure. + * + * @template {TypedJson[]} [RT=TypedJson[]] + * @callback QueryManyFn + * @param {RT} requests + * @returns {PromiseVowOfTupleMappedToGenerics<{ + * [K in keyof RT]: JsonSafe<{ + * error?: string; + * reply: ResponseTo; + * }>; + * }>} + */ + /** * @typedef {{ * system: ScopedBridgeManager<'vlocalchain'>; @@ -171,6 +186,7 @@ export const prepareLocalChainAccountKit = (zone, { watch }) => * @returns {PromiseVowOfTupleMappedToGenerics<{ * [K in keyof MT]: JsonSafe>; * }>} + * * @see {typedJson} which can be used on arguments to get typed return * values. */ @@ -271,20 +287,7 @@ const prepareLocalChain = (zone, makeAccountKit, { watch }) => { this.facets.extractFirstQueryResultWatcher, ); }, - /** - * Send a batch of query requests to the local chain. Unless there is a - * system error, will return all results to indicate their success or - * failure. - * - * @template {import('@agoric/cosmic-proto').TypedJson[]} RT - * @param {RT} requests - * @returns {PromiseVowOfTupleMappedToGenerics<{ - * [K in keyof RT]: JsonSafe<{ - * error?: string; - * reply: ResponseTo; - * }>; - * }>} - */ + /** @type {QueryManyFn} */ async queryMany(requests) { const { system } = this.state; return E(system).toBridge({ diff --git a/packages/vats/tools/fake-bridge.js b/packages/vats/tools/fake-bridge.js index acef4e54045..6c05358643c 100644 --- a/packages/vats/tools/fake-bridge.js +++ b/packages/vats/tools/fake-bridge.js @@ -227,6 +227,56 @@ export const fakeLocalChainBridgeTxMsgHandler = (message, sequence) => { } }; +/** + * Used to mock responses from Cosmos Golang back to SwingSet for for + * E(lca).query() and E(lca).queryMany(). + * + * Returns an empty object per query message unless specified. + * + * @param {object} message + * @returns {unknown} + */ +export const fakeLocalChainBridgeQueryHandler = message => { + switch (message['@type']) { + case '/cosmos.bank.v1beta1.QueryAllBalancesRequest': { + return { + error: '', + height: '1', + reply: { + '@type': '/cosmos.bank.v1beta1.QueryAllBalancesResponse', + balances: [ + { + amount: '10', + denom: 'ubld', + }, + { + amount: '10', + denom: 'uist', + }, + ], + pagination: { nextKey: null, total: '2' }, + }, + }; + } + case '/cosmos.bank.v1beta1.QueryBalanceRequest': { + return { + error: '', + height: '1', + reply: { + '@type': '/cosmos.bank.v1beta1.QueryBalanceResponse', + balance: { + amount: '10', + denom: 'ubld', + }, + }, + }; + } + // returns one empty object per message unless specified + default: + return {}; + } +}; + /** * @param {import('@agoric/zone').Zone} zone * @param {(obj) => void} [onToBridge] @@ -255,6 +305,11 @@ export const makeFakeLocalchainBridge = (zone, onToBridge = () => {}) => { fakeLocalChainBridgeTxMsgHandler(message, lcaExecuteTxSequence), ); } + case 'VLOCALCHAIN_QUERY_MANY': { + return obj.messages.map(message => + fakeLocalChainBridgeQueryHandler(message), + ); + } default: Fail`unknown type ${type}`; }