From e084cac22e62d802a39ab13dd658ae30dcdd71c6 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 13 Dec 2024 15:20:51 -0600 Subject: [PATCH] refactor: factor out actors: TxOracle, LiquidityProvider, UserAgent --- .../test/fast-usdc/fast-usdc.test.ts | 518 +++++++++++------- 1 file changed, 317 insertions(+), 201 deletions(-) diff --git a/multichain-testing/test/fast-usdc/fast-usdc.test.ts b/multichain-testing/test/fast-usdc/fast-usdc.test.ts index 5121bfd6a43..27e42b2e56a 100644 --- a/multichain-testing/test/fast-usdc/fast-usdc.test.ts +++ b/multichain-testing/test/fast-usdc/fast-usdc.test.ts @@ -1,15 +1,17 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; import { encodeAddressHook } from '@agoric/cosmic-proto/address-hooks.js'; import { AmountMath } from '@agoric/ertp'; import type { Denom } from '@agoric/orchestration'; +import type { CurrentWalletRecord } from '@agoric/smart-wallet/src/smartWallet.js'; import type { IBCChannelID } from '@agoric/vats'; import { divideBy, multiplyBy } from '@agoric/zoe/src/contractSupport/ratio.js'; -import anyTest from '@endo/ses-ava/prepare-endo.js'; -import type { TestFn } from 'ava'; +import { Random } from '@cosmjs/crypto'; +import type { ExecutionContext, TestFn } from 'ava'; import { makeDenomTools } from '../../tools/asset-info.js'; import { makeDoOffer, type WalletDriver } from '../../tools/e2e-tools.js'; -import { balancesFromPurses } from '../../tools/purse.js'; import { makeQueryClient } from '../../tools/query.js'; import { makeRandomDigits } from '../../tools/random.js'; +import type { RetryUntilCondition } from '../../tools/sleep.js'; import { createWallet } from '../../tools/wallet.js'; import { commonSetup, type SetupContextWithWallets } from '../support.js'; import { makeFeedPolicy, oracleMnemonics } from './config.js'; @@ -17,12 +19,12 @@ import { makeFeedPolicy, oracleMnemonics } from './config.js'; const { keys, values, fromEntries } = Object; const { isGTE, isEmpty, make } = AmountMath; -const makeRandomNumber = () => Math.random(); +type VStorageClient = Awaited>['vstorageClient']; const test = anyTest as TestFn< SetupContextWithWallets & { - lpUser: WalletDriver; - oracleWds: WalletDriver[]; + txOracles: TxOracle[]; + lpWallet: WalletDriver; nobleAgoricChannelId: IBCChannelID; usdcOnOsmosis: Denom; /** usdc on agoric */ @@ -45,16 +47,23 @@ test.before(async t => { faucetTools, provisionSmartWallet, startContract, + vstorageClient, + retryUntilCondition, } = common; await 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, - }), + const txOracles = await Promise.all( + keys(oracleMnemonics).map(async name => + makeTxOracle( + name, + await provisionSmartWallet(wallets[name], { BLD: 100n }), + wallets[name], + vstorageClient, + retryUntilCondition, + () => Date.now(), + ), ), ); @@ -78,15 +87,16 @@ test.before(async t => { await faucetTools.fundFaucet([['noble', 'uusdc']]); // save an LP in test context - const lpUser = await provisionSmartWallet(wallets['lp'], { + const lpWallet = await provisionSmartWallet(wallets['lp'], { USDC: 8_000n, BLD: 100n, }); t.context = { ...common, - lpUser, - oracleWds, + txOracles, + lpWallet: lpWallet, + provisionSmartWallet, nobleAgoricChannelId, usdcOnOsmosis, usdcDenom, @@ -99,114 +109,314 @@ test.after(async t => { deleteTestKeys(accounts); }); -const toOracleOfferId = (idx: number) => `oracle${idx + 1}-accept`; +const agoricNamesQ = (vsc: VStorageClient) => + harden({ + instance: (name: string) => + vsc + .queryData('published.agoricNames.instance') + .then(pairs => fromEntries(pairs)[name] as Instance), + brands: () => + vsc + .queryData('published.agoricNames.brand') + .then(pairs => fromEntries(pairs) as Record), + }); + +const walletQ = (vsc: VStorageClient) => + harden({ + current: (addr: string) => + vsc.queryData( + `published.wallet.${addr}.current`, + ) as Promise, + }); -test.serial('oracles accept', async t => { - const { oracleWds, retryUntilCondition, vstorageClient, wallets } = t.context; - const brands = await vstorageClient.queryData('published.agoricNames.brand'); - const { Invitation } = Object.fromEntries(brands); +// XXX TODO: import metrics types +export interface PoolStats { + totalBorrows: Amount<'nat'>; + totalContractFees: Amount<'nat'>; + totalPoolFees: Amount<'nat'>; + totalRepays: Amount<'nat'>; +} + +export interface PoolMetrics extends PoolStats { + encumberedBalance: Amount<'nat'>; + shareWorth: Ratio; +} + +const fastLPQ = (vsc: VStorageClient) => + harden({ + metrics: () => + vsc.queryData( + `published.${contractName}.poolMetrics`, + ) as Promise, + info: () => + vsc.queryData(`published.${contractName}`) as Promise<{ + poolAccount: string; + settlementAccount: string; + }>, + txStatus: (txHash: string) => + vsc.queryData(`published.${contractName}.status.${txHash}`), + }); + +const makeTxOracle = ( + name: string, + wd: WalletDriver, + address: string, + vstorageClient: VStorageClient, + retryUntilCondition: RetryUntilCondition, + now: () => number, +) => { + const acceptOfferId = `${name}-accept`; + + const instanceP = agoricNamesQ(vstorageClient).instance(contractName); + const brandP = agoricNamesQ(vstorageClient).brands(); const description = 'oracle operator invitation'; - // ensure we have an unused (or used) oracle invitation in each purse - let hasAccepted = false; - for (const name of keys(oracleMnemonics)) { - const { offerToUsedInvitation, purses } = await vstorageClient.queryData( - `published.wallet.${wallets[name]}.current`, - ); - const { value: invitations } = balancesFromPurses(purses)[Invitation]; - const hasInvitation = invitations.some(x => x.description === description); - const usedInvitation = offerToUsedInvitation?.[0]?.[0] === `${name}-accept`; - t.log({ name, hasInvitation, usedInvitation }); - t.true(hasInvitation || usedInvitation, 'has or accepted invitation'); - if (usedInvitation) hasAccepted = true; - } - // if the oracles have already accepted, skip the rest of the test this is - // primarily to facilitate active development but could support testing on - // images where operator invs are already accepted - if (hasAccepted) return t.pass(); + const doOffer = makeDoOffer(wd); + const self = harden({ + getName: () => name, + getAddr: () => address, + acceptInvitation: async () => { + const instance = await instanceP; + await doOffer({ + id: acceptOfferId, + invitationSpec: { source: 'purse', instance, description }, + proposal: {}, + }); - // accept oracle operator invitations - const instance = fromEntries( - await vstorageClient.queryData('published.agoricNames.instance'), - )[contractName]; - await Promise.all( - oracleWds.map(makeDoOffer).map((doOffer, i) => + await retryUntilCondition( + () => self.checkInvitation(), + check => check.usedInvitation, + `${name} invitation used`, + ); + }, + checkInvitation: async () => { + const { Invitation } = await brandP; + const { offerToUsedInvitation, purses } = + await walletQ(vstorageClient).current(address); + type InvitationDetails = { description: string }; // TODO: import from Zoe + const { value: details } = purses.find(p => p.brand === Invitation)! + .balance as Amount<'set', InvitationDetails>; + const invitation = details.find(x => x.description === description); + const usedInvitation = offerToUsedInvitation.some( + ([k, _v]) => k === `${name}-accept`, + ); + return { invitation, usedInvitation }; + }, + submit: (evidence: Record) => { doOffer({ - id: toOracleOfferId(i), + id: `${now()}-evm-evidence`, invitationSpec: { - source: 'purse', - instance, - description, + source: 'continuing', + previousOffer: acceptOfferId, + invitationMakerName: 'SubmitEvidence', + invitationArgs: [evidence], }, proposal: {}, - }), - ), - ); + }); + }, + }); + return self; +}; +type TxOracle = ReturnType; - 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('oracles accept', async t => { + const { txOracles } = t.context; + + const checks = await Promise.all( + txOracles.map(async op => { + const { invitation, usedInvitation } = await op.checkInvitation(); + t.log({ name: op.getName(), invitation, usedInvitation }); + t.truthy(invitation || usedInvitation, 'has or accepted invitation'); + return usedInvitation; + }), + ); + // if the oracles have already accepted, skip the rest of the test this is + // primarily to facilitate active development but could support testing on + // images where operator invs are already accepted + if (checks.some(b => b)) { + return t.pass(); } + + // accept oracle operator invitations + await t.notThrowsAsync( + Promise.all(txOracles.map(op => op.acceptInvitation())), + ); }); -test.serial('lp deposits', async t => { - const { lpUser, retryUntilCondition, vstorageClient, wallets } = t.context; +const makeLiquidityProvider = ( + my: { + address: string; // XXX should be availble from wd + wd: WalletDriver; + }, + the: { + vstorageClient: VStorageClient; + retryUntilCondition: RetryUntilCondition; + now: () => number; + }, +) => { + const lpDoOffer = makeDoOffer(my.wd); + const { vstorageClient: vsc } = the; + + const self = harden({ + getAddr: () => my.address, + getBalance: async (brand: Brand) => { + const { purses } = await walletQ(vsc).current(my.address); + const found = purses.find(p => p.brand === brand); + return found?.balance; + }, + deposit: async (amt: Amount<'nat'>) => { + const { shareWorth } = await fastLPQ(vsc).metrics(); + const want = { PoolShare: divideBy(amt, shareWorth) }; - const lpDoOffer = makeDoOffer(lpUser); - const brands = await vstorageClient.queryData('published.agoricNames.brand'); - const { USDC, FastLP } = Object.fromEntries(brands); + await lpDoOffer({ + id: `lp-deposit-${the.now()}`, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeDepositInvitation']], + }, + proposal: { give: { USDC: amt }, want }, + }); + return { pre: { shareWorth }, want }; + }, + withdrawAll: async (t: ExecutionContext, chain, usdcDenom) => { + const queryClient = makeQueryClient(chain); + const { FastLP } = await agoricNamesQ(vsc).brands(); + t.log('FastLP brand', FastLP); + + const { shareWorth } = await fastLPQ(vsc).metrics(); + const sharesPre = (await self.getBalance(FastLP)) as Amount<'nat'>; + assert(sharesPre); + t.log('sharesPre', sharesPre); + const want = { USDC: multiplyBy(sharesPre, shareWorth) }; + t.log('want.USDC', want.USDC); + + const { balance: currentUSDCBalance } = await queryClient.queryBalance( + my.address, + usdcDenom, + ); + t.log(`current ${usdcDenom} balance`, currentUSDCBalance); - const usdcToGive = make(USDC, LP_DEPOSIT_AMOUNT); + await lpDoOffer({ + id: `lp-withdraw-${the.now()}`, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeWithdrawInvitation']], + }, + proposal: { give: { PoolShare: sharesPre }, want }, + }); + + await t.notThrowsAsync(() => + the.retryUntilCondition( + () => self.getBalance(FastLP), + poolShares => !poolShares || isEmpty(poolShares), + 'lp no longer has pool shares', + ), + ); - 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(() => + the.retryUntilCondition( + () => queryClient.queryBalance(my.address, usdcDenom), + ({ balance }) => + !!balance?.amount && + BigInt(balance.amount) - BigInt(currentUSDCBalance!.amount!) > + LP_DEPOSIT_AMOUNT, + "lp's USDC balance increases", + ), + ); + return { pre: { uusdc: currentUSDCBalance } }; }, }); + return self; +}; + +test.serial('lp deposits', async t => { + const { + lpWallet: lpWallet, + retryUntilCondition, + vstorageClient, + wallets, + } = t.context; + + const lp = makeLiquidityProvider( + { address: wallets.lp, wd: lpWallet }, + { vstorageClient, now: () => Date.now(), retryUntilCondition }, + ); + const { USDC, FastLP } = await agoricNamesQ(vstorageClient).brands(); + + const { pre, want } = await lp.deposit(make(USDC, LP_DEPOSIT_AMOUNT)); await t.notThrowsAsync(() => retryUntilCondition( - () => vstorageClient.queryData(`published.${contractName}.poolMetrics`), + () => fastLPQ(vstorageClient).metrics(), ({ shareWorth }) => - !isGTE(currShareWorth.numerator, shareWorth.numerator), + !isGTE(pre.shareWorth.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.getBalance(FastLP), + lpShares => Boolean(lpShares && isGTE(lpShares, want.PoolShare)), 'lp has pool shares', ), ); }); +const makeUserAgent = ( + my: { wallet: Awaited> }, + vstorageClient: VStorageClient, + nobleTools: SetupContextWithWallets['nobleTools'], + nobleAgoricChannelId: IBCChannelID, +) => { + return harden({ + sendFast: async (t: ExecutionContext, mintAmt: bigint) => { + const EUD = (await my.wallet.getAccounts())[0].address; + t.log(`EUD wallet created: ${EUD}`); + + // parameterize agoric address + const { settlementAccount } = await fastLPQ(vstorageClient).info(); + 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); + + // TODO export CctpTxEvidence type + const tx = harden({ + txHash: `0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff3875527617${makeRandomDigits(Math.random(), 2n)}`, + tx: { + amount: mintAmt, + forwardingAddress: userForwardingAddr, + }, + aux: { + forwardingChannel: nobleAgoricChannelId, + recipientAddress, + }, + chainId: 42161, + }); + + console.log('User initiates evm mint:', tx.txHash); + + return { tx, EUD, userForwardingAddr }; + }, + }); +}; + const advanceAndSettleScenario = test.macro({ title: (_, mintAmt: bigint, eudChain: string) => `advance ${mintAmt} uusdc to ${eudChain} and settle`, @@ -214,81 +424,45 @@ const advanceAndSettleScenario = test.macro({ const { nobleTools, nobleAgoricChannelId, - oracleWds, retryUntilCondition, useChain, usdcOnOsmosis, vstorageClient, + txOracles, } = t.context; // EUD wallet on the specified chain const eudWallet = await createWallet( useChain(eudChain).chain.bech32_prefix, + undefined, + { getBytes: Random.getBytes }, ); - const EUD = (await eudWallet.getAccounts())[0].address; - t.log(`EUD wallet created: ${EUD}`); - - // 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( + const ua = makeUserAgent( + { wallet: eudWallet }, + vstorageClient, + nobleTools, nobleAgoricChannelId, - recipientAddress, ); - t.log('got forwardingAddress', userForwardingAddr); + const { tx, EUD, userForwardingAddr } = await ua.sendFast(t, mintAmt); // TODO export CctpTxEvidence type const evidence = harden({ + ...tx, blockHash: '0x90d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee665', blockNumber: 21037663n, - txHash: `0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff3875527617${makeRandomDigits(makeRandomNumber(), 2n)}`, - tx: { - amount: mintAmt, - 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: {}, - }), - ), - ); + await Promise.all(txOracles.map(op => op.submit(evidence))); const queryClient = makeQueryClient( await useChain(eudChain).getRestEndpoint(), ); + // XXX this part should be in the ua await t.notThrowsAsync(() => retryUntilCondition( () => queryClient.queryBalance(EUD, usdcOnOsmosis), @@ -299,15 +473,10 @@ const advanceAndSettleScenario = test.macro({ ), ); - const queryTxStatus = async () => - vstorageClient.queryData( - `published.${contractName}.status.${evidence.txHash}`, - ); - const assertTxStatus = async (status: string) => t.notThrowsAsync(() => retryUntilCondition( - () => queryTxStatus(), + () => fastLPQ(vstorageClient).txStatus(evidence.txHash), txStatus => { console.log('tx status', txStatus); return txStatus === status; @@ -322,7 +491,7 @@ const advanceAndSettleScenario = test.macro({ nobleTools.mockCctpMint(mintAmt, userForwardingAddr); await t.notThrowsAsync(() => retryUntilCondition( - () => vstorageClient.queryData(`published.${contractName}.poolMetrics`), + () => fastLPQ(vstorageClient).metrics(), ({ encumberedBalance }) => encumberedBalance && isEmpty(encumberedBalance), 'encumberedBalance returns to 0', @@ -339,73 +508,20 @@ test.serial(advanceAndSettleScenario, LP_DEPOSIT_AMOUNT / 5n, 'agoric'); test.serial('lp withdraws', async t => { const { - lpUser, + lpWallet: 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', - ), + const lp = makeLiquidityProvider( + { address: wallets.lp, wd: lpUser }, + { now: () => Date.now(), retryUntilCondition, vstorageClient }, ); - 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", - ), - ); + await lp.withdrawAll(t, useChain('agoric'), usdcDenom); }); test.todo('insufficient LP funds; forward path');