Skip to content

Commit

Permalink
feat(fast-usdc): detect transfer completion in cli (#10717)
Browse files Browse the repository at this point in the history
fixes #10339

## Description
This just simply polls the USDC balance on the destination account repeatedly to see when it changes

### Security Considerations
There are ways to exploit this detection logic. Namely, if the user or someone else sends USDC to the EUD before the transfer finishes, the CLI would prematurely report that the transfer completed. We could refine this logic if needed, but it's just a UX issue and doesn't affect whether or not they get their funds.

### Scaling Considerations
Polls the API url every 1.2 seconds temporarily, shouldn't be too much of an impact.

### Documentation Considerations
Updated the demo config file with an example for osmosis chain.

### Testing Considerations
We can't test this fully e2e until we have a real testnet setup with the FU contract and IBC to noble testnet and another cosmos chain. However, added unit tests to verify the new transfer detection functionality in this PR. The previously existing functionality of the transfer flow was partially tested in testnets when it was added in #10437 (see "Testing Considerations").

### Upgrade Considerations
This CLI code does not run on-chain.
  • Loading branch information
mergify[bot] authored Dec 18, 2024
2 parents 9e5f628 + 2828444 commit 6fbd20a
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 15 deletions.
9 changes: 8 additions & 1 deletion packages/fast-usdc/demo/testnet/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@
"nobleApi": "https://noble-api.polkachu.com",
"ethRpc": "https://sepolia.drpc.org",
"tokenMessengerAddress": "0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5",
"tokenAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"
"tokenAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
"destinationChains": [
{
"bech32prefix": "osmo",
"api": "https://lcd.osmosis.zone",
"USDCDenom": "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"
}
]
}
5 changes: 5 additions & 0 deletions packages/fast-usdc/src/cli/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ export const initProgram = (
/** @type {string} */ amount,
/** @type {string} */ destination,
) => {
const start = now();
await transferHelpers.transfer(makeConfigFile(), amount, destination);
const duration = now() - start;
stdout.write(
`Transfer finished in ${(duration / 1000).toFixed(1)} seconds`,
);
},
);

Expand Down
9 changes: 9 additions & 0 deletions packages/fast-usdc/src/cli/config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

/**
@typedef {{
bech32Prefix: string,
api: string,
USDCDenom: string
}} DestinationChain
*/

/**
@typedef {{
nobleSeed: string,
Expand All @@ -11,6 +19,7 @@ import { stdin as input, stdout as output } from 'node:process';
ethRpc: string,
tokenMessengerAddress: string,
tokenAddress: string
destinationChains?: DestinationChain[]
}} ConfigOpts
*/

Expand Down
42 changes: 42 additions & 0 deletions packages/fast-usdc/src/cli/transfer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
queryForwardingAccount,
registerFwdAccount,
} from '../util/noble.js';
import { queryUSDCBalance } from '../util/bank.js';

/** @import { File } from '../util/file' */
/** @import { VStorage } from '@agoric/client-utils' */
Expand All @@ -30,6 +31,7 @@ const transfer = async (
/** @type {{signer: SigningStargateClient, address: string} | undefined} */ nobleSigner,
/** @type {ethProvider | undefined} */ ethProvider,
env = process.env,
setTimeout = globalThis.setTimeout,
) => {
const execute = async (
/** @type {import('./config').ConfigOpts} */ config,
Expand Down Expand Up @@ -71,6 +73,18 @@ const transfer = async (
}
}

const destChain = config.destinationChains?.find(chain =>
EUD.startsWith(chain.bech32Prefix),
);
if (!destChain) {
out.error(
`No destination chain found in config with matching bech32 prefix for ${EUD}, cannot query destination address`,
);
throw new Error();
}
const { api, USDCDenom } = destChain;
const startingBalance = await queryUSDCBalance(EUD, api, USDCDenom, fetch);

ethProvider ||= makeProvider(config.ethRpc);
await depositForBurn(
ethProvider,
Expand All @@ -81,6 +95,34 @@ const transfer = async (
amount,
out,
);

const refreshDelayMS = 1200;
const completeP = /** @type {Promise<void>} */ (
new Promise((res, rej) => {
const refreshUSDCBalance = async () => {
out.log('polling usdc balance');
const currentBalance = await queryUSDCBalance(
EUD,
api,
USDCDenom,
fetch,
);
if (currentBalance !== startingBalance) {
res();
} else {
setTimeout(() => refreshUSDCBalance().catch(rej), refreshDelayMS);
}
};
refreshUSDCBalance().catch(rej);
})
).catch(e => {
out.error(
'Error checking destination address balance, could not detect completion of transfer.',
);
out.error(e.message);
});

await completeP;
};

let config;
Expand Down
12 changes: 12 additions & 0 deletions packages/fast-usdc/src/util/bank.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const queryUSDCBalance = async (
/** @type {string} */ address,
/** @type {string} */ api,
/** @type {string} */ denom,
/** @type {typeof globalThis.fetch} */ fetch,
) => {
const query = `${api}/cosmos/bank/v1beta1/balances/${address}`;
const json = await fetch(query).then(res => res.json());
const amount = json.balances?.find(b => b.denom === denom)?.amount ?? '0';

return BigInt(amount);
};
2 changes: 1 addition & 1 deletion packages/fast-usdc/src/util/cctp.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,5 @@ export const depositForBurn = async (

out.log('Transaction confirmed in block', receipt.blockNumber);
out.log('Transaction hash:', receipt.hash);
out.log('USDC transfer initiated successfully, our work here is done.');
out.log('USDC transfer initiated successfully');
};
68 changes: 57 additions & 11 deletions packages/fast-usdc/test/cli/transfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ test('Transfer registers the noble forwarding account if it does not exist', asy
const path = 'config/dir/.fast-usdc/config.json';
const nobleApi = 'http://api.noble.test';
const nobleToAgoricChannel = 'channel-test-7';
const destinationChainApi = 'http://api.dydx.fake-test';
const destinationUSDCDenom = 'ibc/USDCDENOM';
const config = {
agoricRpc: 'http://rpc.agoric.test',
nobleApi,
Expand All @@ -61,6 +63,13 @@ test('Transfer registers the noble forwarding account if it does not exist', asy
ethSeed: 'a4b7f431465df5dc1458cd8a9be10c42da8e3729e3ce53f18814f48ae2a98a08',
tokenMessengerAddress: '0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5',
tokenAddress: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238',
destinationChains: [
{
bech32Prefix: 'dydx',
api: destinationChainApi,
USDCDenom: destinationUSDCDenom,
},
],
};
const out = mockOut();
const file = mockFile(path, JSON.stringify(config));
Expand All @@ -76,11 +85,25 @@ test('Transfer registers the noble forwarding account if it does not exist', asy
agoricSettlementAccount,
{ EUD },
)}/`;
const fetchMock = makeFetchMock({
[nobleFwdAccountQuery]: {
address: 'noble14lwerrcfzkzrv626w49pkzgna4dtga8c5x479h',
exists: false,
},
const destinationBankQuery = `${destinationChainApi}/cosmos/bank/v1beta1/balances/${EUD}`;
let balanceQueryCount = 0;
const fetchMock = makeFetchMock((query: string) => {
if (query === nobleFwdAccountQuery) {
return {
address: 'noble14lwerrcfzkzrv626w49pkzgna4dtga8c5x479h',
exists: false,
};
}
if (query === destinationBankQuery) {
if (balanceQueryCount > 1) {
return {
balances: [{ denom: destinationUSDCDenom, amount }],
};
} else {
balanceQueryCount += 1;
return {};
}
}
});
const nobleSignerAddress = 'noble09876';
const signerMock = makeMockSigner();
Expand All @@ -97,7 +120,6 @@ test('Transfer registers the noble forwarding account if it does not exist', asy
{ signer: signerMock.signer, address: nobleSignerAddress },
mockEthProvider.provider,
);

t.is(vstorageMock.getQueryCounts()[settlementAccountVstoragePath], 1);
t.is(fetchMock.getQueryCounts()[nobleFwdAccountQuery], 1);
t.snapshot(signerMock.getSigned());
Expand All @@ -107,6 +129,8 @@ test('Transfer signs and broadcasts the depositForBurn message on Ethereum', asy
const path = 'config/dir/.fast-usdc/config.json';
const nobleApi = 'http://api.noble.test';
const nobleToAgoricChannel = 'channel-test-7';
const destinationChainApi = 'http://api.dydx.fake-test';
const destinationUSDCDenom = 'ibc/USDCDENOM';
const config = {
agoricRpc: 'http://rpc.agoric.test',
nobleApi,
Expand All @@ -116,6 +140,13 @@ test('Transfer signs and broadcasts the depositForBurn message on Ethereum', asy
ethSeed: 'a4b7f431465df5dc1458cd8a9be10c42da8e3729e3ce53f18814f48ae2a98a08',
tokenMessengerAddress: '0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5',
tokenAddress: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238',
destinationChains: [
{
bech32Prefix: 'dydx',
api: destinationChainApi,
USDCDenom: destinationUSDCDenom,
},
],
};
const out = mockOut();
const file = mockFile(path, JSON.stringify(config));
Expand All @@ -131,11 +162,25 @@ test('Transfer signs and broadcasts the depositForBurn message on Ethereum', asy
agoricSettlementAccount,
{ EUD },
)}/`;
const fetchMock = makeFetchMock({
[nobleFwdAccountQuery]: {
address: 'noble14lwerrcfzkzrv626w49pkzgna4dtga8c5x479h',
exists: true,
},
const destinationBankQuery = `${destinationChainApi}/cosmos/bank/v1beta1/balances/${EUD}`;
let balanceQueryCount = 0;
const fetchMock = makeFetchMock((query: string) => {
if (query === nobleFwdAccountQuery) {
return {
address: 'noble14lwerrcfzkzrv626w49pkzgna4dtga8c5x479h',
exists: true,
};
}
if (query === destinationBankQuery) {
if (balanceQueryCount > 1) {
return {
balances: [{ denom: destinationUSDCDenom, amount }],
};
} else {
balanceQueryCount += 1;
return {};
}
}
});
const nobleSignerAddress = 'noble09876';
const signerMock = makeMockSigner();
Expand All @@ -162,4 +207,5 @@ test('Transfer signs and broadcasts the depositForBurn message on Ethereum', asy
t.deepEqual(mockEthProvider.getTxnArgs()[1], [
'0xf8e4800180949f3b8679c73c2fef8b59b4f3444d4e156fb70aa580b8846fd3504e0000000000000000000000000000000000000000000000000000000008f0d1800000000000000000000000000000000000000000000000000000000000000004000000000000000000000000afdd918f09158436695a754a1b0913ed5ab474f80000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c723882011aa09fc97790b2ba23fbb974554dbcee00df1a1f50e9fec4fdf370454773604aa477a038a1d86afc2a7afdc78088878a912f1a7c678b10c3120d308f8260a277b135a3',
]);
t.is(fetchMock.getQueryCounts()[destinationBankQuery], 3);
});
4 changes: 2 additions & 2 deletions packages/fast-usdc/testing/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ export const makeVstorageMock = (records: { [key: string]: any }) => {
return { vstorage, getQueryCounts: () => queryCounts };
};

export const makeFetchMock = (records: { [key: string]: any }) => {
export const makeFetchMock = get => {
const queryCounts = {};
const fetch = async (path: string) => {
queryCounts[path] = (queryCounts[path] ?? 0) + 1;
return { json: async () => records[path] };
return { json: async () => get(path) };
};

return { fetch, getQueryCounts: () => queryCounts };
Expand Down

0 comments on commit 6fbd20a

Please sign in to comment.