From 46f574333a6f25ed92340f22827fceefba72cdca 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 | 28 ++ .../test/fast-usdc/fast-usdc.test.ts | 386 ++++++++++++++++++ multichain-testing/tools/noble-tools.ts | 23 +- multichain-testing/tools/purse.ts | 10 + multichain-testing/tools/random.ts | 11 + multichain-testing/yarn.lock | 47 ++- 6 files changed, 494 insertions(+), 11 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/purse.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..c7b1833ec6d --- /dev/null +++ b/multichain-testing/test/fast-usdc/config.ts @@ -0,0 +1,28 @@ +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 { + nobleAgoricChannelId, + nobleDomainId: 4, + chainPolicies: { + Arbitrum: { + attenuatedCttpBridgeAddress: + '0xe298b93ffB5eA1FB628e0C0D55A43aeaC268e347', + cctpTokenMessengerAddress: '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + chainId: 42161, + confirmations: 2, + }, + }, + }; +}; +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..1c169197cb0 --- /dev/null +++ b/multichain-testing/test/fast-usdc/fast-usdc.test.ts @@ -0,0 +1,386 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { encodeAddressHook } from '@agoric/cosmic-proto/address-hooks.js'; +import { AmountMath } from '@agoric/ertp'; +import type { Denom } from '@agoric/orchestration'; +import { divideBy, multiplyBy } 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 { commonSetup, type SetupContextWithWallets } from '../support.js'; +import { makeFeedPolicy, oracleMnemonics } from './config.js'; +import { makeRandomDigits } from '../../tools/random.js'; +import { balancesFromPurses } from '../../tools/purse.js'; + +const { keys, values, fromEntries } = Object; +const { isGTE, isEmpty, make } = AmountMath; + +const makeRandomNumber = () => Math.random(); + +const test = anyTest as TestFn< + SetupContextWithWallets & { + lpUser: WalletDriver; + oracleWds: WalletDriver[]; + nobleAgoricChannelId: IBCChannelID; + usdcOnOsmosis: Denom; + /** usdc on agoric */ + usdcDenom: Denom; + } +>; + +const accounts = [...keys(oracleMnemonics), 'lp']; +const contractName = 'fastUsdc'; +const contractBuilder = + '../packages/builders/scripts/fast-usdc/init-fast-usdc.js'; +const LP_DEPOSIT_AMOUNT = 10_000_000n; + +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 oracleWdPs = keys(oracleMnemonics).map(n => + provisionSmartWallet(wallets[n], { + BLD: 100n, + }), + ); + // execute sequentially, to avoid "published.wallet.${addr}.current: fetch failed" + const oracleWds: WalletDriver[] = []; + for (const p of oracleWdPs) { + const wd = await p; + oracleWds.push(wd); + } + + // 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); + t.log('usdcDenom', usdcDenom); + + await startContract(contractName, contractBuilder, { + oracle: keys(oracleMnemonics).map(n => `${n}:${wallets[n]}`), + usdcDenom, + feedPolicy: JSON.stringify(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, + usdcDenom, + 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, wallets } = t.context; + + const lpDoOffer = makeDoOffer(lpUser); + const brands = await vstorageClient.queryData('published.agoricNames.brand'); + const { USDC, FastLP } = Object.fromEntries(brands); + + const usdcToGive = make(USDC, LP_DEPOSIT_AMOUNT); + + const { shareWorth: currShareWorth } = await vstorageClient.queryData( + `published.${contractName}.poolMetrics`, + ); + const poolSharesWanted = divideBy(usdcToGive, currShareWorth); + + await lpDoOffer({ + id: `lp-deposit-${Date.now()}`, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeDepositInvitation']], + }, + proposal: { + give: { USDC: usdcToGive }, + want: { PoolShare: poolSharesWanted }, + }, + }); + + await t.notThrowsAsync(() => + retryUntilCondition( + () => vstorageClient.queryData(`published.${contractName}.poolMetrics`), + ({ shareWorth }) => + !isGTE(currShareWorth.numerator, shareWorth.numerator), + 'share worth numerator increases from deposit', + ), + ); + + await t.notThrowsAsync(() => + retryUntilCondition( + () => + vstorageClient.queryData(`published.wallet.${wallets['lp']}.current`), + ({ purses }) => { + const currentPoolShares = balancesFromPurses(purses)[FastLP]; + return currentPoolShares && isGTE(currentPoolShares, poolSharesWanted); + }, + 'lp has pool shares', + ), + ); +}); + +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 EUD = (await eudWallet.getAccounts())[0].address; + + // parameterize agoric address + const { settlementAccount } = await vstorageClient.queryData( + `published.${contractName}`, + ); + t.log('settlementAccount address', settlementAccount); + + const recipientAddress = encodeAddressHook(settlementAccount, { EUD }); + t.log('recipientAddress', recipientAddress); + + // register forwarding address on noble + const txRes = nobleTools.registerForwardingAcct( + nobleAgoricChannelId, + recipientAddress, + ); + t.is(txRes?.code, 0, 'registered forwarding account'); + + const { address: userForwardingAddr } = nobleTools.queryForwardingAddress( + nobleAgoricChannelId, + recipientAddress, + ); + t.log('got forwardingAddress', userForwardingAddr); + + const mintAmount = 800_000n; + + // TODO export CctpTxEvidence type + const evidence = harden({ + blockHash: + '0x90d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee665', + blockNumber: 21037663n, + txHash: `0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff3875527617${makeRandomDigits(makeRandomNumber(), 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(EUD, usdcOnOsmosis), + ({ balance }) => !!balance?.amount && BigInt(balance.amount) < mintAmount, + `${EUD} advance available from fast-usdc`, + { + // this resolves quickly, so _decrease_ the interval so the timing is more apparent + retryIntervalMs: 500, + }, + ), + ); + + const queryTxStatus = async () => + vstorageClient.queryData( + `published.${contractName}.status.${evidence.txHash}`, + ); + + const assertTxStatus = async (status: string) => + t.notThrowsAsync(() => + retryUntilCondition( + () => queryTxStatus(), + txStatus => { + console.log('tx status', txStatus); + return txStatus === status; + }, + `${evidence.txHash} is ${status}`, + ), + ); + + await assertTxStatus('ADVANCED'); + console.log('Advance completed, waiting for mint...'); + + nobleTools.mockCctpMint(mintAmount, userForwardingAddr); + await t.notThrowsAsync(() => + retryUntilCondition( + () => vstorageClient.queryData(`published.${contractName}.poolMetrics`), + ({ encumberedBalance }) => + encumberedBalance && isEmpty(encumberedBalance), + 'encumberedBalance returns to 0', + ), + ); + + await assertTxStatus('DISBURSED'); +}); + +test.serial('lp withdraws', async t => { + const { + lpUser, + retryUntilCondition, + useChain, + usdcDenom, + vstorageClient, + wallets, + } = t.context; + const queryClient = makeQueryClient( + await useChain('agoric').getRestEndpoint(), + ); + const lpDoOffer = makeDoOffer(lpUser); + const brands = await vstorageClient.queryData('published.agoricNames.brand'); + const { FastLP } = Object.fromEntries(brands); + t.log('FastLP brand', FastLP); + + const { shareWorth: currShareWorth } = await vstorageClient.queryData( + `published.${contractName}.poolMetrics`, + ); + const { purses } = await vstorageClient.queryData( + `published.wallet.${wallets['lp']}.current`, + ); + const currentPoolShares = balancesFromPurses(purses)[FastLP]; + t.log('currentPoolShares', currentPoolShares); + const usdcWanted = multiplyBy(currentPoolShares, currShareWorth); + t.log('usdcWanted', usdcWanted); + + const { balance: currentUSDCBalance } = await queryClient.queryBalance( + wallets['lp'], + usdcDenom, + ); + t.log(`current ${usdcDenom} balance`, currentUSDCBalance); + + await lpDoOffer({ + id: `lp-withdraw-${Date.now()}`, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeWithdrawInvitation']], + }, + proposal: { + give: { PoolShare: currentPoolShares }, + want: { USDC: usdcWanted }, + }, + }); + + await t.notThrowsAsync(() => + retryUntilCondition( + () => + vstorageClient.queryData(`published.wallet.${wallets['lp']}.current`), + ({ purses }) => { + const currentPoolShares = balancesFromPurses(purses)[FastLP]; + return !currentPoolShares || isEmpty(currentPoolShares); + }, + 'lp no longer has pool shares', + ), + ); + + await t.notThrowsAsync(() => + retryUntilCondition( + () => queryClient.queryBalance(wallets['lp'], usdcDenom), + ({ balance }) => + !!balance?.amount && + BigInt(balance.amount) - BigInt(currentUSDCBalance!.amount!) > + LP_DEPOSIT_AMOUNT, + "lp's USDC balance increases", + ), + ); +}); diff --git a/multichain-testing/tools/noble-tools.ts b/multichain-testing/tools/noble-tools.ts index 0ece8dfd8b8..8a08e6a85bb 100644 --- a/multichain-testing/tools/noble-tools.ts +++ b/multichain-testing/tools/noble-tools.ts @@ -19,11 +19,14 @@ const makeKubeArgs = () => { ]; }; -export const makeNobleTools = ({ - execFileSync, -}: { - execFileSync: ExecSync; -}) => { +export const makeNobleTools = ( + { + execFileSync, + }: { + execFileSync: ExecSync; + }, + log: (...args: unknown[]) => void = console.log, +) => { const exec = ( args: string[], opts = { encoding: 'utf-8' as const, stdio: ['ignore', 'pipe', 'ignore'] }, @@ -38,8 +41,9 @@ export const makeNobleTools = ({ const registerForwardingAcct = ( channelId: IBCChannelID, address: ChainAddress['value'], - ) => { + ): { txhash: string; code: number; data: string; height: string } => { checkEnv(); + log('creating forwarding address', address, channelId); return JSON.parse( exec([ 'tx', @@ -57,6 +61,8 @@ export const makeNobleTools = ({ const mockCctpMint = (amount: bigint, destination: ChainAddress['value']) => { checkEnv(); + const denomAmount = `${Number(amount)}uusdc`; + log('mock cctp mint', destination, denomAmount); return JSON.parse( exec([ 'tx', @@ -64,7 +70,7 @@ export const makeNobleTools = ({ 'send', 'faucet', destination, - `${Number(amount)}uusdc`, + denomAmount, '--from=faucet', '-y', '-b', @@ -76,8 +82,9 @@ export const makeNobleTools = ({ const queryForwardingAddress = ( channelId: IBCChannelID, address: ChainAddress['value'], - ) => { + ): { address: string; exists: boolean } => { checkEnv(); + log('querying forwarding address', address, channelId); return JSON.parse( exec([ 'query', diff --git a/multichain-testing/tools/purse.ts b/multichain-testing/tools/purse.ts new file mode 100644 index 00000000000..82a76d2a3f4 --- /dev/null +++ b/multichain-testing/tools/purse.ts @@ -0,0 +1,10 @@ +import type { Amount, Brand } from '@agoric/ertp'; +const { fromEntries } = Object; + +// @ts-expect-error Type 'Brand' does not satisfy the constraint 'string | number | symbol' +type BrandToBalance = Record; + +export const balancesFromPurses = ( + purses: { balance: Amount; brand: Brand }[], +): BrandToBalance => + fromEntries(purses.map(({ balance, brand }) => [brand, balance])); diff --git a/multichain-testing/tools/random.ts b/multichain-testing/tools/random.ts new file mode 100644 index 00000000000..1928bb1ebd3 --- /dev/null +++ b/multichain-testing/tools/random.ts @@ -0,0 +1,11 @@ +/** + * @param randomN pseudorandom number between 0 and 1, e.g. Math.random() + * @param digits number of digits to generate + * @returns a string of digits + */ +export function makeRandomDigits(randomN: number, digits = 2n) { + if (digits < 1n) throw new Error('digits must be positive'); + const maxValue = Math.pow(10, Number(digits)) - 1; + const num = Math.floor(randomN * (maxValue + 1)); + return num.toString().padStart(Number(digits), '0'); +} diff --git a/multichain-testing/yarn.lock b/multichain-testing/yarn.lock index 66b1b0557dc..fd3e9510370 100644 --- a/multichain-testing/yarn.lock +++ b/multichain-testing/yarn.lock @@ -6,12 +6,14 @@ __metadata: cacheKey: 10c0 "@agoric/cosmic-proto@npm:dev": - version: 0.4.1-dev-e596a01.0 - resolution: "@agoric/cosmic-proto@npm:0.4.1-dev-e596a01.0" + version: 0.4.1-dev-bdf5c17.0 + resolution: "@agoric/cosmic-proto@npm:0.4.1-dev-bdf5c17.0" dependencies: "@endo/base64": "npm:^1.0.9" "@endo/init": "npm:^1.1.7" - checksum: 10c0/2048e794ec9a346fb3a618b1b64d54985241967930b8b34c9220316b206fca4d3ecdf738e23e56021d45c3818f4513842e6d4c4d917a537dad59c13651d0ae35 + bech32: "npm:^2.0.0" + query-string: "npm:^9.1.1" + checksum: 10c0/20d4f8763a091b0b741c754fcceb82d666c4eb55bab2eaaef8821f8f7da644e2ee70c1134ef0e1cf90cc940150d61437d935913549d0da8ea17a8f0c80f2d36c languageName: node linkType: hard @@ -1028,6 +1030,13 @@ __metadata: languageName: node linkType: hard +"bech32@npm:^2.0.0": + version: 2.0.0 + resolution: "bech32@npm:2.0.0" + checksum: 10c0/45e7cc62758c9b26c05161b4483f40ea534437cf68ef785abadc5b62a2611319b878fef4f86ddc14854f183b645917a19addebc9573ab890e19194bc8f521942 + languageName: node + linkType: hard + "bfs-path@npm:^1.0.2": version: 1.0.2 resolution: "bfs-path@npm:1.0.2" @@ -1369,6 +1378,13 @@ __metadata: languageName: node linkType: hard +"decode-uri-component@npm:^0.4.1": + version: 0.4.1 + resolution: "decode-uri-component@npm:0.4.1" + checksum: 10c0/a180bbdb5398ec8270d236a3ac07cb988bbf6097428481780b85840f088951dc0318a8d8f9d56796e1a322b55b29859cea29982f22f9b03af0bc60974c54e591 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -1764,6 +1780,13 @@ __metadata: languageName: node linkType: hard +"filter-obj@npm:^5.1.0": + version: 5.1.0 + resolution: "filter-obj@npm:5.1.0" + checksum: 10c0/716e8ad2bc352e206556b3e5695b3cdff8aab80c53ea4b00c96315bbf467b987df3640575100aef8b84e812cf5ea4251db4cd672bbe33b1e78afea88400c67dd + languageName: node + linkType: hard + "find-up-simple@npm:^1.0.0": version: 1.0.0 resolution: "find-up-simple@npm:1.0.0" @@ -2924,6 +2947,17 @@ __metadata: languageName: node linkType: hard +"query-string@npm:^9.1.1": + version: 9.1.1 + resolution: "query-string@npm:9.1.1" + dependencies: + decode-uri-component: "npm:^0.4.1" + filter-obj: "npm:^5.1.0" + split-on-first: "npm:^3.0.0" + checksum: 10c0/16481f17754f660aec3cae7abb838a70e383dfcf152414d184e0d0f81fae426acf112b4d51bf754f9c256eaf83ba4241241ba907c8d58b6ed9704425e1712e8c + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -3183,6 +3217,13 @@ __metadata: languageName: node linkType: hard +"split-on-first@npm:^3.0.0": + version: 3.0.0 + resolution: "split-on-first@npm:3.0.0" + checksum: 10c0/a1262eae12b68de235e1a08e011bf5b42c42621985ddf807e6221fb1e2b3304824913ae7019f18436b96b8fab8aef5f1ad80dedd2385317fdc51b521c3882cd0 + languageName: node + linkType: hard + "sprintf-js@npm:~1.0.2": version: 1.0.3 resolution: "sprintf-js@npm:1.0.3"