From c35fac7e82e6b66d2ffdc62ded1ba29a7d7388ff Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Wed, 27 Nov 2024 19:21:01 -0500 Subject: [PATCH] feat(local-orchestration-account): support multi-hop pfm transfers --- packages/orchestration/src/cosmos-api.ts | 1 + packages/orchestration/src/exos/chain-hub.js | 6 +- .../src/exos/local-orchestration-account.js | 70 +++++++------- .../orchestration/src/orchestration-api.ts | 4 +- .../orchestration/test/exos/chain-hub.test.ts | 6 +- .../local-orchestration-account-kit.test.ts | 96 ++++++++++++++++--- 6 files changed, 127 insertions(+), 56 deletions(-) diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index c0fc2cf196d..661d92a23d5 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -401,5 +401,6 @@ export type TransferRoute = { } | { receiver: string; + forwardInfo?: never; } ); diff --git a/packages/orchestration/src/exos/chain-hub.js b/packages/orchestration/src/exos/chain-hub.js index ce01844bb00..1477ca91de8 100644 --- a/packages/orchestration/src/exos/chain-hub.js +++ b/packages/orchestration/src/exos/chain-hub.js @@ -544,7 +544,7 @@ export const makeChainHub = (zone, agoricNames, vowTools) => { // TODO use getConnectionInfo once its sync const connKey = connectionKey(holdingChainId, destination.chainId); connectionInfos.has(connKey) || - Fail`no connection info found for ${q(connKey)}`; + Fail`no connection info found for ${holdingChainId}<->${destination.chainId}`; const { transferChannel } = denormalizeConnectionInfo( holdingChainId, // from chain (primary) @@ -568,11 +568,11 @@ export const makeChainHub = (zone, agoricNames, vowTools) => { // TODO use getConnectionInfo once its sync const currToIssuerKey = connectionKey(holdingChainId, baseChainId); connectionInfos.has(currToIssuerKey) || - Fail`no connection info found for ${q(currToIssuerKey)}`; + Fail`no connection info found for ${holdingChainId}<->${baseChainId}`; const issuerToDestKey = connectionKey(baseChainId, destination.chainId); connectionInfos.has(issuerToDestKey) || - Fail`no connection info found for ${q(issuerToDestKey)}`; + Fail`no connection info found for ${baseChainId}<->${destination.chainId}`; const currToIssuer = denormalizeConnectionInfo( holdingChainId, diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index 7ab823a0719..153b86ecd69 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -11,7 +11,6 @@ import { Fail, q } from '@endo/errors'; import { AmountArgShape, AnyNatAmountsRecord, - ChainAddressShape, DenomAmountShape, DenomShape, IBCTransferOptionsShape, @@ -24,11 +23,12 @@ import { makeTimestampHelper } from '../utils/time.js'; import { preparePacketTools } from './packet-tools.js'; import { prepareIBCTools } from './ibc-packet.js'; import { coerceCoin, coerceDenomAmount } from '../utils/amounts.js'; +import { TransferRouteShape } from './chain-hub.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, TransferRoute} from '@agoric/orchestration'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. * @import {Zone} from '@agoric/zone'; * @import {Remote} from '@agoric/internal'; @@ -107,7 +107,7 @@ export const prepareLocalOrchestrationAccountKit = ( zoeTools, }, ) => { - const { watch, allVows, asVow, when } = vowTools; + const { watch, asVow, when } = vowTools; const { makeIBCTransferSender } = prepareIBCTools( zone.subZone('ibcTools'), vowTools, @@ -134,11 +134,10 @@ export const prepareLocalOrchestrationAccountKit = ( .returns(VowShape), }), transferWatcher: M.interface('transferWatcher', { - onFulfilled: M.call([M.record(), M.nat()]) + onFulfilled: M.call(M.nat()) .optional({ - destination: ChainAddressShape, opts: M.or(M.undefined(), IBCTransferOptionsShape), - amount: DenomAmountShape, + route: TransferRouteShape, }) .returns(Vow$(M.record())), }), @@ -345,37 +344,34 @@ export const prepareLocalOrchestrationAccountKit = ( }, transferWatcher: { /** - * @param {[ - * { transferChannel: IBCConnectionInfo['transferChannel'] }, - * bigint, - * ]} results + * @param {bigint} timeoutTimestamp * @param {{ - * destination: ChainAddress; - * opts?: IBCMsgTransferOptions; - * amount: DenomAmount; + * opts?: Omit; + * route: TransferRoute; * }} ctx */ - onFulfilled( - [{ transferChannel }, timeoutTimestamp], - { opts, amount, destination }, - ) { + onFulfilled(timeoutTimestamp, { opts, route }) { + const { forwardInfo, ...transferDetails } = route; + /** @type {string | undefined} */ + let memo; + if (opts && 'memo' in opts) { + memo = opts.memo; + } + if (forwardInfo) { + // forward memo takes precedence + memo = JSON.stringify(forwardInfo); + } const transferMsg = typedJson( '/ibc.applications.transfer.v1.MsgTransfer', { - sourcePort: transferChannel.portId, - sourceChannel: transferChannel.channelId, - token: { - amount: String(amount.value), - denom: amount.denom, - }, + ...transferDetails, sender: this.state.address.value, - receiver: destination.value, timeoutHeight: opts?.timeoutHeight ?? { revisionHeight: 0n, revisionNumber: 0n, }, timeoutTimestamp, - memo: opts?.memo ?? '', + memo: memo ?? '', }, ); @@ -682,16 +678,23 @@ export const prepareLocalOrchestrationAccountKit = ( * timeoutTimestamp are not supplied, a default timeoutTimestamp will * be set for 5 minutes in the future * @returns {Vow} + * @throws {Error} if route is not determinable, asset is not + * recognized, or the transfer is rejected (insufficient funds, + * timeout) */ transfer(destination, amount, opts) { return asVow(() => { trace('Transferring funds from LCA over IBC'); + const denomAmount = coerceDenomAmount(chainHub, amount); - const connectionInfoV = watch( - chainHub.getConnectionInfo( - this.state.address.chainId, - destination.chainId, - ), + const { forwardOpts, ...rest } = opts ?? {}; + + // throws if route is not determinable + const route = chainHub.makeTransferRoute( + destination, + denomAmount, + 'agoric', + forwardOpts, ); // set a `timeoutTimestamp` if caller does not supply either `timeoutHeight` or `timeoutTimestamp` @@ -705,12 +708,11 @@ export const prepareLocalOrchestrationAccountKit = ( // 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]), + timeoutTimestampVowOrValue, this.facets.transferWatcher, { - opts, - amount: coerceDenomAmount(chainHub, amount), - destination, + opts: rest, + route, }, ); return resultV; diff --git a/packages/orchestration/src/orchestration-api.ts b/packages/orchestration/src/orchestration-api.ts index 2294f376dce..cb97cadcc56 100644 --- a/packages/orchestration/src/orchestration-api.ts +++ b/packages/orchestration/src/orchestration-api.ts @@ -195,8 +195,8 @@ export interface OrchestrationAccountI { * @param destination - the account to transfer the amount to. * @param [opts] - an optional memo to include with the transfer, which could drive custom PFM behavior, and timeout parameters * @returns void - * - * TODO document the mapping from the address to the destination chain. + * @throws {Error} if route is not determinable, asset is not recognized, or + * the transfer is rejected (insufficient funds, timeout) */ transfer: ( destination: ChainAddress, diff --git a/packages/orchestration/test/exos/chain-hub.test.ts b/packages/orchestration/test/exos/chain-hub.test.ts index 4148f5a1bf5..c8ba5c32a2c 100644 --- a/packages/orchestration/test/exos/chain-hub.test.ts +++ b/packages/orchestration/test/exos/chain-hub.test.ts @@ -488,7 +488,7 @@ test('makeTransferRoute - no connection info single hop', t => { harden({ denom: uusdcOnAgoric, value: 100n }), 'agoric', ), - { message: 'no connection info found for "agoric-3_noble-1"' }, + { message: 'no connection info found for "agoric-3"<->"noble-1"' }, ); }); @@ -517,7 +517,7 @@ test('makeTransferRoute - no connection info multi hop', t => { harden({ denom: uusdcOnAgoric, value: 100n }), 'agoric', ), - { message: 'no connection info found for "noble-1_osmosis-1"' }, + { message: 'no connection info found for "noble-1"<->"osmosis-1"' }, ); // transfer USDC on osmosis to agoric @@ -528,7 +528,7 @@ test('makeTransferRoute - no connection info multi hop', t => { harden({ denom: uusdcOnOsmosis, value: 100n }), 'osmosis', ), - { message: 'no connection info found for "noble-1_osmosis-1"' }, + { message: 'no connection info found for "osmosis-1"<->"noble-1"' }, ); }); 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..8fe222f00aa 100644 --- a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts @@ -9,13 +9,22 @@ 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 { ChainAddress, AmountArg } from '../../src/orchestration-api.js'; +import type { IBCChannelID } from '@agoric/vats'; +import type { + ChainAddress, + AmountArg, + DenomAmount, +} from '../../src/orchestration-api.js'; import { maxClockSkew } from '../../src/utils/cosmos.js'; import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; 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 type { IBCMsgTransferOptions } from '../../src/cosmos-api.js'; +import { PFM_RECEIVER } from '../../src/exos/chain-hub.js'; +import { assetOn } from '../../src/utils/asset.js'; test('deposit, withdraw', async t => { const common = await commonSetup(t); @@ -108,12 +117,12 @@ test('transfer', async t => { const { brands: { bld: stake }, mocks: { transferBridge }, - utils, + utils: { inspectLocalBridge, pourPayment }, } = common; t.truthy(account, 'account is returned'); - const oneHundredStakePmt = await utils.pourPayment(stake.units(100)); + const oneHundredStakePmt = await pourPayment(stake.units(100)); t.log('deposit 100 bld to account'); await VE(account).deposit(oneHundredStakePmt); @@ -127,7 +136,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; @@ -142,7 +150,7 @@ test('transfer', async t => { const startTransfer = async ( amount: AmountArg, dest: ChainAddress, - opts = {}, + opts: IBCMsgTransferOptions = {}, ) => { const transferP = VE(account).transfer(dest, amount, opts); lastSequence += 1n; @@ -163,16 +171,15 @@ test('transfer', async t => { buildVTransferEvent({ receiver: destination.value, sender, - sourceChannel, + sourceChannel: + fetchedChainInfo.agoric.connections[destination.chainId].transferChannel + .channelId, sequence: lastSequence, }), ); const transferRes = await transferP; - t.true( - transferRes === undefined, - 'Successful transfer returns Promise.', - ); + t.true(transferRes === undefined, 'Successful transfer returns Vow.'); await t.throwsAsync( ( @@ -194,7 +201,7 @@ test('transfer', async t => { // XXX dev has to know not to startTransfer here await t.throwsAsync( VE(account).transfer(unknownDestination, { denom: 'ubld', value: 1n }), - { message: /connection not found: agoric-3<->fakenet/ }, + { message: 'no connection info found for "agoric-3"<->"fakenet"' }, 'cannot create transfer msg with unknown chainId', ); @@ -203,11 +210,13 @@ test('transfer', async t => { * @param amount * @param dest * @param opts + * @param sourceChannel */ const doTransfer = async ( amount: AmountArg, dest: ChainAddress, - opts = {}, + opts: IBCMsgTransferOptions = {}, + sourceChannel?: IBCChannelID, ) => { const { transferP: promise } = await startTransfer(amount, dest, opts); // simulate incoming message so that promise resolves @@ -215,20 +224,33 @@ test('transfer', async t => { buildVTransferEvent({ receiver: dest.value, sender, - sourceChannel, + sourceChannel: + sourceChannel || + fetchedChainInfo.agoric.connections[dest.chainId].transferChannel + .channelId, sequence: lastSequence, }), ); return promise; }; + const lastestTxMsg = () => { + const tx = inspectLocalBridge().at(-1); + if (tx.type !== 'VLOCALCHAIN_EXECUTE_TX') { + throw new Error('last message was not VLOCALCHAIN_EXECUTE_TX'); + } + return tx.messages[0]; + }; + 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 + t.like(lastestTxMsg(), { + memo: 'hello', + }); await t.notThrowsAsync( doTransfer({ denom: 'ubld', value: 10n }, destination, { @@ -244,6 +266,52 @@ test('transfer', async t => { }), 'accepts custom timeoutHeight', ); + + const [uusdcOnAgoric] = assetOn('uusdc', 'noble', 'agoric', fetchedChainInfo); + const dydxDest: ChainAddress = { + chainId: 'dydx-mainnet-1', + encoding: 'bech32', + value: 'dydx1test', + }; + const aDenomAmount: DenomAmount = { + denom: uusdcOnAgoric, + value: 100n, + }; + + t.log('Transfer handles multi-hop transfers'); + await t.notThrowsAsync( + doTransfer( + aDenomAmount, + dydxDest, + {}, + fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, + ), + ); + + t.like(lastestTxMsg(), { + receiver: PFM_RECEIVER, + memo: '{"forward":{"receiver":"dydx1test","port":"transfer","channel":"channel-33","retries":3,"timeout":"10min"}}', + }); + + t.log('accepts pfm `forwardOpts`'); + await t.notThrowsAsync( + doTransfer( + aDenomAmount, + dydxDest, + { + forwardOpts: { + timeout: '999min', + }, + }, + fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, + ), + ); + + t.like(JSON.parse(lastestTxMsg().memo), { + forward: { + timeout: '999min', + }, + }); }); test('monitor transfers', async t => {