diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index da4f2a2de7d..08630380909 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -213,9 +213,22 @@ export interface IcaAccount { opts?: Partial>, ) => Promise; /** - * Close the remote account + * Deactivates the ICA account by closing the ICA channel. The `Port` is + * persisted so holders can always call `.reactivate()` to re-establish a new + * channel with the same chain address. + * CAVEAT: Does not retrieve assets so they may be lost if left. + * @throws {Error} if connection is not available or already deactivated */ - close: () => Promise; + deactivate: () => Promise; + /** + * Reactivates the ICA account by re-establishing a new channel with the + * original Port and requested address. + * If a channel is closed for an unexpected reason, such as a packet timeout, + * an automatic attempt to re will be made and the holder should not need + * to call `.reactivate()`. + * @throws {Error} if connection is currently active + */ + reactivate: () => Promise; /** @returns the address of the remote channel */ getRemoteAddress: () => RemoteIbcAddress; /** @returns the address of the local channel */ diff --git a/packages/orchestration/src/exos/README.md b/packages/orchestration/src/exos/README.md index 37dcca5036e..b49eeddc8e3 100644 --- a/packages/orchestration/src/exos/README.md +++ b/packages/orchestration/src/exos/README.md @@ -29,7 +29,8 @@ classDiagram getPort() executeTx() executeEncodedTx() - close() + deactivate() + reactivate() } class ICQConnection { port: Port diff --git a/packages/orchestration/src/exos/cosmos-orchestration-account.js b/packages/orchestration/src/exos/cosmos-orchestration-account.js index 98d742a59d8..1872f85a630 100644 --- a/packages/orchestration/src/exos/cosmos-orchestration-account.js +++ b/packages/orchestration/src/exos/cosmos-orchestration-account.js @@ -85,6 +85,8 @@ export const IcaAccountHolderI = M.interface('IcaAccountHolder', { ), withdrawRewards: M.call().returns(Vow$(M.arrayOf(DenomAmountShape))), undelegate: M.call(M.arrayOf(DelegationShape)).returns(VowShape), + deactivate: M.call().returns(VowShape), + reactivate: M.call().returns(VowShape), }); /** @type {{ [name: string]: [description: string, valueShape: Matcher] }} */ @@ -167,7 +169,8 @@ export const prepareCosmosOrchestrationAccountKit = ( ).returns(M.promise()), WithdrawReward: M.call(ChainAddressShape).returns(M.promise()), Undelegate: M.call(M.arrayOf(DelegationShape)).returns(M.promise()), - CloseAccount: M.call().returns(M.promise()), + DeactivateAccount: M.call().returns(M.promise()), + ReactivateAccount: M.call().returns(M.promise()), TransferAccount: M.call().returns(M.promise()), Send: M.call().returns(M.promise()), SendAll: M.call().returns(M.promise()), @@ -358,8 +361,17 @@ export const prepareCosmosOrchestrationAccountKit = ( return watch(this.facets.holder.undelegate(delegations)); }, 'Undelegate'); }, - CloseAccount() { - throw Error('not yet implemented'); + DeactivateAccount() { + return zcf.makeInvitation(seat => { + seat.exit(); + return watch(this.facets.holder.deactivate()); + }, 'DeactivateAccount'); + }, + ReactivateAccount() { + return zcf.makeInvitation(seat => { + seat.exit(); + return watch(this.facets.holder.reactivate()); + }, 'ReactivateAccount'); }, Send() { /** @@ -660,6 +672,14 @@ export const prepareCosmosOrchestrationAccountKit = ( return watch(undelegateV, this.facets.returnVoidWatcher); }); }, + /** @type {HostOf} */ + deactivate() { + return watch(E(this.facets.helper.owned()).deactivate()); + }, + /** @type {HostOf} */ + reactivate() { + return watch(E(this.facets.helper.owned()).reactivate()); + }, }, }, ); diff --git a/packages/orchestration/src/exos/ica-account-kit.js b/packages/orchestration/src/exos/ica-account-kit.js index 38cca597cd5..a0a02de4afb 100644 --- a/packages/orchestration/src/exos/ica-account-kit.js +++ b/packages/orchestration/src/exos/ica-account-kit.js @@ -14,13 +14,14 @@ import { findAddressField } from '../utils/address.js'; import { makeTxPacket, parseTxPacket } from '../utils/packet.js'; /** + * @import {HostOf} from '@agoric/async-flow'; * @import {Zone} from '@agoric/base-zone'; * @import {Connection, Port} from '@agoric/network'; * @import {Remote, Vow, VowTools} from '@agoric/vow'; * @import {AnyJson} from '@agoric/cosmic-proto'; * @import {TxBody} from '@agoric/cosmic-proto/cosmos/tx/v1beta1/tx.js'; * @import {LocalIbcAddress, RemoteIbcAddress} from '@agoric/vats/tools/ibc-utils.js'; - * @import {ChainAddress} from '../types.js'; + * @import {ChainAddress, IcaAccount} from '../types.js'; */ const trace = makeTracer('IcaAccountKit'); @@ -37,7 +38,8 @@ export const IcaAccountI = M.interface('IcaAccount', { executeEncodedTx: M.call(M.arrayOf(Proto3Shape)) .optional(TxBodyOptsShape) .returns(VowShape), - close: M.call().returns(VowShape), + deactivate: M.call().returns(VowShape), + reactivate: M.call().returns(VowShape), }); /** @@ -49,6 +51,7 @@ export const IcaAccountI = M.interface('IcaAccount', { * requestedRemoteAddress: string; * remoteAddress: RemoteIbcAddress | undefined; * chainAddress: ChainAddress | undefined; + * isInitiatingClose: boolean; * }} State */ @@ -82,6 +85,7 @@ export const prepareIcaAccountKit = (zone, { watch, asVow }) => remoteAddress: undefined, chainAddress: undefined, localAddress: undefined, + isInitiatingClose: false, }), { parseTxPacketWatcher: { @@ -129,29 +133,39 @@ export const prepareIcaAccountKit = (zone, { watch, asVow }) => executeEncodedTx(msgs, opts) { return asVow(() => { const { connection } = this.state; - if (!connection) throw Fail`connection not available`; + if (!connection) { + throw Fail`Connection not available or deactivated.`; + } return watch( E(connection).send(makeTxPacket(msgs, opts)), this.facets.parseTxPacketWatcher, ); }); }, - /** - * Close the remote account - * - * @returns {Vow} - * @throws {Error} if connection is not available or already closed - */ - close() { + /** @type {HostOf} */ + deactivate() { return asVow(() => { - /// TODO #9192 what should the behavior be here? and `onClose`? - // - retrieve assets? - // - revoke the port? const { connection } = this.state; - if (!connection) throw Fail`connection not available`; + if (!connection) throw Fail`Connection not available`; + this.state.isInitiatingClose = true; return E(connection).close(); }); }, + /** @type {HostOf} */ + reactivate() { + return asVow(() => { + const { connection, port, requestedRemoteAddress } = this.state; + if (connection) { + throw Fail`Connection already active. Call .deactivate() first.`; + } + return watch( + E(port).connect( + requestedRemoteAddress, + this.facets.connectionHandler, + ), + ); + }); + }, }, connectionHandler: { /** @@ -175,13 +189,34 @@ export const prepareIcaAccountKit = (zone, { watch, asVow }) => }); }, /** + * This handler fires any time the connection (channel) closes. This + * could be due to external factors (e.g. a packet timeout), or a holder + * initiated action (`.deactivate()`). + * + * Here, we clear the connection and addresses from state as they will + * change - a new channel will be established if the connection is + * reopened. + * + * If the holder did not initiate the closure, a connection is + * re-established using the original requested remote address. This will + * result in a new channelID but the ChainAddress will be preserved. + * * @param {Remote} _connection * @param {unknown} reason + * @see {@link https://docs.cosmos.network/v0.45/ibc/overview.html#:~:text=In%20ORDERED%20channels%2C%20a%20timeout%20of%20a%20single%20packet%20in%20the%20channel%20closes%20the%20channel.} */ async onClose(_connection, reason) { trace(`ICA Channel closed. Reason: ${reason}`); - // FIXME handle connection closing https://github.com/Agoric/agoric-sdk/issues/9192 - // XXX is there a scenario where a connection will unexpectedly close? _I think yes_ + this.state.connection = undefined; + this.state.localAddress = undefined; + this.state.remoteAddress = undefined; + if (this.state.isInitiatingClose === true) { + trace('Account deactivated by holder. Skipping reactivation.'); + this.state.isInitiatingClose = false; + } else { + trace('Automatically reactivating the account.'); + void watch(this.facets.account.reactivate()); + } }, }, }, diff --git a/packages/orchestration/test/cosmos-interchain-service.test.ts b/packages/orchestration/test/cosmos-interchain-service.test.ts index 91bad1f6c22..094ed376109 100644 --- a/packages/orchestration/test/cosmos-interchain-service.test.ts +++ b/packages/orchestration/test/cosmos-interchain-service.test.ts @@ -14,10 +14,13 @@ import { heapVowE as E } from '@agoric/vow/vat.js'; import { decodeBase64 } from '@endo/base64'; import type { LocalIbcAddress } from '@agoric/vats/tools/ibc-utils.js'; import { getMethodNames } from '@agoric/internal'; -import { Port } from '@agoric/network'; +import type { Port } from '@agoric/network'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import type { IBCMethod } from '@agoric/vats'; import { commonSetup } from './supports.js'; import { ChainAddressShape } from '../src/typeGuards.js'; import { tryDecodeResponse } from '../src/utils/cosmos.js'; +import { buildChannelCloseConfirmEvent } from '../tools/ibc-mocks.js'; const CHAIN_ID = 'cosmoshub-99'; const HOST_CONNECTION_ID = 'connection-0'; @@ -169,13 +172,13 @@ test.serial('makeAccount returns an IcaAccountKit', async t => { {}, ); - await E(account).close(); + await E(account).deactivate(); await t.throwsAsync( E(account).executeEncodedTx([delegateMsg]), { - message: 'Connection closed', + message: 'Connection not available or deactivated.', }, - 'cannot execute transaction if connection is closed', + 'cannot execute transaction if account is deactivated', ); }); @@ -210,3 +213,170 @@ test.serial( } }, ); + +test.serial( + 'account automatically reactivates when channel closure is not user-initiated', + async t => { + const { + bootstrap: { cosmosInterchainService }, + mocks: { ibcBridge }, + utils: { inspectDibcBridge }, + } = await commonSetup(t); + + await E(cosmosInterchainService).makeAccount( + CHAIN_ID, + HOST_CONNECTION_ID, + CONTROLLER_CONNECTION_ID, + { version: 'ics27-2', ordering: 'unordered', encoding: 'json' }, + ); + + const { bridgeEvents: bridgeEvents0, bridgeDowncalls: bridgeDowncalls0 } = + await inspectDibcBridge(); + + t.is( + bridgeEvents0[0].event, + 'channelOpenAck', + 'bridged received channelOpenAck event', + ); + t.is(bridgeEvents0.length, 1, 'bridge received 1 event'); + + t.is( + bridgeDowncalls0[0].method, + 'bindPort', + 'bridge received bindPort downcall', + ); + t.is( + bridgeDowncalls0[1].method, + 'startChannelOpenInit', + 'bridge received startChannelOpenInit downcall', + ); + t.is(bridgeDowncalls0.length, 2, 'bridge received 2 downcalls'); + + // get channelInfo from `channelOpenAck` event + const { event, ...channelInfo } = bridgeEvents0[0]; + // simulate channel closing from remote chain + await E(ibcBridge).fromBridge(buildChannelCloseConfirmEvent(channelInfo)); + await eventLoopIteration(); + + const { bridgeEvents: bridgeEvents1, bridgeDowncalls: bridgeDowncalls1 } = + await inspectDibcBridge(); + t.log('auto reactivate bridgeEvents', bridgeEvents1); + t.log('auto reactivate bridgeDowncalls', bridgeDowncalls1); + t.is( + bridgeEvents1.filter(x => x.event === 'channelCloseConfirm').length, + 1, + 'bridged received 1 channelCloseConfirm event', + ); + t.is( + bridgeEvents1[bridgeEvents1.length - 1].event, + 'channelOpenAck', + 'onClose handler automatically reactivates the channel', + ); + t.is( + bridgeEvents1.filter(x => x.event === 'channelOpenAck').length, + 2, + 'channelOpenAck only fires once during automatic reactivate', + ); + t.is(bridgeEvents1.length, 3, 'all bridge events accounted for'); + t.is( + bridgeDowncalls1.filter(x => x.method === 'startChannelOpenInit').length, + 2, + "startChannelOpenInit fires one add'l time during automatic reactivate", + ); + t.falsy( + bridgeDowncalls1.find(x => x.method === 'startChannelCloseInit'), + 'should not send startChannelCloseInit downcall to bridge', + ); + }, +); + +test.serial( + 'reactivate an account (closed channel) after choosing to deactivate()', + async t => { + const { + bootstrap: { cosmosInterchainService }, + utils: { inspectDibcBridge }, + } = await commonSetup(t); + + const account = await E(cosmosInterchainService).makeAccount( + CHAIN_ID, + HOST_CONNECTION_ID, + CONTROLLER_CONNECTION_ID, + ); + + const [chainAddress0, remoteAddress0, localAddress0] = await Promise.all([ + E(account).getAddress(), + E(account).getRemoteAddress(), + E(account).getLocalAddress(), + ]); + + await eventLoopIteration(); // ensure there's an account to close + // deactivate the account + await E(account).deactivate(); + + await eventLoopIteration(); + const { bridgeDowncalls: bridgeDowncalls0 } = await inspectDibcBridge(); + t.is( + bridgeDowncalls0?.[2]?.method, + 'startChannelCloseInit', + 'bridge received startChannelCloseInit downcall', + ); + + // reactivate the account + await E(account).reactivate(); + await eventLoopIteration(); + + const { bridgeDowncalls, bridgeEvents } = await inspectDibcBridge(); + t.log('user initiated deactivate bridgeDowncalls', bridgeDowncalls); + t.log('user initiated deactivate bridgeEvents', bridgeEvents); + t.is( + bridgeDowncalls?.[3]?.method, + 'startChannelOpenInit', + 'bridge received startChannelOpenInit to re-establish the channel', + ); + t.is( + bridgeDowncalls.filter(x => x.method === 'startChannelOpenInit').length, + 2, + 'only sent 2 startChannelOpenInit downcalls to bridge', + ); + + const getPortAndConnectionIDs = ( + obj: IBCMethod<'startChannelOpenInit'>, + ) => { + const { hops, packet } = obj; + const { source_port: sourcePort } = packet; + return { hops, sourcePort }; + }; + + t.deepEqual( + getPortAndConnectionIDs( + bridgeDowncalls[3] as IBCMethod<'startChannelOpenInit'>, + ), + getPortAndConnectionIDs( + bridgeDowncalls[1] as IBCMethod<'startChannelOpenInit'>, + ), + 'same port and connection id are used to re-stablish the channel', + ); + + const [chainAddress, remoteAddress, localAddress] = await Promise.all([ + E(account).getAddress(), + E(account).getRemoteAddress(), + E(account).getLocalAddress(), + ]); + + t.deepEqual(chainAddress, chainAddress0, 'chain address is unchanged'); + t.notDeepEqual( + remoteAddress, + remoteAddress0, + 'remote ibc address is changed', + ); + t.notDeepEqual(localAddress, localAddress0, 'local ibc address is changed'); + const getChannelID = (lAddr: LocalIbcAddress) => + lAddr.split('/ibc-channel/')[1]; + t.not( + getChannelID(localAddress), + getChannelID(localAddress0), + 'channel id is changed', + ); + }, +); diff --git a/packages/orchestration/test/network-fakes.ts b/packages/orchestration/test/network-fakes.ts index f92e1ced53c..532fbabe480 100644 --- a/packages/orchestration/test/network-fakes.ts +++ b/packages/orchestration/test/network-fakes.ts @@ -13,6 +13,7 @@ import type { IBCEvent, ScopedBridgeManagerMethods, IBCConnectionID, + IBCPortID, } from '@agoric/vats'; import { prepareCallbacks as prepareIBCCallbacks, @@ -56,10 +57,9 @@ export const ibcBridgeMocks: { ? ( obj: IBCMethod<'startChannelOpenInit'>, opts: { - bech32Prefix: string; - sequence: number; channelID: IBCChannelID; counterpartyChannelID: IBCChannelID; + mockChainAddress: string; }, ) => IBCEvent<'channelOpenAck'> : T extends 'acknowledgementPacket' @@ -72,20 +72,15 @@ export const ibcBridgeMocks: { channelOpenAck: ( obj: IBCMethod<'startChannelOpenInit'>, { - bech32Prefix, - sequence, channelID, counterpartyChannelID, + mockChainAddress, }: { - bech32Prefix: string; - sequence: number; channelID: IBCChannelID; counterpartyChannelID: IBCChannelID; + mockChainAddress: string; }, ): IBCEvent<'channelOpenAck'> => { - const mockChainAddress = - sequence > 0 ? `${bech32Prefix}1test${sequence}` : `${bech32Prefix}1test`; - return { type: 'IBC_EVENT', blockHeight: 99, @@ -196,6 +191,20 @@ export const makeFakeIBCBridge = ( let bridgeEvents: BridgeEvents = []; let bridgeDowncalls: BridgeDowncalls = []; + /** + * Store remote mock addresses that have been distributed. + * If there's a `channelOpenInit` request for a PortId:ConnnectionId + * pair that's been previously established, let's reuse it to mimic + * the behavior of the ICS-27 protocol. + */ + type AddressKey = `${IBCPortID}:${IBCConnectionID}`; + const getAddressKey = ( + obj: IBCMethod<'startChannelOpenInit'>, + ): AddressKey => { + return `${obj.packet.source_port as IBCPortID}:${obj.hops[0] as IBCConnectionID}`; + }; + const addressMap = new Map(); + return zone.exo('Fake IBC Bridge Manager', undefined, { getBridgeId: () => BridgeId.DIBC, toBridge: async obj => { @@ -204,9 +213,19 @@ export const makeFakeIBCBridge = ( switch (obj.method) { case 'startChannelOpenInit': { const connectionChannelCount = remoteChannelMap[obj.hops[0]] || 0; + const addressKey = getAddressKey(obj); + let mockChainAddress; + if (addressMap.has(addressKey)) { + mockChainAddress = addressMap.get(addressKey); + } else { + mockChainAddress = + channelCount > 0 + ? `${bech32Prefix}1test${channelCount}` + : `${bech32Prefix}1test`; + addressMap.set(addressKey, mockChainAddress); + } const ackEvent = ibcBridgeMocks.channelOpenAck(obj, { - bech32Prefix, - sequence: channelCount, + mockChainAddress, channelID: `channel-${channelCount}`, counterpartyChannelID: `channel-${connectionChannelCount}`, }); diff --git a/packages/orchestration/test/staking-ops.test.ts b/packages/orchestration/test/staking-ops.test.ts index 6f6aaf8e8b4..6072295f813 100644 --- a/packages/orchestration/test/staking-ops.test.ts +++ b/packages/orchestration/test/staking-ops.test.ts @@ -152,10 +152,11 @@ const makeScenario = () => { return doMessage(msgs[0]); }, executeTx: () => Fail`mock`, - close: () => Fail`mock`, + deactivate: () => Fail`mock`, getLocalAddress: () => Fail`mock`, getRemoteAddress: () => Fail`mock`, getPort: () => Fail`mock`, + reactivate: () => Fail`mock`, }); return { account, calls }; }; diff --git a/packages/orchestration/tools/ibc-mocks.ts b/packages/orchestration/tools/ibc-mocks.ts index c030499721e..a6f66a7164d 100644 --- a/packages/orchestration/tools/ibc-mocks.ts +++ b/packages/orchestration/tools/ibc-mocks.ts @@ -6,8 +6,16 @@ import { } from '@agoric/cosmic-proto/tendermint/abci/types.js'; import { encodeBase64, btoa, atob, decodeBase64 } from '@endo/base64'; import { toRequestQueryJson } from '@agoric/cosmic-proto'; -import { IBCChannelID, VTransferIBCEvent, type IBCPacket } from '@agoric/vats'; -import { VTRANSFER_IBC_EVENT } from '@agoric/internal/src/action-types.js'; +import { + IBCChannelID, + IBCEvent, + VTransferIBCEvent, + type IBCPacket, +} from '@agoric/vats'; +import { + IBC_EVENT, + VTRANSFER_IBC_EVENT, +} from '@agoric/internal/src/action-types.js'; import { FungibleTokenPacketData } from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; import type { PacketSDKType } from '@agoric/cosmic-proto/ibc/core/channel/v1/channel.js'; import { LOCALCHAIN_DEFAULT_ADDRESS } from '@agoric/vats/tools/fake-bridge.js'; @@ -228,3 +236,25 @@ export function createMockAckMap( }, {}); return res; } + +/** + * Simulate an IBC channelCloseConfirm event. This can be used to simulate an + * ICA channel closing for an unexpected reason from a remote chain, _or + * anything besides the Connection holder calling `.close()`_. If `close()` is + * called, we'd instead expect to see a Downcall for channelCloseInit. + * + * @param {Pick, 'portID' | 'channelID'>} event + */ +export const buildChannelCloseConfirmEvent = ({ + channelID = 'channel-0', + portID = 'icacontroller-1', +}: Partial> = {}): Partial< + IBCEvent<'channelCloseConfirm'> +> => ({ + blockHeight: 0, + blockTime: 0, + channelID, + event: 'channelCloseConfirm', + portID, + type: IBC_EVENT, +});