diff --git a/packages/builders/test/snapshots/orchestration-imports.test.js.md b/packages/builders/test/snapshots/orchestration-imports.test.js.md index 7749e126b16..879f4029b18 100644 --- a/packages/builders/test/snapshots/orchestration-imports.test.js.md +++ b/packages/builders/test/snapshots/orchestration-imports.test.js.md @@ -85,6 +85,14 @@ Generated by [AVA](https://avajs.dev). }, ], }, + CoinShape: { + amount: Object @match:string { + payload: [], + }, + denom: Object @match:string { + payload: [], + }, + }, CosmosAssetInfoShape: Object @match:splitRecord { payload: [ { @@ -270,6 +278,46 @@ Generated by [AVA](https://avajs.dev). DenomShape: Object @match:string { payload: [], }, + ForwardInfoShape: { + forward: Object @match:splitRecord { + payload: [ + { + channel: Object @match:string { + payload: [], + }, + port: 'transfer', + receiver: Object @match:string { + payload: [], + }, + retries: Object @match:kind { + payload: 'number', + }, + timeout: Object @match:string { + payload: [], + }, + }, + { + next: { + forward: { + channel: Object @match:string { + payload: [], + }, + port: 'transfer', + receiver: Object @match:string { + payload: [], + }, + retries: Object @match:kind { + payload: 'number', + }, + timeout: Object @match:string { + payload: [], + }, + }, + }, + }, + ], + }, + }, IBCChannelIDShape: Object @match:string { payload: [], }, diff --git a/packages/builders/test/snapshots/orchestration-imports.test.js.snap b/packages/builders/test/snapshots/orchestration-imports.test.js.snap index e3536ba0aec..dba58251741 100644 Binary files a/packages/builders/test/snapshots/orchestration-imports.test.js.snap and b/packages/builders/test/snapshots/orchestration-imports.test.js.snap differ diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md index 42cfa097928..62804cfa992 100644 --- a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md +++ b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md @@ -539,14 +539,12 @@ Generated by [AVA](https://avajs.dev). 'ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4': { baseDenom: 'uusdc', baseName: 'noble', - brandKey: undefined, chainName: 'osmosis', }, 'ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9': { baseDenom: 'uusdc', baseName: 'noble', brand: Object @Alleged: USDC brand {}, - brandKey: 'USDC', chainName: 'agoric', }, uusdc: { diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap index dcb9a05cb25..084687b3bab 100644 Binary files a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap and b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap differ diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index 298878634b9..cbbe4658760 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -23,6 +23,7 @@ import type { Port } from '@agoric/network'; import type { IBCChannelID, IBCConnectionID, + IBCPortID, VTransferIBCEvent, } from '@agoric/vats'; import type { @@ -34,7 +35,9 @@ import type { RemoteIbcAddress, } from '@agoric/vats/tools/ibc-utils.js'; import type { QueryDelegationTotalRewardsResponse } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/query.js'; +import type { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; import type { AmountArg, ChainAddress, Denom, DenomAmount } from './types.js'; +import { PFM_RECEIVER } from './exos/chain-hub.js'; /** An address for a validator on some blockchain, e.g., cosmos, eth, etc. */ export type CosmosValidatorAddress = ChainAddress & { @@ -352,3 +355,47 @@ 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; + /** e.g. '10min' */ + timeout: string; + /** default is 3? */ + retries: number; + next?: { + forward: ForwardInfo; + }; + }; +} + +/** + * Object used to help build MsgTransfer parameters for IBC transfers. + * + * If `forwardInfo` is present: + * - it must be stringified and provided as the `memo` field value for + * use with `MsgTransfer`. + * - `receiver` will be set to `"pfm"` - purposely invalid bech32. see {@link https://github.com/cosmos/ibc-apps/blob/26f3ad8f58e4ffc7769c6766cb42b954181dc100/middleware/packet-forward-middleware/README.md#minimal-example---chain-forward-a-b-c} + */ +export type TransferRoute = { + /** typically, `transfer` */ + sourcePort: string; + sourceChannel: IBCChannelID; + token: Coin; +} & ( + | { + receiver: typeof PFM_RECEIVER; + /** contains PFM forwarding info */ + forwardInfo: ForwardInfo; + } + | { + receiver: string; + } +); diff --git a/packages/orchestration/src/exos/chain-hub.js b/packages/orchestration/src/exos/chain-hub.js index 39cd3626d83..f74d9eda964 100644 --- a/packages/orchestration/src/exos/chain-hub.js +++ b/packages/orchestration/src/exos/chain-hub.js @@ -6,8 +6,12 @@ import { BrandShape } from '@agoric/ertp/src/typeGuards.js'; import { VowShape } from '@agoric/vow'; import { ChainAddressShape, + CoinShape, CosmosChainInfoShape, + DenomAmountShape, DenomDetailShape, + ForwardInfoShape, + IBCChannelIDShape, IBCConnectionInfoShape, } from '../typeGuards.js'; import { getBech32Prefix } from '../utils/address.js'; @@ -16,12 +20,15 @@ import { getBech32Prefix } from '../utils/address.js'; * @import {NameHub} from '@agoric/vats'; * @import {Vow, VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; - * @import {CosmosAssetInfo, CosmosChainInfo, IBCConnectionInfo} from '../cosmos-api.js'; + * @import {CosmosAssetInfo, CosmosChainInfo, ForwardInfo, IBCConnectionInfo, TransferRoute} from '../cosmos-api.js'; * @import {ChainInfo, KnownChains} from '../chain-info.js'; - * @import {ChainAddress, Denom} from '../orchestration-api.js'; - * @import {Remote} from '@agoric/internal'; + * @import {ChainAddress, Denom, DenomAmount} from '../orchestration-api.js'; + * @import {Remote, TypedPattern} from '@agoric/internal'; */ +/** receiver address value for ibc transfers that involve PFM */ +export const PFM_RECEIVER = /** @type {const} */ ('pfm'); + /** * If K matches a known chain, narrow the type from generic ChainInfo * @@ -167,6 +174,26 @@ const ChainIdArgShape = M.or( ), ); +// TODO #9324 determine timeout defaults +const DefaultPfmTimeoutOpts = harden( + /** @type {const} */ ({ + retries: 3, + timeout: '10min', + }), +); + +/** @type {TypedPattern} */ +export const TransferRouteShape = M.splitRecord( + { + sourcePort: M.string(), + sourceChannel: IBCChannelIDShape, + token: CoinShape, + receiver: M.string(), + }, + { forwardInfo: ForwardInfoShape }, + {}, +); + const ChainHubI = M.interface('ChainHub', { registerChain: M.call(M.string(), CosmosChainInfoShape).returns(), getChainInfo: M.call(M.string()).returns(VowShape), @@ -181,6 +208,12 @@ const ChainHubI = M.interface('ChainHub', { getAsset: M.call(M.string()).returns(M.or(DenomDetailShape, M.undefined())), getDenom: M.call(BrandShape).returns(M.or(M.string(), M.undefined())), makeChainAddress: M.call(M.string()).returns(ChainAddressShape), + makeTransferRoute: M.call(ChainAddressShape, DenomAmountShape, M.string()) + .optional({ + timeout: M.string(), + retries: M.number(), + }) + .returns(M.or(M.undefined(), TransferRouteShape)), }); /** @@ -454,6 +487,113 @@ export const makeChainHub = (zone, agoricNames, vowTools) => { encoding: /** @type {const} */ ('bech32'), }); }, + /** + * Determine the transfer route for a destination and amount given the + * current holding chain. + * + * XXX consider accepting AmountArg #10449 + * + * @param {ChainAddress} destination + * @param {DenomAmount} denomAmount + * @param {string} holdingChainName + * @param {Pick} [forwardOpts] + * @returns {TransferRoute} single hop, multi hop + * @throws {Error} if unable to determine route + */ + makeTransferRoute(destination, denomAmount, holdingChainName, forwardOpts) { + chainInfos.has(holdingChainName) || + Fail`chain info not found for holding chain: ${q(holdingChainName)}`; + + const denomDetail = chainHub.getAsset(denomAmount.denom); + denomDetail || + Fail`no denom detail for: ${q(denomAmount.denom)}. ensure it is registered in chainHub.`; + + const { baseName, chainName } = /** @type {DenomDetail} */ (denomDetail); + chainName === holdingChainName || + Fail`cannot transfer asset ${q(denomAmount.denom)}. held on ${q(chainName)} not ${q(holdingChainName)}.`; + + // currently unreachable since we can't register an asset before a chain + chainInfos.has(baseName) || + Fail`chain info not found for issuing chain: ${q(baseName)}`; + + const { chainId: baseChainId, pfmEnabled } = chainInfos.get(baseName); + + const holdingChainId = chainInfos.get(holdingChainName).chainId; + + // asset is transferring to or from the issuing chain, return direct route + if ( + baseChainId === destination.chainId || + baseName === holdingChainName + ) { + // TODO use getConnectionInfo once its sync + const connKey = connectionKey(holdingChainId, destination.chainId); + connectionInfos.has(connKey) || + Fail`no connection info found for ${q(connKey)}`; + + const { transferChannel } = denormalizeConnectionInfo( + holdingChainId, // from chain (primary) + destination.chainId, // to chain (counterparty) + connectionInfos.get(connKey), + ); + return harden({ + sourcePort: transferChannel.portId, + sourceChannel: transferChannel.channelId, + token: { + amount: String(denomAmount.value), + denom: denomAmount.denom, + }, + receiver: destination.value, + }); + } + + // asset is issued on a 3rd chain, attempt pfm route + pfmEnabled || Fail`pfm not enabled on issuing chain: ${q(baseName)}`; + + // TODO use getConnectionInfo once its sync + const currToIssuerKey = connectionKey(holdingChainId, baseChainId); + connectionInfos.has(currToIssuerKey) || + Fail`no connection info found for ${q(currToIssuerKey)}`; + + const issuerToDestKey = connectionKey(baseChainId, destination.chainId); + connectionInfos.has(issuerToDestKey) || + Fail`no connection info found for ${q(issuerToDestKey)}`; + + const currToIssuer = denormalizeConnectionInfo( + holdingChainId, + baseChainId, + connectionInfos.get(currToIssuerKey), + ); + const issuerToDest = denormalizeConnectionInfo( + baseChainId, + destination.chainId, + connectionInfos.get(issuerToDestKey), + ); + + /** @type {ForwardInfo} */ + const forwardInfo = harden({ + forward: { + receiver: destination.value, + port: issuerToDest.transferChannel.portId, + channel: issuerToDest.transferChannel.channelId, + ...DefaultPfmTimeoutOpts, + ...forwardOpts, + }, + }); + return harden({ + sourcePort: currToIssuer.transferChannel.portId, + sourceChannel: currToIssuer.transferChannel.channelId, + token: { + amount: String(denomAmount.value), + denom: denomAmount.denom, + }, + /** + * purposely using invalid bech32 + * {@link https://github.com/cosmos/ibc-apps/blob/26f3ad8f58e4ffc7769c6766cb42b954181dc100/middleware/packet-forward-middleware/README.md#minimal-example---chain-forward-a-b-c} + */ + receiver: PFM_RECEIVER, + forwardInfo, + }); + }, }); return chainHub; diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js index 721345c0d9f..f97f2bb24c0 100644 --- a/packages/orchestration/src/typeGuards.js +++ b/packages/orchestration/src/typeGuards.js @@ -4,9 +4,10 @@ import { M } from '@endo/patterns'; /** * @import {TypedPattern} from '@agoric/internal'; - * @import {ChainAddress, CosmosAssetInfo, Chain, ChainInfo, CosmosChainInfo, DenomAmount, DenomInfo, AmountArg, CosmosValidatorAddress, OrchestrationPowers} from './types.js'; + * @import {ChainAddress, CosmosAssetInfo, Chain, ChainInfo, CosmosChainInfo, DenomAmount, DenomInfo, AmountArg, CosmosValidatorAddress, OrchestrationPowers, ForwardInfo} from './types.js'; * @import {Any as Proto3Msg} from '@agoric/cosmic-proto/google/protobuf/any.js'; * @import {TxBody} from '@agoric/cosmic-proto/cosmos/tx/v1beta1/tx.js'; + * @import {Coin} from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; * @import {TypedJson} from '@agoric/cosmic-proto'; * @import {DenomDetail} from './exos/chain-hub.js'; */ @@ -112,6 +113,14 @@ export const ChainInfoShape = M.splitRecord({ }); export const DenomShape = M.string(); +/** @type {TypedPattern} */ +export const CoinShape = { + /** json-safe stringified bigint */ + amount: M.string(), + denom: DenomShape, +}; +harden(CoinShape); + /** @type {TypedPattern>} */ export const DenomInfoShape = { chain: M.remotable('Chain'), @@ -215,3 +224,26 @@ export const OrchestrationPowersShape = { timerService: M.remotable(), }; harden(OrchestrationPowersShape); + +const ForwardArgsShape = { + receiver: M.string(), + port: 'transfer', + channel: M.string(), + timeout: M.string(), + retries: M.number(), +}; +harden(ForwardArgsShape); + +/** @type {TypedPattern} */ +export const ForwardInfoShape = { + forward: M.splitRecord(ForwardArgsShape, { + /** + * Protocol allows us to recursively include `next` keys, but this only + * supports one. In practice, this is all we currently need. + */ + next: { + forward: ForwardArgsShape, + }, + }), +}; +harden(ForwardInfoShape); diff --git a/packages/orchestration/src/utils/asset.js b/packages/orchestration/src/utils/asset.js new file mode 100644 index 00000000000..3a39da7135b --- /dev/null +++ b/packages/orchestration/src/utils/asset.js @@ -0,0 +1,34 @@ +import { denomHash } from './denomHash.js'; + +/** + * @import {ChainHub, CosmosChainInfo, Denom, DenomDetail} from '../types.js'; + */ + +/** + * Helper function for creating {@link DenomDetail} data for {@link ChainHub} + * asset registration. + * + * TODO #10580 remove 'brandKey' in favor of `LegibleCapData` + * + * @param {Denom} baseDenom + * @param {string} baseName + * @param {string} [chainName] + * @param {Record} [infoOf] + * @param {string} [brandKey] + * @returns {[string, DenomDetail & { brandKey?: string }]} + */ +export const assetOn = (baseDenom, baseName, chainName, infoOf, brandKey) => { + if (!chainName) { + return [baseDenom, { baseName, chainName: baseName, baseDenom }]; + } + if (!infoOf) throw Error(`must provide infoOf`); + const issuerInfo = infoOf[baseName]; + const holdingInfo = infoOf[chainName]; + if (!holdingInfo) throw Error(`${chainName} missing`); + if (!holdingInfo.connections) + throw Error(`connections missing for ${chainName}`); + const { channelId } = + holdingInfo.connections[issuerInfo.chainId].transferChannel; + const denom = `ibc/${denomHash({ denom: baseDenom, channelId })}`; + return [denom, { baseName, chainName, baseDenom, brandKey }]; +}; diff --git a/packages/orchestration/src/utils/chain-hub-helper.js b/packages/orchestration/src/utils/chain-hub-helper.js index 9e75248c6e3..306a5ecef56 100644 --- a/packages/orchestration/src/utils/chain-hub-helper.js +++ b/packages/orchestration/src/utils/chain-hub-helper.js @@ -7,6 +7,8 @@ * * If either is not provided, registration will be skipped. * + * TODO #10580 remove 'brandKey' in favor of `LegibleCapData` + * * @param {ChainHub} chainHub * @param {Record>} brands * @param {Record | undefined} chainInfo @@ -27,7 +29,7 @@ export const registerChainsAndAssets = ( for (const [chainName, allInfo] of Object.entries(chainInfo)) { const { connections, ...info } = allInfo; chainHub.registerChain(chainName, info); - conns[info.chainId] = connections; + if (connections) conns[info.chainId] = connections; } const registeredPairs = new Set(); for (const [pChainId, connInfos] of Object.entries(conns)) { @@ -46,9 +48,10 @@ export const registerChainsAndAssets = ( return; } for (const [denom, info] of Object.entries(assetInfo)) { - const infoWithBrand = info.brandKey - ? { ...info, brand: brands[info.brandKey] } - : info; + const { brandKey, ...rest } = info; + const infoWithBrand = brandKey + ? { ...rest, brand: brands[brandKey] } + : rest; chainHub.registerAsset(denom, infoWithBrand); } }; diff --git a/packages/orchestration/test/exos/chain-hub.test.ts b/packages/orchestration/test/exos/chain-hub.test.ts index b4f4823be97..b93e4e34464 100644 --- a/packages/orchestration/test/exos/chain-hub.test.ts +++ b/packages/orchestration/test/exos/chain-hub.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @jessie.js/safe-await-separator -- XXX irrelevant for tests */ import '@agoric/swingset-liveslots/tools/prepare-test-env.js'; import test from '@endo/ses-ava/prepare-endo.js'; @@ -7,6 +6,8 @@ import { prepareSwingsetVowTools } from '@agoric/vow/vat.js'; import { E } from '@endo/far'; import { makeIssuerKit } from '@agoric/ertp'; import { withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; +import { typedJson } from '@agoric/cosmic-proto'; +import { objectMap } from '@endo/patterns'; import { makeChainHub, registerAssets } from '../../src/exos/chain-hub.js'; import { provideFreshRootZone } from '../durability.js'; import { @@ -19,6 +20,10 @@ import type { IBCConnectionInfo, } from '../../src/cosmos-api.js'; import { assets as assetFixture } from '../assets.fixture.js'; +import { registerChainsAndAssets } from '../../src/utils/chain-hub-helper.js'; +import { assetOn } from '../../src/utils/asset.js'; +import { withChainCapabilities } from '../../src/chain-capabilities.js'; +import type { ChainAddress, DenomAmount } from '../../src/orchestration-api.js'; // fresh state for each test const setup = () => { @@ -191,3 +196,368 @@ test('makeChainAddress', async t => { message: 'Missing prefix for "1notbech32"', }); }); + +const [uusdcOnAgoric, agDetail] = assetOn( + 'uusdc', + 'noble', + 'agoric', + knownChains, +); +const [uusdcOnOsmosis, osDetail] = assetOn( + 'uusdc', + 'noble', + 'osmosis', + knownChains, +); + +test('makeTransferRoute - to issuing chain', async t => { + const { chainHub } = setup(); + registerChainsAndAssets( + chainHub, + {}, + withChainCapabilities(knownChains), // adds pfmEnabled + harden({ [uusdcOnAgoric]: agDetail, [uusdcOnOsmosis]: osDetail }), + ); + + const dest: ChainAddress = chainHub.makeChainAddress('noble1234'); + { + // 100 USDC on agoric -> noble + const amt: DenomAmount = harden({ denom: uusdcOnAgoric, value: 100n }); + t.deepEqual(chainHub.makeTransferRoute(dest, amt, 'agoric'), { + receiver: 'noble1234', + sourceChannel: 'channel-62', + sourcePort: 'transfer', + token: { + amount: '100', + denom: + 'ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9', + }, + }); + } + { + // 100 USDC on osmosis -> noble + const amt: DenomAmount = harden({ denom: uusdcOnOsmosis, value: 100n }); + t.deepEqual(chainHub.makeTransferRoute(dest, amt, 'osmosis'), { + receiver: 'noble1234', + sourceChannel: 'channel-750', + sourcePort: 'transfer', + token: { + amount: '100', + denom: + 'ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4', + }, + }); + } +}); + +test('makeTransferRoute - from issuing chain', async t => { + const { chainHub } = setup(); + + registerChainsAndAssets( + chainHub, + {}, + withChainCapabilities(knownChains), // adds pfmEnabled + harden( + Object.fromEntries([ + assetOn('uist', 'agoric'), + assetOn('uosmo', 'osmosis'), + ]), + ), + ); + + const dest: ChainAddress = chainHub.makeChainAddress('noble1234'); + { + // IST on agoric -> noble + const amt: DenomAmount = harden({ denom: 'uist', value: 100n }); + t.deepEqual(chainHub.makeTransferRoute(dest, amt, 'agoric'), { + receiver: 'noble1234', + sourceChannel: 'channel-62', + sourcePort: 'transfer', + token: { + amount: '100', + denom: 'uist', + }, + }); + } + { + // OSMO on osmosis -> noble + const amt: DenomAmount = harden({ denom: 'uosmo', value: 100n }); + t.deepEqual(chainHub.makeTransferRoute(dest, amt, 'osmosis'), { + receiver: 'noble1234', + sourceChannel: 'channel-750', + sourcePort: 'transfer', + token: { + amount: '100', + denom: 'uosmo', + }, + }); + } +}); + +test('makeTransferRoute - through issuing chain', async t => { + const { chainHub } = setup(); + + registerChainsAndAssets( + chainHub, + {}, + withChainCapabilities(knownChains), // adds pfmEnabled + harden({ [uusdcOnAgoric]: agDetail }), + ); + + const dest: ChainAddress = chainHub.makeChainAddress('osmo1234'); + const amt: DenomAmount = harden({ denom: uusdcOnAgoric, value: 100n }); + + // 100 USDC on agoric -> osmosis + const route = chainHub.makeTransferRoute(dest, amt, 'agoric'); + t.deepEqual(route, { + sourcePort: 'transfer', + sourceChannel: 'channel-62', + token: { + amount: '100', + denom: + 'ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9', + }, + receiver: 'pfm', + forwardInfo: { + forward: { + receiver: 'osmo1234', + port: 'transfer', + channel: 'channel-1', + retries: 3, + timeout: '10min', + }, + }, + }); + + // use TransferRoute to build a MsgTransfer + if (!route || !('forwardInfo' in route)) { + throw new Error('forwardInfo not returned'); // appease tsc... + } + + const { forwardInfo, ...rest } = route; + const transferMsg = typedJson('/ibc.applications.transfer.v1.MsgTransfer', { + ...rest, + memo: JSON.stringify(forwardInfo), + // callers of `.makeTransferRoute` will provide these fields themselves: + sender: 'agoric123', + timeoutHeight: { + revisionHeight: 0n, + revisionNumber: 0n, + }, + timeoutTimestamp: 0n, + }); + t.like(transferMsg, { + memo: '{"forward":{"receiver":"osmo1234","port":"transfer","channel":"channel-1","retries":3,"timeout":"10min"}}', + receiver: 'pfm', + }); +}); + +test('makeTransferRoute - takes forwardOpts', t => { + const { chainHub } = setup(); + + registerChainsAndAssets( + chainHub, + {}, + withChainCapabilities(knownChains), // adds pfmEnabled + harden({ [uusdcOnOsmosis]: osDetail }), + ); + + const dest: ChainAddress = chainHub.makeChainAddress('agoric1234'); + const amt: DenomAmount = harden({ denom: uusdcOnOsmosis, value: 100n }); + const forwardOpts = harden({ + retries: 1, + timeout: '3min', + }); + + // 100 USDC on osmosis -> agoric + const route = chainHub.makeTransferRoute(dest, amt, 'osmosis', forwardOpts); + t.like(route, { + sourceChannel: 'channel-750', + token: { + denom: uusdcOnOsmosis, + }, + forwardInfo: { + forward: { + channel: 'channel-21', + ...forwardOpts, + }, + }, + }); + + // test that typeGuard works + t.throws( + () => + chainHub.makeTransferRoute( + dest, + amt, + 'osmosis', + harden({ + ...forwardOpts, + forward: JSON.stringify('stringified nested forward data'), + }), + ), + { message: /Must not have unexpected properties/ }, + ); +}); + +const nobleDest: ChainAddress = harden({ + value: 'noble1234', + chainId: 'noble-1', + encoding: 'bech32', +}); + +test('makeTransferRoute - no chain info', t => { + const { chainHub } = setup(); + + const amt: DenomAmount = harden({ denom: 'uist', value: 100n }); + t.throws(() => chainHub.makeTransferRoute(nobleDest, amt, 'agoric'), { + message: 'chain info not found for holding chain: "agoric"', + }); +}); + +test('makeTransferRoute - no asset info', t => { + const { chainHub } = setup(); + + registerChainsAndAssets( + chainHub, + {}, + withChainCapabilities(knownChains), // adds pfmEnabled + undefined, // do not supply asset info + ); + + t.throws( + () => + chainHub.makeTransferRoute( + nobleDest, + harden({ denom: 'uist', value: 100n }), + 'agoric', + ), + { + message: + 'no denom detail for: "uist". ensure it is registered in chainHub.', + }, + ); + + t.throws( + () => + chainHub.makeTransferRoute( + nobleDest, + harden({ denom: uusdcOnAgoric, value: 100n }), + 'agoric', + ), + { + message: + 'no denom detail for: "ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9". ensure it is registered in chainHub.', + }, + ); +}); + +const knownChainsSansConns = objectMap( + withChainCapabilities(knownChains), + ({ connections, ...rest }) => rest, +); + +test('makeTransferRoute - no connection info single hop', t => { + const { chainHub } = setup(); + + registerChainsAndAssets( + chainHub, + {}, + knownChainsSansConns, // omit connections + harden({ [uusdcOnAgoric]: agDetail }), + ); + + t.throws( + () => + chainHub.makeTransferRoute( + nobleDest, + harden({ denom: uusdcOnAgoric, value: 100n }), + 'agoric', + ), + { message: 'no connection info found for "agoric-3_noble-1"' }, + ); +}); + +test('makeTransferRoute - no connection info multi hop', t => { + const { chainHub } = setup(); + + // only agoric has connection info; osmosis<>noble will be missing + const chainInfo = { ...knownChainsSansConns, agoric: knownChains.agoric }; + registerChainsAndAssets( + chainHub, + {}, + harden(chainInfo), + harden({ [uusdcOnAgoric]: agDetail, [uusdcOnOsmosis]: osDetail }), + ); + + const osmoDest = chainHub.makeChainAddress('osmo1234'); + const agoricDest = chainHub.makeChainAddress('agoric1234'); + + t.throws( + () => + chainHub.makeTransferRoute( + osmoDest, + harden({ denom: uusdcOnAgoric, value: 100n }), + 'agoric', + ), + { message: 'no connection info found for "noble-1_osmosis-1"' }, + ); + + // transfer USDC on osmosis to agoric + t.throws( + () => + chainHub.makeTransferRoute( + agoricDest, + harden({ denom: uusdcOnOsmosis, value: 100n }), + 'osmosis', + ), + { message: 'no connection info found for "noble-1_osmosis-1"' }, + ); +}); + +test('makeTransferRoute - asset not on holding chain', t => { + const { chainHub } = setup(); + + registerChainsAndAssets( + chainHub, + {}, + withChainCapabilities(knownChains), + harden({ [uusdcOnAgoric]: agDetail }), + ); + + // transfer USDC on agoric from osmosis to noble (impossible) + t.throws( + () => + chainHub.makeTransferRoute( + nobleDest, + harden({ denom: uusdcOnAgoric, value: 100n }), + 'osmosis', + ), + { + message: + 'cannot transfer asset "ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9". held on "agoric" not "osmosis".', + }, + ); +}); + +test('makeTransferRoute - no PFM path', t => { + const { chainHub } = setup(); + + registerChainsAndAssets( + chainHub, + {}, + knownChains, // intentionally omit pfmEnabled + harden({ [uusdcOnAgoric]: agDetail }), + ); + + // transfer USDC on agoric to osmosis + t.throws( + () => + chainHub.makeTransferRoute( + chainHub.makeChainAddress('osmo1234'), + harden({ denom: uusdcOnAgoric, value: 100n }), + 'agoric', + ), + { message: 'pfm not enabled on issuing chain: "noble"' }, + ); +}); diff --git a/packages/orchestration/test/snapshots/exports.test.ts.md b/packages/orchestration/test/snapshots/exports.test.ts.md index c57931aa11b..f4a36635d08 100644 --- a/packages/orchestration/test/snapshots/exports.test.ts.md +++ b/packages/orchestration/test/snapshots/exports.test.ts.md @@ -14,6 +14,7 @@ Generated by [AVA](https://avajs.dev). 'AnyNatAmountsRecord', 'ChainAddressShape', 'ChainInfoShape', + 'CoinShape', 'CosmosAssetInfoShape', 'CosmosChainInfoShape', 'DelegationShape', @@ -21,6 +22,7 @@ Generated by [AVA](https://avajs.dev). 'DenomDetailShape', 'DenomInfoShape', 'DenomShape', + 'ForwardInfoShape', 'IBCChannelIDShape', 'IBCChannelInfoShape', 'IBCConnectionIDShape', diff --git a/packages/orchestration/test/snapshots/exports.test.ts.snap b/packages/orchestration/test/snapshots/exports.test.ts.snap index acdc30319ea..3d9da073d5a 100644 Binary files a/packages/orchestration/test/snapshots/exports.test.ts.snap and b/packages/orchestration/test/snapshots/exports.test.ts.snap differ