diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index f70977fd13cf..6880bb5939f1 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -14,6 +14,10 @@ import type { State as IBCConnectionState } from '@agoric/cosmic-proto/ibc/core/ import type { Brand, Purse, Payment, Amount } from '@agoric/ertp/src/types.js'; import type { Port } from '@agoric/network'; import type { IBCChannelID, IBCConnectionID } from '@agoric/vats'; +import type { + TargetApp, + TargetRegistration, +} from '@agoric/vats/src/bridge-target.js'; import type { LocalIbcAddress, RemoteIbcAddress, @@ -208,6 +212,17 @@ export type LocalAccountMethods = { deposit: (payment: Payment<'nat'>) => Promise; /** withdraw a Payment from the account */ withdraw: (amount: Amount<'nat'>) => Promise>; + /** + * Register a handler that receives an event each time ICS-20 transfers are + * sent or received by the underlying account. Each account may be associated + * with at most one handler at a given time. + * Does not grant the handler the ability to intercept a transfer. For a + * blocking handler, aka 'IBC Hooks', leverage `registerActiveTap` from + * `transferMiddleware` directly. + * + * @param tap + */ + monitorTransfers: (tap: TargetApp) => Promise; }; export type IBCMsgTransferOptions = { diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index c5d168189be2..8ca12f03a667 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -21,13 +21,13 @@ import { makeTimestampHelper } from '../utils/time.js'; /** * @import {HostOf} from '@agoric/async-flow'; * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; - * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, OrchestrationAccount, ChainInfo, IBCConnectionInfo, OrchestrationAccountI} from '@agoric/orchestration'; + * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, ChainInfo, IBCConnectionInfo, OrchestrationAccountI} from '@agoric/orchestration'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. * @import {Zone} from '@agoric/zone'; * @import {Remote} from '@agoric/internal'; * @import {InvitationMakers} from '@agoric/smart-wallet/src/types.js'; - * @import {TimerService, TimerBrand, TimestampRecord} from '@agoric/time'; - * @import {PromiseVow, Vow, VowTools} from '@agoric/vow'; + * @import {TimerService, TimestampRecord} from '@agoric/time'; + * @import {Vow, VowTools} from '@agoric/vow'; * @import {TypedJson, JsonSafe} from '@agoric/cosmic-proto'; * @import {Matcher} from '@endo/patterns'; * @import {ChainHub} from './chain-hub.js'; @@ -58,6 +58,9 @@ const HolderI = M.interface('holder', { deposit: M.call(PaymentShape).returns(VowShape), withdraw: M.call(AmountShape).returns(Vow$(PaymentShape)), executeTx: M.call(M.arrayOf(M.record())).returns(Vow$(M.record())), + monitorTransfers: M.call(M.remotable('TransferTap')).returns( + Vow$(M.remotable('TargetRegistration')), + ), }); /** @type {{ [name: string]: [description: string, valueShape: Matcher] }} */ @@ -477,6 +480,10 @@ export const prepareLocalOrchestrationAccountKit = ( throw Fail`not yet implemented`; }); }, + /** @type {HostOf} */ + monitorTransfers(tap) { + return watch(E(this.state.account).monitorTransfers(tap)); + }, }, }, ); diff --git a/packages/orchestration/test/examples/stake-ica.contract.test.ts b/packages/orchestration/test/examples/stake-ica.contract.test.ts index 195ffe42e575..b7cb9f09bbdd 100644 --- a/packages/orchestration/test/examples/stake-ica.contract.test.ts +++ b/packages/orchestration/test/examples/stake-ica.contract.test.ts @@ -9,7 +9,6 @@ import { QueryBalanceRequest, QueryBalanceResponse, } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; -import { TimeMath } from '@agoric/time'; import { commonSetup } from '../supports.js'; import { type StakeIcaTerms } from '../../src/examples/stakeIca.contract.js'; import fetchedChainInfo from '../../src/fetched-chain-info.js'; @@ -78,7 +77,7 @@ const startContract = async ({ }; test('makeAccount, getAddress, getBalances, getBalance', async t => { - const { bootstrap } = await commonSetup(t); + const { bootstrap, mocks } = await commonSetup(t); { // stakeAtom const { publicFacet } = await startContract(bootstrap); @@ -105,7 +104,7 @@ test('makeAccount, getAddress, getBalances, getBalance', async t => { } { // stakeOsmo - const { ibcBridge } = bootstrap; + const { ibcBridge } = mocks; await E(ibcBridge).setAddressPrefix('osmo'); const { publicFacet } = await startContract({ ...bootstrap, diff --git a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts index ed6ddae2fa6b..aee995de0576 100644 --- a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts @@ -10,6 +10,7 @@ import { commonSetup } from '../supports.js'; import { UNBOND_PERIOD_SECONDS } from '../ibc-mocks.js'; import { maxClockSkew } from '../../src/utils/cosmos.js'; import { prepareMakeTestLOAKit } from './make-test-loa-kit.js'; +import { buildVTransferEvent } from '../../tools/ibc-mocks.js'; test('deposit, withdraw', async t => { const common = await commonSetup(t); @@ -172,3 +173,43 @@ test('transfer', async t => { 'accepts custom timeoutHeight', ); }); + +test('monitor transfers', async t => { + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); + const { + mocks: { transferBridge }, + bootstrap: { rootZone }, + } = common; + + let upcallCount = 0; + const zone = rootZone.subZone('tap'); + const tap: TargetApp = zone.exo('tap', undefined, { + receiveUpcall: (obj: unknown) => { + upcallCount += 1; + t.log('receiveUpcall', obj); + return Promise.resolve(); + }, + }); + + const { value: target } = await E(account).getAddress(); + const appRegistration = await E(account).monitorTransfers(tap); + + // simulate upcall from golang to VM + const simulateIncomingTransfer = async () => + E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: target, + }), + ); + + await simulateIncomingTransfer(); + t.is(upcallCount, 1, 'first upcall received'); + await simulateIncomingTransfer(); + t.is(upcallCount, 2, 'second upcall received'); + + await appRegistration.revoke(); + await t.throwsAsync(simulateIncomingTransfer()); + t.is(upcallCount, 2, 'no more events after app is revoked'); +}); diff --git a/packages/orchestration/test/supports.ts b/packages/orchestration/test/supports.ts index 5e80d37c34a2..f15e0e63e5ce 100644 --- a/packages/orchestration/test/supports.ts +++ b/packages/orchestration/test/supports.ts @@ -88,6 +88,7 @@ export const commonSetup = async (t: ExecutionContext) => { interceptorFactory, ); finisher.useRegistry(bridgeTargetKit.targetRegistry); + await E(transferBridge).initHandler(bridgeTargetKit.bridgeHandler); const localBrigeMessages = [] as any[]; const localchainBridge = makeFakeLocalchainBridge(rootZone, obj => @@ -136,12 +137,15 @@ export const commonSetup = async (t: ExecutionContext) => { rootZone: rootZone.subZone('contract'), storage, vowTools, - ibcBridge, }, brands: { bld: bldSansMint, ist: istSansMint, }, + mocks: { + ibcBridge, + transferBridge, + }, commonPrivateArgs: { agoricNames, localchain, diff --git a/packages/orchestration/tools/ibc-mocks.ts b/packages/orchestration/tools/ibc-mocks.ts index c78872283779..cc056eccd378 100644 --- a/packages/orchestration/tools/ibc-mocks.ts +++ b/packages/orchestration/tools/ibc-mocks.ts @@ -6,7 +6,11 @@ import { } from '@agoric/cosmic-proto/tendermint/abci/types.js'; import { encodeBase64, btoa } from '@endo/base64'; import { toRequestQueryJson } from '@agoric/cosmic-proto'; +import { IBCChannelID, VTransferIBCEvent } from '@agoric/vats'; +import { VTRANSFER_IBC_EVENT } from '@agoric/internal/src/action-types.js'; +import { FungibleTokenPacketData } from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; import { makeQueryPacket, makeTxPacket } from '../src/utils/packet.js'; +import { ChainAddress } from '../src/orchestration-api.js'; interface EncoderI { encode: (message: T) => { @@ -125,3 +129,74 @@ export function buildQueryPacketString( ): string { return btoa(makeQueryPacket(msgs.map(msg => toRequestQueryJson(msg, opts)))); } + +type BuildVTransferEventParams = { + event?: VTransferIBCEvent['event']; + /* defaults to cosmos1AccAddress. set to `agoric1fakeLCAAddress` to simulate an outgoing transfer event */ + sender?: ChainAddress['value']; + /** defaults to agoric1fakeLCAAddress. set to a different value to simulate an outgoing transfer event */ + receiver?: ChainAddress['value']; + amount?: bigint; + denom?: string; + destinationChannel?: IBCChannelID; + sourceChannel?: IBCChannelID; +}; + +/** + * `buildVTransferEvent` can be used with `transferBridge` to simulate incoming + * and outgoing IBC fungible tokens transfers to a LocalChain account. + * + * It defaults to simulating incoming transfers. To simulate an outgoing one, + * ensure `sender=agoric1fakeLCAAddress` and this after LocalChainBridge + * receives the outgoing MsgTransfer, + * + * @example + * ```js + * const { mocks: { transferBridge } = await commonSetup(t); + * await E(transferBridge).fromBridge( + * buildVTransferEvent({ + * receiver: 'agoric1fakeLCAAddress', + * amount: 10n, + * denom: 'uatom', + * }), + * ); + * ``` + * + * XXX integrate vlocalchain and vtransfer ScopedBridgeManagers + * in test supports. + * + * @param {{BuildVTransferEventParams}} args + */ +export const buildVTransferEvent = ({ + event = 'acknowledgementPacket' as const, + sender = 'cosmos1AccAddress', + receiver = 'agoric1fakeLCAAddress', + amount = 10n, + denom = 'uatom', + destinationChannel = 'channel-0' as IBCChannelID, + sourceChannel = 'channel-405' as IBCChannelID, +}: BuildVTransferEventParams = {}): VTransferIBCEvent => ({ + type: VTRANSFER_IBC_EVENT, + blockHeight: 0, + blockTime: 0, + event, + acknowledgement: btoa(JSON.stringify({ result: 'AQ==' })), + relayer: 'agoric123', + target: receiver, + packet: { + data: btoa( + JSON.stringify( + FungibleTokenPacketData.fromPartial({ + amount: String(amount), + denom, + sender, + receiver, + }), + ), + ), + destination_channel: destinationChannel, + source_channel: sourceChannel, + destination_port: 'transfer', + source_port: 'transfer', + }, +});