From ae1963e9f73f159be2fab93920fcceeb9ebc555d Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 16 Dec 2024 19:00:26 -0600 Subject: [PATCH 1/5] feat(fast-usdc): add FastLP/ufastlp to vbank --- packages/fast-usdc/src/fast-usdc.start.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/fast-usdc/src/fast-usdc.start.js b/packages/fast-usdc/src/fast-usdc.start.js index 2815569d2ed..44a2838a130 100644 --- a/packages/fast-usdc/src/fast-usdc.start.js +++ b/packages/fast-usdc/src/fast-usdc.start.js @@ -27,6 +27,13 @@ import { fromExternalConfig } from './utils/config-marshal.js'; * @import {FeedPolicy, FastUSDCConfig} from './types.js' */ +const ShareAssetInfo = /** @type {const} */ harden({ + issuerName: 'FastLP', + denom: 'ufastlp', + assetKind: 'nat', + decimalPlaces: 6, +}); + const trace = makeTracer('FUSD-Start', true); const contractName = 'fastUsdc'; @@ -116,6 +123,7 @@ export const startFastUSDC = async ( consume: { agoricNames, namesByAddress, + bankManager, board, chainStorage, chainTimerService: timerService, @@ -206,13 +214,23 @@ export const startFastUSDC = async ( await publishFeedPolicy(storageNode, feedPolicy); const { - issuers: { PoolShares: shareIssuer }, + issuers: fastUsdcIssuers, brands: { PoolShares: shareBrand }, } = await E(zoe).getTerms(instance); + /** @type {{ PoolShares: Issuer<'nat'> }} */ + // @ts-expect-error see zcf.makeZCFMint(...) in fast-usdc.contract.js + const { PoolShares: shareIssuer } = fastUsdcIssuers; produceShareIssuer.resolve(shareIssuer); produceShareBrand.resolve(shareBrand); await publishDisplayInfo(shareBrand, { board, chainStorage }); + const { denom, issuerName } = ShareAssetInfo; + trace('addAsset', denom, shareBrand); + await E(bankManager).addAsset(denom, issuerName, issuerName, { + issuer: shareIssuer, + brand: shareBrand, + }); + await Promise.all( Object.entries(oracleDepositFacets).map(async ([name, depositFacet]) => { const address = oracles[name]; @@ -258,6 +276,8 @@ export const getManifestForFastUSDC = ( fastUsdcKit: true, }, consume: { + bankManager: true, // to add FastLP as vbank asset + chainStorage: true, chainTimerService: true, localchain: true, From b7aa5f091a418fe652d87ab5cbed7c0aacdb643b Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 16 Dec 2024 19:21:36 -0600 Subject: [PATCH 2/5] test(boot): FastLP/ufastlp is in published.agoricNames.vbankAsset --- packages/boot/test/fast-usdc/fast-usdc.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/boot/test/fast-usdc/fast-usdc.test.ts b/packages/boot/test/fast-usdc/fast-usdc.test.ts index 3c17e8b6f55..d8a27106189 100644 --- a/packages/boot/test/fast-usdc/fast-usdc.test.ts +++ b/packages/boot/test/fast-usdc/fast-usdc.test.ts @@ -92,6 +92,14 @@ test.serial( refreshAgoricNamesRemotes(); t.truthy(agoricNamesRemotes.instance.fastUsdc); t.truthy(agoricNamesRemotes.brand.FastLP); + const lpAsset = agoricNamesRemotes.vbankAsset.FastLP; + t.like(lpAsset, { + issuerName: 'FastLP', + denom: 'ufastlp', + displayInfo: { assetKind: 'nat', decimalPlaces: 6 }, + }); + const lpId = lpAsset.brand.getBoardId(); + t.is(agoricNamesRemotes.brand.FastLP.getBoardId(), lpId); const { EV } = t.context.runUtils; const agoricNames = await EV.vat('bootstrap').consumeItem('agoricNames'); @@ -99,6 +107,7 @@ test.serial( const getBoardAux = async name => { const brand = await EV(agoricNames).lookup('brand', name); const id = await EV(board).getId(brand); + t.is(id || null, lpId); t.truthy(storage.data.get(`published.boardAux.${id}`)); return unmarshalFromVstorage( storage.data, From 2ba4a950b2d44bed56fcf69ecf7b16cf40e41608 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 16 Dec 2024 19:32:48 -0600 Subject: [PATCH 3/5] test(boot): FastLP balance not in wallet record --- .../boot/test/fast-usdc/fast-usdc.test.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/boot/test/fast-usdc/fast-usdc.test.ts b/packages/boot/test/fast-usdc/fast-usdc.test.ts index d8a27106189..f581f665a7f 100644 --- a/packages/boot/test/fast-usdc/fast-usdc.test.ts +++ b/packages/boot/test/fast-usdc/fast-usdc.test.ts @@ -98,7 +98,7 @@ test.serial( denom: 'ufastlp', displayInfo: { assetKind: 'nat', decimalPlaces: 6 }, }); - const lpId = lpAsset.brand.getBoardId(); + const lpId = lpAsset.brand.getBoardId() || assert.fail('impossible'); t.is(agoricNamesRemotes.brand.FastLP.getBoardId(), lpId); const { EV } = t.context.runUtils; @@ -107,7 +107,7 @@ test.serial( const getBoardAux = async name => { const brand = await EV(agoricNames).lookup('brand', name); const id = await EV(board).getId(brand); - t.is(id || null, lpId); + t.is(id, lpId); t.truthy(storage.data.get(`published.boardAux.${id}`)); return unmarshalFromVstorage( storage.data, @@ -130,7 +130,7 @@ test.serial( const current = watcherWallet.getCurrentWalletRecord(); - // XXX We should be able to compare objects by identity like this: + // XXX #10491 We should be able to compare objects by identity like this: // // const invitationPurse = current.purses.find( // p => p.brand === agoricNamesRemotes.brand.Invitation, @@ -225,9 +225,11 @@ test.serial('makes usdc advance', async t => { ), ); + const lp = oracles[0]; // somewhat arbitrary + // @ts-expect-error it doesnt recognize usdc as a Brand type const usdc = agoricNamesRemotes.vbankAsset.USDC.brand as Brand<'nat'>; - await oracles[0].sendOffer({ + await lp.sendOffer({ id: 'deposit-lp-0', invitationSpec: { source: 'agoricContract', @@ -242,6 +244,23 @@ test.serial('makes usdc advance', async t => { }); await eventLoopIteration(); + const { getOutboundMessages } = t.context.bridgeUtils; + const lpBankDeposit = getOutboundMessages(BridgeId.BANK).find( + obj => + obj.type === 'VBANK_GIVE' && + obj.denom === 'ufastlp' && + obj.recipient === lp.getAddress(), + ); + t.log('LP vbank deposit', lpBankDeposit); + t.true(BigInt(lpBankDeposit.amount) > 1_000_000n, 'vbank GIVEs shares to LP'); + + const { purses } = lp.getCurrentWalletRecord(); + // XXX #10491 should not need to resort to string match on brand + t.falsy( + purses.find(p => `${p.brand}`.match(/FastLP/)), + 'FastLP balance not in wallet record', + ); + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); harness?.useRunPolicy(true); From 26d3b0f143f89d03bb496403657abd9f24681f70 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Tue, 17 Dec 2024 15:16:05 -0600 Subject: [PATCH 4/5] test: get FastLP/ufastlp balance from x/bank query - prune balancesFromPurses, which was not type-safe - factor out vstorage queries for type safety - add static check on deposit / withdraw proposals - refactor usdcToGive as give.USDC etc. balancesFromPurses() seemed to return a Record but Brands can't be record keys. It "worked" by stringifying the brands. ``` > b = {[Symbol.toStringTag]:'B123'} { [Symbol(Symbol.toStringTag)]: 'B123' } > b.toString() '[object B123]' > fromEntries([[b, 1]]) { '[object B123]': 1 } ``` --- .../test/fast-usdc/fast-usdc.test.ts | 148 +++++++++++------- multichain-testing/tools/purse.ts | 10 -- 2 files changed, 94 insertions(+), 64 deletions(-) delete mode 100644 multichain-testing/tools/purse.ts diff --git a/multichain-testing/test/fast-usdc/fast-usdc.test.ts b/multichain-testing/test/fast-usdc/fast-usdc.test.ts index a7fbf413e32..1b0958a3441 100644 --- a/multichain-testing/test/fast-usdc/fast-usdc.test.ts +++ b/multichain-testing/test/fast-usdc/fast-usdc.test.ts @@ -13,17 +13,20 @@ 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'; import { makeTracer } from '@agoric/internal'; import type { CctpTxEvidence, EvmAddress, + PoolMetrics, } from '@agoric/fast-usdc/src/types.js'; +import type { CurrentWalletRecord } from '@agoric/smart-wallet/src/smartWallet.js'; +import type { QueryBalanceResponseSDKType } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import type { USDCProposalShapes } from '@agoric/fast-usdc/src/pool-share-math.js'; const log = makeTracer('MCFU'); const { keys, values, fromEntries } = Object; -const { isGTE, isEmpty, make } = AmountMath; +const { isGTE, isEmpty, make, subtract } = AmountMath; const makeRandomNumber = () => Math.random(); @@ -107,23 +110,62 @@ test.after(async t => { deleteTestKeys(accounts); }); +type VStorageClient = Awaited>['vstorageClient']; +const agoricNamesQ = (vsc: VStorageClient) => + harden({ + brands: (_assetKind: K) => + vsc + .queryData('published.agoricNames.brand') + .then(pairs => fromEntries(pairs) as Record>), + }); +const walletQ = (vsc: VStorageClient) => { + const self = harden({ + current: (addr: string) => + vsc.queryData( + `published.wallet.${addr}.current`, + ) as Promise, + findInvitationDetail: async (addr: string, description: string) => { + const { Invitation } = await agoricNamesQ(vsc).brands('set'); + const current = await self.current(addr); + const { purses } = current; + const { value: details } = purses.find(p => p.brand === Invitation)! + .balance as Amount<'set', InvitationDetails>; + const detail = details.find(x => x.description === description); + return { current, detail }; + }, + }); + return self; +}; + +const fastLPQ = (vsc: VStorageClient) => + harden({ + metrics: () => + vsc.queryData(`published.fastUsdc.poolMetrics`) as Promise, + info: () => + vsc.queryData(`published.${contractName}`) as Promise<{ + poolAccount: string; + settlementAccount: string; + }>, + }); + const toOracleOfferId = (idx: number) => `oracle${idx + 1}-accept`; 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); 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 { + current: { offerToUsedInvitation }, + detail, + } = await walletQ(vstorageClient).findInvitationDetail( + wallets[name], + description, ); - const { value: invitations } = balancesFromPurses(purses)[Invitation]; - const hasInvitation = invitations.some(x => x.description === description); + const hasInvitation = !!detail; const usedInvitation = offerToUsedInvitation?.[0]?.[0] === `${name}-accept`; t.log({ name, hasInvitation, usedInvitation }); t.true(hasInvitation || usedInvitation, 'has or accepted invitation'); @@ -167,20 +209,24 @@ test.serial('oracles accept', async t => { } }); +const toAmt = ( + brand: Brand<'nat'>, + balance: QueryBalanceResponseSDKType['balance'], +) => make(brand, BigInt(balance?.amount || 0)); + 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 { USDC, FastLP } = await agoricNamesQ(vstorageClient).brands('nat'); - const { shareWorth: currShareWorth } = await vstorageClient.queryData( - `published.${contractName}.poolMetrics`, - ); - const poolSharesWanted = divideBy(usdcToGive, currShareWorth); + const give = { USDC: make(USDC, LP_DEPOSIT_AMOUNT) }; + + const metricsPre = await fastLPQ(vstorageClient).metrics(); + const want = { PoolShare: divideBy(give.USDC, metricsPre.shareWorth) }; + const proposal: USDCProposalShapes['deposit'] = harden({ give, want }); await lpDoOffer({ id: `lp-deposit-${Date.now()}`, invitationSpec: { @@ -188,30 +234,28 @@ test.serial('lp deposits', async t => { instancePath: [contractName], callPipe: [['makeDepositInvitation']], }, - proposal: { - give: { USDC: usdcToGive }, - want: { PoolShare: poolSharesWanted }, - }, + proposal, }); await t.notThrowsAsync(() => retryUntilCondition( - () => vstorageClient.queryData(`published.${contractName}.poolMetrics`), + () => fastLPQ(vstorageClient).metrics(), ({ shareWorth }) => - !isGTE(currShareWorth.numerator, shareWorth.numerator), + !isGTE(metricsPre.shareWorth.numerator, shareWorth.numerator), 'share worth numerator increases from deposit', { log }, ), ); + const { useChain } = t.context; + const queryClient = makeQueryClient( + await useChain('agoric').getRestEndpoint(), + ); + await t.notThrowsAsync(() => retryUntilCondition( - () => - vstorageClient.queryData(`published.wallet.${wallets['lp']}.current`), - ({ purses }) => { - const currentPoolShares = balancesFromPurses(purses)[FastLP]; - return currentPoolShares && isGTE(currentPoolShares, poolSharesWanted); - }, + () => queryClient.queryBalance(wallets['lp'], 'ufastlp'), + ({ balance }) => isGTE(toAmt(FastLP, balance), want.PoolShare), 'lp has pool shares', { log }, ), @@ -344,7 +388,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', @@ -372,27 +416,28 @@ test.serial('lp withdraws', async t => { await useChain('agoric').getRestEndpoint(), ); const lpDoOffer = makeDoOffer(lpUser); - const brands = await vstorageClient.queryData('published.agoricNames.brand'); - const { FastLP } = Object.fromEntries(brands); + const { FastLP } = await agoricNamesQ(vstorageClient).brands('nat'); 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 metricsPre = await fastLPQ(vstorageClient).metrics(); + + const { balance: lpCoins } = await queryClient.queryBalance( + wallets['lp'], + 'ufastlp', ); - const currentPoolShares = balancesFromPurses(purses)[FastLP]; - t.log('currentPoolShares', currentPoolShares); - const usdcWanted = multiplyBy(currentPoolShares, currShareWorth); - t.log('usdcWanted', usdcWanted); + const give = { PoolShare: toAmt(FastLP, lpCoins) }; + t.log('give', give, lpCoins); - const { balance: currentUSDCBalance } = await queryClient.queryBalance( + const { balance: usdcCoinsPre } = await queryClient.queryBalance( wallets['lp'], usdcDenom, ); - t.log(`current ${usdcDenom} balance`, currentUSDCBalance); + t.log('usdc coins pre', usdcCoinsPre); + + const want = { USDC: multiplyBy(give.PoolShare, metricsPre.shareWorth) }; + t.log('want', want); + const proposal: USDCProposalShapes['withdraw'] = harden({ give, want }); await lpDoOffer({ id: `lp-withdraw-${Date.now()}`, invitationSpec: { @@ -400,32 +445,27 @@ test.serial('lp withdraws', async t => { instancePath: [contractName], callPipe: [['makeWithdrawInvitation']], }, - proposal: { - give: { PoolShare: currentPoolShares }, - want: { USDC: usdcWanted }, - }, + proposal, }); await t.notThrowsAsync(() => retryUntilCondition( - () => - vstorageClient.queryData(`published.wallet.${wallets['lp']}.current`), - ({ purses }) => { - const currentPoolShares = balancesFromPurses(purses)[FastLP]; - return !currentPoolShares || isEmpty(currentPoolShares); - }, + () => queryClient.queryBalance(wallets['lp'], 'ufastlp'), + ({ balance }) => isEmpty(toAmt(FastLP, balance)), 'lp no longer has pool shares', { log }, ), ); + const USDC = want.USDC.brand; await t.notThrowsAsync(() => retryUntilCondition( () => queryClient.queryBalance(wallets['lp'], usdcDenom), ({ balance }) => - !!balance?.amount && - BigInt(balance.amount) - BigInt(currentUSDCBalance!.amount!) > - LP_DEPOSIT_AMOUNT, + !isGTE( + make(USDC, LP_DEPOSIT_AMOUNT), + subtract(toAmt(USDC, balance), toAmt(USDC, usdcCoinsPre)), + ), "lp's USDC balance increases", { log }, ), diff --git a/multichain-testing/tools/purse.ts b/multichain-testing/tools/purse.ts deleted file mode 100644 index 82a76d2a3f4..00000000000 --- a/multichain-testing/tools/purse.ts +++ /dev/null @@ -1,10 +0,0 @@ -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])); From 3815e4c856120cbf525968179017e28812964cfb Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Wed, 18 Dec 2024 10:09:49 -0600 Subject: [PATCH 5/5] chore: walletDriver supports getAddress() --- packages/boot/tools/drivers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/boot/tools/drivers.ts b/packages/boot/tools/drivers.ts index d3029886674..850cf668d04 100644 --- a/packages/boot/tools/drivers.ts +++ b/packages/boot/tools/drivers.ts @@ -55,6 +55,7 @@ export const makeWalletFactoryDriver = async ( isNew: boolean, ) => ({ isNew, + getAddress: () => walletAddress, executeOffer(offer: OfferSpec): Promise { const offerCapData = marshaller.toCapData(