diff --git a/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md new file mode 100644 index 000000000000..51c2a235df56 --- /dev/null +++ b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md @@ -0,0 +1,472 @@ +# Snapshot report for `test/bootstrapTests/test-liquidation-visibility.ts` + +The actual snapshot is saved in `test-liquidation-visibility.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## snapshot-storage + +> Snapshot 1 + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 105525000n, + }, + }, + ], + [ + 'vault1', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 103515000n, + }, + }, + ], + [ + 'vault2', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 100500000n, + }, + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [ + [ + 'vault2', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 3425146n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault1', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 3077900n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault0', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 2846403n, + }, + phase: 'liquidated', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 309852n, + }, + collateralOffered: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 45000000n, + }, + collateralRemaining: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 35340699n, + }, + endTime: { + absValue: 5042n, + timerBrand: Object @Alleged: BoardRemotetimerBrand { + getBoardId: Function getBoardId {}, + }, + }, + istTarget: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + mintedProceeds: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + shortfallToReserve: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + }, + ], + ] + +> Snapshot 2 + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.10800.vaults.preAuction', + [ + [ + 'vault3', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 105525000n, + }, + }, + ], + [ + 'vault4', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 103515000n, + }, + }, + ], + [ + 'vault5', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 100500000n, + }, + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.10800.vaults.postAuction', + [ + [ + 'vault5', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 3425146n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault4', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 3077900n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault3', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 2846403n, + }, + phase: 'liquidated', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.10800.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 309852n, + }, + collateralOffered: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 45000000n, + }, + collateralRemaining: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 35340699n, + }, + endTime: { + absValue: 12242n, + timerBrand: Object @Alleged: BoardRemotetimerBrand { + getBoardId: Function getBoardId {}, + }, + }, + istTarget: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + mintedProceeds: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + shortfallToReserve: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + }, + ], + ] + +> Snapshot 3 + + [ + [ + 'published.vaultFactory.managers.manager1.liquidations.14400.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 105525000n, + }, + }, + ], + [ + 'vault1', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 103515000n, + }, + }, + ], + [ + 'vault2', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 100500000n, + }, + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager1.liquidations.14400.vaults.postAuction', + [ + [ + 'vault2', + { + Collateral: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 3425146n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault1', + { + Collateral: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 3077900n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault0', + { + Collateral: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 2846403n, + }, + phase: 'liquidated', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager1.liquidations.14400.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 309852n, + }, + collateralOffered: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 45000000n, + }, + collateralRemaining: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 35340699n, + }, + endTime: { + absValue: 15842n, + timerBrand: Object @Alleged: BoardRemotetimerBrand { + getBoardId: Function getBoardId {}, + }, + }, + istTarget: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + mintedProceeds: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + shortfallToReserve: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + }, + ], + ] diff --git a/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap new file mode 100644 index 000000000000..b3b15a5a857e Binary files /dev/null and b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap differ diff --git a/packages/boot/test/bootstrapTests/test-liquidation-visibility.ts b/packages/boot/test/bootstrapTests/test-liquidation-visibility.ts new file mode 100644 index 000000000000..adc3f2f9562e --- /dev/null +++ b/packages/boot/test/bootstrapTests/test-liquidation-visibility.ts @@ -0,0 +1,442 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { ExecutionContext, TestFn } from 'ava'; +import { ScheduleNotification } from '@agoric/inter-protocol/src/auction/scheduler.js'; +import { NonNullish } from '@agoric/assert/src/assert.js'; +import { TimeMath } from '@agoric/time/src/timeMath.js'; +import { TimestampRecord } from '@agoric/time/src/types'; +import { EconomyBootstrapSpace } from '@agoric/inter-protocol/src/proposals/econ-behaviors.js'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; +import { + ensureVaultCollateral, + LiquidationSetup, + LiquidationTestContext, + makeLiquidationTestContext, + scale6, +} from '../../tools/liquidation.ts'; + +export type LiquidationOutcome = { + reserve: { + allocations: Record; + shortfall: number; + }; + vaults: { + locked: number; + }[]; +}; + +const test = anyTest as TestFn; + +type AnyFunction = (...args: any[]) => any; + +//#region Product spec +const setup: LiquidationSetup = { + // Vaults are sorted in the worst debt/col ratio to the best + vaults: [ + { + atom: 15, + ist: 105, + debt: 105.525, + }, + { + atom: 15, + ist: 103, + debt: 103.515, + }, + { + atom: 15, + ist: 100, + debt: 100.5, + }, + ], + bids: [ + { + give: '80IST', + discount: 0.1, + }, + { + give: '90IST', + price: 9.0, + }, + { + give: '150IST', + discount: 0.15, + }, + ], + price: { + starting: 12.34, + trigger: 9.99, + }, + auction: { + start: { + collateral: 45, + debt: 309.54, + }, + end: { + collateral: 9.659301, + debt: 0, + }, + }, +}; + +const outcome: LiquidationOutcome = { + reserve: { + allocations: { + ATOM: 0.309852, + STARS: 0.309852, + }, + shortfall: 0, + }, + // The order in the setup preserved + vaults: [ + { + locked: 2.846403, + }, + { + locked: 3.0779, + }, + { + locked: 3.425146, + }, + ], +}; +//#endregion + +const runAuction = async (runUtils, advanceTimeBy) => { + const { EV } = runUtils; + const auctioneerKit = await EV.vat('bootstrap').consumeItem('auctioneerKit'); + const { liveAuctionSchedule } = await EV( + auctioneerKit.publicFacet, + ).getSchedules(); + + await advanceTimeBy(3 * Number(liveAuctionSchedule.steps), 'minutes'); + + return liveAuctionSchedule; +}; + +const startAuction = async (t: ExecutionContext) => { + const { readLatest, advanceTimeTo } = t.context; + + const scheduleNotification: ScheduleNotification = readLatest( + 'published.auction.schedule', + ); + + await advanceTimeTo(NonNullish(scheduleNotification.nextStartTime)); +}; + +const addNewVaults = async ({ + t, + collateralBrandKey, + base, +}: { + t: ExecutionContext; + collateralBrandKey: string; + base: number; +}) => { + const { walletFactoryDriver, priceFeedDrivers, placeBids } = t.context; + + await priceFeedDrivers[collateralBrandKey].setPrice(setup.price.starting); + const minter = await walletFactoryDriver.provideSmartWallet('agoric1minter'); + + for (let i = 0; i < setup.vaults.length; i += 1) { + const offerId = `open-${collateralBrandKey}-vault${base + i}`; + await minter.executeOfferMaker(Offers.vaults.OpenVault, { + offerId, + collateralBrandKey, + wantMinted: setup.vaults[i].ist, + giveCollateral: setup.vaults[i].atom, + }); + t.like(minter.getLatestUpdateRecord(), { + updated: 'offerStatus', + status: { id: offerId, numWantsSatisfied: 1 }, + }); + } + + await placeBids(collateralBrandKey, 'agoric1buyer', setup, base); + await priceFeedDrivers[collateralBrandKey].setPrice(setup.price.trigger); + await startAuction(t); +}; + +const initVaults = async ({ + t, + collateralBrandKey, + managerIndex, +}: { + t: ExecutionContext; + collateralBrandKey: string; + managerIndex: number; +}) => { + const { setupVaults, placeBids, priceFeedDrivers, readLatest } = t.context; + + const metricsPath = `published.vaultFactory.managers.manager${managerIndex}.metrics`; + + await setupVaults(collateralBrandKey, managerIndex, setup); + await placeBids(collateralBrandKey, 'agoric1buyer', setup); + + await priceFeedDrivers[collateralBrandKey].setPrice(setup.price.trigger); + await startAuction(t); + + t.like(readLatest(metricsPath), { + numActiveVaults: 0, + numLiquidatingVaults: setup.vaults.length, + liquidatingCollateral: { + value: scale6(setup.auction.start.collateral), + }, + liquidatingDebt: { value: scale6(setup.auction.start.debt) }, + lockedQuote: null, + }); +}; + +test.before(async t => { + t.context = await makeLiquidationTestContext(t); +}); + +const checkVisibility = async ({ + t, + managerIndex, + setupCallback, + base = 0, +}: { + t: ExecutionContext; + managerIndex: number; + setupCallback: AnyFunction; + base?: number; +}) => { + const { readLatest, advanceTimeBy, runUtils } = t.context; + + await setupCallback(); + + const { startTime, startDelay, endTime } = await runAuction( + runUtils, + advanceTimeBy, + ); + const nominalStart: Timestamp = TimeMath.subtractAbsRel( + startTime, + startDelay, + ) as TimestampRecord; + t.log(nominalStart); + + const visibilityPath = `published.vaultFactory.managers.manager${managerIndex}.liquidations.${nominalStart.absValue.toString()}`; + const preAuction = readLatest(`${visibilityPath}.vaults.preAuction`); + const postAuction = readLatest(`${visibilityPath}.vaults.postAuction`); + const auctionResult = readLatest(`${visibilityPath}.auctionResult`); + + const expectedPreAuction: [ + string, + { + collateralAmount: { value: bigint }; + debtAmount: { value: bigint }; + }, + ][] = []; + for (let i = 0; i < setup.vaults.length; i += 1) { + expectedPreAuction.push([ + `vault${base + i}`, + { + collateralAmount: { value: scale6(setup.vaults[i].atom) }, + debtAmount: { value: scale6(setup.vaults[i].debt) }, + }, + ]); + } + t.like(preAuction, expectedPreAuction); + + const expectedPostAuction: [ + string, + { Collateral?: { value: bigint }; Minted?: { value: bigint } }, + ][] = []; + // Iterate from the end because we expect the post auction vaults + // in best to worst order. + for (let i = outcome.vaults.length - 1; i >= 0; i -= 1) { + expectedPostAuction.push([ + `vault${base + i}`, + { Collateral: { value: scale6(outcome.vaults[i].locked) } }, + ]); + } + t.like(postAuction, expectedPostAuction); + + t.like(auctionResult, { + collateralOffered: { value: scale6(setup.auction.start.collateral) }, + istTarget: { value: scale6(setup.auction.start.debt) }, + collateralForReserve: { value: scale6(outcome.reserve.allocations.ATOM) }, + shortfallToReserve: { value: 0n }, + mintedProceeds: { value: scale6(setup.auction.start.debt) }, + collateralSold: { + value: + scale6(setup.auction.start.collateral) - + scale6(setup.auction.end.collateral), + }, + collateralRemaining: { value: 0n }, + endTime: { absValue: endTime.absValue }, + }); + + t.log('preAuction', preAuction); + t.log('postAuction', postAuction); + t.log('auctionResult', auctionResult); +}; + +/** + * @file In this file we test the below scenario: + * - Alice opens a vault + * - Alice gets liquidated + * - Visibility data correctly observed in storage + * - Vault factory gets restarted + * - An auction starts with no vaults to liquidate + * - No unnecessary storage node is created when `liquidateVaults` is invoked with no vaults to liquidate + * - Bob opens a vault + * - Bob gets liquidated + * - Visibility data correctly observed in storage + */ +test.serial('visibility-before-upgrade', async t => { + await checkVisibility({ + t, + managerIndex: 0, + setupCallback: () => + initVaults({ + t, + collateralBrandKey: 'ATOM', + managerIndex: 0, + }), + }); +}); + +test.serial('add-STARS-collateral', async t => { + await ensureVaultCollateral('STARS', t); + await t.context.setupStartingState({ + collateralBrandKey: 'STARS', + managerIndex: 1, + price: setup.price.starting, + }); + t.pass(); // reached here without throws +}); + +test.serial('restart-vault-factory', async t => { + const { + runUtils: { EV }, + } = t.context; + const vaultFactoryKit = await (EV.vat('bootstrap').consumeItem( + 'vaultFactoryKit', + ) as EconomyBootstrapSpace['consume']['vaultFactoryKit']); + + // @ts-expect-error cast XXX missing from type + const { privateArgs } = vaultFactoryKit; + console.log('reused privateArgs', privateArgs, vaultFactoryKit); + + const vfAdminFacet = await EV( + vaultFactoryKit.governorCreatorFacet, + ).getAdminFacet(); + + t.log('awaiting VaultFactory restartContract'); + const upgradeResult = await EV(vfAdminFacet).restartContract(privateArgs); + t.deepEqual(upgradeResult, { incarnationNumber: 1 }); +}); + +test.serial('restart contractGovernor', async t => { + const { EV } = t.context.runUtils; + const vaultFactoryKit = await (EV.vat('bootstrap').consumeItem( + 'vaultFactoryKit', + ) as EconomyBootstrapSpace['consume']['vaultFactoryKit']); + + const { governorAdminFacet } = vaultFactoryKit; + // has no privateArgs of its own. the privateArgs.governed is only for the + // contract startInstance. any changes to those privateArgs have to happen + // through a restart or upgrade using the governed contract's adminFacet + const privateArgs = undefined; + + t.log('awaiting CG restartContract'); + const upgradeResult = + await EV(governorAdminFacet).restartContract(privateArgs); + t.deepEqual(upgradeResult, { incarnationNumber: 1 }); +}); + +test.serial('no-unnecessary-storage-nodes', async t => { + const { + runUtils: { EV }, + readLatest, + } = t.context; + const auctioneerKit = await EV.vat('bootstrap').consumeItem('auctioneerKit'); + const { nextAuctionSchedule } = await EV( + auctioneerKit.publicFacet, + ).getSchedules(); + t.log('nextAuctionSchedule', nextAuctionSchedule); + await startAuction(t); + + const scheduleNotification = readLatest('published.auction.schedule'); + t.log('scheduleNotification', scheduleNotification); + + // Make sure the auction started properly + t.is( + nextAuctionSchedule.startTime.absValue, + scheduleNotification.activeStartTime.absValue, + ); + + t.throws( + () => + readLatest( + `published.vaultFactory.managers.manager0.liquidations.${scheduleNotification.activeStartTime.absValue.toString()}`, + ), + { + message: `no data for "published.vaultFactory.managers.manager0.liquidations.${scheduleNotification.activeStartTime.absValue.toString()}"`, + }, + ); +}); + +test.serial('visibility-after-upgrade', async t => { + await checkVisibility({ + t, + managerIndex: 0, + setupCallback: () => + addNewVaults({ + t, + collateralBrandKey: 'ATOM', + base: setup.vaults.length, + }), + base: 3, + }); +}); + +test.serial('here-check-STARS-visibility', async t => { + await checkVisibility({ + t, + managerIndex: 1, + setupCallback: () => + addNewVaults({ + t, + collateralBrandKey: 'STARS', + base: 0, + }), + }); +}); + +test.serial('snapshot-storage', async t => { + const { readLatest } = t.context; + + const buildSnapshotItem = ( + paths: string[], + managerIndex: number, + auctionTime: bigint, + ) => { + const basePath = `published.vaultFactory.managers.manager${managerIndex}.liquidations.${auctionTime}`; + const item = {}; + for (const path of paths) { + const exactPath = `${basePath}.${path}`; + item[exactPath] = readLatest(exactPath); + } + t.snapshot(Object.entries(item)); + }; + + buildSnapshotItem( + ['vaults.preAuction', 'vaults.postAuction', 'auctionResult'], + 0, + 3600n, + ); + + buildSnapshotItem( + ['vaults.preAuction', 'vaults.postAuction', 'auctionResult'], + 0, + 10800n, + ); + + buildSnapshotItem( + ['vaults.preAuction', 'vaults.postAuction', 'auctionResult'], + 1, + 14400n, + ); +}); diff --git a/packages/boot/tools/liquidation.ts b/packages/boot/tools/liquidation.ts index 284848b2d7d5..70ca761c8ba7 100644 --- a/packages/boot/tools/liquidation.ts +++ b/packages/boot/tools/liquidation.ts @@ -256,6 +256,7 @@ export const makeLiquidationTestKit = async ({ collateralBrandKey: string, buyerWalletAddress: string, setup: LiquidationSetup, + base = 0, // number of bids made before ) => { const buyer = await walletFactoryDriver.provideSmartWallet(buyerWalletAddress); @@ -275,7 +276,11 @@ export const makeLiquidationTestKit = async ({ const maxBuy = `10000${collateralBrandKey}`; for (let i = 0; i < setup.bids.length; i += 1) { +<<<<<<< HEAD const offerId = `${collateralBrandKey}-bid${i + 1}`; +======= + const offerId = `${collateralBrandKey}-bid${i + 1 + base}`; +>>>>>>> 69a90400e (# This is a combination of 22 commits.) // bids are long-lasting offers so we can't wait here for completion await buyer.sendOfferMaker(Offers.auction.Bid, { offerId, @@ -300,11 +305,12 @@ export const makeLiquidationTestKit = async ({ priceFeedDrivers, setupVaults, placeBids, + setupStartingState, }; }; export const makeLiquidationTestContext = async t => { - const swingsetTestKit = await makeSwingsetTestKit(t.log); + const swingsetTestKit = await makeSwingsetTestKit(t.log, 'bundles/vaults'); console.time('DefaultTestContext'); const { runUtils, storage } = swingsetTestKit; diff --git a/packages/inter-protocol/src/vaultFactory/liquidation.js b/packages/inter-protocol/src/vaultFactory/liquidation.js index 2c992d150868..de166daff14f 100644 --- a/packages/inter-protocol/src/vaultFactory/liquidation.js +++ b/packages/inter-protocol/src/vaultFactory/liquidation.js @@ -19,6 +19,20 @@ const trace = makeTracer('LIQ'); /** @import {RelativeTimeRecord} from '@agoric/time' */ /** @import {PriceAuthority, PriceDescription, PriceQuote, PriceQuoteValue, PriceQuery,} from '@agoric/zoe/tools/types.js'; */ +/** + * @typedef {MapStore< + * Vault, + * { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } + * >} VaultData + */ + +/** + * @typedef {MapStore< + * Vault, + * { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } + * >} VaultData + */ + const makeCancelToken = makeCancelTokenMaker('liq'); /** @@ -270,12 +284,7 @@ export const getLiquidatableVaults = ( const vaultsToLiquidate = prioritizedVaults.removeVaultsBelow( collateralizationDetails, ); - /** - * @type {MapStore< - * Vault, - * { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } - * >} - */ + /** @type {VaultData} */ const vaultData = makeScalarMapStore(); const { zcfSeat: liqSeat } = zcf.makeEmptySeatKit(); diff --git a/packages/inter-protocol/src/vaultFactory/types-ambient.js b/packages/inter-protocol/src/vaultFactory/types-ambient.js index cc6cdd0fe7e3..30fbd05e3619 100644 --- a/packages/inter-protocol/src/vaultFactory/types-ambient.js +++ b/packages/inter-protocol/src/vaultFactory/types-ambient.js @@ -11,6 +11,7 @@ * @import {AssetReservePublicFacet} from '../reserve/assetReserve.js' * @import {AuctioneerPublicFacet} from '../auction/auctioneer.js' * @import {Timestamp} from '@agoric/time' + * @import {TimestampRecord} from '@agoric/time' * @import {RelativeTime} from '@agoric/time' */ @@ -132,3 +133,26 @@ */ /** @typedef {{ key: 'governedParams' | { collateralBrand: Brand } }} VaultFactoryParamPath */ + +/** + * @typedef {{ + * plan: import('./proceeds.js').DistributionPlan; + * vaultsInPlan: Array; + * }} PostAuctionParams + * + * @typedef {{ + * plan: import('./proceeds.js').DistributionPlan; + * totalCollateral: Amount<'nat'>; + * totalDebt: Amount<'nat'>; + * auctionSchedule: import('../auction/scheduler.js').FullSchedule; + * }} AuctionResultsParams + */ + +/** + * @typedef {import('./liquidation.js').VaultData} VaultData + * + * @typedef {object} LiquidationVisibilityWriters + * @property {(vaultData: VaultData) => Promise} writePreAuction + * @property {(postAuctionParams: PostAuctionParams) => Promise} writePostAuction + * @property {(auctionResultParams: AuctionResultsParams) => Promise} writeAuctionResults + */ diff --git a/packages/inter-protocol/src/vaultFactory/vault.js b/packages/inter-protocol/src/vaultFactory/vault.js index 2216ad24f161..ad3a390552ad 100644 --- a/packages/inter-protocol/src/vaultFactory/vault.js +++ b/packages/inter-protocol/src/vaultFactory/vault.js @@ -132,6 +132,9 @@ export const VaultI = M.interface('Vault', { getCurrentDebt: M.call().returns(AmountShape), getNormalizedDebt: M.call().returns(AmountShape), getVaultSeat: M.call().returns(SeatShape), + getVaultState: M.call().returns( + harden({ idInManager: M.string(), phase: M.string() }), + ), initVaultKit: M.call(SeatShape, StorageNodeShape).returns(M.promise()), liquidated: M.call().returns(undefined), liquidating: M.call().returns(undefined), @@ -604,6 +607,13 @@ export const prepareVault = (baggage, makeRecorderKit, zcf) => { return this.state.vaultSeat; }, + getVaultState() { + return { + idInManager: this.state.idInManager, + phase: this.state.phase, + }; + }, + /** * @param {ZCFSeat} seat * @param {StorageNode} storageNode diff --git a/packages/inter-protocol/src/vaultFactory/vaultDirector.js b/packages/inter-protocol/src/vaultFactory/vaultDirector.js index 82504a081764..3b8a2a13b7ca 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultDirector.js +++ b/packages/inter-protocol/src/vaultFactory/vaultDirector.js @@ -434,7 +434,7 @@ const prepareVaultDirector = ( makeLiquidationWaker() { return makeWaker('liquidationWaker', _timestamp => { // XXX floating promise - allManagersDo(vm => vm.liquidateVaults(auctioneer)); + allManagersDo(vm => vm.liquidateVaults(auctioneer, _timestamp)); }); }, makeReschedulerWaker() { diff --git a/packages/inter-protocol/src/vaultFactory/vaultManager.js b/packages/inter-protocol/src/vaultFactory/vaultManager.js index e80341b5b619..b1e50847d004 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -25,7 +25,7 @@ import { NotifierShape, RatioShape, } from '@agoric/ertp'; -import { makeTracer } from '@agoric/internal'; +import { allValuesSettled, makeTracer } from '@agoric/internal'; import { makeStoredNotifier, observeNotifier } from '@agoric/notifier'; import { appendToStoredArray } from '@agoric/store/src/stores/store-utils.js'; import { @@ -50,7 +50,8 @@ import { TopicsRecordShape, } from '@agoric/zoe/src/contractSupport/index.js'; import { PriceQuoteShape, SeatShape } from '@agoric/zoe/src/typeGuards.js'; -import { E } from '@endo/eventual-send'; +import { E, Far } from '@endo/far'; +import { TimestampShape } from '@agoric/time'; import { checkDebtLimit, makeNatAmountShape, @@ -176,6 +177,7 @@ export const watchQuoteNotifier = async (notifierP, watcher, ...args) => { * @typedef {{ * assetTopicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; * debtBrand: Brand<'nat'>; + * liquidationsStorageNode: StorageNode; * liquidatingVaults: SetStore; * metricsTopicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; * poolIncrementSeat: ZCFSeat; @@ -210,6 +212,35 @@ export const watchQuoteNotifier = async (notifierP, watcher, ...args) => { * storedCollateralQuote: PriceQuote | null; * }} */ + +/** + * @typedef {( + * | string + * | { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } + * )[][]} PreAuctionState + * + * @typedef {(string | { phase: string })[][]} PostAuctionState + * + * @typedef {{ + * collateralOffered?: Amount<'nat'>; + * istTarget?: Amount<'nat'>; + * collateralForReserve?: Amount<'nat'>; + * shortfallToReserve?: Amount<'nat'>; + * mintedProceeds?: Amount<'nat'>; + * collateralSold?: Amount<'nat'>; + * collateralRemaining?: Amount<'nat'>; + * endTime?: import('@agoric/time').TimestampRecord | null; + * }} AuctionResultState + * + * @typedef {{ + * preAuctionRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; + * postAuctionRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; + * auctionResultRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; + * }} LiquidationRecorderKits + */ + +/** @typedef {import('./liquidation.js').VaultData} VaultData */ + // any b/c will be filled after start() const collateralEphemera = makeEphemeraProvider(() => /** @type {any} */ ({})); @@ -232,7 +263,10 @@ export const prepareVaultManagerKit = ( const makeVault = prepareVault(baggage, makeRecorderKit, zcf); /** - * @param {HeldParams & { metricsStorageNode: StorageNode }} params + * @param {HeldParams & { + * metricsStorageNode: StorageNode; + * liquidationsStorageNode: StorageNode; + * }} params * @returns {HeldParams & ImmutableState & MutableState} */ const initState = params => { @@ -240,6 +274,7 @@ export const prepareVaultManagerKit = ( debtMint, collateralBrand, metricsStorageNode, + liquidationsStorageNode, startTimeStamp, storageNode, } = params; @@ -249,7 +284,7 @@ export const prepareVaultManagerKit = ( const immutable = { debtBrand, poolIncrementSeat: zcf.makeEmptySeatKit().zcfSeat, - + liquidationsStorageNode, /** * Vaults that have been sent for liquidation. When we get proceeds (or * lack thereof) back from the liquidator, we will allocate them among the @@ -341,7 +376,9 @@ export const prepareVaultManagerKit = ( getCollateralQuote: M.call().returns(PriceQuoteShape), getPublicFacet: M.call().returns(M.remotable('publicFacet')), lockOraclePrices: M.call().returns(PriceQuoteShape), - liquidateVaults: M.call(M.eref(AuctionPFShape)).returns(M.promise()), + liquidateVaults: M.call(M.eref(AuctionPFShape), TimestampShape).returns( + M.promise(), + ), }), }, initState, @@ -656,6 +693,148 @@ export const prepareVaultManagerKit = ( return E(metricsTopicKit.recorder).write(payload); }, + /** + * @param {TimestampRecord} timestamp + * @returns {Promise} + */ + async makeLiquidationVisibilityWriters(timestamp) { + const liquidationRecorderKits = + await this.facets.helper.makeLiquidationRecorderKits(timestamp); + + /** @param {VaultData} vaultData */ + const writePreAuction = vaultData => { + /** @type PreAuctionState */ + const preAuctionState = [...vaultData.entries()].map( + ([vault, data]) => [ + `vault${vault.getVaultState().idInManager}`, + { ...data }, + ], + ); + + return E( + liquidationRecorderKits.preAuctionRecorderKit.recorder, + ).writeFinal(preAuctionState); + }; + + /** + * @param {PostAuctionParams} params + * @returns {Promise} + */ + const writePostAuction = ({ plan, vaultsInPlan }) => { + /** @type PostAuctionState */ + const postAuctionState = plan.transfersToVault.map( + ([id, transfer]) => [ + `vault${vaultsInPlan[id].getVaultState().idInManager}`, + { + ...transfer, + phase: vaultsInPlan[id].getVaultState().phase, + }, + ], + ); + return E( + liquidationRecorderKits.postAuctionRecorderKit.recorder, + ).writeFinal(postAuctionState); + }; + + /** @param {AuctionResultsParams} params */ + const writeAuctionResults = ({ + plan, + totalCollateral, + totalDebt, + auctionSchedule, + }) => { + /** @type AuctionResultState */ + const auctionResultState = { + collateralOffered: totalCollateral, + istTarget: totalDebt, + collateralForReserve: plan.collateralForReserve, + shortfallToReserve: plan.shortfallToReserve, + mintedProceeds: plan.mintedProceeds, + collateralSold: plan.collateralSold, + collateralRemaining: plan.collatRemaining, + // @ts-expect-error + // eslint-disable-next-line @endo/no-optional-chaining + endTime: auctionSchedule?.liveAuctionSchedule.endTime, + }; + return E( + liquidationRecorderKits.auctionResultRecorderKit.recorder, + ).writeFinal(auctionResultState); + }; + + return Far('Liquidation Visibility Writers', { + writePreAuction, + writePostAuction, + writeAuctionResults, + }); + }, + + /** + * This method checks if liquidationVisibilityWriters is undefined or + * not in case of a rejected promise when creating the writers. If + * liquidationVisibilityWriters is undefined it silently notifies the + * console. Otherwise, it goes on with the writing. + * + * @param {LiquidationVisibilityWriters} liquidationVisibilityWriters + * @param {[string, object][]} writes + */ + async writeLiqVisibility(liquidationVisibilityWriters, writes) { + console.log('WRITES', writes); + if (!liquidationVisibilityWriters) { + trace( + 'writeLiqVisibility', + `Error: liquidationVisibilityWriters is ${liquidationVisibilityWriters}`, + ); + return; + } + + for (const [methodName, params] of writes) { + trace('DEBUG', methodName, params); + void liquidationVisibilityWriters[methodName](params); + } + }, + + /** + * @param {TimestampRecord} timestamp + * @returns {Promise} + */ + async makeLiquidationRecorderKits(timestamp) { + const { + state: { liquidationsStorageNode }, + } = this; + + const timestampStorageNode = E(liquidationsStorageNode).makeChildNode( + `${timestamp.absValue}`, + ); + + const [ + preAuctionStorageNode, + postAuctionStorageNode, + auctionResultStorageNode, + ] = await Promise.all([ + E(E(timestampStorageNode).makeChildNode('vaults')).makeChildNode( + 'preAuction', + ), + E(E(timestampStorageNode).makeChildNode('vaults')).makeChildNode( + 'postAuction', + ), + E(timestampStorageNode).makeChildNode('auctionResult'), + ]); + + const preAuctionRecorderKit = makeRecorderKit(preAuctionStorageNode); + const postAuctionRecorderKit = makeRecorderKit( + postAuctionStorageNode, + ); + const auctionResultRecorderKit = makeRecorderKit( + auctionResultStorageNode, + ); + + return { + preAuctionRecorderKit, + postAuctionRecorderKit, + auctionResultRecorderKit, + }; + }, + /** * This is designed to tolerate an incomplete plan, in case * calculateDistributionPlan encounters an error during its calculation. @@ -1122,8 +1301,11 @@ export const prepareVaultManagerKit = ( void facets.helper.writeMetrics(); return storedCollateralQuote; }, - /** @param {ERef} auctionPF */ - async liquidateVaults(auctionPF) { + /** + * @param {ERef} auctionPF + * @param {TimestampRecord} timestamp + */ + async liquidateVaults(auctionPF, timestamp) { const { state, facets } = this; const { self, helper } = facets; const { @@ -1186,11 +1368,12 @@ export const prepareVaultManagerKit = ( liquidatingVaults.getSize(), totalCollateral, ); + const schedulesP = E(auctionPF).getSchedules(); helper.markLiquidating(totalDebt, totalCollateral); void helper.writeMetrics(); - const { userSeatPromise, deposited } = await E.when( + const makeDeposit = E.when( E(auctionPF).makeDepositInvitation(), depositInvitation => offerTo( @@ -1204,6 +1387,26 @@ export const prepareVaultManagerKit = ( ), ); + // helper.makeLiquidationVisibilityWriters and schedulesP depends on others vats, + // so we switched from Promise.all to Promise.allSettled because if one of those vats fail + // we don't want those failures to prevent liquidation process from going forward. + // We don't handle the case where 'makeDeposit' rejects as liquidation depends on + // 'makeDeposit' being fulfilled. + const { + makeDeposit: { userSeatPromise, deposited }, + liquidationVisibilityWriters, + auctionSchedule, + } = await allValuesSettled({ + makeDeposit, + liquidationVisibilityWriters: + helper.makeLiquidationVisibilityWriters(timestamp), + auctionSchedule: schedulesP, + }); + + void helper.writeLiqVisibility(liquidationVisibilityWriters, [ + ['writePreAuction', vaultData], + ]); + // This is expected to wait for the duration of the auction, which // is controlled by the auction parameters startFrequency, clockStep, // and the difference between startingRate and lowestRate. @@ -1214,8 +1417,10 @@ export const prepareVaultManagerKit = ( ); trace(`LiqV after long wait`, proceeds); + let plan; + let vaultsInPlan; try { - const { plan, vaultsInPlan } = helper.planProceedsDistribution( + ({ plan, vaultsInPlan } = helper.planProceedsDistribution( proceeds, totalDebt, // If a quote was available at the start of liquidation, but is no @@ -1224,7 +1429,7 @@ export const prepareVaultManagerKit = ( storedCollateralQuote || collateralQuoteBefore, vaultData, totalCollateral, - ); + )); trace('PLAN', plan); // distributeProceeds may reconstitute vaults, removing them from liquidatingVaults helper.distributeProceeds({ @@ -1244,7 +1449,27 @@ export const prepareVaultManagerKit = ( vault.liquidated(); liquidatingVaults.delete(vault); } - + void helper.writeLiqVisibility( + liquidationVisibilityWriters, + harden([ + [ + 'writeAuctionResults', + { + plan, + totalCollateral, + totalDebt, + auctionSchedule, + }, + ], + [ + 'writePostAuction', + { + plan, + vaultsInPlan, + }, + ], + ]), + ); void helper.writeMetrics(); }, }, @@ -1272,16 +1497,19 @@ export const prepareVaultManagerKit = ( /** * @param {Omit< * Parameters[0], - * 'metricsStorageNode' + * 'metricsStorageNode' | 'liquidationsStorageNode' * >} externalParams */ const makeVaultManagerKit = async externalParams => { - const metricsStorageNode = await E( - externalParams.storageNode, - ).makeChildNode('metrics'); + const [metricsStorageNode, liquidationsStorageNode] = await Promise.all([ + E(externalParams.storageNode).makeChildNode('metrics'), + E(externalParams.storageNode).makeChildNode('liquidations'), + ]); + return makeVaultManagerKitInternal({ ...externalParams, metricsStorageNode, + liquidationsStorageNode, }); }; return makeVaultManagerKit; diff --git a/packages/inter-protocol/test/liquidationVisibility/assertions.js b/packages/inter-protocol/test/liquidationVisibility/assertions.js new file mode 100644 index 000000000000..9fe9ef084290 --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/assertions.js @@ -0,0 +1,268 @@ +import '@agoric/zoe/exported.js'; +import { E } from '@endo/eventual-send'; +import { assertPayoutAmount } from '@agoric/zoe/test/zoeTestHelpers.js'; +import { AmountMath } from '@agoric/ertp'; +import { + ceilMultiplyBy, + makeRatio, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { TimeMath } from '@agoric/time'; +import { headValue } from '../supports.js'; +import { getDataFromVstorage } from './tools.js'; + +export const assertBidderPayout = async ( + t, + bidderSeat, + run, + curr, + aeth, + coll, +) => { + const bidderResult = await E(bidderSeat).getOfferResult(); + t.is(bidderResult, 'Your bid has been accepted'); + const payouts = await E(bidderSeat).getPayouts(); + const { Collateral: bidderCollateral, Bid: bidderBid } = payouts; + (!bidderBid && curr === 0n) || + (await assertPayoutAmount(t, run.issuer, bidderBid, run.make(curr))); + (!bidderCollateral && coll === 0n) || + (await assertPayoutAmount( + t, + aeth.issuer, + bidderCollateral, + aeth.make(coll), + 'amount ', + )); +}; + +export const assertReserveState = async (metricTracker, method, expected) => { + switch (method) { + case 'initial': + await metricTracker.assertInitial(expected); + break; + case 'like': + await metricTracker.assertLike(expected); + break; + case 'state': + await metricTracker.assertState(expected); + break; + default: + console.log('Default'); + break; + } +}; + +export const assertVaultCurrentDebt = async (t, vault, debt) => { + const debtAmount = await E(vault).getCurrentDebt(); + + if (debt === 0n) { + t.deepEqual(debtAmount.value, debt); + return; + } + + const fee = ceilMultiplyBy(debt, t.context.rates.mintFee); + + t.deepEqual( + debtAmount, + AmountMath.add(debt, fee), + 'borrower Minted amount does not match Vault current debt', + ); +}; + +export const assertVaultCollateral = async ( + t, + vault, + collateralValue, + asset, +) => { + const collateralAmount = await E(vault).getCollateralAmount(); + + t.deepEqual(collateralAmount, asset.make(collateralValue)); +}; + +export const assertMintedAmount = async (t, vaultSeat, wantMinted) => { + const { Minted } = await E(vaultSeat).getFinalAllocation(); + + t.truthy(AmountMath.isEqual(Minted, wantMinted)); +}; + +export const assertMintedProceeds = async (t, vaultSeat, wantMinted) => { + const { Minted } = await E(vaultSeat).getFinalAllocation(); + const { Minted: proceedsMinted } = await E(vaultSeat).getPayouts(); + + t.truthy(AmountMath.isEqual(Minted, wantMinted)); + + t.truthy( + AmountMath.isEqual( + await E(t.context.run.issuer).getAmountOf(proceedsMinted), + wantMinted, + ), + ); +}; + +export const assertVaultLocked = async ( + t, + vaultNotifier, + lockedValue, + asset, +) => { + const notification = await E(vaultNotifier).getUpdateSince(); + const lockedAmount = notification.value.locked; + + t.deepEqual(lockedAmount, asset.make(lockedValue)); +}; + +export const assertVaultDebtSnapshot = async (t, vaultNotifier, wantMinted) => { + const notification = await E(vaultNotifier).getUpdateSince(); + const debtSnapshot = notification.value.debtSnapshot; + const fee = ceilMultiplyBy(wantMinted, t.context.rates.mintFee); + + t.deepEqual(debtSnapshot, { + debt: AmountMath.add(wantMinted, fee), + interest: makeRatio(100n, t.context.run.brand), + }); + + return notification; +}; + +export const assertVaultState = async (t, vaultNotifier, phase) => { + const notification = await E(vaultNotifier).getUpdateSince(); + const vaultState = notification.value.vaultState; + + t.is(vaultState, phase); + + return notification; +}; + +export const assertVaultSeatExited = async (t, vaultSeat) => { + t.truthy(await E(vaultSeat).hasExited()); +}; + +export const assertVaultFactoryRewardAllocation = async ( + t, + vaultFactory, + rewardValue, +) => { + const rewardAllocation = await E(vaultFactory).getRewardAllocation(); + + t.deepEqual(rewardAllocation, { + Minted: t.context.run.make(rewardValue), + }); +}; + +export const assertCollateralProceeds = async (t, seat, colWanted, issuer) => { + const { Collateral: withdrawnCol } = await E(seat).getFinalAllocation(); + const proceeds4 = await E(seat).getPayouts(); + t.deepEqual(withdrawnCol, colWanted); + + const collateralWithdrawn = await proceeds4.Collateral; + t.truthy( + AmountMath.isEqual( + await E(issuer).getAmountOf(collateralWithdrawn), + colWanted, + ), + ); +}; + +// Update these assertions to use a tracker similar to test-auctionContract +export const assertBookData = async ( + t, + auctioneerBookDataSubscriber, + expectedBookData, +) => { + const auctioneerBookData = await E( + auctioneerBookDataSubscriber, + ).getUpdateSince(); + + t.deepEqual(auctioneerBookData.value, expectedBookData); +}; + +export const assertAuctioneerSchedule = async ( + t, + auctioneerPublicTopics, + expectedSchedule, +) => { + const auctioneerSchedule = await E( + auctioneerPublicTopics.schedule.subscriber, + ).getUpdateSince(); + + t.deepEqual(auctioneerSchedule.value, expectedSchedule); +}; + +export const assertAuctioneerPathData = async ( + t, + hasTopics, + brand, + topicName, + path, + dataKeys, +) => { + let topic; + if (brand) { + topic = await E(hasTopics) + .getPublicTopics(brand) + .then(topics => topics[topicName]); + } else { + topic = await E(hasTopics) + .getPublicTopics() + .then(topics => topics[topicName]); + } + + t.is(await topic?.storagePath, path, 'topic storagePath must match'); + const latest = /** @type {Record} */ ( + await headValue(topic.subscriber) + ); + if (dataKeys !== undefined) { + // TODO consider making this a shape instead + t.deepEqual(Object.keys(latest), dataKeys, 'keys in topic feed must match'); + } +}; + +export const assertVaultData = async ( + t, + vaultDataSubscriber, + vaultDataVstorage, +) => { + const auctioneerBookData = await E(vaultDataSubscriber).getUpdateSince(); + t.deepEqual(auctioneerBookData.value, vaultDataVstorage[0][1]); +}; + +export const assertNodeInStorage = async ({ + t, + rootNode, + desiredNode, + expected, +}) => { + const [...storageData] = await getDataFromVstorage(rootNode, desiredNode); + t.is(storageData.length !== 0, expected); +}; + +// Currently supports only one collateral manager +export const assertLiqNodeForAuctionCreated = async ({ + t, + rootNode, + auctioneerPF, + auctionType = 'next', // 'live' is the other option + expected = false, +}) => { + const schedules = await E(auctioneerPF).getSchedules(); + const { startTime, startDelay } = schedules[`${auctionType}AuctionSchedule`]; + const nominalStart = TimeMath.subtractAbsRel(startTime, startDelay); + + await assertNodeInStorage({ + t, + rootNode, + desiredNode: `vaultFactory.managers.manager0.liquidations.${nominalStart}`, + expected, + }); +}; + +export const assertStorageData = async ({ t, path, storageRoot, expected }) => { + /** @type Array */ + const [[, value]] = await getDataFromVstorage(storageRoot, path); + t.deepEqual(value, expected); +}; + +export const assertVaultNotification = async ({ t, notifier, expected }) => { + const { value } = await E(notifier).getUpdateSince(); + t.like(value, expected); +}; diff --git a/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js b/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js new file mode 100644 index 000000000000..f7adb16557ba --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js @@ -0,0 +1,752 @@ +import '@agoric/governance/exported.js'; +import '@agoric/zoe/exported.js'; +import '@agoric/zoe/src/contracts/exported.js'; + +import { AmountMath, AmountShape, BrandShape } from '@agoric/ertp'; +import { handleParamGovernance } from '@agoric/governance'; +import { BASIS_POINTS, makeTracer } from '@agoric/internal'; +import { prepareDurablePublishKit } from '@agoric/notifier'; +import { mustMatch } from '@agoric/store'; +import { appendToStoredArray } from '@agoric/store/src/stores/store-utils.js'; +import { M, provideDurableMapStore } from '@agoric/vat-data'; +import { + atomicRearrange, + ceilDivideBy, + ceilMultiplyBy, + defineERecorderKit, + defineRecorderKit, + floorDivideBy, + floorMultiplyBy, + makeRatio, + makeRatioFromAmounts, + makeRecorderTopic, + natSafeMath, + prepareRecorder, + provideEmptySeat, + offerTo, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { FullProposalShape } from '@agoric/zoe/src/typeGuards.js'; +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; + +import { makeNatAmountShape } from '../../src/contractSupport.js'; +import { + makeOfferSpecShape, + prepareAuctionBook, +} from '../../src/auction/auctionBook.js'; +import { auctioneerParamTypes } from '../../src/auction/params.js'; +import { makeScheduler } from '../../src/auction/scheduler.js'; +import { AuctionState } from '../../src/auction/util.js'; + +/** @typedef {import('@agoric/vat-data').Baggage} Baggage */ + +const { Fail, quote: q } = assert; +const { add, multiply } = natSafeMath; + +const trace = makeTracer('Auction', true); + +/** + * @file In this file, 'Bid' is the name of the ERTP issuer used to purchase + * collateral from various issuers. It's too confusing to also use Bid as a + * verb or a description of amounts offered, so we've tried to find + * alternatives in all those cases. + */ + +const MINIMUM_BID_GIVE = 1n; + +/** + * @param {NatValue} rate + * @param {Brand<'nat'>} bidBrand + * @param {Brand<'nat'>} collateralBrand + */ +const makeBPRatio = (rate, bidBrand, collateralBrand = bidBrand) => + makeRatioFromAmounts( + AmountMath.make(bidBrand, rate), + AmountMath.make(collateralBrand, BASIS_POINTS), + ); + +/** + * The auction sold some amount of collateral, and raised a certain amount of + * Bid. The excess collateral was returned as `unsoldCollateral`. The Bid amount + * collected from the auction participants is `proceeds`. + * + * Return a set of transfers for atomicRearrange() that distribute + * `unsoldCollateral` and `proceeds` proportionally to each seat's deposited + * amount. Any uneven split should be allocated to the reserve. + * + * @param {Amount} unsoldCollateral + * @param {Amount} proceeds + * @param {{ seat: ZCFSeat; amount: Amount<'nat'>; goal: Amount<'nat'> }[]} deposits + * @param {ZCFSeat} collateralSeat + * @param {ZCFSeat} bidHoldingSeat seat with the Bid allocation to be + * distributed + * @param {string} collateralKeyword The Reserve will hold multiple collaterals, + * so they need distinct keywords + * @param {ZCFSeat} reserveSeat + * @param {Brand} brand + */ +const distributeProportionalShares = ( + unsoldCollateral, + proceeds, + deposits, + collateralSeat, + bidHoldingSeat, + collateralKeyword, + reserveSeat, + brand, +) => { + const totalCollDeposited = deposits.reduce((prev, { amount }) => { + return AmountMath.add(prev, amount); + }, AmountMath.makeEmpty(brand)); + + const collShare = makeRatioFromAmounts(unsoldCollateral, totalCollDeposited); + const currShare = makeRatioFromAmounts(proceeds, totalCollDeposited); + /** @type {TransferPart[]} */ + const transfers = []; + let proceedsLeft = proceeds; + let collateralLeft = unsoldCollateral; + + // each depositor gets a share that equals their amount deposited + // divided by the total deposited multiplied by the Bid and + // collateral being distributed. + for (const { seat, amount } of deposits.values()) { + const currPortion = floorMultiplyBy(amount, currShare); + proceedsLeft = AmountMath.subtract(proceedsLeft, currPortion); + const collPortion = floorMultiplyBy(amount, collShare); + collateralLeft = AmountMath.subtract(collateralLeft, collPortion); + transfers.push([bidHoldingSeat, seat, { Bid: currPortion }]); + transfers.push([collateralSeat, seat, { Collateral: collPortion }]); + } + + transfers.push([bidHoldingSeat, reserveSeat, { Bid: proceedsLeft }]); + + if (!AmountMath.isEmpty(collateralLeft)) { + transfers.push([ + collateralSeat, + reserveSeat, + { Collateral: collateralLeft }, + { [collateralKeyword]: collateralLeft }, + ]); + } + + return transfers; +}; + +/** + * The auction sold some amount of collateral, and raised a certain amount of + * Bid. The excess collateral was returned as `unsoldCollateral`. The Bid amount + * collected from the auction participants is `proceeds`. + * + * Return a set of transfers for atomicRearrange() that distribute + * `unsoldCollateral` and `proceeds` proportionally to each seat's deposited + * amount. Any uneven split should be allocated to the reserve. + * + * This function is exported for testability, and is not expected to be used + * outside the contract below. + * + * Some or all of the depositors may have specified a goal amount. + * + * - A if none did, return collateral and Bid prorated to deposits. + * - B if proceeds < proceedsGoal everyone gets prorated amounts of both. + * - C if proceeds matches proceedsGoal, everyone gets the Bid they asked for, + * plus enough collateral to reach the same proportional payout. If any + * depositor's goal amount exceeded their share of the total, we'll fall back + * to the first approach. + * - D if proceeds > proceedsGoal && all depositors specified a limit, all + * depositors get their goal first, then we distribute the remainder + * (collateral and Bid) to get the same proportional payout. + * - E if proceeds > proceedsGoal && some depositors didn't specify a limit, + * depositors who did will get their goal first, then we distribute the + * remainder (collateral and Bid) to get the same proportional payout. If any + * depositor's goal amount exceeded their share of the total, we'll fall back + * as above. Think of it this way: those who specified a limit want as much + * collateral back as possible, consistent with raising a certain amount of + * Bid. Those who didn't specify a limit are trying to sell collateral, and + * would prefer to have as much as possible converted to Bid. + * + * @param {Amount<'nat'>} unsoldCollateral + * @param {Amount<'nat'>} proceeds + * @param {{ seat: ZCFSeat; amount: Amount<'nat'>; goal: Amount<'nat'> }[]} deposits + * @param {ZCFSeat} collateralSeat + * @param {ZCFSeat} bidHoldingSeat seat with the Bid allocation to be + * distributed + * @param {string} collateralKeyword The Reserve will hold multiple collaterals, + * so they need distinct keywords + * @param {ZCFSeat} reserveSeat + * @param {Brand} brand + */ +export const distributeProportionalSharesWithLimits = ( + unsoldCollateral, + proceeds, + deposits, + collateralSeat, + bidHoldingSeat, + collateralKeyword, + reserveSeat, + brand, +) => { + trace('distributeProportionally with limits'); + // unmatched is the sum of the deposits by those who didn't specify a goal + const [collDeposited, proceedsGoal, unmatchedDeposits] = deposits.reduce( + (prev, { amount, goal }) => { + const nextDeposit = AmountMath.add(prev[0], amount); + const [proceedsSum, unmatchedSum] = goal + ? [AmountMath.add(goal, prev[1]), prev[2]] + : [prev[1], AmountMath.add(prev[2], amount)]; + return [nextDeposit, proceedsSum, unmatchedSum]; + }, + [ + AmountMath.makeEmpty(brand), + AmountMath.makeEmptyFromAmount(proceeds), + AmountMath.makeEmpty(brand), + ], + ); + + const distributeProportionally = () => + distributeProportionalShares( + unsoldCollateral, + proceeds, + deposits, + collateralSeat, + bidHoldingSeat, + collateralKeyword, + reserveSeat, + brand, + ); + + // cases A and B + if ( + AmountMath.isEmpty(proceedsGoal) || + !AmountMath.isGTE(proceeds, proceedsGoal) + ) { + return distributeProportionally(); + } + + // Calculate multiplier for collateral that gives total value each depositor + // should get. + // + // The average price of collateral is proceeds / CollateralSold. + // The value of Collateral is Price * unsoldCollateral. + // The overall total value to be distributed is + // Proceeds + collateralValue. + // Each depositor should get bid and collateral that sum to the overall + // total value multiplied by the ratio of that depositor's collateral + // deposited to all the collateral deposited. + // + // To improve the resolution of the result, we only divide once, so we + // multiply each depositor's collateral remaining by this expression. + // + // collSold * proceeds + proceeds * unsoldCollateral + // ----------------------------------------------------------- + // collSold * totalCollDeposit + // + // If you do the dimension analysis, we'll multiply collateral by a ratio + // representing Bid/collateral. + + // average value of collateral is collateralSold / proceeds + const collateralSold = AmountMath.subtract(collDeposited, unsoldCollateral); + const numeratorValue = add( + multiply(collateralSold.value, proceeds.value), + multiply(unsoldCollateral.value, proceeds.value), + ); + const denominatorValue = multiply(collateralSold.value, collDeposited.value); + const totalValueRatio = makeRatioFromAmounts( + AmountMath.make(proceeds.brand, numeratorValue), + AmountMath.make(brand, denominatorValue), + ); + + const avgPrice = makeRatioFromAmounts(proceeds, collateralSold); + + // Allocate the proceedsGoal amount to depositors who specified it. Add + // collateral to reach their share. Then see what's left, and allocate it + // among the remaining depositors. Escape to distributeProportionalShares if + // anything doesn't work. + /** @type {TransferPart[]} */ + const transfers = []; + let proceedsLeft = proceeds; + let collateralLeft = unsoldCollateral; + + // case C + if (AmountMath.isEqual(proceedsGoal, proceeds)) { + // each depositor gets a share that equals their amount deposited + // multiplied by totalValueRatio computed above. + + for (const { seat, amount, goal } of deposits.values()) { + const depositorValue = floorMultiplyBy(amount, totalValueRatio); + if (goal === null || AmountMath.isGTE(depositorValue, goal)) { + let valueNeeded = depositorValue; + if (goal !== null && !AmountMath.isEmpty(goal)) { + proceedsLeft = AmountMath.subtract(proceedsLeft, goal); + transfers.push([bidHoldingSeat, seat, { Bid: goal }]); + valueNeeded = AmountMath.subtract(depositorValue, goal); + } + + const collateralToAdd = floorDivideBy(valueNeeded, avgPrice); + collateralLeft = AmountMath.subtract(collateralLeft, collateralToAdd); + transfers.push([collateralSeat, seat, { Collateral: collateralToAdd }]); + } else { + // This depositor asked for more than their share. + // ignore `transfers` and distribute everything proportionally. + return distributeProportionally(); + } + } + } else { + // Cases D & E. Proceeds > proceedsGoal, so those who specified a limit + // receive at least their target. + + const collateralValue = floorMultiplyBy(unsoldCollateral, avgPrice); + const totalDistributableValue = AmountMath.add(proceeds, collateralValue); + // The share for those who specified a limit is proportional to their + // collateral. ceiling because it's a lower limit on the restrictive branch + const limitedShare = ceilMultiplyBy( + AmountMath.subtract(collDeposited, unmatchedDeposits), + makeRatioFromAmounts(totalDistributableValue, collDeposited), + ); + + // if proceedsGoal + value of unsoldCollateral >= limitedShare then those + // who specified a limit can get all the excess over their limit in + // collateral. Others share whatever is left. + // If proceedsGoal + unsoldCollateral < limitedShare then those who + // specified share all the collateral, and everyone gets Bid to cover + // the remainder of their share. + const limitedGetMaxCollateral = AmountMath.isGTE( + AmountMath.add(proceedsGoal, collateralValue), + limitedShare, + ); + + const calcNotLimitedCollateralShare = () => { + if (limitedGetMaxCollateral) { + // those who limited will get limitedShare - proceedsGoal in collateral + const ltdCollatValue = AmountMath.subtract(limitedShare, proceedsGoal); + const ltdCollatShare = ceilDivideBy(ltdCollatValue, avgPrice); + // the unlimited will get the remainder of the collateral + return AmountMath.subtract(unsoldCollateral, ltdCollatShare); + } else { + return AmountMath.makeEmpty(brand); + } + }; + const notLimitedCollateralShare = calcNotLimitedCollateralShare(); + + for (const { seat, amount, goal } of deposits.values()) { + const depositorValue = floorMultiplyBy(amount, totalValueRatio); + + const addRemainderInBid = collateralAdded => { + const collateralVal = ceilMultiplyBy(collateralAdded, avgPrice); + /** @type {Amount<'nat'>} XXX for package depth type resolution */ + const valueNeeded = AmountMath.subtract(depositorValue, collateralVal); + + proceedsLeft = AmountMath.subtract(proceedsLeft, valueNeeded); + transfers.push([bidHoldingSeat, seat, { Bid: valueNeeded }]); + }; + + if (goal === null || AmountMath.isEmpty(goal)) { + const collateralShare = floorMultiplyBy( + notLimitedCollateralShare, + makeRatioFromAmounts(amount, unmatchedDeposits), + ); + collateralLeft = AmountMath.subtract(collateralLeft, collateralShare); + addRemainderInBid(collateralShare); + transfers.push([collateralSeat, seat, { Collateral: collateralShare }]); + } else if (limitedGetMaxCollateral) { + proceedsLeft = AmountMath.subtract(proceedsLeft, goal); + transfers.push([bidHoldingSeat, seat, { Bid: goal }]); + + const valueNeeded = AmountMath.subtract(depositorValue, goal); + const collateralToAdd = floorDivideBy(valueNeeded, avgPrice); + collateralLeft = AmountMath.subtract(collateralLeft, collateralToAdd); + transfers.push([collateralSeat, seat, { Collateral: collateralToAdd }]); + } else { + // There's not enough collateral to completely cover the gap above + // the proceedsGoal amount, so each depositor gets a proportional share + // of unsoldCollateral plus enough Bid to reach their share. + const collateralShare = floorMultiplyBy( + unsoldCollateral, + makeRatioFromAmounts(amount, collDeposited), + ); + collateralLeft = AmountMath.subtract(collateralLeft, collateralShare); + addRemainderInBid(collateralShare); + transfers.push([collateralSeat, seat, { Collateral: collateralShare }]); + } + } + } + + transfers.push([bidHoldingSeat, reserveSeat, { Bid: proceedsLeft }]); + + if (!AmountMath.isEmpty(collateralLeft)) { + transfers.push([ + collateralSeat, + reserveSeat, + { Collateral: collateralLeft }, + { [collateralKeyword]: collateralLeft }, + ]); + } + return transfers; +}; + +/** + * @param {ZCF< + * GovernanceTerms & { + * timerService: import('@agoric/time').TimerService; + * reservePublicFacet: AssetReservePublicFacet; + * priceAuthority: PriceAuthority; + * } + * >} zcf + * @param {{ + * initialPoserInvitation: Invitation; + * storageNode: StorageNode; + * marshaller: Marshaller; + * }} privateArgs + * @param {Baggage} baggage + */ +export const start = async (zcf, privateArgs, baggage) => { + const { brands, timerService: timer, priceAuthority } = zcf.getTerms(); + timer || Fail`Timer must be in Auctioneer terms`; + const timerBrand = await E(timer).getTimerBrand(); + + const bidAmountShape = { brand: brands.Bid, value: M.nat() }; + + /** + * @type {MapStore< + * Brand, + * import('../../src/auction/auctionBook.js').AuctionBook + * >} + */ + const books = provideDurableMapStore(baggage, 'auctionBooks'); + /** + * @type {MapStore< + * Brand, + * { seat: ZCFSeat; amount: Amount<'nat'>; goal: Amount<'nat'> }[] + * >} + */ + const deposits = provideDurableMapStore(baggage, 'deposits'); + /** @type {MapStore} */ + const brandToKeyword = provideDurableMapStore(baggage, 'brandToKeyword'); + + const reserveSeat = provideEmptySeat(zcf, baggage, 'collateral'); + + let bookCounter = 0; + + const makeDurablePublishKit = prepareDurablePublishKit( + baggage, + 'Auction publish kit', + ); + const makeRecorder = prepareRecorder(baggage, privateArgs.marshaller); + + const makeRecorderKit = defineRecorderKit({ + makeRecorder, + makeDurablePublishKit, + }); + + const makeAuctionBook = prepareAuctionBook(baggage, zcf, makeRecorderKit); + + const makeERecorderKit = defineERecorderKit({ + makeRecorder, + makeDurablePublishKit, + }); + const scheduleKit = makeERecorderKit( + E(privateArgs.storageNode).makeChildNode('schedule'), + /** + * @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher< + * import('../../src/auction/scheduler.js').ScheduleNotification + * >} + */ (M.any()), + ); + + /** + * @param {ZCFSeat} seat + * @param {Amount<'nat'>} amount + * @param {Amount<'nat'> | null} goal + */ + const addDeposit = (seat, amount, goal = null) => { + appendToStoredArray(deposits, amount.brand, harden({ seat, amount, goal })); + }; + + const sendToReserve = keyword => { + const { reservePublicFacet } = zcf.getTerms(); + + const amount = reserveSeat.getCurrentAllocation()[keyword]; + if (!amount || AmountMath.isEmpty(amount)) { + return; + } + + const invitation = E(reservePublicFacet).makeAddCollateralInvitation(); + // don't wait for a response + void E.when(invitation, invite => { + const proposal = { give: { Collateral: amount } }; + void offerTo( + zcf, + invite, + { [keyword]: 'Collateral' }, + proposal, + reserveSeat, + ); + }); + }; + + // Called "discount" rate even though it can be above or below 100%. + /** @type {NatValue} */ + let currentDiscountRateBP; + + const distributeProceeds = () => { + for (const brand of deposits.keys()) { + const book = books.get(brand); + const { collateralSeat, bidHoldingSeat } = book.getSeats(); + + const depositsForBrand = deposits.get(brand); + if (depositsForBrand.length === 1) { + // send it all to the one + const liqSeat = depositsForBrand[0].seat; + + atomicRearrange( + zcf, + harden([ + [collateralSeat, liqSeat, collateralSeat.getCurrentAllocation()], + [bidHoldingSeat, liqSeat, bidHoldingSeat.getCurrentAllocation()], + ]), + ); + liqSeat.exit(); + deposits.set(brand, []); + } else if (depositsForBrand.length > 1) { + const collProceeds = collateralSeat.getCurrentAllocation().Collateral; + const currProceeds = + bidHoldingSeat.getCurrentAllocation().Bid || + AmountMath.makeEmpty(brands.Bid); + const transfers = distributeProportionalSharesWithLimits( + collProceeds, + currProceeds, + depositsForBrand, + collateralSeat, + bidHoldingSeat, + brandToKeyword.get(brand), + reserveSeat, + brand, + ); + atomicRearrange(zcf, harden(transfers)); + + for (const { seat } of depositsForBrand) { + seat.exit(); + } + + sendToReserve(brandToKeyword.get(brand)); + deposits.set(brand, []); + } + } + }; + + const { augmentPublicFacet, makeFarGovernorFacet, params } = + await handleParamGovernance( + zcf, + privateArgs.initialPoserInvitation, + auctioneerParamTypes, + privateArgs.storageNode, + privateArgs.marshaller, + ); + + const tradeEveryBook = () => { + const offerScalingRatio = makeRatio( + currentDiscountRateBP, + brands.Bid, + BASIS_POINTS, + ); + + for (const book of books.values()) { + book.settleAtNewRate(offerScalingRatio); + } + }; + + const driver = Far('Auctioneer', { + reducePriceAndTrade: () => { + trace('reducePriceAndTrade'); + + natSafeMath.isGTE(currentDiscountRateBP, params.getDiscountStep()) || + Fail`rates must fall ${currentDiscountRateBP}`; + + currentDiscountRateBP = natSafeMath.subtract( + currentDiscountRateBP, + params.getDiscountStep(), + ); + + tradeEveryBook(); + }, + finalize: () => { + trace('finalize'); + + for (const book of books.values()) { + book.endAuction(); + } + distributeProceeds(); + }, + startRound() { + trace('startRound'); + + currentDiscountRateBP = params.getStartingRate(); + for (const book of books.values()) { + book.setStartingRate(makeBPRatio(currentDiscountRateBP, brands.Bid)); + } + + tradeEveryBook(); + }, + capturePrices() { + for (const book of books.values()) { + book.captureOraclePriceForRound(); + } + }, + }); + + // eslint-disable-next-line no-use-before-define + const isActive = () => scheduler.getAuctionState() === AuctionState.ACTIVE; + + /** + * @param {ZCFSeat} zcfSeat + * @param {{ goal: Amount<'nat'> }} offerArgs + */ + const depositOfferHandler = (zcfSeat, offerArgs) => { + const goalMatcher = M.or(undefined, { goal: bidAmountShape }); + mustMatch(offerArgs, harden(goalMatcher)); + const { Collateral: collateralAmount } = zcfSeat.getCurrentAllocation(); + const book = books.get(collateralAmount.brand); + trace(`deposited ${q(collateralAmount)} goal: ${q(offerArgs?.goal)}`); + + book.addAssets(collateralAmount, zcfSeat, offerArgs?.goal); + addDeposit(zcfSeat, collateralAmount, offerArgs?.goal); + return 'deposited'; + }; + + const makeDepositInvitation = () => + zcf.makeInvitation( + depositOfferHandler, + 'deposit Collateral', + undefined, + M.splitRecord({ give: { Collateral: AmountShape } }), + ); + + const biddingProposalShape = M.splitRecord( + { + give: { + Bid: makeNatAmountShape(brands.Bid, MINIMUM_BID_GIVE), + }, + }, + { + maxBuy: M.or({ Collateral: AmountShape }, {}), + exit: FullProposalShape.exit, + }, + ); + + let rejectGetSchedules = false; + const publicFacet = augmentPublicFacet( + harden({ + /** @param {Brand<'nat'>} collateralBrand */ + makeBidInvitation(collateralBrand) { + mustMatch(collateralBrand, BrandShape); + books.has(collateralBrand) || + Fail`No book for brand ${collateralBrand}`; + const offerSpecShape = makeOfferSpecShape(brands.Bid, collateralBrand); + /** + * @param {ZCFSeat} zcfSeat + * @param {import('../../src/auction/auctionBook.js').OfferSpec} offerSpec + */ + const newBidHandler = (zcfSeat, offerSpec) => { + // xxx consider having Zoe guard the offerArgs with a provided shape + mustMatch(offerSpec, offerSpecShape); + const auctionBook = books.get(collateralBrand); + auctionBook.addOffer(offerSpec, zcfSeat, isActive()); + return 'Your bid has been accepted'; + }; + + return zcf.makeInvitation( + newBidHandler, + 'new bidding offer', + {}, + biddingProposalShape, + ); + }, + getSchedules() { + if (rejectGetSchedules === true) { + return Promise.reject(new Error('getSchedules promise has failed')); + } else { + // eslint-disable-next-line no-use-before-define + return scheduler.getSchedule(); + } + }, + setRejectGetSchedules(flag) { + rejectGetSchedules = flag; + }, + getScheduleUpdates() { + return scheduleKit.subscriber; + }, + getBookDataUpdates(brand) { + return books.get(brand).getDataUpdates(); + }, + getPublicTopics(brand) { + if (brand) { + return books.get(brand).getPublicTopics(); + } + + return { + schedule: makeRecorderTopic('Auction schedule', scheduleKit), + }; + }, + makeDepositInvitation, + ...params, + }), + ); + + const scheduler = await E.when(scheduleKit.recorderP, scheduleRecorder => + makeScheduler( + driver, + timer, + // @ts-expect-error types are correct. How to convince TS? + params, + timerBrand, + scheduleRecorder, + publicFacet.getSubscription(), + ), + ); + + const creatorFacet = makeFarGovernorFacet( + Far('Auctioneer creatorFacet', { + /** + * @param {Issuer} issuer + * @param {Keyword} kwd + */ + async addBrand(issuer, kwd) { + zcf.assertUniqueKeyword(kwd); + !baggage.has(kwd) || + Fail`cannot add brand with keyword ${kwd}. it's in use`; + const { brand } = await zcf.saveIssuer(issuer, kwd); + + const bookId = `book${bookCounter}`; + bookCounter += 1; + const bNode = await E(privateArgs.storageNode).makeChildNode(bookId); + + const newBook = await makeAuctionBook( + brands.Bid, + brand, + priceAuthority, + bNode, + ); + + // These three store.init() calls succeed or fail atomically + deposits.init(brand, harden([])); + books.init(brand, newBook); + brandToKeyword.init(brand, kwd); + }, + /** + * @returns {Promise< + * import('../../src/auction/scheduler.js').FullSchedule + * >} + */ + getSchedule() { + return E(scheduler).getSchedule(); + }, + }), + ); + + return { publicFacet, creatorFacet }; +}; + +/** @typedef {ContractOf} AuctioneerContract */ +/** @typedef {AuctioneerContract['publicFacet']} AuctioneerPublicFacet */ +/** @typedef {AuctioneerContract['creatorFacet']} AuctioneerCreatorFacet */ + +export const AuctionPFShape = M.remotable('Auction Public Facet'); diff --git a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js new file mode 100644 index 000000000000..21fe87ea7056 --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js @@ -0,0 +1,568 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { E } from '@endo/eventual-send'; +import { M } from '@endo/patterns'; +import { makeIssuerKit, AssetKind } from '@agoric/ertp'; +import { makeTracer } from '@agoric/internal'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import '../../src/vaultFactory/types.js'; +import '@agoric/zoe/exported.js'; +import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data/src/index.js'; +import { providePriceAuthorityRegistry } from '@agoric/vats/src/priceAuthorityRegistry.js'; +import { makeScriptedPriceAuthority } from '@agoric/zoe/tools/scriptedPriceAuthority.js'; +import * as utils from '@agoric/vats/src/core/utils.js'; +import { makePromiseSpace, makeAgoricNamesAccess } from '@agoric/vats'; +import { makeFakeBoard } from '@agoric/vats/tools/board-utils.js'; +import { produceDiagnostics } from '@agoric/vats/src/core/basic-behaviors.js'; +import { Far } from '@endo/far'; +import { unmarshalFromVstorage } from '@agoric/internal/src/marshal.js'; +import { bindAllMethods } from '@agoric/internal/src/method-tools.js'; +import { defaultMarshaller } from '@agoric/internal/src/storage-test-utils.js'; +import { + isStreamCell, + assertPathSegment, +} from '@agoric/internal/src/lib-chainStorage.js'; +import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import * as cb from '@agoric/internal/src/callback.js'; +import { installPuppetGovernance, produceInstallations } from '../supports.js'; +import { startEconomicCommittee } from '../../src/proposals/startEconCommittee.js'; +import { + SECONDS_PER_WEEK, + setupReserve, + startAuctioneer, +} from '../../src/proposals/econ-behaviors.js'; + +let blockMakeChildNode = ''; + +export const setBlockMakeChildNode = nodeName => { + blockMakeChildNode = nodeName; + return `LOG: blockMakeChildNode set to node ${nodeName}`; +}; + +/** + * This represents a node in an IAVL tree. + * + * The active implementation is x/vstorage, an Agoric extension of the Cosmos + * SDK. + * + * Vstorage is a hierarchical externally-reachable storage structure that + * identifies children by restricted ASCII name and is associated with arbitrary + * string-valued data for each node, defaulting to the empty string. + * + * @typedef {object} StorageNode + * @property {(data: string) => Promise} setValue publishes some data + * @property {() => string} getPath the chain storage path at which the node was + * constructed + * @property {() => Promise} getStoreKey DEPRECATED use getPath + * @property {( + * subPath: string, + * options?: { sequence?: boolean }, + * ) => StorageNode} makeChildNode + */ + +const ChainStorageNodeI = M.interface('StorageNode', { + setValue: M.callWhen(M.string()).returns(), + getPath: M.call().returns(M.string()), + getStoreKey: M.callWhen().returns(M.record()), + makeChildNode: M.call(M.string()) + .optional(M.splitRecord({}, { sequence: M.boolean() }, {})) + .returns(M.or(M.remotable('StorageNode'), M.promise())), +}); + +/** + * Must match the switch in vstorage.go using `vstorageMessage` type + * + * @typedef {| 'get' + * | 'getStoreKey' + * | 'has' + * | 'children' + * | 'entries' + * | 'values' + * | 'size'} StorageGetByPathMessageMethod + * + * @typedef {'set' | 'setWithoutNotify' | 'append'} StorageUpdateEntriesMessageMethod + * + * @typedef {| StorageGetByPathMessageMethod + * | StorageUpdateEntriesMessageMethod} StorageMessageMethod + * + * @typedef {[path: string]} StorageGetByPathMessageArgs + * + * @typedef {[path: string, value?: string | null]} StorageEntry + * + * @typedef {StorageEntry[]} StorageUpdateEntriesMessageArgs + * + * @typedef {| { + * method: StorageGetByPathMessageMethod; + * args: StorageGetByPathMessageArgs; + * } + * | { + * method: StorageUpdateEntriesMessageMethod; + * args: StorageUpdateEntriesMessageArgs; + * }} StorageMessage + */ + +/** @param {import('@agoric/base-zone').Zone} zone */ +const prepareChainStorageNode = zone => { + /** + * Create a storage node for a given backing storage interface and path. + * + * @param {import('@agoric/internal/src/callback.js').Callback< + * (message: StorageMessage) => any + * >} messenger + * a callback for sending a storageMessage object to the storage + * implementation (cf. golang/cosmos/x/vstorage/vstorage.go) + * @param {string} path + * @param {object} [options] + * @param {boolean} [options.sequence] set values with `append` messages + * rather than `set` messages so the backing implementation employs a + * wrapping structure that preserves each value set within a single block. + * Child nodes default to inheriting this option from their parent. + * @returns {StorageNode} + */ + const makeChainStorageNode = zone.exoClass( + 'ChainStorageNode', + ChainStorageNodeI, + /** + * @param {import('@agoric/internal/src/callback.js').Callback< + * (message: StorageMessage) => any + * >} messenger + * @param {string} path + * @param {object} [options] + * @param {boolean} [options.sequence] + */ + (messenger, path, { sequence = false } = {}) => { + assert.typeof(path, 'string'); + assert.typeof(sequence, 'boolean'); + return harden({ path, messenger, sequence }); + }, + { + getPath() { + return this.state.path; + }, + /** + * @deprecated use getPath + * @type {() => Promise} + */ + async getStoreKey() { + const { path, messenger } = this.state; + return cb.callE(messenger, { + method: 'getStoreKey', + args: [path], + }); + }, + + makeChildNode(name, childNodeOptions = {}) { + if (blockMakeChildNode === name) { + console.log(`Log: MOCK makeChildNode REJECTED for node ${name}`); + setBlockMakeChildNode(''); + return Promise.reject(); + } + + const { sequence, path, messenger } = this.state; + assertPathSegment(name); + const mergedOptions = { sequence, ...childNodeOptions }; + return makeChainStorageNode( + messenger, + `${path}.${name}`, + mergedOptions, + ); + }, + /** @type {(value: string) => Promise} */ + async setValue(value) { + const { sequence, path, messenger } = this.state; + assert.typeof(value, 'string'); + /** @type {StorageEntry} */ + let entry; + if (!sequence && !value) { + entry = [path]; + } else { + entry = [path, value]; + } + await cb.callE(messenger, { + method: sequence ? 'append' : 'set', + args: [entry], + }); + }, + // Possible extensions: + // * getValue() + // * getChildNames() and/or makeChildNodes() + // * getName() + // * recursive delete + // * batch operations + // * local buffering (with end-of-block commit) + }, + ); + return makeChainStorageNode; +}; + +const makeHeapChainStorageNode = prepareChainStorageNode(makeHeapZone()); + +/** + * Create a heap-based root storage node for a given backing function and root + * path. + * + * @param {(message: StorageMessage) => any} handleStorageMessage a function for + * sending a storageMessage object to the storage implementation (cf. + * golang/cosmos/x/vstorage/vstorage.go) + * @param {string} rootPath + * @param {object} [rootOptions] + * @param {boolean} [rootOptions.sequence] employ a wrapping structure that + * preserves each value set within a single block, and default child nodes to + * do the same + */ +function makeChainStorageRoot( + handleStorageMessage, + rootPath, + rootOptions = {}, +) { + const messenger = cb.makeFunctionCallback(handleStorageMessage); + + // Use the heapZone directly. + const rootNode = makeHeapChainStorageNode(messenger, rootPath, rootOptions); + return rootNode; +} + +const { Fail } = assert; + +/** + * A map corresponding with a total function such that `get(key)` is assumed to + * always succeed. + * + * @template K, V + * @typedef {{ [k in Exclude, 'get'>]: Map[k] } & { + * get: (key: K) => V; + * }} TotalMap + */ + +/** + * For testing, creates a chainStorage root node over an in-memory map and + * exposes both the map and the sequence of received messages. The `sequence` + * option defaults to true. + * + * @param {string} rootPath + * @param {Parameters[2]} [rootOptions] + */ +const makeFakeStorageKit = (rootPath, rootOptions) => { + const trace = makeTracer('StorTU', false); + const resolvedOptions = { sequence: true, ...rootOptions }; + /** @type {TotalMap} */ + const data = new Map(); + /** @param {string} prefix */ + const getChildEntries = prefix => { + assert(prefix.endsWith('.')); + const childEntries = new Map(); + for (const [path, value] of data.entries()) { + if (!path.startsWith(prefix)) { + continue; + } + const [segment, ...suffix] = path.slice(prefix.length).split('.'); + if (suffix.length === 0) { + childEntries.set(segment, value); + } else if (!childEntries.has(segment)) { + childEntries.set(segment, null); + } + } + return childEntries; + }; + /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageMessage[]} */ + const messages = []; + /** @param {import('@agoric/internal/src/lib-chainStorage.js').StorageMessage} message */ + // eslint-disable-next-line consistent-return + const toStorage = message => { + messages.push(message); + switch (message.method) { + case 'getStoreKey': { + const [key] = message.args; + return { storeName: 'swingset', storeSubkey: `fake:${key}` }; + } + case 'get': { + const [key] = message.args; + return data.has(key) ? data.get(key) : null; + } + case 'children': { + const [key] = message.args; + const childEntries = getChildEntries(`${key}.`); + return [...childEntries.keys()]; + } + case 'entries': { + const [key] = message.args; + const childEntries = getChildEntries(`${key}.`); + return [...childEntries.entries()].map(entry => + entry[1] != null ? entry : [entry[0]], + ); + } + case 'set': + case 'setWithoutNotify': { + trace('toStorage set', message); + /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageEntry[]} */ + const newEntries = message.args; + for (const [key, value] of newEntries) { + if (value != null) { + data.set(key, value); + } else { + data.delete(key); + } + } + break; + } + case 'append': { + trace('toStorage append', message); + /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageEntry[]} */ + const newEntries = message.args; + for (const [key, value] of newEntries) { + value != null || Fail`attempt to append with no value`; + // In the absence of block boundaries, everything goes in a single StreamCell. + const oldVal = data.get(key); + let streamCell; + if (oldVal != null) { + try { + streamCell = JSON.parse(oldVal); + assert(isStreamCell(streamCell)); + } catch (_err) { + streamCell = undefined; + } + } + if (streamCell === undefined) { + streamCell = { + blockHeight: '0', + values: oldVal != null ? [oldVal] : [], + }; + } + streamCell.values.push(value); + data.set(key, JSON.stringify(streamCell)); + } + break; + } + case 'size': + // Intentionally incorrect because it counts non-child descendants, + // but nevertheless supports a "has children" test. + return [...data.keys()].filter(k => k.startsWith(`${message.args[0]}.`)) + .length; + default: + throw Error(`unsupported method: ${message.method}`); + } + }; + const rootNode = makeChainStorageRoot(toStorage, rootPath, resolvedOptions); + return { + rootNode, + // eslint-disable-next-line object-shorthand + data: /** @type {Map} */ (data), + messages, + toStorage, + }; +}; +harden(makeFakeStorageKit); +/** @typedef {ReturnType} FakeStorageKit */ + +const makeMockChainStorageRoot = () => { + const { rootNode, data } = makeFakeStorageKit('mockChainStorageRoot'); + return Far('mockChainStorage', { + ...bindAllMethods(rootNode), + /** + * Defaults to deserializing slot references into plain Remotable objects + * having the specified interface name (as from `Far(iface)`), but can + * accept a different marshaller for producing Remotables that e.g. embed + * the slot string in their iface name. + * + * @param {string} path + * @param {import('@agoric/internal/src/lib-chainStorage.js').Marshaller} marshaller + * @param {number} [index] + * @returns {unknown} + */ + getBody: (path, marshaller = defaultMarshaller, index = -1) => { + data.size || Fail`no data in storage`; + /** + * @type {ReturnType< + * typeof import('@endo/marshal').makeMarshal + * >['fromCapData']} + */ + const fromCapData = (...args) => + Reflect.apply(marshaller.fromCapData, marshaller, args); + return unmarshalFromVstorage(data, path, fromCapData, index); + }, + keys: () => [...data.keys()], + }); +}; +/** @typedef {ReturnType} MockChainStorageRoot */ + +/** + * @param {any} t + * @param {import('@agoric/time').TimerService} [optTimer] + */ +const setupBootstrap = async (t, optTimer) => { + const trace = makeTracer('PromiseSpace', false); + const space = /** @type {any} */ (makePromiseSpace(trace)); + const { produce, consume } = /** + * @type {import('../../src/proposals/econ-behaviors.js').EconomyBootstrapPowers & + * BootstrapPowers} + */ (space); + + await produceDiagnostics(space); + + const timer = optTimer || buildManualTimer(t.log); + produce.chainTimerService.resolve(timer); + // @ts-expect-error + produce.chainStorage.resolve(makeMockChainStorageRoot()); + produce.board.resolve(makeFakeBoard()); + + const { zoe, feeMintAccess, run } = t.context; + produce.zoe.resolve(zoe); + produce.feeMintAccess.resolve(feeMintAccess); + + const { agoricNames, agoricNamesAdmin, spaces } = + await makeAgoricNamesAccess(); + produce.agoricNames.resolve(agoricNames); + produce.agoricNamesAdmin.resolve(agoricNamesAdmin); + + const { brand, issuer } = spaces; + brand.produce.IST.resolve(run.brand); + issuer.produce.IST.resolve(run.issuer); + + return { produce, consume, modules: { utils: { ...utils } }, ...spaces }; +}; + +/** + * @typedef {Record & { + * aeth: IssuerKit & import('../supports.js').AmountUtils; + * run: IssuerKit & import('../supports.js').AmountUtils; + * bundleCache: Awaited< + * ReturnType< + * typeof import('@agoric/swingset-vat/tools/bundleTool.js').unsafeMakeBundleCache + * > + * >; + * rates: VaultManagerParamValues; + * interestTiming: InterestTiming; + * zoe: ZoeService; + * }} Context + */ + +/** + * @param {import('ava').ExecutionContext} t + * @param {IssuerKit<'nat'>} run + * @param {IssuerKit<'nat'>} aeth + * @param {NatValue[] | Ratio} priceOrList + * @param {RelativeTime} quoteInterval + * @param {Amount | undefined} unitAmountIn + * @param {Partial} actionParamArgs + * @param {| { + * btc: any; + * btcPrice: Ratio; + * btcAmountIn: any; + * } + * | undefined} extraAssetKit + */ +export const setupElectorateReserveAndAuction = async ( + t, + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + { + StartFrequency = SECONDS_PER_WEEK, + DiscountStep = 2000n, + LowestRate = 5500n, + ClockStep = 2n, + StartingRate = 10_500n, + AuctionStartDelay = 10n, + PriceLockPeriod = 3n, + }, + extraAssetKit = undefined, +) => { + const { + zoe, + electorateTerms = { committeeName: 'The Cabal', committeeSize: 1 }, + timer, + } = t.context; + + const space = await setupBootstrap(t, timer); + installPuppetGovernance(zoe, space.installation.produce); + produceInstallations(space, t.context.installation); + + await startEconomicCommittee(space, electorateTerms); + await setupReserve(space); + const quoteIssuerKit = makeIssuerKit('quote', AssetKind.SET); + + // priceAuthorityReg is the registry, which contains and multiplexes multiple + // individual priceAuthorities, including aethPriceAuthority. + // priceAuthorityAdmin supports registering more individual priceAuthorities + // with the registry. + /** @type {import('@agoric/zoe/tools/manualPriceAuthority.js').ManualPriceAuthority} */ + // @ts-expect-error scriptedPriceAuthority doesn't actually match this, but manualPriceAuthority does + const aethTestPriceAuthority = Array.isArray(priceOrList) + ? makeScriptedPriceAuthority({ + actualBrandIn: aeth.brand, + actualBrandOut: run.brand, + priceList: priceOrList, + timer, + quoteMint: quoteIssuerKit.mint, + unitAmountIn, + quoteInterval, + }) + : makeManualPriceAuthority({ + actualBrandIn: aeth.brand, + actualBrandOut: run.brand, + initialPrice: priceOrList, + timer, + quoteIssuerKit, + }); + + let abtcTestPriceAuthority; + if (extraAssetKit) { + abtcTestPriceAuthority = Array.isArray(extraAssetKit.btcPrice) + ? makeScriptedPriceAuthority({ + actualBrandIn: extraAssetKit.btc.brand, + actualBrandOut: run.brand, + priceList: extraAssetKit.btcPrice, + timer, + quoteMint: quoteIssuerKit.mint, + unitAmountIn: extraAssetKit.btcAmountIn, + quoteInterval, + }) + : makeManualPriceAuthority({ + actualBrandIn: extraAssetKit.btc.brand, + actualBrandOut: run.brand, + initialPrice: extraAssetKit.btcPrice, + timer, + quoteIssuerKit, + }); + } + + const baggage = makeScalarBigMapStore('baggage'); + const { priceAuthority: priceAuthorityReg, adminFacet: priceAuthorityAdmin } = + providePriceAuthorityRegistry(baggage); + await E(priceAuthorityAdmin).registerPriceAuthority( + aethTestPriceAuthority, + aeth.brand, + run.brand, + ); + + if (extraAssetKit && abtcTestPriceAuthority) { + await E(priceAuthorityAdmin).registerPriceAuthority( + abtcTestPriceAuthority, + extraAssetKit.btc.brand, + run.brand, + ); + } + + space.produce.priceAuthority.resolve(priceAuthorityReg); + + const auctionParams = { + StartFrequency, + ClockStep, + StartingRate, + LowestRate, + DiscountStep, + AuctionStartDelay, + PriceLockPeriod, + }; + + await startAuctioneer(space, { auctionParams }); + return { + space, + priceAuthority: priceAuthorityReg, + priceAuthorityAdmin, + aethTestPriceAuthority, + abtcTestPriceAuthority, + }; +}; diff --git a/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md new file mode 100644 index 000000000000..6e4e42e80bbf --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md @@ -0,0 +1,603 @@ +# Snapshot report for `test/liquidationVisibility/test-liquidationVisibility.js` + +The actual snapshot is saved in `test-liquidationVisibility.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## liq-flow-1 + +> Scenario 1 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 0n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + }, + ], + ], + ], + ] + +## liq-flow-1.1 + +> Scenario 1.1 Liquidation Visibility Snapshot [Aeth] +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 0n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + }, + ], + ], + ], + ] + +> Scenario 1.1 Liquidation Visibility Snapshot [Abtc] +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager1.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aBtc brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aBtc brand {}, + value: 400n, + }, + collateralRemaining: { + brand: Object @Alleged: aBtc brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aBtc brand {}, + value: 400n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 0n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager1.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager1.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aBtc brand {}, + value: 400n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + }, + ], + ], + ], + ] + +## liq-flow-2a + +> Scenario 2 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 5250n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 3185n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 2065n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 5250n, + }, + }, + ], + ], + ], + ] + +## liq-flow-2b + +> Scenario 3 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 12n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 63n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 5n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 8n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 258n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 34n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 66n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [ + [ + 'vault1', + { + Collateral: { + brand: Object @Alleged: aEth brand {}, + value: 43n, + }, + phase: 'active', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 15n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 100n, + }, + }, + ], + [ + 'vault1', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 48n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 158n, + }, + }, + ], + ], + ], + ] + +## liq-result-scenario-1 + +> Scenario 1 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 0n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + }, + ], + ], + ], + ] + +## liq-result-scenario-2 + +> Scenario 2 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 5250n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 3185n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 2065n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 5250n, + }, + }, + ], + ], + ], + ] + +## liq-result-scenario-3 + +> Scenario 3 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 12n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 63n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 5n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 8n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 258n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 34n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 66n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [ + [ + 'vault1', + { + Collateral: { + brand: Object @Alleged: aEth brand {}, + value: 43n, + }, + phase: 'active', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 15n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 100n, + }, + }, + ], + [ + 'vault1', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 48n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 158n, + }, + }, + ], + ], + ], + ] diff --git a/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.snap b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.snap new file mode 100644 index 000000000000..3d8c83a3e2a5 Binary files /dev/null and b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.snap differ diff --git a/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js b/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js new file mode 100644 index 000000000000..74e04ddd5fbd --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js @@ -0,0 +1,1476 @@ +// @ts-nocheck + +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { E } from '@endo/eventual-send'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import { deeplyFulfilled } from '@endo/marshal'; +import { makeTracer } from '@agoric/internal'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import { + makeRatio, + makeRatioFromAmounts, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { documentStorageSchema } from '@agoric/governance/tools/storageDoc.js'; +import { AmountMath } from '@agoric/ertp'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { + defaultParamValues, + legacyOfferResult, +} from '../vaultFactory/vaultFactoryUtils.js'; +import { + SECONDS_PER_HOUR as ONE_HOUR, + SECONDS_PER_DAY as ONE_DAY, + SECONDS_PER_WEEK as ONE_WEEK, +} from '../../src/proposals/econ-behaviors.js'; +import { reserveInitialState } from '../metrics.js'; +import { + bid, + setClockAndAdvanceNTimes, + setupBasics, + setupServices, + startAuctionClock, + openVault, + getMetricTrackers, + adjustVault, + closeVault, + getDataFromVstorage, +} from './tools.js'; +import { + assertBidderPayout, + assertCollateralProceeds, + assertMintedAmount, + assertReserveState, + assertVaultCollateral, + assertVaultCurrentDebt, + assertVaultDebtSnapshot, + assertVaultFactoryRewardAllocation, + assertVaultLocked, + assertVaultSeatExited, + assertVaultState, + assertMintedProceeds, + assertLiqNodeForAuctionCreated, + assertStorageData, + assertVaultNotification, +} from './assertions.js'; +import { Phase } from '../vaultFactory/driver.js'; +import { setBlockMakeChildNode } from './mock-setupChainStorage.js'; + +const trace = makeTracer('TestLiquidationVisibility', false); + +// IST is set as RUN to be able to use ../supports.js methods +test.before(async t => { + const { zoe, feeMintAccessP } = await setUpZoeForTest(); + const feeMintAccess = await feeMintAccessP; + + const contractsWrapper = { + auctioneer: './test/liquidationVisibility/auctioneer-contract-wrapper.js', + }; + + const { run, aeth, abtc, bundleCache, bundles, installation } = + await setupBasics(zoe, contractsWrapper); + + const contextPs = { + zoe, + feeMintAccess, + bundles, + installation, + electorateTerms: undefined, + interestTiming: { + chargingPeriod: 2n, + recordingPeriod: 10n, + }, + minInitialDebt: 50n, + referencedUi: undefined, + rates: defaultParamValues(run.brand), + }; + const frozenCtx = await deeplyFulfilled(harden(contextPs)); + + t.context = { + ...frozenCtx, + bundleCache, + aeth, + abtc, + run, + }; + + trace(t, 'CONTEXT'); +}); + +/* Test liquidation flow 1: + * Auction raises enough IST to cover debt */ +test('liq-flow-1', async t => { + const { zoe, run, aeth } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { vaultFactory, aethCollateralManager }, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + let expectedReserveState = reserveInitialState(run.makeEmpty()); + await assertReserveState(reserveTracker, 'initial', expectedReserveState); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const collateralAmount = aeth.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desired = aeth.make(400n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + vault, + publicNotifiers: { vault: vaultNotifier }, + } = await legacyOfferResult(vaultSeat); + + await assertVaultCurrentDebt(t, vault, wantMinted); + await assertVaultState(t, vaultNotifier, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifier, wantMinted); + await assertMintedAmount(t, vaultSeat, wantMinted); + await assertVaultCollateral(t, vault, 400n, aeth); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + // drop collateral price from 5:1 to 4:1 and liquidate vault + aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + + await assertVaultState(t, vaultNotifier, 'active'); + + const { startTime, time, endTime } = await startAuctionClock( + auctioneerKit, + manualTimer, + ); + let currentTime = time; + + // Check that {timestamp}.vaults.preAuction values are correct before auction is completed + const vstorageDuringLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.not(vstorageDuringLiquidation.length, 0); + const debtDuringLiquidation = await E(vault).getCurrentDebt(); + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount, + debtAmount: debtDuringLiquidation, + }, + ], + ], + }); + + await assertVaultState(t, vaultNotifier, 'liquidating'); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertVaultCurrentDebt(t, vault, wantMinted); + + currentTime = await setClockAndAdvanceNTimes(manualTimer, 2, startTime, 2n); + trace(`advanced time to `, currentTime); + + await assertVaultState(t, vaultNotifier, 'liquidated'); + await assertVaultSeatExited(t, vaultSeat); + await assertVaultLocked(t, vaultNotifier, 0n, aeth); + await assertVaultCurrentDebt(t, vault, 0n); + await assertVaultFactoryRewardAllocation(t, vaultFactory, 80n); + + const closeSeat = await closeVault({ t, vault }); + await E(closeSeat).getOfferResult(); + + await assertCollateralProceeds(t, closeSeat, aeth.makeEmpty(), aeth.issuer); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertBidderPayout(t, bidderSeat, run, 320n, aeth, 400n); + + expectedReserveState = { + allocations: { + Aeth: undefined, + Fee: undefined, + }, + }; + await assertReserveState(reserveTracker, 'like', expectedReserveState); + + // Check that {timestamp}.vaults.postAuction values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.postAuction`, + expected: [], + }); + + // Check that {timestamp}.auctionResult values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.auctionResult`, + expected: { + collateralOffered: collateralAmount, + istTarget: run.make(1680n), + collateralForReserve: aeth.makeEmpty(), + shortfallToReserve: run.makeEmpty(), + mintedProceeds: run.make(1680n), + collateralSold: aeth.make(400n), + collateralRemaining: aeth.makeEmpty(), + endTime, + }, + }); + + // Create snapshot of the storage node + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 1 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}`, + }); +}); + +// assert that vaultId being recorded under liquidations correspond to the correct vaultId under vaults +// test flow with more than one vaultManager +test('liq-flow-1.1', async t => { + const { zoe, run, aeth, abtc } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + true, + ); + + const { + vaultFactory: { + vaultFactory, + aethCollateralManager, + abtcCollateralManager, + }, + aethTestPriceAuthority, + abtcTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker: reserveTrackerAeth } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + let expectedReserveStateAeth = reserveInitialState(run.makeEmpty()); + await assertReserveState( + reserveTrackerAeth, + 'initial', + expectedReserveStateAeth, + ); + + const { reserveTracker: reserveTrackerAbtc } = await getMetricTrackers({ + t, + collateralManager: abtcCollateralManager, + reservePublicFacet, + }); + + let expectedReserveStateAbtc = reserveInitialState(run.makeEmpty()); + await assertReserveState( + reserveTrackerAbtc, + 'initial', + expectedReserveStateAbtc, + ); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + await E(reserveCreatorFacet).addIssuer(abtc.issuer, 'Abtc'); + + const collateralAmountAeth = aeth.make(400n); + const collateralAmountAbtc = abtc.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeatAeth = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount: collateralAmountAeth, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + const vaultSeatAbtc = await openVault({ + t, + cm: abtcCollateralManager, + collateralAmount: collateralAmountAbtc, + colKeyword: 'abtc', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desiredAeth = aeth.make(400n); + const desiredAbtc = abtc.make(400n); + const bidderSeatAeth = await bid( + t, + zoe, + auctioneerKit, + aeth, + bidAmount, + desiredAeth, + ); + const bidderSeatAbtc = await bid( + t, + zoe, + auctioneerKit, + abtc, + bidAmount, + desiredAbtc, + ); + + const { + vault: vaultAeth, + publicNotifiers: { vault: vaultNotifierAeth }, + } = await legacyOfferResult(vaultSeatAeth); + const { + vault: vaultAbtc, + publicNotifiers: { vault: vaultNotifierAbtc }, + } = await legacyOfferResult(vaultSeatAbtc); + + // aeth assertions + await assertVaultCurrentDebt(t, vaultAeth, wantMinted); + await assertVaultState(t, vaultNotifierAeth, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifierAeth, wantMinted); + await assertMintedAmount(t, vaultSeatAeth, wantMinted); + await assertVaultCollateral(t, vaultAeth, 400n, aeth); + + // abtc assertions + await assertVaultCurrentDebt(t, vaultAbtc, wantMinted); + await assertVaultState(t, vaultNotifierAbtc, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifierAbtc, wantMinted); + await assertMintedAmount(t, vaultSeatAbtc, wantMinted); + await assertVaultCollateral(t, vaultAbtc, 400n, abtc); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + // drop collateral price from 5:1 to 4:1 and liquidate vault + aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + abtcTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, abtc.brand)); + + await assertVaultState(t, vaultNotifierAeth, 'active'); + await assertVaultState(t, vaultNotifierAbtc, 'active'); + + const { startTime, time, endTime } = await startAuctionClock( + auctioneerKit, + manualTimer, + ); + let currentTime = time; + + // Check that {timestamp}.vaults.preAuction values are correct before auction is completed + // aeth + const vstorageDuringLiquidationAeth = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.not(vstorageDuringLiquidationAeth.length, 0); + const debtDuringLiquidationAeth = await E(vaultAeth).getCurrentDebt(); + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount: collateralAmountAeth, + debtAmount: debtDuringLiquidationAeth, + }, + ], + ], + }); + + // abtc + const vstorageDuringLiquidationAbtc = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager1.liquidations`, + ); + t.not(vstorageDuringLiquidationAbtc.length, 0); + const debtDuringLiquidationAbtc = await E(vaultAbtc).getCurrentDebt(); + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount: collateralAmountAbtc, + debtAmount: debtDuringLiquidationAbtc, + }, + ], + ], + }); + + // aeth + await assertVaultState(t, vaultNotifierAeth, 'liquidating'); + await assertVaultCollateral(t, vaultAeth, 0n, aeth); + await assertVaultCurrentDebt(t, vaultAeth, wantMinted); + + // abtc + await assertVaultState(t, vaultNotifierAbtc, 'liquidating'); + await assertVaultCollateral(t, vaultAbtc, 0n, abtc); + await assertVaultCurrentDebt(t, vaultAbtc, wantMinted); + + currentTime = await setClockAndAdvanceNTimes(manualTimer, 2, startTime, 2n); + trace(`advanced time to `, currentTime); + + // aeth + await assertVaultState(t, vaultNotifierAeth, 'liquidated'); + await assertVaultSeatExited(t, vaultSeatAeth); + await assertVaultLocked(t, vaultNotifierAeth, 0n, aeth); + await assertVaultCurrentDebt(t, vaultAeth, 0n); + await assertVaultFactoryRewardAllocation(t, vaultFactory, 160n); + + // abtc + await assertVaultState(t, vaultNotifierAbtc, 'liquidated'); + await assertVaultSeatExited(t, vaultSeatAbtc); + await assertVaultLocked(t, vaultNotifierAbtc, 0n, abtc); + await assertVaultCurrentDebt(t, vaultAbtc, 0n); + + const closeSeatAeth = await closeVault({ t, vault: vaultAeth }); + await E(closeSeatAeth).getOfferResult(); + + const closeSeatAbtc = await closeVault({ t, vault: vaultAbtc }); + await E(closeSeatAbtc).getOfferResult(); + + // aeth + await assertCollateralProceeds( + t, + closeSeatAeth, + aeth.makeEmpty(), + aeth.issuer, + ); + await assertVaultCollateral(t, vaultAeth, 0n, aeth); + await assertBidderPayout(t, bidderSeatAeth, run, 320n, aeth, 400n); + + // abtc + await assertCollateralProceeds( + t, + closeSeatAbtc, + abtc.makeEmpty(), + abtc.issuer, + ); + await assertVaultCollateral(t, vaultAbtc, 0n, abtc); + await assertBidderPayout(t, bidderSeatAbtc, run, 320n, abtc, 400n); + + expectedReserveStateAeth = { + allocations: { + Aeth: undefined, + Fee: undefined, + }, + }; + await assertReserveState( + reserveTrackerAeth, + 'like', + expectedReserveStateAeth, + ); + + expectedReserveStateAbtc = { + allocations: { + Abtc: undefined, + Fee: undefined, + }, + }; + await assertReserveState( + reserveTrackerAbtc, + 'like', + expectedReserveStateAbtc, + ); + + // Check that {timestamp}.vaults.postAuction values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount: collateralAmountAeth, + debtAmount: debtDuringLiquidationAeth, + }, + ], + ], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount: collateralAmountAbtc, + debtAmount: debtDuringLiquidationAbtc, + }, + ], + ], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.postAuction`, + expected: [], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}.vaults.postAuction`, + expected: [], + }); + + // Check that {timestamp}.auctionResult values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.auctionResult`, + expected: { + collateralOffered: collateralAmountAeth, + istTarget: run.make(1680n), + collateralForReserve: aeth.makeEmpty(), + shortfallToReserve: run.makeEmpty(), + mintedProceeds: run.make(1680n), + collateralSold: aeth.make(400n), + collateralRemaining: aeth.makeEmpty(), + endTime, + }, + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}.auctionResult`, + expected: { + collateralOffered: collateralAmountAbtc, + istTarget: run.make(1680n), + collateralForReserve: abtc.makeEmpty(), + shortfallToReserve: run.makeEmpty(), + mintedProceeds: run.make(1680n), + collateralSold: abtc.make(400n), + collateralRemaining: abtc.makeEmpty(), + endTime, + }, + }); + + // Create snapshot of the storage node + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 1.1 Liquidation Visibility Snapshot [Aeth]', + node: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}`, + }); + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 1.1 Liquidation Visibility Snapshot [Abtc]', + node: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}`, + }); +}); + +/* Test liquidation flow 2a: + * Auction does not raise enough to cover IST debt; + * All collateral sold and debt is not covered. */ +test('liq-flow-2a', async t => { + const { zoe, aeth, run, rates: defaultRates } = t.context; + + // Add a vaultManager with 10000 aeth collateral at a 200 aeth/Minted rate + const rates = harden({ + ...defaultRates, + // charge 40% interest / year + interestRate: run.makeRatio(40n), + liquidationMargin: run.makeRatio(130n), + }); + t.context.rates = rates; + + // Interest is charged daily, and auctions are every week + t.context.interestTiming = { + chargingPeriod: ONE_DAY, + recordingPeriod: ONE_DAY, + }; + + const manualTimer = buildManualTimer(); + const services = await setupServices( + t, + makeRatio(100n, run.brand, 10n, aeth.brand), + aeth.make(1n), + manualTimer, + ONE_WEEK, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { aethCollateralManager }, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const { reserveTracker, collateralManagerTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + await assertReserveState( + reserveTracker, + 'initial', + reserveInitialState(run.makeEmpty()), + ); + let shortfallBalance = 0n; + + await collateralManagerTracker.assertInitial({ + // present + numActiveVaults: 0, + numLiquidatingVaults: 0, + totalCollateral: aeth.make(0n), + totalDebt: run.make(0n), + retainedCollateral: aeth.make(0n), + + // running + numLiquidationsCompleted: 0, + numLiquidationsAborted: 0, + totalOverageReceived: run.make(0n), + totalProceedsReceived: run.make(0n), + totalCollateralSold: aeth.make(0n), + liquidatingCollateral: aeth.make(0n), + liquidatingDebt: run.make(0n), + totalShortfallReceived: run.make(0n), + lockedQuote: null, + }); + + // Create a loan for Alice for 5000 Minted with 1000 aeth collateral + // ratio is 4:1 + const aliceCollateralAmount = aeth.make(1000n); + const aliceWantMinted = run.make(5000n); + /** @type {UserSeat} */ + const aliceVaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount: aliceCollateralAmount, + wantMintedAmount: aliceWantMinted, + colKeyword: 'aeth', + }); + const { + vault: aliceVault, + publicNotifiers: { vault: aliceNotifier }, + } = await legacyOfferResult(aliceVaultSeat); + + await assertVaultCurrentDebt(t, aliceVault, aliceWantMinted); + await assertMintedProceeds(t, aliceVaultSeat, aliceWantMinted); + await assertVaultDebtSnapshot(t, aliceNotifier, aliceWantMinted); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 1, + totalCollateral: { value: 1000n }, + totalDebt: { value: 5250n }, + }); + + // reduce collateral + trace(t, 'alice reduce collateral'); + + // Alice reduce collateral by 300. That leaves her at 700 * 10 > 1.05 * 5000. + // Prices will drop from 10 to 7, she'll be liquidated: 700 * 7 < 1.05 * 5000. + const collateralDecrement = aeth.make(300n); + const aliceReduceCollateralSeat = await adjustVault({ + t, + vault: aliceVault, + proposal: { + want: { Collateral: collateralDecrement }, + }, + }); + await E(aliceReduceCollateralSeat).getOfferResult(); + + trace('alice '); + await assertCollateralProceeds( + t, + aliceReduceCollateralSeat, + aeth.make(300n), + aeth.issuer, + ); + + await assertVaultDebtSnapshot(t, aliceNotifier, aliceWantMinted); + trace(t, 'alice reduce collateral'); + await collateralManagerTracker.assertChange({ + totalCollateral: { value: 700n }, + }); + + await assertLiqNodeForAuctionCreated({ + t, + rootNode: chainStorage, + auctioneerPF: auctioneerKit.publicFacet, + }); + + await E(aethTestPriceAuthority).setPrice( + makeRatio(70n, run.brand, 10n, aeth.brand), + ); + trace(t, 'changed price to 7 RUN/Aeth'); + + // A bidder places a bid + const bidAmount = run.make(3300n); + const desired = aeth.make(700n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + startTime: start1, + time: now1, + endTime, + } = await startAuctionClock(auctioneerKit, manualTimer); + + let currentTime = now1; + + await collateralManagerTracker.assertChange({ + lockedQuote: makeRatioFromAmounts( + aeth.make(1_000_000n), + run.make(7_000_000n), + ), + }); + + // expect Alice to be liquidated because her collateral is too low. + await assertVaultState(t, aliceNotifier, Phase.LIQUIDATING); + + // Check vaults.preAuction here + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}.vaults.preAuction`, // now1 is the nominal start time + expected: [ + [ + 'vault0', + { + collateralAmount: aeth.make(700n), + debtAmount: await E(aliceVault).getCurrentDebt(), + }, + ], + ], + }); + + currentTime = await setClockAndAdvanceNTimes(manualTimer, 2, start1, 2n); + + await assertVaultState(t, aliceNotifier, Phase.LIQUIDATED); + trace(t, 'alice liquidated', currentTime); + await collateralManagerTracker.assertChange({ + numActiveVaults: 0, + numLiquidatingVaults: 1, + liquidatingCollateral: { value: 700n }, + liquidatingDebt: { value: 5250n }, + lockedQuote: null, + }); + + shortfallBalance += 2065n; + await reserveTracker.assertChange({ + shortfallBalance: { value: shortfallBalance }, + }); + + await collateralManagerTracker.assertChange({ + liquidatingDebt: { value: 0n }, + liquidatingCollateral: { value: 0n }, + totalCollateral: { value: 0n }, + totalDebt: { value: 0n }, + numLiquidatingVaults: 0, + numLiquidationsCompleted: 1, + totalCollateralSold: { value: 700n }, + totalProceedsReceived: { value: 3185n }, + totalShortfallReceived: { value: shortfallBalance }, + }); + + // Bidder bought 800 Aeth + await assertBidderPayout(t, bidderSeat, run, 115n, aeth, 700n); + + // Check vaults.postAuction and auctionResults here + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}.vaults.postAuction`, // now1 is the nominal start time + expected: [], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}.auctionResult`, // now1 is the nominal start time + expected: { + collateralOffered: aeth.make(700n), + istTarget: run.make(5250n), + collateralForReserve: aeth.makeEmpty(), + shortfallToReserve: run.make(2065n), + mintedProceeds: run.make(3185n), + collateralSold: aeth.make(700n), + collateralRemaining: aeth.makeEmpty(), + endTime, + }, + }); + + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 2 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}`, + }); +}); + +/* Test liquidation flow 2b: + * Auction does not raise enough to cover IST debt; + * Collateral remains but debt is still not covered by IST raised by auction end */ +test('liq-flow-2b', async t => { + const { zoe, aeth, run, rates: defaultRates } = t.context; + + const rates = harden({ + ...defaultRates, + interestRate: run.makeRatio(0n), + liquidationMargin: run.makeRatio(150n), + }); + t.context.rates = rates; + + const manualTimer = buildManualTimer(); + const services = await setupServices( + t, + makeRatio(1500n, run.brand, 100n, aeth.brand), + aeth.make(1n), + manualTimer, + ONE_WEEK, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { aethCollateralManager }, + auctioneerKit, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + chainStorage, + } = services; + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const { reserveTracker, collateralManagerTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + await collateralManagerTracker.assertInitial({ + // present + numActiveVaults: 0, + numLiquidatingVaults: 0, + totalCollateral: aeth.make(0n), + totalDebt: run.make(0n), + retainedCollateral: aeth.make(0n), + + // running + numLiquidationsCompleted: 0, + numLiquidationsAborted: 0, + totalOverageReceived: run.make(0n), + totalProceedsReceived: run.make(0n), + totalCollateralSold: aeth.make(0n), + liquidatingCollateral: aeth.make(0n), + liquidatingDebt: run.make(0n), + totalShortfallReceived: run.make(0n), + lockedQuote: null, + }); + + // Create a loan for Alice of 95 with 5% fee produces a debt of 100. + const aliceCollateralAmount = aeth.make(15n); + const aliceWantMinted = run.make(95n); + /** @type {UserSeat} */ + const aliceVaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount: aliceCollateralAmount, + colKeyword: 'aeth', + wantMintedAmount: aliceWantMinted, + }); + const { + vault: aliceVault, + publicNotifiers: { vault: aliceNotifier }, + } = await legacyOfferResult(aliceVaultSeat); + + await assertVaultCurrentDebt(t, aliceVault, aliceWantMinted); + await assertMintedProceeds(t, aliceVaultSeat, aliceWantMinted); + + await assertVaultDebtSnapshot(t, aliceNotifier, aliceWantMinted); + await assertVaultState(t, aliceNotifier, Phase.ACTIVE); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 1, + totalDebt: { value: 100n }, + totalCollateral: { value: 15n }, + }); + + // BOB takes out a loan + const bobCollateralAmount = aeth.make(48n); + const bobWantMinted = run.make(150n); + /** @type {UserSeat} */ + const bobVaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount: bobCollateralAmount, + colKeyword: 'aeth', + wantMintedAmount: bobWantMinted, + }); + const { + vault: bobVault, + publicNotifiers: { vault: bobNotifier }, + } = await legacyOfferResult(bobVaultSeat); + + await assertVaultCurrentDebt(t, bobVault, bobWantMinted); + await assertMintedProceeds(t, bobVaultSeat, bobWantMinted); + + await assertVaultDebtSnapshot(t, bobNotifier, bobWantMinted); + await assertVaultState(t, bobNotifier, Phase.ACTIVE); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 2, + totalDebt: { value: 258n }, + totalCollateral: { value: 63n }, + }); + + // A bidder places a bid + const bidAmount = run.make(100n); + const desired = aeth.make(8n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + // price falls + await aethTestPriceAuthority.setPrice( + makeRatio(400n, run.brand, 100n, aeth.brand), + ); + await eventLoopIteration(); + + // Assert node not created + await assertLiqNodeForAuctionCreated({ + t, + rootNode: chainStorage, + auctioneerPF: auctioneerKit.publicFacet, + }); + + const { startTime, time, endTime } = await startAuctionClock( + auctioneerKit, + manualTimer, + ); + + await assertVaultState(t, aliceNotifier, Phase.LIQUIDATING); + await assertVaultState(t, bobNotifier, Phase.LIQUIDATING); + + // Check vaults.preAuction here + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, // time is the nominal start time + expected: [ + [ + 'vault0', // Alice's vault + { + collateralAmount: aliceCollateralAmount, + debtAmount: await E(aliceVault).getCurrentDebt(), + }, + ], + [ + 'vault1', // Bob's vault + { + collateralAmount: bobCollateralAmount, + debtAmount: await E(bobVault).getCurrentDebt(), + }, + ], + ], + }); + + await collateralManagerTracker.assertChange({ + lockedQuote: makeRatioFromAmounts( + aeth.make(1_000_000n), + run.make(4_000_000n), + ), + }); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 0, + liquidatingDebt: { value: 258n }, + liquidatingCollateral: { value: 63n }, + numLiquidatingVaults: 2, + lockedQuote: null, + }); + + await setClockAndAdvanceNTimes(manualTimer, 2n, startTime, 2n); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 1, + liquidatingDebt: { value: 0n }, + liquidatingCollateral: { value: 0n }, + totalDebt: { value: 158n }, + totalCollateral: { value: 44n }, + totalProceedsReceived: { value: 34n }, + totalShortfallReceived: { value: 66n }, + totalCollateralSold: { value: 8n }, + numLiquidatingVaults: 0, + numLiquidationsCompleted: 1, + numLiquidationsAborted: 1, + }); + + await assertVaultNotification({ + t, + notifier: aliceNotifier, + expected: { + vaultState: Phase.LIQUIDATED, + locked: aeth.makeEmpty(), + }, + }); + + // Reduce Bob's collateral by liquidation penalty + // bob's share is 7 * 158/258, which rounds up to 5 + const recoveredBobCollateral = AmountMath.subtract( + bobCollateralAmount, + aeth.make(5n), + ); + + await assertVaultNotification({ + t, + notifier: bobNotifier, + expected: { + vaultState: Phase.ACTIVE, + locked: recoveredBobCollateral, + debtSnapshot: { debt: run.make(158n) }, + }, + }); + + await assertBidderPayout(t, bidderSeat, run, 66n, aeth, 8n); + + await assertReserveState(reserveTracker, 'like', { + allocations: { + Aeth: aeth.make(12n), + Fee: undefined, + }, + }); + + // Check vaults.postAuction and auctionResults here + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.postAuction`, // time is the nominal start time + expected: [ + [ + 'vault1', // Bob got reinstated + { + Collateral: recoveredBobCollateral, + phase: Phase.ACTIVE, + }, + ], + ], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.auctionResult`, // now1 is the nominal start time + expected: { + collateralOffered: aeth.make(63n), + istTarget: run.make(258n), + collateralForReserve: aeth.make(12n), + shortfallToReserve: run.make(66n), + mintedProceeds: run.make(34n), + collateralSold: aeth.make(8n), + collateralRemaining: aeth.make(5n), + endTime, + }, + }); + + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 3 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}`, + }); +}); + +/* Auction starts with no liquidatable vaults + * In this scenario, no child node of liquidation should be created */ +test('liq-no-vaults', async t => { + const { zoe, run, aeth } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { aethCollateralManager }, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + const expectedReserveState = reserveInitialState(run.makeEmpty()); + await assertReserveState(reserveTracker, 'initial', expectedReserveState); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const collateralAmount = aeth.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desired = aeth.make(400n); + await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + vault, + publicNotifiers: { vault: vaultNotifier }, + } = await legacyOfferResult(vaultSeat); + + await assertVaultCurrentDebt(t, vault, wantMinted); + await assertVaultState(t, vaultNotifier, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifier, wantMinted); + await assertMintedAmount(t, vaultSeat, wantMinted); + await assertVaultCollateral(t, vault, 400n, aeth); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + // the auction will start but no vault will be liquidated + await startAuctionClock(auctioneerKit, manualTimer); + await assertVaultState(t, vaultNotifier, 'active'); + + // Check that no child node with auction start time's name created after the auction started + const vstorageDuringLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageDuringLiquidation.length, 0); +}); + +/* The auctionSchedule returned schedulesP will be a rejected promise + * In this scenario, the state of auctionResult node should have endTime as undefined */ +test('liq-rejected-schedule', async t => { + const { zoe, run, aeth } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { vaultFactory, aethCollateralManager }, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + let expectedReserveState = reserveInitialState(run.makeEmpty()); + await assertReserveState(reserveTracker, 'initial', expectedReserveState); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const collateralAmount = aeth.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desired = aeth.make(400n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + vault, + publicNotifiers: { vault: vaultNotifier }, + } = await legacyOfferResult(vaultSeat); + + await assertVaultCurrentDebt(t, vault, wantMinted); + await assertVaultState(t, vaultNotifier, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifier, wantMinted); + await assertMintedAmount(t, vaultSeat, wantMinted); + await assertVaultCollateral(t, vault, 400n, aeth); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + // drop collateral price from 5:1 to 4:1 and liquidate vault + aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + + await assertVaultState(t, vaultNotifier, 'active'); + + await E(auctioneerKit.publicFacet).setRejectGetSchedules(true); + + const { startTime, time } = await startAuctionClock( + auctioneerKit, + manualTimer, + ); + let currentTime = time; + + // Check that {timestamp}.vaults.preAuction values are correct before auction is completed + const vstorageDuringLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.not(vstorageDuringLiquidation.length, 0); + const debtDuringLiquidation = await E(vault).getCurrentDebt(); + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount, + debtAmount: debtDuringLiquidation, + }, + ], + ], + }); + + await assertVaultState(t, vaultNotifier, 'liquidating'); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertVaultCurrentDebt(t, vault, wantMinted); + + await E(auctioneerKit.publicFacet).setRejectGetSchedules(false); + + currentTime = await setClockAndAdvanceNTimes(manualTimer, 2, startTime, 2n); + trace(`advanced time to `, currentTime); + + await assertVaultState(t, vaultNotifier, 'liquidated'); + await assertVaultSeatExited(t, vaultSeat); + await assertVaultLocked(t, vaultNotifier, 0n, aeth); + await assertVaultCurrentDebt(t, vault, 0n); + await assertVaultFactoryRewardAllocation(t, vaultFactory, 80n); + + const closeSeat = await closeVault({ t, vault }); + await E(closeSeat).getOfferResult(); + + await assertCollateralProceeds(t, closeSeat, aeth.makeEmpty(), aeth.issuer); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertBidderPayout(t, bidderSeat, run, 320n, aeth, 400n); + + expectedReserveState = { + allocations: { + Aeth: undefined, + Fee: undefined, + }, + }; + await assertReserveState(reserveTracker, 'like', expectedReserveState); + + // Check that {timestamp}.vaults.postAuction values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount, + debtAmount: debtDuringLiquidation, + }, + ], + ], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.postAuction`, + expected: [], + }); + + // Check that {timestamp}.auctionResult values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.auctionResult`, + expected: { + collateralOffered: collateralAmount, + istTarget: run.make(1680n), + collateralForReserve: aeth.makeEmpty(), + shortfallToReserve: run.makeEmpty(), + mintedProceeds: run.make(1680n), + collateralSold: aeth.make(400n), + collateralRemaining: aeth.makeEmpty(), + endTime: undefined, + }, + }); +}); + +/* The timestampStorageNode returned makeChildNode will be a rejected promise + * In this scenario, the error should be handled and printed its message */ +test('liq-rejected-timestampStorageNode', async t => { + const { zoe, run, aeth } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { vaultFactory, aethCollateralManager }, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const collateralAmount = aeth.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desired = aeth.make(400n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + vault, + publicNotifiers: { vault: vaultNotifier }, + } = await legacyOfferResult(vaultSeat); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + setBlockMakeChildNode('3600'); + + // drop collateral price from 5:1 to 4:1 and liquidate vault + aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + + const { startTime } = await startAuctionClock(auctioneerKit, manualTimer); + + // Check that no child node with auction start time's name created after the liquidation + const vstorageDuringLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageDuringLiquidation.length, 0); + + await assertVaultState(t, vaultNotifier, 'liquidating'); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertVaultCurrentDebt(t, vault, wantMinted); + + const currentTime = await setClockAndAdvanceNTimes( + manualTimer, + 2, + startTime, + 2n, + ); + trace(`advanced time to `, currentTime); + + await assertVaultState(t, vaultNotifier, 'liquidated'); + await assertVaultSeatExited(t, vaultSeat); + await assertVaultLocked(t, vaultNotifier, 0n, aeth); + await assertVaultCurrentDebt(t, vault, 0n); + await assertVaultFactoryRewardAllocation(t, vaultFactory, 80n); + + const closeSeat = await closeVault({ t, vault }); + await E(closeSeat).getOfferResult(); + + await assertCollateralProceeds(t, closeSeat, aeth.makeEmpty(), aeth.issuer); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertBidderPayout(t, bidderSeat, run, 320n, aeth, 400n); + + await assertReserveState(reserveTracker, 'like', { + allocations: { + Aeth: undefined, + Fee: undefined, + }, + }); +}); diff --git a/packages/inter-protocol/test/liquidationVisibility/test-visibilityAssertions.js b/packages/inter-protocol/test/liquidationVisibility/test-visibilityAssertions.js new file mode 100644 index 000000000000..02d58055885b --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/test-visibilityAssertions.js @@ -0,0 +1,92 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { E, Far } from '@endo/far'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { defaultMarshaller } from '@agoric/internal/src/storage-test-utils.js'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { makeMockChainStorageRoot } from '../supports.js'; +import { assertNodeInStorage, assertStorageData } from './assertions.js'; + +const writeToStorage = async (storageNode, data) => { + await E(storageNode).setValue( + JSON.stringify(defaultMarshaller.toCapData(harden(data))), + ); +}; + +test('storage-node-created', async t => { + const storageRoot = makeMockChainStorageRoot(); + + await assertNodeInStorage({ + t, + rootNode: storageRoot, + desiredNode: 'test', + expected: false, + }); + + const testNode = await E(storageRoot).makeChildNode('test'); + await writeToStorage(testNode, { dummy: 'foo' }); + + await assertNodeInStorage({ + t, + rootNode: storageRoot, + desiredNode: 'test', + expected: true, + }); +}); + +test('storage-assert-data', async t => { + const storageRoot = makeMockChainStorageRoot(); + const moolaKit = makeIssuerKit('Moola'); + + const testNode = await E(storageRoot).makeChildNode('dummyNode'); + await writeToStorage(testNode, { + moolaForReserve: AmountMath.make(moolaKit.brand, 100n), + }); + + await assertStorageData({ + t, + path: 'dummyNode', + storageRoot, + expected: { moolaForReserve: AmountMath.make(moolaKit.brand, 100n) }, + }); +}); + +test('map-test-auction', async t => { + const vaultData = makeScalarBigMapStore('Vaults'); + + vaultData.init( + Far('key', { getId: () => 1, getPhase: () => 'liquidated' }), + harden({ + collateral: 19n, + debt: 18n, + }), + ); + vaultData.init( + Far('key1', { getId: () => 2, getPhase: () => 'liquidated' }), + harden({ + collateral: 19n, + debt: 18n, + }), + ); + vaultData.init( + Far('key2', { getId: () => 3, getPhase: () => 'liquidated' }), + harden({ + collateral: 19n, + debt: 18n, + }), + ); + vaultData.init( + Far('key3', { getId: () => 4, getPhase: () => 'liquidated' }), + harden({ + collateral: 19n, + debt: 18n, + }), + ); + + const preAuction = [...vaultData.entries()].map(([vault, data]) => [ + vault.getId(), + { ...data, phase: vault.getPhase() }, + ]); + t.log(preAuction); + + t.pass(); +}); diff --git a/packages/inter-protocol/test/liquidationVisibility/tools.js b/packages/inter-protocol/test/liquidationVisibility/tools.js new file mode 100644 index 000000000000..7ac97e98602a --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/tools.js @@ -0,0 +1,480 @@ +import { E } from '@endo/eventual-send'; +import { makeIssuerKit } from '@agoric/ertp'; +import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; +import { allValues, makeTracer, objectMap } from '@agoric/internal'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import { + makeRatio, + makeRatioFromAmounts, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { TimeMath } from '@agoric/time'; +import { subscribeEach } from '@agoric/notifier'; +import '../../src/vaultFactory/types.js'; +import { withAmountUtils } from '../supports.js'; +import { getRunFromFaucet } from '../vaultFactory/vaultFactoryUtils.js'; +import { subscriptionTracker, vaultManagerMetricsTracker } from '../metrics.js'; +import { startVaultFactory } from '../../src/proposals/econ-behaviors.js'; +import { setupElectorateReserveAndAuction } from './mock-setupChainStorage.js'; + +export const BASIS_POINTS = 10000n; + +let contractRoots = { + faucet: './test/vaultFactory/faucet.js', + VaultFactory: './src/vaultFactory/vaultFactory.js', + reserve: './src/reserve/assetReserve.js', + auctioneer: './src/auction/auctioneer.js', +}; + +const trace = makeTracer('VisibilityTools', true); + +export const setupBasics = async (zoe, contractsWrapper) => { + const stableIssuer = await E(zoe).getFeeIssuer(); + const stableBrand = await E(stableIssuer).getBrand(); + + // @ts-expect-error missing mint + const run = withAmountUtils({ issuer: stableIssuer, brand: stableBrand }); + const aeth = withAmountUtils( + makeIssuerKit('aEth', 'nat', { decimalPlaces: 6 }), + ); + const abtc = withAmountUtils( + makeIssuerKit('aBtc', 'nat', { decimalPlaces: 6 }), + ); + + if (contractsWrapper) { + contractRoots = { ...contractRoots, ...contractsWrapper }; + } + + const bundleCache = await unsafeMakeBundleCache('./bundles/'); + const bundles = await allValues({ + faucet: bundleCache.load(contractRoots.faucet, 'faucet'), + VaultFactory: bundleCache.load(contractRoots.VaultFactory, 'VaultFactory'), + reserve: bundleCache.load(contractRoots.reserve, 'reserve'), + auctioneer: bundleCache.load(contractRoots.auctioneer, 'auction'), + }); + const installation = objectMap(bundles, bundle => E(zoe).install(bundle)); + + return { + run, + aeth, + abtc, + bundleCache, + bundles, + installation, + }; +}; + +/** + * @typedef {Record & { + * aeth: IssuerKit & import('../supports.js').AmountUtils; + * run: IssuerKit & import('../supports.js').AmountUtils; + * bundleCache: Awaited>; + * rates: VaultManagerParamValues; + * interestTiming: InterestTiming; + * zoe: ZoeService; + * }} Context + */ + +/** + * NOTE: called separately by each test so zoe/priceAuthority don't interfere + * This helper function will economicCommittee, reserve and auctioneer. It will + * start the vaultFactory and open a new vault with the collateral provided in + * the context. The collateral value will be set by the priceAuthority with the + * ratio provided by priceOrList + * + * @param {import('ava').ExecutionContext} t + * @param {NatValue[] | Ratio} priceOrList + * @param {Amount | undefined} unitAmountIn + * @param {import('@agoric/time').TimerService} timer + * @param {RelativeTime} quoteInterval + * @param {Partial} [auctionParams] + * @param setupExtraAsset + */ +export const setupServices = async ( + t, + priceOrList, + unitAmountIn, + timer = buildManualTimer(), + quoteInterval = 1n, + auctionParams = {}, + setupExtraAsset = false, +) => { + const { + zoe, + run, + aeth, + abtc, + interestTiming, + minInitialDebt, + referencedUi, + rates, + } = t.context; + + t.context.timer = timer; + + const btcKit = setupExtraAsset + ? { + btc: abtc, + btcPrice: makeRatio(50n, run.brand, 10n, abtc.brand), + btcAmountIn: abtc.make(400n), + } + : undefined; + + const { + space, + priceAuthorityAdmin, + aethTestPriceAuthority, + abtcTestPriceAuthority, + } = await setupElectorateReserveAndAuction( + t, + // @ts-expect-error inconsistent types with withAmountUtils + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + auctionParams, + btcKit, + ); + + const { + consume, + installation: { produce: iProduce }, + } = space; + + iProduce.VaultFactory.resolve(t.context.installation.VaultFactory); + iProduce.liquidate.resolve(t.context.installation.liquidate); + + await startVaultFactory( + space, + { interestTiming, options: { referencedUi } }, + minInitialDebt, + ); + + const governorCreatorFacet = E.get( + consume.vaultFactoryKit, + ).governorCreatorFacet; + const vaultFactoryCreatorFacetP = E.get(consume.vaultFactoryKit).creatorFacet; + + const reserveCreatorFacet = E.get(consume.reserveKit).creatorFacet; + const reservePublicFacet = E.get(consume.reserveKit).publicFacet; + const reserveKit = { reserveCreatorFacet, reservePublicFacet }; + + const aethVaultManagerP = E(vaultFactoryCreatorFacetP).addVaultType( + aeth.issuer, + 'AEth', + rates, + ); + + let abtcVaultManagerP; + if (setupExtraAsset) { + await eventLoopIteration(); + abtcVaultManagerP = E(vaultFactoryCreatorFacetP).addVaultType( + abtc.issuer, + 'ABtc', + rates, + ); + } + + /** @typedef {import('../../src/proposals/econ-behaviors.js').AuctioneerKit} AuctioneerKit */ + /** @typedef {import('@agoric/zoe/tools/manualPriceAuthority.js').ManualPriceAuthority} ManualPriceAuthority */ + /** @typedef {import('../../src/vaultFactory/vaultFactory.js').VaultFactoryContract} VFC */ + /** + * @type {[ + * any, + * VaultFactoryCreatorFacet, + * VFC['publicFacet'], + * VaultManager, + * VaultManager | undefined, + * AuctioneerKit, + * ManualPriceAuthority, + * CollateralManager, + * CollateralManager | undefined, + * chainStorage, + * board, + * ]} + */ + const [ + governorInstance, + vaultFactory, // creator + vfPublic, + aethVaultManager, + abtcVaultManager, + auctioneerKit, + priceAuthority, + aethCollateralManager, + abtcCollateralManager, + chainStorage, + board, + ] = await Promise.all([ + E(consume.agoricNames).lookup('instance', 'VaultFactoryGovernor'), + vaultFactoryCreatorFacetP, + E.get(consume.vaultFactoryKit).publicFacet, + aethVaultManagerP, + abtcVaultManagerP || Promise.resolve(undefined), + consume.auctioneerKit, + /** @type {Promise} */ (consume.priceAuthority), + E(aethVaultManagerP).getPublicFacet(), + abtcVaultManagerP + ? E(abtcVaultManagerP).getPublicFacet() + : Promise.resolve(undefined), + consume.chainStorage, + consume.board, + ]); + trace(t, 'pa', { + governorInstance, + vaultFactory, + vfPublic, + priceAuthority: !!priceAuthority, + }); + + const { g, v } = { + g: { + governorInstance, + governorPublicFacet: E(zoe).getPublicFacet(governorInstance), + governorCreatorFacet, + }, + v: { + vaultFactory, + vfPublic, + aethVaultManager, + aethCollateralManager, + abtcVaultManager, + abtcCollateralManager, + }, + }; + + await E(auctioneerKit.creatorFacet).addBrand(aeth.issuer, 'Aeth'); + if (setupExtraAsset) { + await E(auctioneerKit.creatorFacet).addBrand(abtc.issuer, 'ABtc'); + } + + return { + zoe, + timer, + space, + governor: g, + vaultFactory: v, + runKit: { issuer: run.issuer, brand: run.brand }, + priceAuthority, + reserveKit, + auctioneerKit, + priceAuthorityAdmin, + aethTestPriceAuthority, + abtcTestPriceAuthority, + chainStorage, + board, + }; +}; + +export const setClockAndAdvanceNTimes = async ( + timer, + times, + start, + incr = 1n, +) => { + let currentTime = start; + // first time through is at START, then n TIMES more plus INCR + for (let i = 0; i <= times; i += 1) { + await timer.advanceTo(TimeMath.absValue(currentTime)); + await eventLoopIteration(); + currentTime = TimeMath.addAbsRel(currentTime, TimeMath.relValue(incr)); + } + return currentTime; +}; + +// Calculate the nominalStart time (when liquidations happen), and the priceLock +// time (when prices are locked). Advance the clock to the priceLock time, then +// to the nominal start time. return the nominal start time and the auction +// start time, so the caller can check on liquidations in process before +// advancing the clock. +export const startAuctionClock = async (auctioneerKit, manualTimer) => { + const schedule = await E(auctioneerKit.creatorFacet).getSchedule(); + const priceDelay = await E(auctioneerKit.publicFacet).getPriceLockPeriod(); + const { startTime, startDelay, endTime } = schedule.nextAuctionSchedule; + const nominalStart = TimeMath.subtractAbsRel(startTime, startDelay); + const priceLockTime = TimeMath.subtractAbsRel(nominalStart, priceDelay); + await manualTimer.advanceTo(TimeMath.absValue(priceLockTime)); + await eventLoopIteration(); + + await manualTimer.advanceTo(TimeMath.absValue(nominalStart)); + await eventLoopIteration(); + return { startTime, time: nominalStart, endTime }; +}; + +export const bid = async (t, zoe, auctioneerKit, aeth, bidAmount, desired) => { + const bidderSeat = await E(zoe).offer( + E(auctioneerKit.publicFacet).makeBidInvitation(aeth.brand), + harden({ give: { Bid: bidAmount } }), + harden({ Bid: getRunFromFaucet(t, bidAmount.value) }), + { maxBuy: desired, offerPrice: makeRatioFromAmounts(bidAmount, desired) }, + ); + return bidderSeat; +}; + +/** + * @typedef {object} OpenVaultParams + * @property {any} t + * @property {CollateralManager} cm + * @property {Amount<'nat'>} collateralAmount + * @property {string} colKeyword + * @property {Amount<'nat'>} wantMintedAmount + */ + +/** + * @param {OpenVaultParams} params + * @returns {Promise>} + */ +export const openVault = async ({ + t, + cm, + collateralAmount, + colKeyword, + wantMintedAmount, +}) => { + return E(t.context.zoe).offer( + await E(cm).makeVaultInvitation(), + harden({ + give: { Collateral: collateralAmount }, + want: { Minted: wantMintedAmount }, + }), + harden({ + Collateral: t.context[colKeyword].mint.mintPayment(collateralAmount), + }), + ); +}; + +/** + * @typedef {object} AdjustVaultParams + * @property {object} t + * @property {Vault} vault + * @property {{ + * want: [ + * { + * Collateral: Amount<'nat'>; + * Minted: Amount<'nat'>; + * }, + * ]; + * give: [ + * { + * Collateral: Amount<'nat'>; + * Minted: Amount<'nat'>; + * }, + * ]; + * }} proposal + * @property {{ + * want: [ + * { + * Collateral: Payment; + * Minted: Payment; + * }, + * ]; + * give: [ + * { + * Collateral: Payment; + * Minted: Payment; + * }, + * ]; + * }} [payment] + */ + +/** + * @param {AdjustVaultParams} adjustVaultParams + * @returns {Promise} + */ +export const adjustVault = async ({ t, vault, proposal, payment }) => { + return E(t.context.zoe).offer( + E(vault).makeAdjustBalancesInvitation(), + harden(proposal), + payment, + ); +}; + +/** + * @typedef {object} CloseVaultParams + * @property {Vault} vault + * @property {object} t + */ + +/** + * @param {CloseVaultParams} closeVaultParams + * @returns {Promise} + */ +export const closeVault = async ({ t, vault }) => { + return E(t.context.zoe).offer(E(vault).makeCloseInvitation()); +}; + +/** + * @typedef {object} GetTrackerParams + * @property {any} t + * @property {CollateralManager} collateralManager + * @property {AssetReservePublicFacet} reservePublicFacet + */ + +/** + * @typedef {object} Trackers + * @property {object} [reserveTracker] + * @property {object} [collateralManagerTracker] + */ + +/** + * @param {GetTrackerParams} getTrackerParams + * @returns {Promise} + */ +export const getMetricTrackers = async ({ + t, + collateralManager, + reservePublicFacet, +}) => { + /** @type {Trackers} */ + const trackers = {}; + if (reservePublicFacet) { + const metricsTopic = await E.get(E(reservePublicFacet).getPublicTopics()) + .metrics; + trackers.reserveTracker = await subscriptionTracker(t, metricsTopic); + } + + if (collateralManager) { + trackers.collateralManagerTracker = await vaultManagerMetricsTracker( + t, + collateralManager, + ); + } + + return harden(trackers); +}; + +export const getBookDataTracker = async (t, auctioneerPublicFacet, brand) => { + const tracker = E.when( + E(auctioneerPublicFacet).getBookDataUpdates(brand), + subscription => subscriptionTracker(t, subscribeEach(subscription)), + ); + + return tracker; +}; + +export const getSchedulerTracker = async (t, auctioneerPublicFacet) => { + const tracker = E.when( + E(auctioneerPublicFacet).getPublicTopics(), + subscription => + subscriptionTracker(t, subscribeEach(subscription.schedule.subscriber)), + ); + + return tracker; +}; + +export const getDataFromVstorage = async (storage, node) => { + const illustration = [...storage.keys()].sort().map( + /** @type {(k: string) => [string, unknown]} */ + key => [ + key.replace('mockChainStorageRoot.', 'published.'), + storage.getBody(key), + ], + ); + + const pruned = illustration.filter( + node ? ([key, _]) => key.startsWith(`published.${node}`) : _entry => true, + ); + + return pruned; +}; diff --git a/packages/internal/test/utils.test.js b/packages/internal/test/utils.test.js index 27d391ca4880..b50010a263ff 100644 --- a/packages/internal/test/utils.test.js +++ b/packages/internal/test/utils.test.js @@ -10,6 +10,7 @@ import { forever, deeplyFulfilledObject, synchronizedTee, + allValuesSettled, } from '../src/utils.js'; test('deeplyFulfilledObject', async t => { @@ -217,3 +218,17 @@ test('synchronizedTee - consume synchronized', async t => { t.deepEqual(output1, sourceData.slice(0, i)); t.deepEqual(output2, sourceData.slice(0, i)); }); + +test('allValuesSettled', async t => { + const result = await allValuesSettled({ + promiseOne: Promise.resolve('I am a happy promise - One'), + promiseTwo: Promise.reject(new Error('I am an upset promise')), + promiseThree: Promise.resolve('I am a happy promise - Three'), + }); + + t.deepEqual(result, { + promiseOne: 'I am a happy promise - One', + promiseTwo: undefined, + promiseThree: 'I am a happy promise - Three', + }); +});