Skip to content

Commit

Permalink
feat: chainHub.makeTransferRoute() (#10584)
Browse files Browse the repository at this point in the history
refs: #10445
refs: #10006

## Description
Adds `.makeTransferRoute(destination: ChainAddress, amount: DenomAmount, holdingChainName: string)`  to `ChainHub` to facilitate building IBC `MsgTransfer` parameters for single-hop and multi-hop (pfm) routes.

Returns synchronously using local `chainHub` data to facilitate ease of use at call sites and support future plans to make every call synchronous. It achieves this by interfacing  with the `chainInfos` and `connInfos` map stores directly instead of the public interface methods that currently fall back to remote calls to `agoricNames`.

Assumes the longest route will only be 1 intermediary hop (through the issuing chain). Does not support unwinding nested denoms (e.g. noble uusdc  sent directly from osmosis to agoric `agoric(osmosis(noble(uusdc)))`).

### Security Considerations
An incorrect implementation could result in loss of funds. #9324 remains open to determine sensible defaults for timeout parameters.

### Scaling Considerations
Nothing new, but each contract's `chainHub` will accumulate quite a bit of data .

### Documentation Considerations
JSdoc and tests

### Testing Considerations
Includes unit tests covering all codepaths and known scenarios.

### Upgrade Considerations
N/A, library code. This feature is needed for FUSDC.
  • Loading branch information
mergify[bot] authored Nov 28, 2024
2 parents 8a52ddd + 1717b2a commit 17503a9
Show file tree
Hide file tree
Showing 12 changed files with 685 additions and 11 deletions.
48 changes: 48 additions & 0 deletions packages/builders/test/snapshots/orchestration-imports.test.js.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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: [],
},
Expand Down
Binary file modified packages/builders/test/snapshots/orchestration-imports.test.js.snap
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Binary file modified packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap
Binary file not shown.
47 changes: 47 additions & 0 deletions packages/orchestration/src/cosmos-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { Port } from '@agoric/network';
import type {
IBCChannelID,
IBCConnectionID,
IBCPortID,
VTransferIBCEvent,
} from '@agoric/vats';
import type {
Expand All @@ -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 & {
Expand Down Expand Up @@ -352,3 +355,47 @@ export type CosmosChainAccountMethods<CCI extends CosmosChainInfo> =
export type ICQQueryFunction = (
msgs: JsonSafe<RequestQuery>[],
) => Promise<JsonSafe<ResponseQuery>[]>;

/**
* 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;
}
);
146 changes: 143 additions & 3 deletions packages/orchestration/src/exos/chain-hub.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
*
Expand Down Expand Up @@ -167,6 +174,26 @@ const ChainIdArgShape = M.or(
),
);

// TODO #9324 determine timeout defaults
const DefaultPfmTimeoutOpts = harden(
/** @type {const} */ ({
retries: 3,
timeout: '10min',
}),
);

/** @type {TypedPattern<TransferRoute>} */
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),
Expand All @@ -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)),
});

/**
Expand Down Expand Up @@ -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<ForwardInfo['forward'], 'retries' | 'timeout'>} [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;
Expand Down
34 changes: 33 additions & 1 deletion packages/orchestration/src/typeGuards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
*/
Expand Down Expand Up @@ -112,6 +113,14 @@ export const ChainInfoShape = M.splitRecord({
});
export const DenomShape = M.string();

/** @type {TypedPattern<Coin>} */
export const CoinShape = {
/** json-safe stringified bigint */
amount: M.string(),
denom: DenomShape,
};
harden(CoinShape);

/** @type {TypedPattern<DenomInfo<any, any>>} */
export const DenomInfoShape = {
chain: M.remotable('Chain'),
Expand Down Expand Up @@ -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<ForwardInfo>} */
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);
Loading

0 comments on commit 17503a9

Please sign in to comment.