From cfd26c8b9a0b8c8cdae51c24aeeda8cf33746341 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Wed, 4 Dec 2024 00:33:26 -0500 Subject: [PATCH] test: fast-usdc advance happy path --- multichain-testing/test/fast-usdc/config.ts | 27 ++ .../test/fast-usdc/fast-usdc.test.ts | 287 ++++++++++++++++++ multichain-testing/tools/noble-tools.ts | 4 +- multichain-testing/tools/random.ts | 6 + 4 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 multichain-testing/test/fast-usdc/config.ts create mode 100644 multichain-testing/test/fast-usdc/fast-usdc.test.ts create mode 100644 multichain-testing/tools/random.ts diff --git a/multichain-testing/test/fast-usdc/config.ts b/multichain-testing/test/fast-usdc/config.ts new file mode 100644 index 00000000000..c2b70987f50 --- /dev/null +++ b/multichain-testing/test/fast-usdc/config.ts @@ -0,0 +1,27 @@ +import type { IBCChannelID } from '@agoric/vats'; + +export const oracleMnemonics = { + oracle1: + 'cause eight cattle slot course mail more aware vapor slab hobby match', + oracle2: + 'flower salute inspire label latin cattle believe sausage match total bless refuse', + oracle3: + 'surge magnet typical drive cement artist stay latin chief obey word always', +}; +harden(oracleMnemonics); + +export const makeFeedPolicy = (nobleAgoricChannelId: IBCChannelID) => { + return JSON.stringify({ + nobleAgoricChannelId, + nobleDomainId: 4, + chainPolicies: { + Arbitrum: { + cctpTokenMessengerAddress: '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + chainId: 42161, + confirmations: 2, + nobleContractAddress: '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + }, + }, + }); +}; +harden(makeFeedPolicy); diff --git a/multichain-testing/test/fast-usdc/fast-usdc.test.ts b/multichain-testing/test/fast-usdc/fast-usdc.test.ts new file mode 100644 index 00000000000..ef0c219f16c --- /dev/null +++ b/multichain-testing/test/fast-usdc/fast-usdc.test.ts @@ -0,0 +1,287 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { AmountMath } from '@agoric/ertp'; +import type { Denom } from '@agoric/orchestration'; +import { divideBy } from '@agoric/zoe/src/contractSupport/ratio.js'; +import type { IBCChannelID } from '@agoric/vats'; +import { makeDoOffer, type WalletDriver } from '../../tools/e2e-tools.js'; +import { makeDenomTools } from '../../tools/asset-info.js'; +import { createWallet } from '../../tools/wallet.js'; +import { makeQueryClient } from '../../tools/query.js'; +import { makeFundAndTransfer } from '../../tools/ibc-transfer.js'; +import { commonSetup, type SetupContextWithWallets } from '../support.js'; +import { makeFeedPolicy, oracleMnemonics } from './config.js'; +import { makeRandomDigits } from '../../tools/random.js'; + +const { keys, values, fromEntries } = Object; +const { isGTE, isEmpty, make } = AmountMath; + +const test = anyTest as TestFn< + SetupContextWithWallets & { + lpUser: WalletDriver; + oracleWds: WalletDriver[]; + nobleAgoricChannelId: IBCChannelID; + usdcOnOsmosis: Denom; + } +>; + +const accounts = [...keys(oracleMnemonics), 'lp']; +const contractName = 'fastUsdc'; +const contractBuilder = + '../packages/builders/scripts/fast-usdc/init-fast-usdc.js'; + +test.before(async t => { + const { setupTestKeys, ...common } = await commonSetup(t); + const { + chainInfo, + commonBuilderOpts, + deleteTestKeys, + faucetTools, + provisionSmartWallet, + startContract, + } = common; + deleteTestKeys(accounts).catch(); + const wallets = await setupTestKeys(accounts, values(oracleMnemonics)); + + // provision oracle wallets first so invitation deposits don't fail + const oracleWds = await Promise.all( + keys(oracleMnemonics).map(n => + provisionSmartWallet(wallets[n], { + BLD: 100n, + }), + ), + ); + + // calculate denomHash and channelId for privateArgs / builder opts + const { getTransferChannelId, toDenomHash } = makeDenomTools(chainInfo); + const usdcDenom = toDenomHash('uusdc', 'noblelocal', 'agoric'); + const usdcOnOsmosis = toDenomHash('uusdc', 'noblelocal', 'osmosis'); + const nobleAgoricChannelId = getTransferChannelId('agoriclocal', 'noble'); + if (!nobleAgoricChannelId) throw new Error('nobleAgoricChannelId not found'); + t.log('nobleAgoricChannelId', nobleAgoricChannelId); + + await startContract(contractName, contractBuilder, { + oracle: keys(oracleMnemonics).map(n => `${n}:${wallets[n]}`), + usdcDenom: usdcDenom, + feedPolicy: makeFeedPolicy(nobleAgoricChannelId), + ...commonBuilderOpts, + }); + + // provide faucet funds for LPs + await faucetTools.fundFaucet([['noble', 'uusdc']]); + + // save an LP in test context + const lpUser = await provisionSmartWallet(wallets['lp'], { + USDC: 100n, + BLD: 100n, + }); + + t.context = { + ...common, + lpUser, + oracleWds, + nobleAgoricChannelId, + usdcOnOsmosis, + wallets, + }; +}); + +test.after(async t => { + const { deleteTestKeys } = t.context; + deleteTestKeys(accounts); +}); + +const toOracleOfferId = (idx: number) => `oracle${idx + 1}-accept`; + +test.serial('oracles accept', async t => { + const { oracleWds, retryUntilCondition, vstorageClient, wallets } = t.context; + + const instances = await vstorageClient.queryData( + 'published.agoricNames.instance', + ); + const instance = fromEntries(instances)[contractName]; + + // accept oracle operator invitations + await Promise.all( + oracleWds.map(makeDoOffer).map((doOffer, i) => + doOffer({ + id: toOracleOfferId(i), + invitationSpec: { + source: 'purse', + instance, + description: 'oracle operator invitation', // TODO export/import INVITATION_MAKERS_DESC + }, + proposal: {}, + }), + ), + ); + + for (const name of keys(oracleMnemonics)) { + const addr = wallets[name]; + await t.notThrowsAsync(() => + retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${addr}.current`), + ({ offerToUsedInvitation }) => { + return offerToUsedInvitation[0][0] === `${name}-accept`; + }, + `${name} invitation used`, + ), + ); + } +}); + +test.serial('lp deposits', async t => { + const { lpUser, retryUntilCondition, vstorageClient } = t.context; + + const lpDoOffer = makeDoOffer(lpUser); + const brands = await vstorageClient.queryData('published.agoricNames.brand'); + const { USDC } = Object.fromEntries(brands); + + const usdcGive = make(USDC, 10_000_000n); + + const { shareWorth: currShareWorth } = await vstorageClient.queryData( + `published.${contractName}.poolMetrics`, + ); + + await lpDoOffer({ + id: `lp-deposit-${Date.now()}`, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeDepositInvitation']], + }, + proposal: { + give: { USDC: usdcGive }, + want: { PoolShare: divideBy(usdcGive, currShareWorth) }, + }, + }); + + await t.notThrowsAsync(() => + retryUntilCondition( + () => vstorageClient.queryData(`published.${contractName}.poolMetrics`), + ({ shareWorth }) => + !isGTE(currShareWorth.numerator, shareWorth.numerator), + 'share worth numerator increases from deposit', + ), + ); +}); + +test.serial('advance and settlement', async t => { + const { + nobleTools, + nobleAgoricChannelId, + oracleWds, + retryUntilCondition, + useChain, + usdcOnOsmosis, + vstorageClient, + } = t.context; + + // EUD wallet on osmosis + const eudWallet = await createWallet(useChain('osmosis').chain.bech32_prefix); + const eudAddress = (await eudWallet.getAccounts())[0].address; + + // parameterize agoric address + const { settlementAccount } = await vstorageClient.queryData( + `published.${contractName}`, + ); + // TODO #10614 use bech32 encoding + const recipientAddress = `${settlementAccount}?EUD=${eudAddress}`; + t.log('recipientAddress', recipientAddress); + + // register forwarding address on noble + const txRes = await nobleTools.registerForwardingAcct( + nobleAgoricChannelId, + eudAddress, + ); + t.is(txRes?.code, 0, 'registered forwarding account'); + + const { address: userForwardingAddr } = + await nobleTools.queryForwardingAddress(nobleAgoricChannelId, eudAddress); + t.log('got forwardingAddress', userForwardingAddr); + + const mintAmount = 800_000n; + + // TODO export CctpTxEvidence type + const evidence = harden({ + blockHash: + '0x90d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee665', + blockNumber: 21037663n, + txHash: `0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff3875527617${makeRandomDigits(2n)}`, + tx: { + amount: mintAmount, + forwardingAddress: userForwardingAddr, + }, + aux: { + forwardingChannel: nobleAgoricChannelId, + recipientAddress, + }, + chainId: 42161, + }); + + console.log('User initiates evm mint', evidence.txHash); + + // submit evidences + await Promise.all( + oracleWds.map(makeDoOffer).map((doOffer, i) => + doOffer({ + id: `${Date.now()}-evm-evidence`, + invitationSpec: { + source: 'continuing', + previousOffer: toOracleOfferId(i), + invitationMakerName: 'SubmitEvidence', + invitationArgs: [evidence], + }, + proposal: {}, + }), + ), + ); + + const queryClient = makeQueryClient( + await useChain('osmosis').getRestEndpoint(), + ); + + await t.notThrowsAsync(() => + retryUntilCondition( + () => queryClient.queryBalance(eudAddress, usdcOnOsmosis), + ({ balance }) => !!balance?.amount && BigInt(balance.amount) < mintAmount, + `${eudAddress} advance available from fast-usdc`, + { + // this resolves quickly, so _decrease_ the interval so the timing is more apparent + retryIntervalMs: 500, + }, + ), + ); + + console.log('Advance completed, waiting for mint...'); + + await nobleTools.mockCctpMint(mintAmount, userForwardingAddr); + // TODO #10614: notThrowsAsync + // Noble executes transfer but it's not recognized by vtransfer + await t.throwsAsync(() => + retryUntilCondition( + () => vstorageClient.queryData(`published.${contractName}.poolMetrics`), + ({ encumberedBalance }) => + encumberedBalance && isEmpty(encumberedBalance), + 'encumberedBalance returns to 0', + ), + ); +}); + +// remove once settlement works, or move to failure path testing +test.serial('tap settler', async t => { + const { retryUntilCondition, vstorageClient, useChain } = t.context; + const fundAndTransfer = makeFundAndTransfer(t, retryUntilCondition, useChain); + + const { settlementAccount } = await vstorageClient.queryData( + `published.${contractName}`, + ); + await fundAndTransfer('noble', settlementAccount, 10_000_000n, 'uusdc'); + + // consider testing that no metrics change + // for now, seeing "no EUD parameter" works to know an IBC transfer to the + // forwarding address works / tap was registered. + t.pass(); +}); + +test.todo('lp withdraws and earns fees'); diff --git a/multichain-testing/tools/noble-tools.ts b/multichain-testing/tools/noble-tools.ts index 0ece8dfd8b8..5c6c91c99f6 100644 --- a/multichain-testing/tools/noble-tools.ts +++ b/multichain-testing/tools/noble-tools.ts @@ -38,7 +38,7 @@ export const makeNobleTools = ({ const registerForwardingAcct = ( channelId: IBCChannelID, address: ChainAddress['value'], - ) => { + ): { txhash: string; code: number; data: string; height: string } => { checkEnv(); return JSON.parse( exec([ @@ -76,7 +76,7 @@ export const makeNobleTools = ({ const queryForwardingAddress = ( channelId: IBCChannelID, address: ChainAddress['value'], - ) => { + ): { address: string; exists: boolean } => { checkEnv(); return JSON.parse( exec([ diff --git a/multichain-testing/tools/random.ts b/multichain-testing/tools/random.ts new file mode 100644 index 00000000000..21a2f9fd2f0 --- /dev/null +++ b/multichain-testing/tools/random.ts @@ -0,0 +1,6 @@ +export function makeRandomDigits(digits = 2n) { + if (digits < 1n) throw new Error('digits must be positive'); + const maxValue = Math.pow(10, Number(digits)) - 1; + const num = Math.floor(Math.random() * (maxValue + 1)); + return num.toString().padStart(Number(digits), '0'); +}