From b1b6a0392467fda9cb6e51392756ff5ffff75256 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Wed, 7 Aug 2024 10:33:21 -0400 Subject: [PATCH 1/6] feat: publish local and remote ibc addresses to vstorage - publishes remoteAddress (RemoteIBCAddress) and localAddress (LocalIBCAddress) to vstorage for CosmosOrchestrationAccount - goal is to faciliate off-chain clients, which need portId, connectionId, and channelId for the host and controller to perform queries - a better design might publish these values individually, versus putting the burden of parsing on the client - refs: #9066 --- .../test/bootstrapTests/orchestration.test.ts | 21 +++- .../orchestration/restart-contracts.test.ts | 7 +- .../src/examples/stakeIca.contract.js | 25 ++-- .../src/exos/cosmos-orchestration-account.js | 40 ++++++- .../src/exos/remote-chain-facade.js | 55 ++++++--- .../snapshots/stake-ica.contract.test.ts.md | 6 +- .../snapshots/stake-ica.contract.test.ts.snap | Bin 404 -> 638 bytes .../test/examples/stake-ica.contract.test.ts | 14 ++- .../test/exos/make-test-coa-kit.ts | 21 ++-- .../orchestration/test/staking-ops.test.ts | 113 +++++++++++++----- packages/orchestration/test/types.test-d.ts | 1 - 11 files changed, 222 insertions(+), 81 deletions(-) diff --git a/packages/boot/test/bootstrapTests/orchestration.test.ts b/packages/boot/test/bootstrapTests/orchestration.test.ts index 5be8257f3ef..cea3574fe42 100644 --- a/packages/boot/test/bootstrapTests/orchestration.test.ts +++ b/packages/boot/test/bootstrapTests/orchestration.test.ts @@ -174,7 +174,12 @@ test.serial('stakeAtom - smart wallet', async t => { t.like(wd.getLatestUpdateRecord(), { status: { id: 'request-account', numWantsSatisfied: 1 }, }); - t.is(readLatest('published.stakeAtom.accounts.cosmos1test'), ''); + t.deepEqual(readLatest('published.stakeAtom.accounts.cosmos1test'), { + localAddress: + '/ibc-port/icacontroller-1/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-1', + remoteAddress: + '/ibc-hop/connection-8/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-1', + }); const { ATOM } = agoricNamesRemotes.brand; ATOM || Fail`ATOM missing from agoricNames`; @@ -321,7 +326,12 @@ test('basic-flows', async t => { t.like(wd.getLatestUpdateRecord(), { status: { id: 'request-coa', numWantsSatisfied: 1 }, }); - t.is(readLatest('published.basicFlows.cosmos1test'), ''); + t.deepEqual(readLatest('published.basicFlows.cosmos1test'), { + localAddress: + '/ibc-port/icacontroller-4/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-4', + remoteAddress: + '/ibc-hop/connection-8/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-4', + }); // create a local orchestration account await wd.executeOffer({ @@ -506,7 +516,12 @@ test.serial('basic-flows - portfolio holder', async t => { status: { id: 'request-portfolio-acct', numWantsSatisfied: 1 }, }); // XXX this overrides a previous account, since mocks only provide one address - t.is(readLatest('published.basicFlows.cosmos1test'), ''); + t.deepEqual(readLatest('published.basicFlows.cosmos1test'), { + localAddress: + '/ibc-port/icacontroller-3/ordered/{"version":"ics27-1","controllerConnectionId":"connection-1","hostConnectionId":"connection-1649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-3', + remoteAddress: + '/ibc-hop/connection-1/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-1","hostConnectionId":"connection-1649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-3', + }); // XXX this overrides a previous account, since mocks only provide one address t.is(readLatest('published.basicFlows.agoric1fakeLCAAddress'), ''); diff --git a/packages/boot/test/orchestration/restart-contracts.test.ts b/packages/boot/test/orchestration/restart-contracts.test.ts index f1d28bc0630..fe6104b6b12 100644 --- a/packages/boot/test/orchestration/restart-contracts.test.ts +++ b/packages/boot/test/orchestration/restart-contracts.test.ts @@ -141,7 +141,12 @@ test.serial('stakeAtom', async t => { const accountPath = 'published.stakeAtom.accounts.cosmos1test'; t.throws(() => readLatest(accountPath)); t.is(await flushInboundQueue(), 1); - t.is(readLatest(accountPath), ''); + t.deepEqual(readLatest(accountPath), { + localAddress: + '/ibc-port/icacontroller-1/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-1', + remoteAddress: + '/ibc-hop/connection-8/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-1', + }); // request-account is complete const { ATOM } = agoricNamesRemotes.brand; diff --git a/packages/orchestration/src/examples/stakeIca.contract.js b/packages/orchestration/src/examples/stakeIca.contract.js index 91f14ccabe0..13d6421fb6b 100644 --- a/packages/orchestration/src/examples/stakeIca.contract.js +++ b/packages/orchestration/src/examples/stakeIca.contract.js @@ -115,17 +115,24 @@ export const start = async (zcf, privateArgs, baggage) => { ? await E(orchestration).provideICQConnection(controllerConnectionId) : undefined; - const accountAddress = await E(account).getAddress(); - trace('account address', accountAddress); + const [chainAddress, localAddress, remoteAddress] = await Promise.all([ + E(account).getAddress(), + E(account).getLocalAddress(), + E(account).getRemoteAddress(), + ]); + trace('account address', chainAddress); const accountNode = await E(accountsStorageNode).makeChildNode( - accountAddress.value, + chainAddress.value, + ); + const holder = makeCosmosOrchestrationAccount( + { chainAddress, bondDenom, localAddress, remoteAddress }, + { + account, + storageNode: accountNode, + icqConnection, + timer, + }, ); - const holder = makeCosmosOrchestrationAccount(accountAddress, bondDenom, { - account, - storageNode: accountNode, - icqConnection, - timer, - }); return holder; } diff --git a/packages/orchestration/src/exos/cosmos-orchestration-account.js b/packages/orchestration/src/exos/cosmos-orchestration-account.js index 1872f85a630..8af61ed745d 100644 --- a/packages/orchestration/src/exos/cosmos-orchestration-account.js +++ b/packages/orchestration/src/exos/cosmos-orchestration-account.js @@ -49,6 +49,7 @@ import { makeTimestampHelper } from '../utils/time.js'; * @import {ResponseQuery} from '@agoric/cosmic-proto/tendermint/abci/types.js'; * @import {JsonSafe} from '@agoric/cosmic-proto'; * @import {Matcher} from '@endo/patterns'; + * @import {LocalIbcAddress, RemoteIbcAddress} from '@agoric/vats/tools/ibc-utils.js'; */ const trace = makeTracer('ComosOrchestrationAccountHolder'); @@ -65,12 +66,21 @@ const { Vow$ } = NetworkShape; // TODO #9611 * topicKit: RecorderKit; * account: IcaAccount; * chainAddress: ChainAddress; + * localAddress: LocalIbcAddress; + * remoteAddress: RemoteIbcAddress; * icqConnection: ICQConnection | undefined; * bondDenom: string; * timer: Remote; * }} State */ +/** + * @typedef {{ + * localAddress: LocalIbcAddress; + * remoteAddress: RemoteIbcAddress; + * }} CosmosOrchestrationAccountStorageState + */ + /** @see {OrchestrationAccountI} */ export const IcaAccountHolderI = M.interface('IcaAccountHolder', { ...orchestrationAccountMethods, @@ -178,8 +188,11 @@ export const prepareCosmosOrchestrationAccountKit = ( }), }, /** - * @param {ChainAddress} chainAddress - * @param {string} bondDenom e.g. 'uatom' + * @param {object} info + * @param {ChainAddress} info.chainAddress + * @param {string} info.bondDenom e.g. 'uatom' + * @param {LocalIbcAddress} info.localAddress + * @param {RemoteIbcAddress} info.remoteAddress * @param {object} io * @param {IcaAccount} io.account * @param {Remote} io.storageNode @@ -187,14 +200,29 @@ export const prepareCosmosOrchestrationAccountKit = ( * @param {Remote} io.timer * @returns {State} */ - (chainAddress, bondDenom, io) => { + ({ chainAddress, bondDenom, localAddress, remoteAddress }, io) => { const { storageNode, ...rest } = io; // must be the fully synchronous maker because the kit is held in durable state const topicKit = makeRecorderKit(storageNode, PUBLIC_TOPICS.account[1]); // TODO determine what goes in vstorage https://github.com/Agoric/agoric-sdk/issues/9066 - void E(topicKit.recorder).write(''); - - return { chainAddress, bondDenom, topicKit, ...rest }; + // XXX consider parsing local/remoteAddr to portId, channelId, counterpartyPortId, counterpartyChannelId, connectionId, counterpartyConnectionId + // FIXME these values will not update if IcaAccount gets new values after reopening. + // consider having IcaAccount responsible for the owning the writer. It might choose to share it with COA. + void E(topicKit.recorder).write( + /** @type {CosmosOrchestrationAccountStorageState} */ ({ + localAddress, + remoteAddress, + }), + ); + + return { + chainAddress, + bondDenom, + localAddress, + remoteAddress, + topicKit, + ...rest, + }; }, { helper: { diff --git a/packages/orchestration/src/exos/remote-chain-facade.js b/packages/orchestration/src/exos/remote-chain-facade.js index 21c2aa0e171..7cca5e3cb68 100644 --- a/packages/orchestration/src/exos/remote-chain-facade.js +++ b/packages/orchestration/src/exos/remote-chain-facade.js @@ -14,6 +14,7 @@ import { ChainAddressShape, ChainFacadeI, ICQMsgShape } from '../typeGuards.js'; * @import {TimerService} from '@agoric/time'; * @import {Remote} from '@agoric/internal'; * @import {Vow, VowTools} from '@agoric/vow'; + * @import {LocalIbcAddress, RemoteIbcAddress} from '@agoric/vats/tools/ibc-utils.js'; * @import {CosmosInterchainService} from './cosmos-interchain-service.js'; * @import {prepareCosmosOrchestrationAccount} from './cosmos-orchestration-account.js'; * @import {CosmosChainInfo, IBCConnectionInfo, ChainAddress, IcaAccount, Chain, ICQConnection} from '../types.js'; @@ -79,13 +80,18 @@ const prepareRemoteChainFacadeKit = ( ]).returns(VowShape), }, ), - getAddressWatcher: M.interface('getAddressWatcher', { - onFulfilled: M.call(ChainAddressShape, M.remotable()).returns(VowShape), + getAddressesWatcher: M.interface('getAddressWatcher', { + onFulfilled: M.call( + [ChainAddressShape, M.string(), M.string()], + M.remotable(), + ).returns(VowShape), }), makeChildNodeWatcher: M.interface('makeChildNodeWatcher', { onFulfilled: M.call(M.remotable(), { account: M.remotable(), chainAddress: ChainAddressShape, + localAddress: M.string(), + remoteAddress: M.string(), }).returns(M.remotable()), }), }, @@ -170,8 +176,12 @@ const prepareRemoteChainFacadeKit = ( // no need to pass icqConnection in ctx; we can get it from state } return watch( - E(account).getAddress(), - this.facets.getAddressWatcher, + allVows([ + E(account).getAddress(), + E(account).getLocalAddress(), + E(account).getRemoteAddress(), + ]), + this.facets.getAddressesWatcher, account, ); }, @@ -189,16 +199,16 @@ const prepareRemoteChainFacadeKit = ( return watch(E(icqConnection).query(msgs)); }, }, - getAddressWatcher: { + getAddressesWatcher: { /** - * @param {ChainAddress} chainAddress + * @param {[ChainAddress, LocalIbcAddress, RemoteIbcAddress]} chainAddresses * @param {IcaAccount} account */ - onFulfilled(chainAddress, account) { + onFulfilled([chainAddress, localAddress, remoteAddress], account) { return watch( E(storageNode).makeChildNode(chainAddress.value), this.facets.makeChildNodeWatcher, - { account, chainAddress }, + { account, chainAddress, localAddress, remoteAddress }, ); }, }, @@ -208,20 +218,33 @@ const prepareRemoteChainFacadeKit = ( * @param {{ * account: IcaAccount; * chainAddress: ChainAddress; + * localAddress: LocalIbcAddress; + * remoteAddress: RemoteIbcAddress; * }} ctx */ - onFulfilled(childNode, { account, chainAddress }) { + onFulfilled( + childNode, + { account, chainAddress, localAddress, remoteAddress }, + ) { const { remoteChainInfo, icqConnection } = this.state; const stakingDenom = remoteChainInfo.stakingTokens?.[0]?.denom; if (!stakingDenom) throw Fail`chain info lacks staking denom`; - return makeCosmosOrchestrationAccount(chainAddress, stakingDenom, { - account, - // FIXME storage path https://github.com/Agoric/agoric-sdk/issues/9066 - storageNode: childNode, - icqConnection, - timer, - }); + return makeCosmosOrchestrationAccount( + { + chainAddress, + bondDenom: stakingDenom, + localAddress, + remoteAddress, + }, + { + account, + // FIXME storage path https://github.com/Agoric/agoric-sdk/issues/9066 + storageNode: childNode, + icqConnection, + timer, + }, + ); }, }, }, 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 e82b5bc1faa..e93a0cae30a 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 @@ -11,14 +11,14 @@ Generated by [AVA](https://avajs.dev). Object @Map Iterator { [ 'mockChainStorageRoot.stakeAtom.accounts.cosmos1test', - '{"body":"#\\"\\"","slots":[]}', + '{"body":"#{\\"localAddress\\":\\"/ibc-port/icacontroller-1/ordered/{\\\\\\"version\\\\\\":\\\\\\"ics27-1\\\\\\",\\\\\\"controllerConnectionId\\\\\\":\\\\\\"connection-8\\\\\\",\\\\\\"hostConnectionId\\\\\\":\\\\\\"connection-649\\\\\\",\\\\\\"address\\\\\\":\\\\\\"cosmos1test\\\\\\",\\\\\\"encoding\\\\\\":\\\\\\"proto3\\\\\\",\\\\\\"txType\\\\\\":\\\\\\"sdk_multi_msg\\\\\\"}/ibc-channel/channel-0\\",\\"remoteAddress\\":\\"/ibc-hop/connection-8/ibc-port/icahost/ordered/{\\\\\\"version\\\\\\":\\\\\\"ics27-1\\\\\\",\\\\\\"controllerConnectionId\\\\\\":\\\\\\"connection-8\\\\\\",\\\\\\"hostConnectionId\\\\\\":\\\\\\"connection-649\\\\\\",\\\\\\"address\\\\\\":\\\\\\"cosmos1test\\\\\\",\\\\\\"encoding\\\\\\":\\\\\\"proto3\\\\\\",\\\\\\"txType\\\\\\":\\\\\\"sdk_multi_msg\\\\\\"}/ibc-channel/channel-0\\"}","slots":[]}', ], [ 'mockChainStorageRoot.stakeAtom.accounts.cosmos1test1', - '{"body":"#\\"\\"","slots":[]}', + '{"body":"#{\\"localAddress\\":\\"/ibc-port/icacontroller-2/ordered/{\\\\\\"version\\\\\\":\\\\\\"ics27-1\\\\\\",\\\\\\"controllerConnectionId\\\\\\":\\\\\\"connection-8\\\\\\",\\\\\\"hostConnectionId\\\\\\":\\\\\\"connection-649\\\\\\",\\\\\\"address\\\\\\":\\\\\\"cosmos1test1\\\\\\",\\\\\\"encoding\\\\\\":\\\\\\"proto3\\\\\\",\\\\\\"txType\\\\\\":\\\\\\"sdk_multi_msg\\\\\\"}/ibc-channel/channel-1\\",\\"remoteAddress\\":\\"/ibc-hop/connection-8/ibc-port/icahost/ordered/{\\\\\\"version\\\\\\":\\\\\\"ics27-1\\\\\\",\\\\\\"controllerConnectionId\\\\\\":\\\\\\"connection-8\\\\\\",\\\\\\"hostConnectionId\\\\\\":\\\\\\"connection-649\\\\\\",\\\\\\"address\\\\\\":\\\\\\"cosmos1test1\\\\\\",\\\\\\"encoding\\\\\\":\\\\\\"proto3\\\\\\",\\\\\\"txType\\\\\\":\\\\\\"sdk_multi_msg\\\\\\"}/ibc-channel/channel-1\\"}","slots":[]}', ], [ 'mockChainStorageRoot.stakeOsmo.accounts.osmo1test2', - '{"body":"#\\"\\"","slots":[]}', + '{"body":"#{\\"localAddress\\":\\"/ibc-port/icacontroller-3/ordered/{\\\\\\"version\\\\\\":\\\\\\"ics27-1\\\\\\",\\\\\\"controllerConnectionId\\\\\\":\\\\\\"connection-1\\\\\\",\\\\\\"hostConnectionId\\\\\\":\\\\\\"connection-1649\\\\\\",\\\\\\"address\\\\\\":\\\\\\"osmo1test2\\\\\\",\\\\\\"encoding\\\\\\":\\\\\\"proto3\\\\\\",\\\\\\"txType\\\\\\":\\\\\\"sdk_multi_msg\\\\\\"}/ibc-channel/channel-2\\",\\"remoteAddress\\":\\"/ibc-hop/connection-1/ibc-port/icahost/ordered/{\\\\\\"version\\\\\\":\\\\\\"ics27-1\\\\\\",\\\\\\"controllerConnectionId\\\\\\":\\\\\\"connection-1\\\\\\",\\\\\\"hostConnectionId\\\\\\":\\\\\\"connection-1649\\\\\\",\\\\\\"address\\\\\\":\\\\\\"osmo1test2\\\\\\",\\\\\\"encoding\\\\\\":\\\\\\"proto3\\\\\\",\\\\\\"txType\\\\\\":\\\\\\"sdk_multi_msg\\\\\\"}/ibc-channel/channel-0\\"}","slots":[]}', ], } 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 5571fd10de5c40947bbd29c897803ee36448e500..96291e4fd799e78938190c69ce7baa09fc655dff 100644 GIT binary patch literal 638 zcmV-^0)hQORzV@5l#;RIwDRNf=i6NK#u7XhXCfNlW7# zjE$3IUS8u%rMbIbh*}2RmXdIBaU{B43XrF zcwLOD-}X19Xx!s}C8v}ex+T{tBdnD?uRP1>IGylQ8#zIS2Pzl=)Gy!%q| Ywp4U#qasU7T literal 404 zcmV;F0c-w2RzVWO> z8TU?2G9QZw00000000B6kk3j2Q543%Go$uLLB(3Nk!jIJB(0US(N;krR1lRd?#$fO zoS7?gPmx3m*F8k!GxQj(+PCUKiW!wqYEg7H=ezLn`(4h{rR^!Fr-viSUH0VdrZ0N3 z?l|f?;8L+Ix$e55)H==$;fcVJGmowgL|=Cl>*2_C1&dRoVPXN%&>|&v05kyX12_Rt zpj4hxP5VkZOq8e0f#?_aScZaCn9JrsOP~)>n#dkQ(nJr@rZ_%F(TLFI8-RBJ8#KWt z>tQGcWK2^wn~jUd1e_7@I9D|1i!TJc60mB_785hY<@f^`6v)5|nV@8zwtVIEb~~aQ z9I*;TTOKOKWzEqwS@~r#KB+D{O8ZJzSZbayh^COWJIhw?z^Ym6Evsc&B};pXwN*R6 yxc^?e{ReI3AH3C{y-n?_FK?uct<_&yr6+9Z@f1_+2E`jaHhluV;h%=H0ssJYrn~+C diff --git a/packages/orchestration/test/examples/stake-ica.contract.test.ts b/packages/orchestration/test/examples/stake-ica.contract.test.ts index dbc5ba6a4b3..ffcf19973cb 100644 --- a/packages/orchestration/test/examples/stake-ica.contract.test.ts +++ b/packages/orchestration/test/examples/stake-ica.contract.test.ts @@ -219,14 +219,24 @@ test('makeAccountInvitationMaker', async t => { offerResult.publicSubscribers.account.subscriber, ); const storageUpdate = await E(accountNotifier).getUpdateSince(); + const expectedStorageValue = { + localAddress: + '/ibc-port/icacontroller-1/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-0', + remoteAddress: + '/ibc-hop/connection-8/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-0', + }; + t.deepEqual(storageUpdate, { updateCount: 1n, - value: '', + value: expectedStorageValue, }); const vstorageEntry = bootstrap.storage.data.get( 'mockChainStorageRoot.stakeAtom.accounts.cosmos1test', ); t.truthy(vstorageEntry, 'vstorage account entry created'); - t.is(bootstrap.marshaller.fromCapData(JSON.parse(vstorageEntry!)), ''); + t.deepEqual( + bootstrap.marshaller.fromCapData(JSON.parse(vstorageEntry!)), + expectedStorageValue, + ); }); diff --git a/packages/orchestration/test/exos/make-test-coa-kit.ts b/packages/orchestration/test/exos/make-test-coa-kit.ts index 4cfb283a580..636ac23baaf 100644 --- a/packages/orchestration/test/exos/make-test-coa-kit.ts +++ b/packages/orchestration/test/exos/make-test-coa-kit.ts @@ -66,15 +66,22 @@ export const prepareMakeTestCOAKit = ( controllerConnectionId, ); - const accountAddress = await E(cosmosOrchAccount).getAddress(); + const [chainAddress, localAddress, remoteAddress] = await Promise.all([ + E(cosmosOrchAccount).getAddress(), + E(cosmosOrchAccount).getLocalAddress(), + E(cosmosOrchAccount).getRemoteAddress(), + ]); t.log('make a CosmosOrchestrationAccount'); - const holder = makeCosmosOrchestrationAccount(accountAddress, bondDenom, { - account: cosmosOrchAccount, - storageNode: storageNode.makeChildNode(accountAddress.value), - icqConnection: undefined, - timer, - }); + const holder = makeCosmosOrchestrationAccount( + { chainAddress, bondDenom, localAddress, remoteAddress }, + { + account: cosmosOrchAccount, + storageNode: storageNode.makeChildNode(chainAddress.value), + icqConnection: undefined, + timer, + }, + ); return holder; }; diff --git a/packages/orchestration/test/staking-ops.test.ts b/packages/orchestration/test/staking-ops.test.ts index 6072295f813..b7fd6275014 100644 --- a/packages/orchestration/test/staking-ops.test.ts +++ b/packages/orchestration/test/staking-ops.test.ts @@ -45,6 +45,10 @@ test('MsgDelegateResponse trivial response', t => { const configStaking = { acct1: { value: 'agoric1spy36ltduehs5dmszfrp792f0k2emcntrql3nx', + localAddress: + '/ibc-port/icacontroller-1/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-0', + remoteAddress: + '/ibc-hop/connection-8/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-8","hostConnectionId":"connection-649","address":"cosmos1test","encoding":"proto3","txType":"sdk_multi_msg"}/ibc-channel/channel-0', }, validator: { value: 'agoric1valoper234', @@ -153,8 +157,8 @@ const makeScenario = () => { }, executeTx: () => Fail`mock`, deactivate: () => Fail`mock`, - getLocalAddress: () => Fail`mock`, - getRemoteAddress: () => Fail`mock`, + getLocalAddress: () => configStaking.acct1.localAddress, + getRemoteAddress: () => configStaking.acct1.remoteAddress, getPort: () => Fail`mock`, reactivate: () => Fail`mock`, }); @@ -241,12 +245,20 @@ test('makeAccount() writes to storage', async t => { zcf, }); - const { holder } = make(account.getAddress(), 'uatom', { - account, - storageNode, - icqConnection, - timer, - }); + const { holder } = make( + { + chainAddress: account.getAddress(), + localAddress: account.getLocalAddress(), + remoteAddress: account.getRemoteAddress(), + bondDenom: 'uatom', + }, + { + account, + storageNode, + icqConnection, + timer, + }, + ); const { publicSubscribers } = await E.when(holder.asContinuingOffer()); const accountNotifier = makeNotifierFromSubscriber( // @ts-expect-error the promise from `subscriber.getUpdateSince` can't be used in a flow @@ -255,7 +267,10 @@ test('makeAccount() writes to storage', async t => { const storageUpdate = await E(accountNotifier).getUpdateSince(); t.deepEqual(storageUpdate, { updateCount: 1n, - value: '', + value: { + localAddress: configStaking.acct1.localAddress, + remoteAddress: configStaking.acct1.remoteAddress, + }, }); }); @@ -280,12 +295,20 @@ test('withdrawRewards() on StakingAccountHolder formats message correctly', asyn }); // Higher fidelity tests below use invitationMakers. - const { holder } = make(account.getAddress(), 'uatom', { - account, - storageNode, - icqConnection, - timer, - }); + const { holder } = make( + { + chainAddress: account.getAddress(), + localAddress: account.getLocalAddress(), + remoteAddress: account.getRemoteAddress(), + bondDenom: 'uatom', + }, + { + account, + storageNode, + icqConnection, + timer, + }, + ); const { validator } = configStaking; const actual = await E(holder).withdrawReward(validator); t.deepEqual(actual, [{ denom: 'uatom', value: 2n }]); @@ -318,12 +341,20 @@ test(`delegate; redelegate using invitationMakers`, async t => { zcf, }); - const { invitationMakers } = makeAccountKit(account.getAddress(), 'uatom', { - account, - storageNode, - icqConnection, - timer, - }); + const { invitationMakers } = makeAccountKit( + { + chainAddress: account.getAddress(), + localAddress: account.getLocalAddress(), + remoteAddress: account.getRemoteAddress(), + bondDenom: 'uatom', + }, + { + account, + storageNode, + icqConnection, + timer, + }, + ); const { validator, delegations } = configStaking; { @@ -401,12 +432,20 @@ test(`withdraw rewards using invitationMakers`, async t => { zcf, }); - const { invitationMakers } = makeAccountKit(account.getAddress(), 'uatom', { - account, - storageNode, - icqConnection, - timer, - }); + const { invitationMakers } = makeAccountKit( + { + chainAddress: account.getAddress(), + localAddress: account.getLocalAddress(), + remoteAddress: account.getRemoteAddress(), + bondDenom: 'uatom', + }, + { + account, + storageNode, + icqConnection, + timer, + }, + ); const { validator } = configStaking; const toWithdraw = await E(invitationMakers).WithdrawReward(validator); @@ -442,12 +481,20 @@ test(`undelegate waits for unbonding period`, async t => { zcf, }); - const { invitationMakers } = makeAccountKit(account.getAddress(), 'uatom', { - account, - storageNode, - icqConnection, - timer, - }); + const { invitationMakers } = makeAccountKit( + { + chainAddress: account.getAddress(), + localAddress: account.getLocalAddress(), + remoteAddress: account.getRemoteAddress(), + bondDenom: 'uatom', + }, + { + account, + storageNode, + icqConnection, + timer, + }, + ); const { validator, delegations } = configStaking; diff --git a/packages/orchestration/test/types.test-d.ts b/packages/orchestration/test/types.test-d.ts index 894a91938a6..6a0db165743 100644 --- a/packages/orchestration/test/types.test-d.ts +++ b/packages/orchestration/test/types.test-d.ts @@ -75,7 +75,6 @@ expectNotType(chainAddr); makeCosmosOrchestrationAccount( anyVal, anyVal, - anyVal, ) satisfies HostInterface; } From cee75f67afccafc4cc723f761761774ff4caf18f Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Wed, 7 Aug 2024 16:19:17 -0400 Subject: [PATCH 2/6] feat: parseRemoteAddress and parseLocalAddress tools - helper functions for extracting connectionID, portID, and channelID from a negotiated (Local|Remote)IBCAddress string --- multichain-testing/test/tools/address.test.ts | 31 ++++++++++ multichain-testing/tools/address.ts | 56 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 multichain-testing/test/tools/address.test.ts create mode 100644 multichain-testing/tools/address.ts diff --git a/multichain-testing/test/tools/address.test.ts b/multichain-testing/test/tools/address.test.ts new file mode 100644 index 00000000000..6579434ddf0 --- /dev/null +++ b/multichain-testing/test/tools/address.test.ts @@ -0,0 +1,31 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { parseRemoteAddress, parseLocalAddress } from '../../tools/address.js'; + +const test = anyTest as TestFn>; + +test('parseRemoteAddress correctly parses a remote IBC address', t => { + const remoteAddress = + '/ibc-hop/connection-0/ibc-port/icahost/ordered/{"version":"ics27-1","controller_connection_id":"connection-0","host_connection_id":"connection-1","address":"cosmos1wrz3ltf3k6leg39t353qmj5xk5gfpf4wh5mtw7mqcrw2k2072j2sud9rhl","encoding":"proto3","tx_type":"sdk_multi_msg"}/ibc-channel/channel-2'; + + const result = parseRemoteAddress(remoteAddress); + + t.deepEqual(result, { + rConnectionID: 'connection-1', + rPortID: 'icahost', + rChannelID: 'channel-2', + }); +}); + +test('parseLocalAddress correctly parses a local IBC address', t => { + const localAddress = + '/ibc-port/icacontroller-3/ordered/{"version":"ics27-1","controller_connection_id":"connection-0","host_connection_id":"connection-1","address":"cosmos1wrz3ltf3k6leg39t353qmj5xk5gfpf4wh5mtw7mqcrw2k2072j2sud9rhl","encoding":"proto3","tx_type":"sdk_multi_msg"}/ibc-channel/channel-4'; + + const result = parseLocalAddress(localAddress); + + t.deepEqual(result, { + lConnectionID: 'connection-0', + lPortID: 'icacontroller-3', + lChannelID: 'channel-4', + }); +}); diff --git a/multichain-testing/tools/address.ts b/multichain-testing/tools/address.ts new file mode 100644 index 00000000000..e81e3f94164 --- /dev/null +++ b/multichain-testing/tools/address.ts @@ -0,0 +1,56 @@ +import type { IBCChannelID, IBCConnectionID, IBCPortID } from '@agoric/vats'; +import { + type LocalIbcAddress, + type RemoteIbcAddress, +} from '@agoric/vats/tools/ibc-utils.js'; + +// XXX consider moving to vats/tools/ibc-utils by updating current values or renaming as `NEGOTIATED_...` +// These take those values and build off of them +const REMOTE_ADDR_RE = + /^(?(?:\/ibc-hop\/[^/]+)*)\/ibc-port\/(?[^/]+)\/(?ordered|unordered)\/(?{[^}]+})\/ibc-channel\/(?[^/]+)$/; +const LOCAL_ADDR_RE = + /^\/ibc-port\/(?[^/]+)\/(?ordered|unordered)\/(?{[^}]+})\/ibc-channel\/(?[^/]+)$/; + +export const parseRemoteAddress = ( + address: RemoteIbcAddress, +): { + rConnectionID: IBCConnectionID; + rPortID: IBCPortID; + rChannelID: IBCChannelID; +} => { + const match = address.match(REMOTE_ADDR_RE); + if (!match || !match.groups) { + throw new Error('Invalid remote address format'); + } + + const { portID, version, channelID } = match.groups; + const versionObj = JSON.parse(version); + + return { + rConnectionID: versionObj.host_connection_id, + rPortID: portID, + rChannelID: channelID as IBCChannelID, + }; +}; + +export const parseLocalAddress = ( + address: LocalIbcAddress, +): { + lConnectionID: IBCConnectionID; + lPortID: IBCPortID; + lChannelID: IBCChannelID; +} => { + const match = address.match(LOCAL_ADDR_RE); + if (!match || !match.groups) { + throw new Error('Invalid local address format'); + } + + const { portID, version, channelID } = match.groups; + const versionObj = JSON.parse(version); + + return { + lConnectionID: versionObj.controller_connection_id, + lPortID: portID, + lChannelID: channelID as IBCChannelID, + }; +}; From ac9d0c2622c719357010d3c4d7a65f7a42e92ebe Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Wed, 7 Aug 2024 17:38:35 -0400 Subject: [PATCH 3/6] feat: add hermes client to tools --- multichain-testing/test/support.ts | 11 +++- multichain-testing/tools/hermes-tools.ts | 70 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 multichain-testing/tools/hermes-tools.ts diff --git a/multichain-testing/test/support.ts b/multichain-testing/test/support.ts index 98f7c0490ac..a3172eaf9ff 100644 --- a/multichain-testing/test/support.ts +++ b/multichain-testing/test/support.ts @@ -9,6 +9,7 @@ import { makeGetFile, makeSetupRegistry } from '../tools/registry.js'; import { generateMnemonic } from '../tools/wallet.js'; import { makeRetryUntilCondition } from '../tools/sleep.js'; import { makeDeployBuilder } from '../tools/deploy.js'; +import { makeHermes } from '../tools/hermes-tools.js'; const setupRegistry = makeSetupRegistry(makeGetFile({ dirname, join })); @@ -59,8 +60,16 @@ export const commonSetup = async (t: ExecutionContext) => { log: t.log, setTimeout: globalThis.setTimeout, }); + const hermes = makeHermes(childProcess); - return { useChain, ...tools, ...keyring, retryUntilCondition, deployBuilder }; + return { + useChain, + ...tools, + ...keyring, + retryUntilCondition, + deployBuilder, + hermes, + }; }; export type SetupContext = Awaited>; diff --git a/multichain-testing/tools/hermes-tools.ts b/multichain-testing/tools/hermes-tools.ts new file mode 100644 index 00000000000..4868fdd3f88 --- /dev/null +++ b/multichain-testing/tools/hermes-tools.ts @@ -0,0 +1,70 @@ +import type { IBCChannelID, IBCConnectionID, IBCPortID } from '@agoric/vats'; +import type { ExecSync } from './agd-lib.js'; + +const kubectlBinary = 'kubectl'; + +// based on config.yaml +const relayerMap: { [key: string]: string } = { + osmosis: 'hermes-agoric-osmosis-0', + cosmoshub: 'hermes-agoric-gaia-0', +}; + +const makeKubeArgs = (chainName: string) => { + if (!relayerMap[chainName]) throw Error('Unsupported chain: ' + chainName); + return [ + 'exec', + '-i', + relayerMap[chainName], + '-c', + 'relayer', + '--tty=false', + '--', + 'hermes', + ]; +}; + +type ChannelCloseParams = { + dst: { + chainId: string; + portID: IBCPortID; + channelID: IBCChannelID; + connectionID: IBCConnectionID; + }; + src: { + chainId: string; + portID: IBCPortID; + channelID: IBCChannelID; + }; +}; + +export const makeHermes = ({ execFileSync }: { execFileSync: ExecSync }) => { + const exec = ( + chainName: string, + args: string[], + opts = { encoding: 'utf-8' as const, stdio: ['ignore', 'pipe', 'ignore'] }, + ) => execFileSync(kubectlBinary, [...makeKubeArgs(chainName), ...args], opts); + + /** Submit MsgChannelCloseInit to the src chain */ + const channelCloseInit = ( + chainName: string, + dst: ChannelCloseParams['dst'], + src: ChannelCloseParams['src'], + ) => { + return exec(chainName, [ + 'tx', + 'chan-close-init', + `--dst-chain=${dst.chainId}`, + `--src-chain=${src.chainId}`, + `--dst-connection=${dst.connectionID}`, + `--dst-port=${dst.portID}`, + `--src-port=${src.portID}`, + `--dst-channel=${dst.channelID}`, + `--src-channel=${src.channelID}`, + ]); + }; + + return { + exec, + channelCloseInit, + }; +}; From 3639831175485d5500965a5e493b007683fe8dbf Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Wed, 28 Aug 2024 19:34:30 -0400 Subject: [PATCH 4/6] feat: startContract helper - helper function to install a contract from a proposal builder - queries vstorage and skips installation and CoreEval if instance is found --- multichain-testing/test/auto-stake-it.test.ts | 11 ++------ multichain-testing/test/basic-flows.test.ts | 11 ++------ multichain-testing/test/send-anywhere.test.ts | 11 ++------ multichain-testing/test/support.ts | 28 +++++++++++++++++++ 4 files changed, 34 insertions(+), 27 deletions(-) diff --git a/multichain-testing/test/auto-stake-it.test.ts b/multichain-testing/test/auto-stake-it.test.ts index e9a82de97a2..69af5caf669 100644 --- a/multichain-testing/test/auto-stake-it.test.ts +++ b/multichain-testing/test/auto-stake-it.test.ts @@ -25,15 +25,8 @@ test.before(async t => { deleteTestKeys(accounts).catch(); const wallets = await setupTestKeys(accounts); t.context = { ...rest, wallets, deleteTestKeys }; - - t.log('bundle and install contract', contractName); - await t.context.deployBuilder(contractBuilder); - const { vstorageClient } = t.context; - await t.context.retryUntilCondition( - () => vstorageClient.queryData(`published.agoricNames.instance`), - res => contractName in Object.fromEntries(res), - `${contractName} instance is available`, - ); + const { startContract } = rest; + await startContract(contractName, contractBuilder); }); test.after(async t => { diff --git a/multichain-testing/test/basic-flows.test.ts b/multichain-testing/test/basic-flows.test.ts index f9e710c5be3..9d8d9f57e44 100644 --- a/multichain-testing/test/basic-flows.test.ts +++ b/multichain-testing/test/basic-flows.test.ts @@ -20,15 +20,8 @@ test.before(async t => { deleteTestKeys(accounts).catch(); const wallets = await setupTestKeys(accounts); t.context = { ...rest, wallets, deleteTestKeys }; - - t.log('bundle and install contract', contractName); - await t.context.deployBuilder(contractBuilder); - const { vstorageClient } = t.context; - await t.context.retryUntilCondition( - () => vstorageClient.queryData(`published.agoricNames.instance`), - res => contractName in Object.fromEntries(res), - `${contractName} instance is available`, - ); + const { startContract } = rest; + await startContract(contractName, contractBuilder); }); test.after(async t => { diff --git a/multichain-testing/test/send-anywhere.test.ts b/multichain-testing/test/send-anywhere.test.ts index d27639c8607..54a7f0fe3cb 100644 --- a/multichain-testing/test/send-anywhere.test.ts +++ b/multichain-testing/test/send-anywhere.test.ts @@ -24,15 +24,8 @@ test.before(async t => { deleteTestKeys(accounts).catch(); const wallets = await setupTestKeys(accounts); t.context = { ...rest, wallets, deleteTestKeys }; - - t.log('bundle and install contract', contractName); - await t.context.deployBuilder(contractBuilder); - const { vstorageClient } = t.context; - await t.context.retryUntilCondition( - () => vstorageClient.queryData(`published.agoricNames.instance`), - res => contractName in Object.fromEntries(res), - `${contractName} instance is available`, - ); + const { startContract } = rest; + await startContract(contractName, contractBuilder); }); test.after(async t => { diff --git a/multichain-testing/test/support.ts b/multichain-testing/test/support.ts index a3172eaf9ff..065c656c995 100644 --- a/multichain-testing/test/support.ts +++ b/multichain-testing/test/support.ts @@ -62,6 +62,33 @@ export const commonSetup = async (t: ExecutionContext) => { }); const hermes = makeHermes(childProcess); + /** + * Starts a contract if instance not found. Takes care of installing + * bundles and voting on the CoreEval proposal. + * + * @param contractName name of the contract in agoricNames + * @param contractBuilder path to proposal builder + */ + const startContract = async ( + contractName: string, + contractBuilder: string, + ) => { + const { vstorageClient } = tools; + const instances = Object.fromEntries( + await vstorageClient.queryData(`published.agoricNames.instance`), + ); + if (contractName in instances) { + return t.log('Contract found. Skipping installation...'); + } + t.log('bundle and install contract', contractName); + await deployBuilder(contractBuilder); + await retryUntilCondition( + () => vstorageClient.queryData(`published.agoricNames.instance`), + res => contractName in Object.fromEntries(res), + `${contractName} instance is available`, + ); + }; + return { useChain, ...tools, @@ -69,6 +96,7 @@ export const commonSetup = async (t: ExecutionContext) => { retryUntilCondition, deployBuilder, hermes, + startContract, }; }; From ad92ca286bf3e164d347c6677e1f181290ac121c Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 19 Aug 2024 15:12:36 -0400 Subject: [PATCH 5/6] test: e2e test of ica channel close flows - CosmosOrchAccount (IcaAccout) holder can deactivate their account (close channel) - CosmosOrchAccount (IcaAccount) holder can reactivate their account (open new channel w same portID) --- .../test/ica-channel-close.test.ts | 206 ++++++++++++++++++ multichain-testing/tools/query.ts | 8 + 2 files changed, 214 insertions(+) create mode 100644 multichain-testing/test/ica-channel-close.test.ts diff --git a/multichain-testing/test/ica-channel-close.test.ts b/multichain-testing/test/ica-channel-close.test.ts new file mode 100644 index 00000000000..80610740633 --- /dev/null +++ b/multichain-testing/test/ica-channel-close.test.ts @@ -0,0 +1,206 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import type { CosmosOrchestrationAccountStorageState } from '@agoric/orchestration/src/exos/cosmos-orchestration-account.js'; +import type { IdentifiedChannelSDKType } from '@agoric/cosmic-proto/ibc/core/channel/v1/channel.js'; +import type { IBCPortID } from '@agoric/vats'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import { + commonSetup, + SetupContextWithWallets, + chainConfig, +} from './support.js'; +import { makeQueryClient } from '../tools/query.js'; +import { parseLocalAddress, parseRemoteAddress } from '../tools/address.js'; + +const test = anyTest as TestFn; + +const accounts = ['cosmoshub', 'osmosis']; + +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); +}); + +// XXX until https://github.com/Agoric/agoric-sdk/issues/9066, +// where localAddr + remoteAddr are reliably published to vstorage, +// us original port to determine new channelID. currently, vstorage +// values won't be updated when the ICA channel is reopened. +const findNewChannel = ( + channels: IdentifiedChannelSDKType[], + { rPortID, lPortID }: { rPortID: IBCPortID; lPortID: IBCPortID }, +) => + channels.find( + c => + c.port_id === rPortID && + c.counterparty.port_id === lPortID && + // @ts-expect-error ChannelSDKType.state is a string not a number + c.state === 'STATE_OPEN', + ); + +/** The account holder chooses to close their ICA account (channel) */ +const intentionalCloseAccountScenario = test.macro({ + title: (_, chainName: string) => `Close and reopen account 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 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} makeAccount offer`); + const offerId = `${chainName}-makeAccount-${Date.now()}`; + + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeOrchAccountInvitation']], + }, + offerArgs: { chainName }, + proposal: {}, + }); + const currentWalletRecord = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[offerId], + `${offerId} continuing invitation is in vstorage`, + ); + const offerToPublicSubscriberMap = Object.fromEntries( + currentWalletRecord.offerToPublicSubscriberPaths, + ); + + const accountStoragePath = offerToPublicSubscriberMap[offerId]?.account; + t.assert(accountStoragePath, 'account storage path returned'); + const address = accountStoragePath.split('.').pop(); + t.log('Got address:', address); + + const { + remoteAddress, + localAddress, + }: CosmosOrchestrationAccountStorageState = + await vstorageClient.queryData(accountStoragePath); + const { rPortID, rChannelID } = parseRemoteAddress(remoteAddress); + + const remoteQueryClient = makeQueryClient( + await useChain(chainName).getRestEndpoint(), + ); + const localQueryClient = makeQueryClient( + await useChain('agoric').getRestEndpoint(), + ); + + const { channel } = await retryUntilCondition( + () => remoteQueryClient.queryChannel(rPortID, rChannelID), + // @ts-expect-error ChannelSDKType.state is a string not a number + ({ channel }) => channel?.state === 'STATE_OPEN', + `ICA channel is open on Host - ${chainName}`, + ); + t.log('Channel State Before', channel); + // @ts-expect-error ChannelSDKType.state is a string not a number + t.is(channel?.state, 'STATE_OPEN', 'channel is open'); + + const closeAccountOfferId = `${chainName}-deactivateAccount-${Date.now()}`; + await doOffer({ + id: closeAccountOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: offerId, + invitationMakerName: 'DeactivateAccount', + }, + proposal: {}, + }); + + const { channel: rChannelAfterClose } = await retryUntilCondition( + () => remoteQueryClient.queryChannel(rPortID, rChannelID), + // @ts-expect-error ChannelSDKType.state is a string not a number + ({ channel }) => channel?.state === 'STATE_CLOSED', + `ICA channel is closed on Host - ${chainName}`, + ); + t.log('Remote Channel State After', rChannelAfterClose); + t.is( + rChannelAfterClose?.state, + // @ts-expect-error ChannelSDKType.state is a string not a number + 'STATE_CLOSED', + `channel is closed from host perspective - ${chainName}`, + ); + + const { lPortID, lChannelID } = parseLocalAddress(localAddress); + const { channel: lChannelAfterClose } = await retryUntilCondition( + () => localQueryClient.queryChannel(lPortID, lChannelID), + // @ts-expect-error ChannelSDKType.state is a string not a number + ({ channel }) => channel?.state === 'STATE_CLOSED', + `ICA channel is closed on Controller - ${chainName}`, + ); + t.log('Local Channel State After', lChannelAfterClose); + if (!lChannelAfterClose?.state) throw Error('channel state is available'); + t.is( + lChannelAfterClose.state, + // @ts-expect-error ChannelSDKType.state is a string not a number + 'STATE_CLOSED', + `channel is closed from controller perspective - ${chainName}`, + ); + + const reopenAccountOfferId = `${chainName}-reactivateAccount-${Date.now()}`; + await doOffer({ + id: reopenAccountOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: offerId, + invitationMakerName: 'ReactivateAccount', + }, + proposal: {}, + }); + + const { channels } = await retryUntilCondition( + () => remoteQueryClient.queryChannels(), + ({ channels }) => !!findNewChannel(channels, { rPortID, lPortID }), + `ICA channel is reopened on ${chainName} Host`, + ); + const newChannel = findNewChannel(channels, { rPortID, lPortID }); + t.log('New Channel after Reactivate', newChannel); + if (!newChannel) throw new Error('Channel not found'); + const newAddress = JSON.parse(newChannel.version).address; + t.is(newAddress, address, `same chain address is returned - ${chainName}`); + t.is( + newChannel.state, + // @ts-expect-error ChannelSDKType.state is a string not a number + 'STATE_OPEN', + `channel is open on ${chainName} Host`, + ); + t.not(newChannel.channel_id, rChannelID, 'remote channel id changed'); + t.not( + newChannel.counterparty.channel_id, + lChannelID, + 'local channel id changed', + ); + }, +}); + +test.serial(intentionalCloseAccountScenario, 'cosmoshub'); +test.serial(intentionalCloseAccountScenario, 'osmosis'); diff --git a/multichain-testing/tools/query.ts b/multichain-testing/tools/query.ts index 5f1d6d18f2e..bfd9adf0ce4 100644 --- a/multichain-testing/tools/query.ts +++ b/multichain-testing/tools/query.ts @@ -7,6 +7,8 @@ import type { QueryValidatorsResponseSDKType } from '@agoric/cosmic-proto/cosmos import type { QueryDelegatorDelegationsResponseSDKType } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; import type { QueryDelegatorUnbondingDelegationsResponseSDKType } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; import type { QueryDenomHashResponseSDKType } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/query.js'; +import type { QueryChannelResponseSDKType } from '@agoric/cosmic-proto/ibc/core/channel/v1/query.js'; +import { QueryChannelsResponseSDKType } from '@agoric/cosmic-proto/ibc/core/channel/v1/query.js'; // TODO use telescope generated query client from @agoric/cosmic-proto // https://github.com/Agoric/agoric-sdk/issues/9200 @@ -52,5 +54,11 @@ export function makeQueryClient(apiUrl: string) { query( `/ibc/apps/transfer/v1/denom_hashes/${path}/${baseDenom}`, ), + queryChannel: (portID: string, channelID: string) => + query( + `/ibc/core/channel/v1/channels/${channelID}/ports/${portID}`, + ), + queryChannels: () => + query(`/ibc/core/channel/v1/channels`), }; } From bb6d53d8192a096a04444136f236303187e861f6 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 19 Aug 2024 15:14:21 -0400 Subject: [PATCH 6/6] test: e2e channel closure - ensures clients cannot initiate channelCloseInit --- .../test/ica-channel-close.test.ts | 172 +++++++++++++++++- 1 file changed, 171 insertions(+), 1 deletion(-) diff --git a/multichain-testing/test/ica-channel-close.test.ts b/multichain-testing/test/ica-channel-close.test.ts index 80610740633..3fa37d1498b 100644 --- a/multichain-testing/test/ica-channel-close.test.ts +++ b/multichain-testing/test/ica-channel-close.test.ts @@ -2,7 +2,7 @@ import anyTest from '@endo/ses-ava/prepare-endo.js'; import type { TestFn } from 'ava'; import type { CosmosOrchestrationAccountStorageState } from '@agoric/orchestration/src/exos/cosmos-orchestration-account.js'; import type { IdentifiedChannelSDKType } from '@agoric/cosmic-proto/ibc/core/channel/v1/channel.js'; -import type { IBCPortID } from '@agoric/vats'; +import type { IBCChannelID, IBCPortID } from '@agoric/vats'; import { makeDoOffer } from '../tools/e2e-tools.js'; import { commonSetup, @@ -11,6 +11,7 @@ import { } from './support.js'; import { makeQueryClient } from '../tools/query.js'; import { parseLocalAddress, parseRemoteAddress } from '../tools/address.js'; +import chainInfo from '../starship-chain-info.js'; const test = anyTest as TestFn; @@ -202,5 +203,174 @@ const intentionalCloseAccountScenario = test.macro({ }, }); +/** Only application logic should be able to close an ICA channel; not channelCloseInit. */ +const channelCloseInitScenario = test.macro({ + title: (_, chainName: string) => + `Clients cannot initiate channelCloseInit 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, + hermes, + } = t.context; + + // make an account so there's an ICA channel we can attempt to close + 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} makeAccount offer`); + const offerId = `${chainName}-makeAccount-${Date.now()}`; + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeOrchAccountInvitation']], + }, + offerArgs: { chainName }, + proposal: {}, + }); + const currentWalletRecord = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[offerId], + `${offerId} continuing invitation is in vstorage`, + ); + const offerToPublicSubscriberMap = Object.fromEntries( + currentWalletRecord.offerToPublicSubscriberPaths, + ); + + const accountStoragePath = offerToPublicSubscriberMap[offerId]?.account; + t.assert(accountStoragePath, 'account storage path returned'); + const address = accountStoragePath.split('.').pop(); + t.log('Got address:', address); + + const { + remoteAddress, + localAddress, + }: CosmosOrchestrationAccountStorageState = + await vstorageClient.queryData(accountStoragePath); + const { rPortID, rChannelID, rConnectionID } = + parseRemoteAddress(remoteAddress); + const { lPortID, lChannelID, lConnectionID } = + parseLocalAddress(localAddress); + + const dst = { + chainId: chainInfo['agoric'].chainId, + channelID: lChannelID, + portID: lPortID, + connectionID: lConnectionID, + }; + const src = { + chainId: useChain(chainName).chainInfo.chain.chain_id, + channelID: rChannelID, + portID: rPortID, + connectionID: rConnectionID, + }; + console.log( + `Initiating channelCloseInit for dst: ${JSON.stringify(dst)} src: ${JSON.stringify(src)}`, + ); + t.throws( + () => hermes.channelCloseInit(chainName, dst, src), + { message: /Command failed/ }, + 'hermes channelCloseInit failed from agoric side for ICA', + ); + t.throws( + () => hermes.channelCloseInit(chainName, src, dst), + { message: /Command failed/ }, + `hermes channelCloseInit failed from ${chainName} side for ICA`, + ); + + const remoteQueryClient = makeQueryClient( + await useChain(chainName).getRestEndpoint(), + ); + const { channel } = await retryUntilCondition( + () => remoteQueryClient.queryChannel(rPortID, rChannelID), + // @ts-expect-error ChannelSDKType.state is a string not a number + ({ channel }) => channel?.state === 'STATE_OPEN', + 'Hermes closeChannelInit failed so ICA channel is still open', + ); + t.log(channel); + t.is( + channel?.state, + // @ts-expect-error ChannelSDKType.state is a string not a number + 'STATE_OPEN', + 'ICA channel is still open', + ); + + { + const transferChannel = ( + await remoteQueryClient.queryChannels() + ).channels.find( + x => x.port_id === 'transfer' && x.connection_hops[0] === rConnectionID, + ); + if (!transferChannel) throw new Error('Transfer channel not found.'); + + const dstTransferChannel = { + chainId: chainInfo['agoric'].chainId, + channelID: transferChannel.counterparty.channel_id as IBCChannelID, + portID: 'transfer', + connectionID: lConnectionID, + }; + const srcTransferChannel = { + chainId: useChain(chainName).chainInfo.chain.chain_id, + channelID: transferChannel.channel_id as IBCChannelID, + portID: 'transfer', + connectionID: rConnectionID, + }; + t.throws( + () => + hermes.channelCloseInit( + chainName, + dstTransferChannel, + srcTransferChannel, + ), + { message: /Command failed/ }, + 'hermes channelCloseInit failed from agoric side for transfer', + ); + t.throws( + () => + hermes.channelCloseInit( + chainName, + srcTransferChannel, + dstTransferChannel, + ), + { message: /Command failed/ }, + `hermes channelCloseInit failed from ${chainName} side for transfer`, + ); + + const { channel } = await retryUntilCondition( + () => + remoteQueryClient.queryChannel( + 'transfer', + transferChannel.channel_id, + ), + // @ts-expect-error ChannelSDKType.state is a string not a number + ({ channel }) => channel?.state === 'STATE_OPEN', + 'Hermes closeChannelInit failed so transfer channel is still open', + ); + t.log(channel); + t.is( + channel?.state, + // @ts-expect-error ChannelSDKType.state is a string not a number + 'STATE_OPEN', + 'Transfer channel is still open', + ); + } + }, +}); + test.serial(intentionalCloseAccountScenario, 'cosmoshub'); test.serial(intentionalCloseAccountScenario, 'osmosis'); +test.serial(channelCloseInitScenario, 'cosmoshub'); +test.serial(channelCloseInitScenario, 'osmosis');