diff --git a/packages/boot/test/orchestration/restart-contracts.test.ts b/packages/boot/test/orchestration/restart-contracts.test.ts index 80e942e2a00..730a5f8b565 100644 --- a/packages/boot/test/orchestration/restart-contracts.test.ts +++ b/packages/boot/test/orchestration/restart-contracts.test.ts @@ -18,8 +18,9 @@ test.before(async t => { }); test.after.always(t => t.context.shutdown?.()); -// Not interesting because it doesn't wait on other chains. Leaving here because maybe it will before it's done. -test.serial('sendAnywhere', async t => { +// FIXME the test needs to be able to send the acknowledgementPacket ack +// so that the transfer vow resolves. +test.serial.failing('sendAnywhere', async t => { const { walletFactoryDriver, buildProposal, @@ -109,7 +110,7 @@ const hasResult = (r: UpdateRecord) => { }; // Tests restart but not of an orchestration() flow -test('stakeAtom', async t => { +test.serial('stakeAtom', async t => { const { buildProposal, evalProposal, diff --git a/packages/boot/tools/supports.ts b/packages/boot/tools/supports.ts index 0af6b514588..377cfc16bfa 100644 --- a/packages/boot/tools/supports.ts +++ b/packages/boot/tools/supports.ts @@ -382,130 +382,134 @@ export const makeSwingsetTestKit = async ( switch (bridgeId) { case BridgeId.BANK: { trace( - 'bridgeOutbound BANK', + 'bridgeOutbound bank', obj.type, obj.recipient, obj.amount, obj.denom, ); + break; + } + case BridgeId.STORAGE: + return storage.toStorage(obj); + case BridgeId.PROVISION: + case BridgeId.PROVISION_SMART_WALLET: + case BridgeId.WALLET: + console.warn('Bridge returning undefined for', bridgeId, ':', obj); + return undefined; + default: + break; + } + + const bridgeTargetRegistered = new Set(); + const bridgeType = `${bridgeId}:${obj.type}`; + switch (bridgeType) { + case `${BridgeId.BANK}:VBANK_GET_MODULE_ACCOUNT_ADDRESS`: { // bridgeOutbound bank : { // moduleName: 'vbank/reserve', // type: 'VBANK_GET_MODULE_ACCOUNT_ADDRESS' // } - switch (obj.type) { - case 'VBANK_GET_MODULE_ACCOUNT_ADDRESS': { - const { moduleName } = obj; - const moduleDescriptor = Object.values(VBankAccount).find( - ({ module }) => module === moduleName, - ); - if (!moduleDescriptor) { - return 'undefined'; - } - return moduleDescriptor.address; - } - - // Observed message: - // address: 'agoric1megzytg65cyrgzs6fvzxgrcqvwwl7ugpt62346', - // denom: 'ibc/toyatom', - // type: 'VBANK_GET_BALANCE' - case 'VBANK_GET_BALANCE': { - // TODO consider letting config specify vbank assets - // empty balances for test. - return '0'; - } + const { moduleName } = obj; + const moduleDescriptor = Object.values(VBankAccount).find( + ({ module }) => module === moduleName, + ); + if (!moduleDescriptor) { + return 'undefined'; + } + return moduleDescriptor.address; + } - case 'VBANK_GRAB': - case 'VBANK_GIVE': { - lastNonce += 1n; - // Also empty balances. - return harden({ - type: 'VBANK_BALANCE_UPDATE', - nonce: `${lastNonce}`, - updated: [], - }); - } + // Observed message: + // address: 'agoric1megzytg65cyrgzs6fvzxgrcqvwwl7ugpt62346', + // denom: 'ibc/toyatom', + // type: 'VBANK_GET_BALANCE' + case `${BridgeId.BANK}:VBANK_GET_BALANCE`: { + // TODO consider letting config specify vbank assets + // empty balances for test. + return '0'; + } - default: { - return 'undefined'; - } - } + case `${BridgeId.BANK}:VBANK_GRAB`: + case `${BridgeId.BANK}:VBANK_GIVE`: { + lastNonce += 1n; + // Also empty balances. + return harden({ + type: 'VBANK_BALANCE_UPDATE', + nonce: `${lastNonce}`, + updated: [], + }); } - case BridgeId.CORE: - case BridgeId.DIBC: - switch (obj.type) { - case 'IBC_METHOD': - switch (obj.method) { - case 'startChannelOpenInit': - pushInbound(BridgeId.DIBC, icaMocks.channelOpenAck(obj)); - return undefined; - case 'sendPacket': - switch (obj.packet.data) { - case protoMsgMocks.delegate.msg: { - return ackLater(obj, protoMsgMocks.delegate.ack); - } - case protoMsgMocks.delegateWithOpts.msg: { - return ackLater(obj, protoMsgMocks.delegateWithOpts.ack); - } - case protoMsgMocks.queryBalance.msg: { - return ackLater(obj, protoMsgMocks.queryBalance.ack); - } - case protoMsgMocks.queryUnknownPath.msg: { - return ackLater(obj, protoMsgMocks.queryUnknownPath.ack); - } - case protoMsgMocks.queryBalanceMulti.msg: { - return ackLater(obj, protoMsgMocks.queryBalanceMulti.ack); - } - case protoMsgMocks.queryBalanceUnknownDenom.msg: { - return ackLater( - obj, - protoMsgMocks.queryBalanceUnknownDenom.ack, - ); - } - default: { - // An error that would be triggered before reception on another chain - return ackImmediately(obj, protoMsgMocks.error.ack); - } - } - default: - return undefined; + + case `${BridgeId.CORE}:IBC_METHOD`: + case `${BridgeId.DIBC}:IBC_METHOD`: + case `${BridgeId.VTRANSFER}:IBC_METHOD`: { + switch (obj.method) { + case 'startChannelOpenInit': + pushInbound(BridgeId.DIBC, icaMocks.channelOpenAck(obj)); + return undefined; + case 'sendPacket': + switch (obj.packet.data) { + case protoMsgMocks.delegate.msg: { + return ackLater(obj, protoMsgMocks.delegate.ack); + } + case protoMsgMocks.delegateWithOpts.msg: { + return ackLater(obj, protoMsgMocks.delegateWithOpts.ack); + } + case protoMsgMocks.queryBalance.msg: { + return ackLater(obj, protoMsgMocks.queryBalance.ack); + } + case protoMsgMocks.queryUnknownPath.msg: { + return ackLater(obj, protoMsgMocks.queryUnknownPath.ack); + } + case protoMsgMocks.queryBalanceMulti.msg: { + return ackLater(obj, protoMsgMocks.queryBalanceMulti.ack); + } + case protoMsgMocks.queryBalanceUnknownDenom.msg: { + return ackLater( + obj, + protoMsgMocks.queryBalanceUnknownDenom.ack, + ); + } + default: { + // An error that would be triggered before reception on another chain + return ackImmediately(obj, protoMsgMocks.error.ack); + } } default: return undefined; } - case BridgeId.PROVISION: - case BridgeId.PROVISION_SMART_WALLET: - case BridgeId.VTRANSFER: - case BridgeId.WALLET: - console.warn('Bridge returning undefined for', bridgeId, ':', obj); + } + case `${BridgeId.VTRANSFER}:BRIDGE_TARGET_REGISTER`: { + bridgeTargetRegistered.add(obj.target); return undefined; - case BridgeId.STORAGE: - return storage.toStorage(obj); - case BridgeId.VLOCALCHAIN: - switch (obj.type) { - case 'VLOCALCHAIN_ALLOCATE_ADDRESS': - return 'agoric1mockVlocalchainAddress'; - case 'VLOCALCHAIN_EXECUTE_TX': { - return obj.messages.map(message => { - switch (message['@type']) { - case '/cosmos.staking.v1beta1.MsgDelegate': { - if (message.amount.amount === '504') { - // FIXME - how can we propagate the error? - // this results in `syscall.callNow failed: device.invoke failed, see logs for details` - throw Error('simulated packet timeout'); - } - return {} as JsonSafe; - } - // returns one empty object per message unless specified - default: - return {}; + } + case `${BridgeId.VTRANSFER}:BRIDGE_TARGET_UNREGISTER`: { + bridgeTargetRegistered.delete(obj.target); + return undefined; + } + case `${BridgeId.VLOCALCHAIN}:VLOCALCHAIN_ALLOCATE_ADDRESS`: { + return 'agoric1mockVlocalchainAddress'; + } + case `${BridgeId.VLOCALCHAIN}:VLOCALCHAIN_EXECUTE_TX`: { + return obj.messages.map(message => { + switch (message['@type']) { + case '/cosmos.staking.v1beta1.MsgDelegate': { + if (message.amount.amount === '504') { + // FIXME - how can we propagate the error? + // this results in `syscall.callNow failed: device.invoke failed, see logs for details` + throw Error('simulated packet timeout'); } - }); + return {} as JsonSafe; + } + // returns one empty object per message unless specified + default: + return {}; } - default: - throw Error(`VLOCALCHAIN message of unknown type ${obj.type}`); - } - default: - throw Error(`unknown bridgeId ${bridgeId}`); + }); + } + default: { + throw Error(`FIXME missing support for ${bridgeId}: ${obj.type}`); + } } }; diff --git a/packages/orchestration/src/exos/ibc-packet.js b/packages/orchestration/src/exos/ibc-packet.js new file mode 100644 index 00000000000..dd25ac55fa9 --- /dev/null +++ b/packages/orchestration/src/exos/ibc-packet.js @@ -0,0 +1,219 @@ +import { assertAllDefined } from '@agoric/internal'; +import { base64ToBytes, Shape as NetworkShape } from '@agoric/network'; +import { M } from '@endo/patterns'; +import { E } from '@endo/far'; + +// As specified in ICS20, the success result is a base64-encoded '\0x1' byte. +export const ICS20_TRANSFER_SUCCESS_RESULT = 'AQ=='; + +/** + * @import {JsonSafe, TypedJson, ResponseTo} from '@agoric/cosmic-proto'; + * @import {Vow, VowTools} from '@agoric/vow'; + * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; + * @import {PacketOptions} from './packet-tools.js'; + */ + +const { Fail, bare } = assert; +const { Vow$ } = NetworkShape; // TODO #9611 + +/** + * Create a pattern for alterative representations of a sequence number. + * + * @param {any} sequence + * @returns {Pattern} + */ +export const createSequencePattern = sequence => { + const sequencePatterns = []; + + try { + const bintSequence = BigInt(sequence); + bintSequence > 0n && sequencePatterns.push(bintSequence); + } catch (e) { + // ignore + } + + const numSequence = Number(sequence); + numSequence > 0 && + Number.isSafeInteger(numSequence) && + sequencePatterns.push(numSequence); + + const strSequence = String(sequence); + strSequence && sequencePatterns.push(strSequence); + + if (!sequencePatterns.find(seq => seq === sequence)) { + sequencePatterns.push(sequence); + } + + switch (sequencePatterns.length) { + case 0: + throw Fail`sequence ${sequence} is not valid`; + case 1: + return sequencePatterns[0]; + default: + return M.or(...sequencePatterns); + } +}; +harden(createSequencePattern); + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {VowTools & { makeIBCReplyKit: MakeIBCReplyKit }} powers + */ +export const prepareIBCTransferSender = (zone, { watch, makeIBCReplyKit }) => { + const makeIBCTransferSenderKit = zone.exoClassKit( + 'IBCTransferSenderKit', + { + public: M.interface('IBCTransferSender', { + sendPacket: M.call(Vow$(M.any()), M.any()).returns(Vow$(M.record())), + }), + responseWatcher: M.interface('responseWatcher', { + onFulfilled: M.call([M.record()], M.record()).returns(M.any()), + }), + verifyTransferSuccess: M.interface('verifyTransferSuccess', { + onFulfilled: M.call(M.any()).returns(), + }), + }, + /** + * @param {{ + * executeTx: LocalChainAccount['executeTx']; + * }} txExecutor + * @param {TypedJson<'/ibc.applications.transfer.v1.MsgTransfer'>} transferMsg + */ + (txExecutor, transferMsg) => ({ + txExecutor, + transferMsg: harden(transferMsg), + }), + { + public: { + sendPacket(match, opts) { + const { txExecutor, transferMsg } = this.state; + return watch( + E(txExecutor).executeTx([transferMsg]), + this.facets.responseWatcher, + { opts, match }, + ); + }, + }, + responseWatcher: { + /** + * Wait for successfully sending the transfer packet. + * + * @param {[ + * JsonSafe< + * ResponseTo< + * TypedJson<'/ibc.applications.transfer.v1.MsgTransfer'> + * > + * >, + * ]} response + * @param {Record} ctx + */ + onFulfilled([{ sequence }], ctx) { + const { match } = ctx; + const { transferMsg } = this.state; + + // Match the port/channel and sequence number. + const replyPacketPattern = M.splitRecord({ + source_port: transferMsg.sourcePort, + source_channel: transferMsg.sourceChannel, + sequence: createSequencePattern(sequence), + }); + + const { resultV: ackDataV, ...rest } = makeIBCReplyKit( + replyPacketPattern, + match, + ctx, + ); + const resultV = watch(ackDataV, this.facets.verifyTransferSuccess); + return harden({ resultV, ...rest }); + }, + }, + verifyTransferSuccess: { + onFulfilled(ackData) { + let obj; + try { + obj = JSON.parse(ackData); + } catch { + Fail`ICS20-1 transfer ack data is not JSON: ${ackData}`; + } + const { result, error } = obj; + error === undefined || Fail`ICS20-1 transfer error ${error}`; + result ?? Fail`Missing result in ICS20-1 transfer ack ${obj}`; + result === ICS20_TRANSFER_SUCCESS_RESULT || + Fail`ICS20-1 transfer unsuccessful with ack result ${result}`; + }, + }, + }, + ); + + /** + * @param {Parameters} args + */ + return (...args) => makeIBCTransferSenderKit(...args).public; +}; +harden(prepareIBCTransferSender); + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {VowTools} vowTools + */ +export const prepareIBCReplyKit = (zone, vowTools) => { + const { watch } = vowTools; + const ibcWatcher = zone.exo( + 'ibcResultWatcher', + M.interface('processIBCWatcher', { + onFulfilled: M.call(M.record(), M.record()).returns(Vow$(M.string())), + }), + { + onFulfilled({ event, acknowledgement }, { opName = 'unknown' }) { + assertAllDefined({ event, acknowledgement }); + switch (event) { + case 'acknowledgementPacket': + return base64ToBytes(acknowledgement); + case 'timeoutPacket': + throw Fail`${bare(opName)} operation received timeout packet`; + default: + throw Fail`Unexpected event: ${event}`; + } + }, + }, + ); + + /** + * @param {Pattern} replyPacketPattern + * @param {Vow} matchV + * @param {PacketOptions} opts + */ + const makeIBCReplyKit = (replyPacketPattern, matchV, opts) => { + const eventPattern = M.or( + M.splitRecord({ + event: 'acknowledgementPacket', + packet: replyPacketPattern, + acknowledgement: M.string(), + }), + M.splitRecord({ + event: 'timeoutPacket', + packet: replyPacketPattern, + }), + ); + const resultV = watch(matchV, ibcWatcher, opts); + return harden({ eventPattern, resultV }); + }; + + return makeIBCReplyKit; +}; +harden(prepareIBCReplyKit); +/** @typedef {ReturnType} MakeIBCReplyKit */ + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {VowTools} vowTools + */ +export const prepareIBCTools = (zone, vowTools) => { + const makeIBCReplyKit = prepareIBCReplyKit(zone, vowTools); + const makeIBCTransferSender = prepareIBCTransferSender(zone, { + makeIBCReplyKit, + ...vowTools, + }); + return harden({ makeIBCTransferSender, makeIBCReplyKit }); +}; +harden(prepareIBCTools); diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index 450fbe3cbe4..f5b90894ef8 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -6,6 +6,7 @@ import { Shape as NetworkShape } from '@agoric/network'; import { M } from '@agoric/vat-data'; import { VowShape } from '@agoric/vow'; import { E } from '@endo/far'; + import { ChainAddressShape, DenomAmountShape, @@ -16,6 +17,8 @@ import { import { maxClockSkew } from '../utils/cosmos.js'; import { orchestrationAccountMethods } from '../utils/orchestrationAccount.js'; import { makeTimestampHelper } from '../utils/time.js'; +import { preparePacketTools } from './packet-tools.js'; +import { prepareIBCTools } from './ibc-packet.js'; /** * @import {HostOf} from '@agoric/async-flow'; @@ -24,12 +27,14 @@ import { makeTimestampHelper } from '../utils/time.js'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. * @import {Zone} from '@agoric/zone'; * @import {Remote} from '@agoric/internal'; + * @import {Bytes} from '@agoric/network'; * @import {InvitationMakers} from '@agoric/smart-wallet/src/types.js'; * @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 {PromiseVow, EVow, Vow, VowTools} from '@agoric/vow'; + * @import {TypedJson, JsonSafe, ResponseTo} from '@agoric/cosmic-proto'; + * @import {Matcher, Pattern} from '@endo/patterns'; * @import {ChainHub} from './chain-hub.js'; + * @import {PacketTools} from './packet-tools.js'; */ const trace = makeTracer('LOA'); @@ -37,6 +42,8 @@ const trace = makeTracer('LOA'); const { Fail } = assert; const { Vow$ } = NetworkShape; // TODO #9611 +const EVow$ = shape => M.or(Vow$(shape), M.promise(/* shape */)); + /** * @typedef {object} LocalChainAccountNotification * @property {string} address @@ -45,6 +52,7 @@ const { Vow$ } = NetworkShape; // TODO #9611 /** * @typedef {{ * topicKit: RecorderKit; + * packetTools: PacketTools; * account: LocalChainAccount; * address: ChainAddress; * }} State @@ -57,9 +65,11 @@ 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')), - ), + sendThenWaitForAck: M.call(EVow$(M.remotable('PacketSender'))) + .optional(M.any()) + .returns(EVow$(M.string())), + matchFirstPacket: M.call(M.any()).returns(EVow$(M.any())), + monitorTransfers: M.call(M.remotable('TargetApp')).returns(EVow$(M.any())), }); /** @type {{ [name: string]: [description: string, valueShape: Matcher] }} */ @@ -80,9 +90,18 @@ export const prepareLocalOrchestrationAccountKit = ( makeRecorderKit, zcf, timerService, - { watch, allVows, asVow, when }, + vowTools, chainHub, ) => { + const { watch, allVows, asVow, when } = vowTools; + const { makeIBCTransferSender } = prepareIBCTools( + zone.subZone('ibcTools'), + vowTools, + ); + const makePacketTools = preparePacketTools( + zone.subZone('packetTools'), + vowTools, + ); const timestampHelper = makeTimestampHelper(timerService); /** Make an object wrapping an LCA with Zoe interfaces. */ @@ -117,9 +136,7 @@ export const prepareLocalOrchestrationAccountKit = ( .returns(M.any()), }), returnVoidWatcher: M.interface('returnVoidWatcher', { - onFulfilled: M.call(M.any()) - .optional(M.arrayOf(M.undefined())) - .returns(M.undefined()), + onFulfilled: M.call(M.any()).optional(M.any()).returns(M.undefined()), }), getBalanceWatcher: M.interface('getBalanceWatcher', { onFulfilled: M.call(AmountShape) @@ -144,8 +161,9 @@ export const prepareLocalOrchestrationAccountKit = ( 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(''); + const packetTools = makePacketTools(account); - return { account, address, topicKit }; + return { account, address, topicKit, packetTools }; }, { invitationMakers: { @@ -228,26 +246,35 @@ export const prepareLocalOrchestrationAccountKit = ( [{ transferChannel }, timeoutTimestamp], { opts, amount, destination }, ) { - return watch( - E(this.state.account).executeTx([ - typedJson('/ibc.applications.transfer.v1.MsgTransfer', { - sourcePort: transferChannel.portId, - sourceChannel: transferChannel.channelId, - token: { - amount: String(amount.value), - denom: amount.denom, - }, - sender: this.state.address.value, - receiver: destination.value, - timeoutHeight: opts?.timeoutHeight ?? { - revisionHeight: 0n, - revisionNumber: 0n, - }, - timeoutTimestamp, - memo: opts?.memo ?? '', - }), - ]), + const transferMsg = typedJson( + '/ibc.applications.transfer.v1.MsgTransfer', + { + sourcePort: transferChannel.portId, + sourceChannel: transferChannel.channelId, + token: { + amount: String(amount.value), + denom: amount.denom, + }, + sender: this.state.address.value, + receiver: destination.value, + timeoutHeight: opts?.timeoutHeight ?? { + revisionHeight: 0n, + revisionNumber: 0n, + }, + timeoutTimestamp, + memo: opts?.memo ?? '', + }, + ); + + const { holder } = this.facets; + const sender = makeIBCTransferSender( + /** @type {any} */ (holder), + transferMsg, ); + // Begin capturing packets, send the transfer packet, then return a + // vow that rejects unless the packet acknowledgment comes back and is + // verified. + return holder.sendThenWaitForAck(sender); }, }, /** @@ -264,15 +291,8 @@ export const prepareLocalOrchestrationAccountKit = ( return results[0]; }, }, - /** - * takes an array of results (from `executeEncodedTx`) and returns void - * since we are not interested in the result - */ returnVoidWatcher: { - /** - * @param {unknown} _result - */ - onFulfilled(_result) { + onFulfilled() { return undefined; }, }, @@ -442,7 +462,7 @@ export const prepareLocalOrchestrationAccountKit = ( * @param {IBCMsgTransferOptions} [opts] if either timeoutHeight or * timeoutTimestamp are not supplied, a default timeoutTimestamp will * be set for 5 minutes in the future - * @returns {Vow} + * @returns {Vow} */ transfer(amount, destination, opts) { return asVow(() => { @@ -464,15 +484,14 @@ export const prepareLocalOrchestrationAccountKit = ( ? 0n : E(timestampHelper).getTimeoutTimestampNS()); - const transferV = watch( + // don't resolve the vow until the transfer is confirmed on remote + // and reject vow if the transfer fails for any reason + const resultV = watch( allVows([connectionInfoV, timeoutTimestampVowOrValue]), this.facets.transferWatcher, { opts, amount, destination }, ); - // FIXME https://github.com/Agoric/agoric-sdk/issues/9783 - // don't resolve the vow until the transfer is confirmed on remote - // and reject vow if the transfer fails - return watch(transferV, this.facets.returnVoidWatcher); + return resultV; }); }, /** @type {HostOf} */ @@ -482,14 +501,25 @@ export const prepareLocalOrchestrationAccountKit = ( throw Fail`not yet implemented`; }); }, + /** @type {HostOf} */ + sendThenWaitForAck(sender, opts) { + return watch( + E(this.state.packetTools).sendThenWaitForAck(sender, opts), + ); + }, + /** @type {HostOf} */ + matchFirstPacket(patternV) { + return watch(E(this.state.packetTools).matchFirstPacket(patternV)); + }, /** @type {HostOf} */ monitorTransfers(tap) { - return watch(E(this.state.account).monitorTransfers(tap)); + return watch(E(this.state.packetTools).monitorTransfers(tap)); }, }, }, ); return makeLocalOrchestrationAccountKit; }; + /** @typedef {ReturnType} MakeLocalOrchestrationAccountKit */ /** @typedef {ReturnType} LocalOrchestrationAccountKit */ diff --git a/packages/orchestration/src/exos/packet-tools.js b/packages/orchestration/src/exos/packet-tools.js new file mode 100644 index 00000000000..fc78bdff30f --- /dev/null +++ b/packages/orchestration/src/exos/packet-tools.js @@ -0,0 +1,372 @@ +import { makeMarshal, decodeToJustin } from '@endo/marshal'; +import { Shape as NetworkShape } from '@agoric/network'; +import { M, matches } from '@endo/patterns'; +import { E } from '@endo/far'; +import { pickFacet } from '@agoric/vat-data'; + +const { toCapData } = makeMarshal(undefined, undefined, { + marshalName: 'JustEncoder', + serializeBodyFormat: 'capdata', +}); +const just = obj => { + const { body } = toCapData(obj); + return decodeToJustin(JSON.parse(body), true); +}; + +/** + * @import {Pattern} from '@endo/patterns'; + * @import {EVow, Remote, Vow, VowResolver, VowTools} from '@agoric/vow'; + * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; + * @import {TargetApp, TargetRegistration} from '@agoric/vats/src/bridge-target.js'; + */ + +/** + * @callback MatchEvent + * @param {EVow} pattern + * @returns {Vow<{ resolver: VowResolver; match: Vow }>} + */ + +/** + * @typedef {object} PacketSender + * @property {( + * opts: PacketOptions, + * ) => Vow<{ eventPattern: Pattern; resultV: Vow }>} sendPacket + */ + +/** + * @typedef {object} PacketOptions + * @property {string} [opName] + * @property {PacketTimeout} [timeout] + */ + +/** + * @typedef {Pick< + * import('../cosmos-api').IBCMsgTransferOptions, + * 'timeoutHeight' | 'timeoutTimestamp' + * >} PacketTimeout + */ + +const { Vow$ } = NetworkShape; // TODO #9611 + +const EVow$ = shape => M.or(Vow$(shape), M.promise(/* shape */)); + +const sink = () => {}; +harden(sink); + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {VowTools} vowTools + */ +export const preparePacketTools = (zone, vowTools) => { + const { allVows, makeVowKit, watch, when } = vowTools; + + const makePacketToolsKit = zone.exoClassKit( + 'PacketToolsKit', + { + public: M.interface('PacketTools', { + sendThenWaitForAck: M.call(EVow$(M.remotable('PacketSender'))) + .optional(M.any()) + .returns(EVow$(M.any())), + matchFirstPacket: M.call(M.any()).returns(EVow$(M.any())), + monitorTransfers: M.call(M.remotable('TargetApp')).returns( + EVow$(M.any()), + ), + }), + tap: M.interface('tap', { + // eslint-disable-next-line no-restricted-syntax + receiveUpcall: M.callWhen(M.any()).returns(M.any()), + }), + monitorRegistration: M.interface('monitorRegistration', { + // eslint-disable-next-line no-restricted-syntax + updateTargetApp: M.callWhen( + M.await(M.remotable('TargetApp')), + ).returns(), + // eslint-disable-next-line no-restricted-syntax + revoke: M.callWhen().returns(), + }), + watchPacketMatch: M.interface('watchPacketMatch', { + onFulfilled: M.call(M.any(), M.record()).returns(M.any()), + }), + watchPacketPattern: M.interface('watchPacketPattern', { + onFulfilled: M.call(M.any(), M.record()).returns(M.any()), + onRejected: M.call(M.any(), M.record()).returns(M.any()), + }), + watchDecrPendingPatterns: M.interface('watchDecrPendingPatterns', { + onFulfilled: M.call(M.any()).returns(M.any()), + onRejected: M.call(M.any()).returns(M.any()), + }), + sendPacketWatcher: M.interface('sendPacketWatcher', { + onFulfilled: M.call( + [M.record(), M.remotable('PacketSender')], + M.record(), + ).returns(M.any()), + }), + packetWasSentWatcher: M.interface('packetWasSentWatcher', { + onFulfilled: M.call( + { eventPattern: M.pattern(), resultV: Vow$(M.any()) }, + M.record(), + ).returns(M.any()), + }), + utils: M.interface('utils', { + subscribeToTransfers: M.call().returns(M.promise()), + unsubscribeFromTransfers: M.call().returns(M.promise()), + incrPendingPatterns: M.call().returns(Vow$(M.undefined())), + decrPendingPatterns: M.call().returns(Vow$(M.undefined())), + }), + rejectResolverAndRethrowWatcher: M.interface('rejectResolverWatcher', { + onRejected: M.call(M.any(), { + resolver: M.remotable('resolver'), + }).returns(M.any()), + }), + }, + /** + * @param {LocalChainAccount} lca + */ + lca => { + const resolverToPattern = zone.detached().mapStore('resolverToPattern'); + return { + lca, + reg: /** @type {Remote | null} */ (null), + resolverToPattern, + upcallQueue: /** @type {any[] | null} */ (null), + pending: 0, + extra: null, + monitor: /** @type {Remote | null} */ (null), + }; + }, + { + public: { + /** + * @param {ERef} monitor + */ + // eslint-disable-next-line no-restricted-syntax + async monitorTransfers(monitor) { + // We set the monitor here, but we only ever subscribe our + // this.facets.tap handler to transfers. + const mreg = this.facets.monitorRegistration; + await mreg.updateTargetApp(monitor); + return mreg; + }, + /** + * @type {MatchEvent} + */ + matchFirstPacket(patternP) { + return watch( + this.facets.utils.incrPendingPatterns(), + this.facets.watchPacketMatch, + { patternP }, + ); + }, + /** + * @param {Remote} packetSender + * @param {PacketOptions} [opts] + * @returns {Vow} + */ + sendThenWaitForAck(packetSender, opts = {}) { + /** @type {import('@agoric/vow').VowKit} */ + const pattern = makeVowKit(); + + // Establish the packet matcher immediately, but don't fulfill + // the match until after pattern.vow has been resolved. + const matchV = watch( + allVows([ + this.facets.public.matchFirstPacket(pattern.vow), + packetSender, + ]), + this.facets.sendPacketWatcher, + { opts }, + ); + + // When the packet is sent, resolve the resultV for the reply. + const resultV = watch(matchV, this.facets.packetWasSentWatcher, { + opts, + patternResolver: pattern.resolver, + }); + + // If anything fails, try to reject the packet sender. + return watch(resultV, this.facets.rejectResolverAndRethrowWatcher, { + resolver: pattern.resolver, + }); + }, + }, + monitorRegistration: { + /** @type {TargetRegistration['updateTargetApp']} */ + // eslint-disable-next-line no-restricted-syntax + async updateTargetApp(tap) { + this.state.monitor = await tap; + await this.facets.utils.subscribeToTransfers(); + }, + /** @type {TargetRegistration['revoke']} */ + // eslint-disable-next-line no-restricted-syntax + async revoke() { + this.state.monitor = null; + }, + }, + tap: { + // eslint-disable-next-line no-restricted-syntax + async receiveUpcall(obj) { + const { monitor, resolverToPattern, upcallQueue, pending } = + this.state; + console.debug( + `Trying ${resolverToPattern.getSize()} current patterns and ${pending} pending patterns against`, + just(obj), + ); + + if (monitor) { + // Call the monitor (if any), but in a future turn. + void E(monitor).receiveUpcall(obj); + } + // Check all our fulfilled patterns for matches. + for (const [resolver, pattern] of resolverToPattern.entries()) { + if (matches(obj, pattern)) { + console.debug('Matched pattern:', just(pattern)); + resolver.resolve(obj); + resolverToPattern.delete(resolver); + return; + } + } + if (upcallQueue) { + // We have some pending patterns (ones that have been requested but + // haven't yet settled) that may match this object. + console.debug('Stashing object in upcallQueue'); + this.state.upcallQueue = harden(upcallQueue.concat(obj)); + } + console.debug('No match yet.'); + }, + }, + sendPacketWatcher: { + onFulfilled([{ match }, sender], ctx) { + return watch(E(sender).sendPacket(match, ctx.opts)); + }, + }, + packetWasSentWatcher: { + onFulfilled({ eventPattern, resultV }, ctx) { + const { patternResolver } = ctx; + patternResolver.resolve(eventPattern); + return resultV; + }, + }, + rejectResolverAndRethrowWatcher: { + onRejected(rej, { resolver }) { + resolver.reject(rej); + throw rej; + }, + }, + watchPacketMatch: { + onFulfilled(_, { patternP }) { + const { vow, resolver } = makeVowKit(); + const patternV = watch( + patternP, + this.facets.watchPacketPattern, + harden({ resolver }), + ); + /* void */ watch(patternV, this.facets.watchDecrPendingPatterns); + return harden({ match: vow, resolver }); + }, + }, + watchDecrPendingPatterns: { + onFulfilled() { + return this.facets.utils.decrPendingPatterns(); + }, + onRejected() { + return this.facets.utils.decrPendingPatterns(); + }, + }, + watchPacketPattern: { + onFulfilled(pattern, { resolver }) { + const { resolverToPattern, upcallQueue } = this.state; + + console.debug('watchPacketPattern onFulfilled', just(pattern)); + if (!upcallQueue) { + // Save the pattern for later. + console.debug('No upcall queue yet. Save the pattern for later.'); + resolverToPattern.init(resolver, pattern); + return; + } + + // Try matching the first in queue. + const i = upcallQueue.findIndex(obj => matches(obj, pattern)); + if (i < 0) { + // No match yet. Save the pattern for later. + console.debug('No match yet. Save the pattern for later.'); + resolverToPattern.init(resolver, pattern); + return; + } + + // Success! Remove the matched object from the queue. + console.debug( + 'Success! Remove the matched object from the queue.', + just(upcallQueue[i]), + ); + resolver.resolve(upcallQueue[i]); + this.state.upcallQueue = harden( + upcallQueue.slice(0, i).concat(upcallQueue.slice(i + 1)), + ); + }, + onRejected(reason, { resolver }) { + resolver.reject(reason); + }, + }, + + utils: { + incrPendingPatterns() { + const { pending, reg, upcallQueue } = this.state; + this.state.pending += 1; + if (!upcallQueue) { + this.state.upcallQueue = harden([]); + } + if (reg || pending > 0) { + return watch(undefined); + } + return watch(this.facets.utils.subscribeToTransfers()); + }, + decrPendingPatterns() { + this.state.pending -= 1; + if (this.state.pending > 0) { + return; + } + this.state.pending = 0; + this.state.upcallQueue = null; + // FIXME when it returns undefined this causes an error: + // In "unsubscribeFromTransfers" method of (PacketToolsKit utils): result: undefined "[undefined]" - Must be a promise + return watch(this.facets.utils.unsubscribeFromTransfers()); + }, + subscribeToTransfers() { + // Subscribe to the transfers for this account. + const { lca, reg } = this.state; + if (reg) { + return when(reg); + } + const { tap } = this.facets; + // XXX racy; fails if subscribeToTransfers is called while this promise is in flight + // e.g. 'Target "agoric1fakeLCAAddress" already registered' + return when(E(lca).monitorTransfers(tap), r => { + this.state.reg = r; + return r; + }); + }, + unsubscribeFromTransfers() { + const { reg, monitor } = this.state; + if (!reg || monitor) { + return undefined; + } + // this.state.reg = null; + // return E(reg).revoke().then(sink); + }, + }, + }, + { + finish(context) { + void context.facets.utils.subscribeToTransfers(); + }, + }, + ); + + const makePacketTools = pickFacet(makePacketToolsKit, 'public'); + return makePacketTools; +}; +harden(preparePacketTools); + +/** + * @typedef {Awaited>>} PacketTools + */ diff --git a/packages/orchestration/test/examples/auto-stake-it.contract.test.ts b/packages/orchestration/test/examples/auto-stake-it.contract.test.ts index 261fb9605fb..0eef254adad 100644 --- a/packages/orchestration/test/examples/auto-stake-it.contract.test.ts +++ b/packages/orchestration/test/examples/auto-stake-it.contract.test.ts @@ -25,7 +25,7 @@ test('auto-stake-it - make accounts, register tap, return invitationMakers', asy bootstrap: { storage }, commonPrivateArgs, mocks: { transferBridge }, - utils: { inspectLocalBridge, inspectDibcBridge }, + utils: { inspectLocalBridge, inspectDibcBridge, transmitTransferAck }, } = await commonSetup(t); const { zoe, bundleAndInstall } = await setUpZoeForTest(); @@ -118,6 +118,7 @@ test('auto-stake-it - make accounts, register tap, return invitationMakers', asy }, 'tokens transferred from LOA to COA', ); + await transmitTransferAck(); const { acknowledgement } = (await inspectDibcBridge()).at( -1, ) as IBCEvent<'acknowledgementPacket'>; diff --git a/packages/orchestration/test/examples/sendAnywhere.test.ts b/packages/orchestration/test/examples/sendAnywhere.test.ts index 1bcf4fd2771..0f7f11593df 100644 --- a/packages/orchestration/test/examples/sendAnywhere.test.ts +++ b/packages/orchestration/test/examples/sendAnywhere.test.ts @@ -5,11 +5,16 @@ import { E } from '@endo/far'; import path from 'path'; import { mustMatch } from '@endo/patterns'; import { makeIssuerKit } from '@agoric/ertp'; -import { inspectMapStore } from '@agoric/internal/src/testing-utils.js'; +import { + eventLoopIteration, + inspectMapStore, +} from '@agoric/internal/src/testing-utils.js'; +import { inspect } from 'util'; import { CosmosChainInfo, IBCConnectionInfo } from '../../src/cosmos-api.js'; import { commonSetup } from '../supports.js'; import { SingleAmountRecord } from '../../src/examples/sendAnywhere.contract.js'; import { registerChain } from '../../src/chain-info.js'; +import { buildVTransferEvent } from '../../tools/ibc-mocks.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); @@ -58,7 +63,7 @@ test('send using arbitrary chain info', async t => { bootstrap, commonPrivateArgs, brands: { ist }, - utils: { inspectLocalBridge, pourPayment }, + utils: { inspectLocalBridge, pourPayment, transmitTransferAck }, } = await commonSetup(t); const vt = bootstrap.vowTools; @@ -124,6 +129,7 @@ test('send using arbitrary chain info', async t => { { Send }, { destAddr: 'hot1destAddr', chainName }, ); + await transmitTransferAck(); await vt.when(E(userSeat).getOfferResult()); const history = inspectLocalBridge(); @@ -157,6 +163,7 @@ test('send using arbitrary chain info', async t => { { Send }, { destAddr: 'cosmos1destAddr', chainName: 'cosmoshub' }, ); + await transmitTransferAck(); await vt.when(E(userSeat).getOfferResult()); const history = inspectLocalBridge(); const { messages, address: execAddr } = history.at(-1); @@ -203,6 +210,7 @@ test('send using arbitrary chain info', async t => { { Send }, { destAddr: 'hot1destAddr', chainName: 'hot' }, ); + await transmitTransferAck(); await vt.when(E(userSeat).getOfferResult()); const history = inspectLocalBridge(); const { messages, address: execAddr } = history.at(-1); diff --git a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md index 96a80b9caa8..c833c759c21 100644 --- a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md @@ -50,6 +50,14 @@ Generated by [AVA](https://avajs.dev). Orchestrator_kindHandle: 'Alleged: kind', RemoteChainFacade_kindHandle: 'Alleged: kind', chainName: {}, + ibcTools: { + IBCTransferSenderKit_kindHandle: 'Alleged: kind', + ibcResultWatcher_kindHandle: 'Alleged: kind', + ibcResultWatcher_singleton: 'Alleged: ibcResultWatcher', + }, + packetTools: { + PacketToolsKit_kindHandle: 'Alleged: kind', + }, }, vows: { PromiseWatcher_kindHandle: 'Alleged: kind', diff --git a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap index 19f55894a6e..1017ab57dbc 100644 Binary files a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap and b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap differ diff --git a/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.md b/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.md index f845c173a4a..98c8b7a6295 100644 --- a/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.md @@ -43,6 +43,14 @@ Generated by [AVA](https://avajs.dev). omniflixhub: 'Alleged: RemoteChainFacade public', stride: 'Alleged: RemoteChainFacade public', }, + ibcTools: { + IBCTransferSenderKit_kindHandle: 'Alleged: kind', + ibcResultWatcher_kindHandle: 'Alleged: kind', + ibcResultWatcher_singleton: 'Alleged: ibcResultWatcher', + }, + packetTools: { + PacketToolsKit_kindHandle: 'Alleged: kind', + }, }, vows: { PromiseWatcher_kindHandle: 'Alleged: kind', diff --git a/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.snap b/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.snap index 40913935b2f..92e7e65fe76 100644 Binary files a/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.snap and b/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.snap differ diff --git a/packages/orchestration/test/examples/swapExample.test.ts b/packages/orchestration/test/examples/swapExample.test.ts index f87ab716969..12a79c09c1d 100644 --- a/packages/orchestration/test/examples/swapExample.test.ts +++ b/packages/orchestration/test/examples/swapExample.test.ts @@ -20,7 +20,7 @@ Error#20: {"type":1,"data":"CmgKIy9jb3Ntb3Muc3Rha2luZy52MWJldGExLk1zZ0RlbGVnYXRl at parseTxPacket (file:///Users/markmiller/src/ongithub/agoric/agoric-sdk/packages/orchestration/src/utils/packet.js:87:14) ``` */ -test.skip('start', async t => { +test.failing('start', async t => { const { bootstrap, brands: { ist }, 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 aee995de057..030250ef8c8 100644 --- a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts @@ -2,9 +2,9 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { AmountMath } from '@agoric/ertp'; import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; -import { heapVowE as E } from '@agoric/vow/vat.js'; +import { heapVowE as VE } from '@agoric/vow/vat.js'; import { TargetApp } from '@agoric/vats/src/bridge-target.js'; -import { ChainAddress } from '../../src/orchestration-api.js'; +import { ChainAddress, type AmountArg } from '../../src/orchestration-api.js'; import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; import { commonSetup } from '../supports.js'; import { UNBOND_PERIOD_SECONDS } from '../ibc-mocks.js'; @@ -25,13 +25,13 @@ test('deposit, withdraw', async t => { const oneHundredStakePmt = await utils.pourPayment(stake.units(100)); t.log('deposit 100 bld to account'); - await E(account).deposit(oneHundredStakePmt); + await VE(account).deposit(oneHundredStakePmt); // FIXME #9211 // t.deepEqual(await E(account).getBalance('ubld'), stake.units(100)); // XXX races in the bridge await eventLoopIteration(); - const withdrawal1 = await E(account).withdraw(stake.units(50)); + const withdrawal1 = await VE(account).withdraw(stake.units(50)); t.true( AmountMath.isEqual( await stake.issuer.getAmountOf(withdrawal1), @@ -40,16 +40,16 @@ test('deposit, withdraw', async t => { ); await t.throwsAsync( - E(account).withdraw(stake.units(51)), + VE(account).withdraw(stake.units(51)), undefined, 'fails to overwithdraw', ); await t.notThrowsAsync( - E(account).withdraw(stake.units(50)), + VE(account).withdraw(stake.units(50)), 'succeeeds at exactly empty', ); await t.throwsAsync( - E(account).withdraw(stake.make(1n)), + VE(account).withdraw(stake.make(1n)), undefined, 'fails to overwithdraw', ); @@ -66,7 +66,7 @@ test('delegate, undelegate', async t => { utils, } = common; - await E(account).deposit(await utils.pourPayment(bld.units(100))); + await VE(account).deposit(await utils.pourPayment(bld.units(100))); const validatorAddress = 'agoric1validator1'; @@ -74,8 +74,8 @@ test('delegate, undelegate', async t => { // 1. these succeed even if funds aren't available // 2. there are no return values // 3. there are no side-effects such as assets being locked - await E(account).delegate(validatorAddress, bld.units(999)); - const undelegateP = E(account).undelegate(validatorAddress, bld.units(999)); + await VE(account).delegate(validatorAddress, bld.units(999)); + const undelegateP = VE(account).undelegate(validatorAddress, bld.units(999)); const completionTime = UNBOND_PERIOD_SECONDS + maxClockSkew; const notTooSoon = Promise.race([ @@ -96,8 +96,11 @@ test('transfer', async t => { const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); const account = await makeTestLOAKit(); + const { value: sender } = await VE(account).getAddress(); + const { brands: { bld: stake }, + mocks: { transferBridge }, utils, } = common; @@ -106,7 +109,7 @@ test('transfer', async t => { const oneHundredStakePmt = await utils.pourPayment(stake.units(100)); t.log('deposit 100 bld to account'); - await E(account).deposit(oneHundredStakePmt); + await VE(account).deposit(oneHundredStakePmt); // FIXME #9211 // t.deepEqual(await E(account).getBalance('ubld'), stake.units(100)); @@ -115,22 +118,64 @@ test('transfer', async t => { value: 'cosmos1pleab', encoding: 'bech32', }; + const sourceChannel = 'channel-5'; // observed in toBridge VLOCALCHAIN_EXECUTE_TX sourceChannel + + // TODO rename to lastSequence + /** The running tally of transfer messages that were sent over the bridge */ + let sequence = 0n; + /** + * Helper to start the transfer without awaiting the result. It await the + * event loop so the promise starts and increments sequence for use in the + * acknowledgementPacket bridge message and wants + * @param amount + * @param dest + * @param opts + */ + const startTransfer = async ( + amount: AmountArg, + dest: ChainAddress, + opts = {}, + ) => { + const transferP = VE(account).transfer(amount, dest, opts); + sequence += 1n; + // Ensure the toBridge of the transferP happens before the fromBridge is awaited after this function returns + await eventLoopIteration(); + return { transferP }; + }; // TODO #9211, support ERTP amounts t.log('ERTP Amounts not yet supported for AmountArg'); - await t.throwsAsync(() => E(account).transfer(stake.units(1), destination), { + await t.throwsAsync(() => VE(account).transfer(stake.units(1), destination), { message: 'ERTP Amounts not yet supported', }); t.log('.transfer() 1 bld to cosmos using DenomAmount'); - const transferResp = await E(account).transfer( + const { transferP } = await startTransfer( { denom: 'ubld', value: 1_000_000n }, destination, ); - t.is(transferResp, undefined, 'Successful transfer returns Promise.'); + t.is(await Promise.race([transferP, 'not yet']), 'not yet'); + + // simulate incoming message so that the transfer promise resolves + await VE(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: destination.value, + sender, + sourceChannel, + sequence, + }), + ); + + const transferRes = await transferP; + t.true( + transferRes === undefined, + 'Successful transfer returns Promise.', + ); await t.throwsAsync( - () => E(account).transfer({ denom: 'ubld', value: 504n }, destination), + // XXX the bridge fakes the timeout response automatically for 504n + (await startTransfer({ denom: 'ubld', value: 504n }, destination)) + .transferP, { message: 'simulated unexpected MsgTransfer packet timeout', }, @@ -141,35 +186,57 @@ test('transfer', async t => { value: 'fakenet1pleab', encoding: 'bech32', }; + // XXX dev has to know not to startTransfer here await t.throwsAsync( - () => E(account).transfer({ denom: 'ubld', value: 1n }, unknownDestination), + VE(account).transfer({ denom: 'ubld', value: 1n }, unknownDestination), { message: /connection not found: agoric-3<->fakenet/ }, 'cannot create transfer msg with unknown chainId', ); - await t.notThrowsAsync( - () => - E(account).transfer({ denom: 'ubld', value: 10n }, destination, { - memo: 'hello', + /** + * Helper to start the transfer AND send the ack packet so this promise can be awaited + * @param amount + * @param dest + * @param opts + */ + const doTransfer = async ( + amount: AmountArg, + dest: ChainAddress, + opts = {}, + ) => { + const { transferP: promise } = await startTransfer(amount, dest, opts); + // simulate incoming message so that promise resolves + await VE(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: dest.value, + sender, + sourceChannel, + sequence, }), + ); + return promise; + }; + + await t.notThrowsAsync( + doTransfer({ denom: 'ubld', value: 10n }, destination, { + memo: 'hello', + }), 'can create transfer msg with memo', ); // TODO, intercept/spy the bridge message to see that it has a memo await t.notThrowsAsync( - () => - E(account).transfer({ denom: 'ubld', value: 10n }, destination, { - // sets to current time, which shouldn't work in a real env - timeoutTimestamp: BigInt(new Date().getTime()) * NANOSECONDS_PER_SECOND, - }), + doTransfer({ denom: 'ubld', value: 10n }, destination, { + // sets to current time, which shouldn't work in a real env + timeoutTimestamp: BigInt(new Date().getTime()) * NANOSECONDS_PER_SECOND, + }), 'accepts custom timeoutTimestamp', ); await t.notThrowsAsync( - () => - E(account).transfer({ denom: 'ubld', value: 10n }, destination, { - timeoutHeight: { revisionHeight: 100n, revisionNumber: 1n }, - }), + doTransfer({ denom: 'ubld', value: 10n }, destination, { + timeoutHeight: { revisionHeight: 100n, revisionNumber: 1n }, + }), 'accepts custom timeoutHeight', ); }); @@ -193,12 +260,16 @@ test('monitor transfers', async t => { }, }); - const { value: target } = await E(account).getAddress(); - const appRegistration = await E(account).monitorTransfers(tap); + const { value: target } = await VE(account).getAddress(); + // XXX let the PacketTools subscribeToTransfers complete before triggering it + // again with monitorTransfers + await eventLoopIteration(); + + const appRegistration = await VE(account).monitorTransfers(tap); // simulate upcall from golang to VM const simulateIncomingTransfer = async () => - E(transferBridge).fromBridge( + VE(transferBridge).fromBridge( buildVTransferEvent({ receiver: target, }), @@ -210,6 +281,6 @@ test('monitor transfers', async t => { t.is(upcallCount, 2, 'second upcall received'); await appRegistration.revoke(); - await t.throwsAsync(simulateIncomingTransfer()); + await 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 b3754320e28..cbc58d3cf21 100644 --- a/packages/orchestration/test/supports.ts +++ b/packages/orchestration/test/supports.ts @@ -13,7 +13,7 @@ import { makeFakeLocalchainBridge, makeFakeTransferBridge, } from '@agoric/vats/tools/fake-bridge.js'; -import { prepareVowTools } from '@agoric/vow'; +import { prepareSwingsetVowTools } from '@agoric/vow/vat.js'; import type { Installation } from '@agoric/zoe/src/zoeService/utils.js'; import { buildZoeManualTimer } from '@agoric/zoe/tools/manualTimer.js'; import { withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; @@ -21,9 +21,11 @@ import { makeHeapZone, type Zone } from '@agoric/zone'; import { makeDurableZone } from '@agoric/zone/durable.js'; import { E } from '@endo/far'; import type { ExecutionContext } from 'ava'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; import { registerKnownChains } from '../src/chain-info.js'; import { prepareCosmosInterchainService } from '../src/exos/cosmos-interchain-service.js'; import { setupFakeNetwork } from './network-fakes.js'; +import { buildVTransferEvent } from '../tools/ibc-mocks.js'; export { makeFakeLocalchainBridge, @@ -69,7 +71,7 @@ export const commonSetup = async (t: ExecutionContext) => { }), ); - const vowTools = prepareVowTools(rootZone.subZone('vows')); + const vowTools = prepareSwingsetVowTools(rootZone.subZone('vows')); const transferBridge = makeFakeTransferBridge(rootZone); const { makeBridgeTargetKit } = prepareBridgeTargetModule( @@ -90,9 +92,9 @@ export const commonSetup = async (t: ExecutionContext) => { finisher.useRegistry(bridgeTargetKit.targetRegistry); await E(transferBridge).initHandler(bridgeTargetKit.bridgeHandler); - const localBrigeMessages = [] as any[]; + const localBridgeMessages = [] as any[]; const localchainBridge = makeFakeLocalchainBridge(rootZone, obj => - localBrigeMessages.push(obj), + localBridgeMessages.push(obj), ); const localchain = prepareLocalChainTools( rootZone.subZone('localchain'), @@ -124,6 +126,27 @@ export const commonSetup = async (t: ExecutionContext) => { await registerKnownChains(agoricNamesAdmin, () => {}); + let ibcSequenceNonce = 0n; + /** simulate incoming message as if the transfer completed over IBC */ + const transmitTransferAck = async () => { + // assume this is called after each outgoing IBC transfer + ibcSequenceNonce += 1n; + // let the promise for the transfer start + await eventLoopIteration(); + const lastMsgTransfer = localBridgeMessages.at(-1).messages[0]; + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: lastMsgTransfer.receiver, + sender: lastMsgTransfer.sender, + target: lastMsgTransfer.sender, + sourceChannel: lastMsgTransfer.sourceChannel, + sequence: ibcSequenceNonce, + }), + ); + // let the bridge handler finish + await eventLoopIteration(); + }; + return { bootstrap: { agoricNames, @@ -162,8 +185,9 @@ export const commonSetup = async (t: ExecutionContext) => { }, utils: { pourPayment, - inspectLocalBridge: () => harden([...localBrigeMessages]), + inspectLocalBridge: () => harden([...localBridgeMessages]), inspectDibcBridge: () => E(ibcBridge).inspectDibcBridge(), + transmitTransferAck, }, }; }; diff --git a/packages/orchestration/tools/ibc-mocks.ts b/packages/orchestration/tools/ibc-mocks.ts index cc056eccd37..778cc1513ee 100644 --- a/packages/orchestration/tools/ibc-mocks.ts +++ b/packages/orchestration/tools/ibc-mocks.ts @@ -6,9 +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 { IBCChannelID, VTransferIBCEvent, type IBCPacket } 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 type { PacketSDKType } from '@agoric/cosmic-proto/ibc/core/channel/v1/channel.js'; +import { LOCALCHAIN_DEFAULT_ADDRESS } from '@agoric/vats/tools/fake-bridge.js'; import { makeQueryPacket, makeTxPacket } from '../src/utils/packet.js'; import { ChainAddress } from '../src/orchestration-api.js'; @@ -136,10 +138,12 @@ type BuildVTransferEventParams = { sender?: ChainAddress['value']; /** defaults to agoric1fakeLCAAddress. set to a different value to simulate an outgoing transfer event */ receiver?: ChainAddress['value']; + target?: ChainAddress['value']; amount?: bigint; denom?: string; destinationChannel?: IBCChannelID; sourceChannel?: IBCChannelID; + sequence?: PacketSDKType['sequence']; }; /** @@ -170,11 +174,13 @@ type BuildVTransferEventParams = { export const buildVTransferEvent = ({ event = 'acknowledgementPacket' as const, sender = 'cosmos1AccAddress', - receiver = 'agoric1fakeLCAAddress', + receiver = LOCALCHAIN_DEFAULT_ADDRESS, + target = LOCALCHAIN_DEFAULT_ADDRESS, amount = 10n, denom = 'uatom', destinationChannel = 'channel-0' as IBCChannelID, sourceChannel = 'channel-405' as IBCChannelID, + sequence = 0n, }: BuildVTransferEventParams = {}): VTransferIBCEvent => ({ type: VTRANSFER_IBC_EVENT, blockHeight: 0, @@ -182,7 +188,7 @@ export const buildVTransferEvent = ({ event, acknowledgement: btoa(JSON.stringify({ result: 'AQ==' })), relayer: 'agoric123', - target: receiver, + target, packet: { data: btoa( JSON.stringify( @@ -198,5 +204,6 @@ export const buildVTransferEvent = ({ source_channel: sourceChannel, destination_port: 'transfer', source_port: 'transfer', - }, + sequence, + } as IBCPacket, }); diff --git a/packages/vats/src/localchain.js b/packages/vats/src/localchain.js index 9ece0eb8c98..4637e0d12e1 100644 --- a/packages/vats/src/localchain.js +++ b/packages/vats/src/localchain.js @@ -151,10 +151,14 @@ export const prepareLocalChainAccountKit = (zone, { watch }) => return E(purse).withdraw(amount); }, /** - * Execute a batch of messages in order as a single atomic transaction - * and return the responses. If any of the messages fails, the entire - * batch will be rolled back. Use `typedJson()` on the arguments to get - * typed return values. + * Execute a batch of messages on the local chain. Note in particular, + * that for IBC `MsgTransfer`, execution only queues a packet for the + * local chain's IBC stack, and returns a `MsgTransferResponse` + * immediately, not waiting for the confirmation on the other chain. + * + * Messages are executed in order as a single atomic transaction and + * returns the responses. If any of the messages fails, the entire batch + * will be rolled back on the local chain. * * @template {TypedJson[]} MT messages tuple (use const with multiple * elements or it will be a mixed array) @@ -162,6 +166,8 @@ export const prepareLocalChainAccountKit = (zone, { watch }) => * @returns {PromiseVowOfTupleMappedToGenerics<{ * [K in keyof MT]: JsonSafe>; * }>} + * @see {typedJson} which can be used on arguments to get typed return + * values. */ async executeTx(messages) { const { address, system } = this.state; diff --git a/packages/vats/src/types.d.ts b/packages/vats/src/types.d.ts index 5931569378d..a2f901dd144 100644 --- a/packages/vats/src/types.d.ts +++ b/packages/vats/src/types.d.ts @@ -142,9 +142,9 @@ export type IBCPacket = { source_port: IBCPortID; destination_channel: IBCChannelID; destination_port: IBCPortID; - sequence?: number; - timeout_height?: number; - timeout_timestamp?: number; + sequence?: PacketSDKType['sequence']; + timeout_height?: PacketSDKType['timeout_height']; + timeout_timestamp?: PacketSDKType['timeout_timestamp']; }; export type IBCCounterParty = { diff --git a/packages/vow/test/vat.test.js b/packages/vow/test/vat.test.js index 019742ec4e5..4babdb3a04b 100644 --- a/packages/vow/test/vat.test.js +++ b/packages/vow/test/vat.test.js @@ -2,7 +2,7 @@ import test from 'ava'; import { E, Far } from '@endo/far'; -import { heapVowE, heapVowTools } from '../vat.js'; +import { heapVowE as VE, heapVowTools } from '../vat.js'; const { makeVowKit } = heapVowTools; @@ -13,7 +13,7 @@ test('heap messages', async t => { /** @type {ReturnType>} */ const { vow, resolver } = makeVowKit(); - const retP = heapVowE(vow).hello('World'); + const retP = VE(vow).hello('World'); resolver.resolve(greeter); // Happy path: WE(vow)[method](...args) calls the method. @@ -29,10 +29,10 @@ test('heap messages', async t => { ); // Happy path: await WE.when unwraps the vow. - t.is(await heapVowE.when(vow), greeter); + t.is(await VE.when(vow), greeter); t.is( - await heapVowE.when(vow, res => { + await VE.when(vow, res => { t.is(res, greeter); return 'done'; }),