Skip to content

Commit

Permalink
feat(local-orchestration-account): support multi-hop pfm transfers
Browse files Browse the repository at this point in the history
  • Loading branch information
0xpatrickdev committed Oct 23, 2024
1 parent 19a3107 commit efe74b3
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 21 deletions.
22 changes: 21 additions & 1 deletion packages/orchestration/src/cosmos-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -332,3 +332,23 @@ 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;
// TODO type me better e.g. '30min'
timeout: string;
/** default is 3? */
retries: number;
next?: {
forward: ForwardInfo;
};
};
}
114 changes: 97 additions & 17 deletions packages/orchestration/src/exos/local-orchestration-account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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())),
}),
Expand Down Expand Up @@ -352,24 +353,25 @@ 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',
{
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 : '',
timeoutHeight: opts?.timeoutHeight ?? {
revisionHeight: 0n,
revisionNumber: 0n,
Expand Down Expand Up @@ -684,15 +686,24 @@ export const prepareLocalOrchestrationAccountKit = (
* @returns {Vow<any>}
*/
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
Expand All @@ -702,15 +713,84 @@ 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(
allVows([connectionInfoV, timeoutTimestampVowOrValue]),
this.facets.transferWatcher,
{
opts,
amount: coerceDenomAmount(chainHub, amount),
denomAmount,
destination,
isPfm: false,
},
);
return resultV;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ 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';
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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -163,7 +165,9 @@ test('transfer', async t => {
buildVTransferEvent({
receiver: destination.value,
sender,
sourceChannel,
sourceChannel:
fetchedChainInfo.agoric.connections[destination.chainId].transferChannel
.channelId,
sequence: lastSequence,
}),
);
Expand Down Expand Up @@ -203,19 +207,24 @@ 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
await VE(transferBridge).fromBridge(
buildVTransferEvent({
receiver: dest.value,
sender,
sourceChannel,
sourceChannel:
sourceChannel ||
fetchedChainInfo.agoric.connections[dest.chainId].transferChannel
.channelId,
sequence: lastSequence,
}),
);
Expand Down Expand Up @@ -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 => {
Expand Down

0 comments on commit efe74b3

Please sign in to comment.