diff --git a/multichain-testing/test/basic-flows.test.ts b/multichain-testing/test/basic-flows.test.ts index f9e710c5be3a..21591575e156 100644 --- a/multichain-testing/test/basic-flows.test.ts +++ b/multichain-testing/test/basic-flows.test.ts @@ -22,13 +22,21 @@ test.before(async t => { 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 { vstorageClient, deployBuilder, retryUntilCondition } = t.context; + const installedContracts = await vstorageClient.queryData( + `published.agoricNames.instance`, ); + const isInstalled = contractName in Object.fromEntries(installedContracts); + if (!isInstalled) { + await deployBuilder(contractBuilder); + await retryUntilCondition( + () => vstorageClient.queryData(`published.agoricNames.instance`), + res => contractName in Object.fromEntries(res), + `${contractName} instance is available`, + ); + } else { + t.log('Contract found. Skipping installation...'); + } }); test.after(async t => { 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 000000000000..e2982e14356f --- /dev/null +++ b/multichain-testing/test/ica-channel-close.test.ts @@ -0,0 +1,219 @@ +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 }; + + t.log('bundle and install contract', contractName); + const { vstorageClient, deployBuilder, retryUntilCondition } = t.context; + const installedContracts = await vstorageClient.queryData( + `published.agoricNames.instance`, + ); + const isInstalled = contractName in Object.fromEntries(installedContracts); + if (!isInstalled) { + await deployBuilder(contractBuilder); + await retryUntilCondition( + () => vstorageClient.queryData(`published.agoricNames.instance`), + res => contractName in Object.fromEntries(res), + `${contractName} instance is available`, + ); + } else { + t.log('Contract found. Skipping installation...'); + } +}); + +test.after(async t => { + const { deleteTestKeys } = t.context; + deleteTestKeys(accounts); +}); + +// XXX until new localAddr + remoteAddr are published to vstorage, use +// original port to determine new channelID +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 5f1d6d18f2e4..bfd9adf0ce40 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`), }; }