From 9e08694f9402915861d9bf6d53fa347d51a727d5 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Wed, 23 Oct 2024 17:25:44 -0400 Subject: [PATCH] feat(local-orchestration-account): support multi-hop pfm transfers --- packages/orchestration/src/cosmos-api.ts | 22 +++- .../src/exos/local-orchestration-account.js | 114 +++++++++++++++--- .../local-orchestration-account-kit.test.ts | 37 +++++- 3 files changed, 152 insertions(+), 21 deletions(-) diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index 5c7fe19817d..a1c614477b9 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -19,7 +19,7 @@ import type { } from '@agoric/cosmic-proto/tendermint/abci/types.js'; 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 { IBCChannelID, IBCConnectionID, IBCPortID } from '@agoric/vats'; import type { TargetApp, TargetRegistration, @@ -332,3 +332,23 @@ export type CosmosChainAccountMethods = export type ICQQueryFunction = ( msgs: JsonSafe[], ) => Promise[]>; + +/** + * Message structure for PFM memo + * + * @see {@link https://github.com/cosmos/chain-registry/blob/58b603bbe01f70e911e3ad2bdb6b90c4ca665735/_memo_keys/ICS20_memo_keys.json#L38-L60} + */ +export interface ForwardInfo { + forward: { + receiver: ChainAddress['value']; + port: IBCPortID; + channel: IBCChannelID; + // TODO type me better e.g. '30min' + timeout: string; + /** default is 3? */ + retries: number; + next?: { + forward: ForwardInfo; + }; + }; +} diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index bc5e1f2a48a..ee2caa12e78 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -6,7 +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 { Fail, q } from '@endo/errors'; +import { Fail, q, makeError } from '@endo/errors'; import { AmountArgShape, @@ -28,7 +28,7 @@ import { coerceCoin, coerceDenomAmount } from '../utils/amounts.js'; /** * @import {HostOf} from '@agoric/async-flow'; * @import {LocalChain, LocalChainAccount} from '@agoric/vats/src/localchain.js'; - * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, IBCConnectionInfo, OrchestrationAccountI, LocalAccountMethods} from '@agoric/orchestration'; + * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, IBCConnectionInfo, OrchestrationAccountI, LocalAccountMethods, ForwardInfo} from '@agoric/orchestration'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. * @import {Zone} from '@agoric/zone'; * @import {Remote} from '@agoric/internal'; @@ -138,7 +138,8 @@ export const prepareLocalOrchestrationAccountKit = ( .optional({ destination: ChainAddressShape, opts: M.or(M.undefined(), IBCTransferOptionsShape), - amount: DenomAmountShape, + denomAmount: DenomAmountShape, + isPfm: M.boolean(), }) .returns(Vow$(M.record())), }), @@ -352,12 +353,13 @@ export const prepareLocalOrchestrationAccountKit = ( * @param {{ * destination: ChainAddress; * opts?: IBCMsgTransferOptions; - * amount: DenomAmount; + * denomAmount: DenomAmount; + * isPfm: boolean; * }} ctx */ onFulfilled( [{ transferChannel }, timeoutTimestamp], - { opts, amount, destination }, + { opts, denomAmount, destination, isPfm }, ) { const transferMsg = typedJson( '/ibc.applications.transfer.v1.MsgTransfer', @@ -365,11 +367,11 @@ export const prepareLocalOrchestrationAccountKit = ( sourcePort: transferChannel.portId, sourceChannel: transferChannel.channelId, token: { - amount: String(amount.value), - denom: amount.denom, + amount: String(denomAmount.value), + denom: denomAmount.denom, }, sender: this.state.address.value, - receiver: destination.value, + receiver: !isPfm ? destination.value : 'pfm', timeoutHeight: opts?.timeoutHeight ?? { revisionHeight: 0n, revisionNumber: 0n, @@ -684,15 +686,24 @@ export const prepareLocalOrchestrationAccountKit = ( * @returns {Vow} */ transfer(destination, amount, opts) { - return asVow(() => { + // eslint-disable-next-line no-restricted-syntax + return asVow(async () => { trace('Transferring funds from LCA over IBC'); - - const connectionInfoV = watch( - chainHub.getConnectionInfo( - this.state.address.chainId, - destination.chainId, - ), - ); + const denomAmount = coerceDenomAmount(chainHub, amount); + const denomDetail = chainHub.getAsset(denomAmount.denom); + if (!denomDetail) { + // TODO test + throw makeError( + `Unable to fetch denom detail for ${denomAmount.denom}`, + ); + } + const { baseName, chainName } = denomDetail; + if (chainName !== 'agoric') { + // TODO test + throw makeError( + `Cannot transfer asset that's not present on ${this.state.address.chainId}. Ensure it's registered in ChainHub.`, + ); + } // set a `timeoutTimestamp` if caller does not supply either `timeoutHeight` or `timeoutTimestamp` // TODO #9324 what's a reasonable default? currently 5 minutes @@ -702,6 +713,74 @@ export const prepareLocalOrchestrationAccountKit = ( ? 0n : E(timestampHelper).getTimeoutTimestampNS()); + // XXX FIXME, don't await when(). + const { + chainId: baseChainId, + // @ts-expect-error Property 'pfmEnabled' does not exist on type 'ChainInfo'.ts(2339) + pfmEnabled, + } = await vowTools.when(chainHub.getChainInfo(baseName)); + + // Do we need to route through another chain? + if (baseChainId !== destination.chainId && baseName !== 'agoric') { + if (!pfmEnabled) + throw makeError( + `PFM not supported on ${q(baseName)} - ${q(baseChainId)}`, + ); + + // XXX FIXME, don't await when(). + const currToIssuer = await vowTools.when( + chainHub.getConnectionInfo( + this.state.address.chainId, + baseChainId, + ), + ); + if (!currToIssuer.transferChannel) { + throw makeError( + `No transfer channel found between ${q(this.state.address.chainId)} and ${q(baseChainId)}`, + ); + } + + // XXX FIXME, don't await when(). + const issuerToDest = await vowTools.when( + chainHub.getConnectionInfo(baseChainId, destination.chainId), + ); + if (!issuerToDest.transferChannel) { + throw makeError( + `No transfer channel found between ${q(baseChainId)} and ${q(destination.chainId)}`, + ); + } + + /** @type {ForwardInfo} */ + const pfmMemo = { + forward: { + receiver: destination.value, + port: issuerToDest.transferChannel.portId, + channel: issuerToDest.transferChannel.channelId, + timeout: '10m', // TODO const + expose in MsgTransferOpts? + retries: 3, // TODO const + expose in MsgTransferOpts? + }, + }; + + const resultV = watch( + allVows([currToIssuer, timeoutTimestampVowOrValue]), + this.facets.transferWatcher, + { + opts: { ...opts, memo: JSON.stringify(pfmMemo) }, + denomAmount, + destination, + isPfm: true, + }, + ); + return resultV; + } + + const connectionInfoV = watch( + chainHub.getConnectionInfo( + this.state.address.chainId, + destination.chainId, + ), + ); + // 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( @@ -709,8 +788,9 @@ export const prepareLocalOrchestrationAccountKit = ( this.facets.transferWatcher, { opts, - amount: coerceDenomAmount(chainHub, amount), + denomAmount, destination, + isPfm: false, }, ); return resultV; 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 c1eed2f6730..8b429532c64 100644 --- a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts @@ -9,6 +9,7 @@ import { } from '@agoric/vats/tools/fake-bridge.js'; import { heapVowE as VE } from '@agoric/vow/vat.js'; import { withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; +import type { IBCChannelID } from '@agoric/vats'; import type { ChainAddress, AmountArg } from '../../src/orchestration-api.js'; import { maxClockSkew } from '../../src/utils/cosmos.js'; import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; @@ -16,6 +17,8 @@ import { buildVTransferEvent } from '../../tools/ibc-mocks.js'; import { UNBOND_PERIOD_SECONDS } from '../ibc-mocks.js'; import { commonSetup } from '../supports.js'; import { prepareMakeTestLOAKit } from './make-test-loa-kit.js'; +import fetchedChainInfo from '../../src/fetched-chain-info.js'; +import { denomHash } from '../../src/utils/denomHash.js'; test('deposit, withdraw', async t => { const common = await commonSetup(t); @@ -127,7 +130,6 @@ test('transfer', async t => { value: 'cosmos1pleab', encoding: 'bech32', }; - const sourceChannel = 'channel-5'; // observed in toBridge VLOCALCHAIN_EXECUTE_TX sourceChannel, confirmed via fetched-chain-info.js /** The running tally of transfer messages that were sent over the bridge */ let lastSequence = 0n; @@ -163,7 +165,9 @@ test('transfer', async t => { buildVTransferEvent({ receiver: destination.value, sender, - sourceChannel, + sourceChannel: + fetchedChainInfo.agoric.connections[destination.chainId].transferChannel + .channelId, sequence: lastSequence, }), ); @@ -203,11 +207,13 @@ test('transfer', async t => { * @param amount * @param dest * @param opts + * @param sourceChannel */ const doTransfer = async ( amount: AmountArg, dest: ChainAddress, opts = {}, + sourceChannel?: IBCChannelID, ) => { const { transferP: promise } = await startTransfer(amount, dest, opts); // simulate incoming message so that promise resolves @@ -215,7 +221,10 @@ test('transfer', async t => { buildVTransferEvent({ receiver: dest.value, sender, - sourceChannel, + sourceChannel: + sourceChannel || + fetchedChainInfo.agoric.connections[dest.chainId].transferChannel + .channelId, sequence: lastSequence, }), ); @@ -244,6 +253,28 @@ test('transfer', async t => { }), 'accepts custom timeoutHeight', ); + + t.log('Transfer handles multi-hop transfers'); + await t.notThrowsAsync( + doTransfer( + { + denom: `ibc/${denomHash({ + channelId: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel + .channelId, + denom: 'uusdc', + })}`, + value: 100n, + }, + { + chainId: 'dydx-mainnet-1', + encoding: 'bech32', + value: 'dydx1test', + }, + {}, + fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, + ), + ); }); test('monitor transfers', async t => {