diff --git a/packages/inter-protocol/src/vaultFactory/liquidation.js b/packages/inter-protocol/src/vaultFactory/liquidation.js index f4863cf5713..18bfb07d4d8 100644 --- a/packages/inter-protocol/src/vaultFactory/liquidation.js +++ b/packages/inter-protocol/src/vaultFactory/liquidation.js @@ -18,6 +18,13 @@ const trace = makeTracer('LIQ'); /** @typedef {import('@agoric/time').CancelToken} CancelToken */ /** @typedef {import('@agoric/time').RelativeTimeRecord} RelativeTimeRecord */ +/** + * @typedef {MapStore< + * Vault, + * { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } + * >} VaultData + */ + const makeCancelToken = makeCancelTokenMaker('liq'); /** @@ -269,12 +276,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.js b/packages/inter-protocol/src/vaultFactory/types.js index 01c3750b9ad..c438c16ceb3 100644 --- a/packages/inter-protocol/src/vaultFactory/types.js +++ b/packages/inter-protocol/src/vaultFactory/types.js @@ -21,6 +21,8 @@ * * @typedef {import('@agoric/time').Timestamp} Timestamp * + * @typedef {import('@agoric/time').TimestampRecord} TimestampRecord + * * @typedef {import('@agoric/time').RelativeTime} RelativeTime */ @@ -142,3 +144,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 15fb0a1fc80..9c9602e6da7 100644 --- a/packages/inter-protocol/src/vaultFactory/vault.js +++ b/packages/inter-protocol/src/vaultFactory/vault.js @@ -131,6 +131,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), @@ -597,6 +600,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 1d3d0aeac30..8281251adab 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultDirector.js +++ b/packages/inter-protocol/src/vaultFactory/vaultDirector.js @@ -423,7 +423,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 3b5413f331d..2ce7b9b90b0 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -51,7 +51,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 { AuctionPFShape } from '../auction/auctioneer.js'; import { checkDebtLimit, @@ -171,6 +172,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; @@ -205,6 +207,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} */ ({})); @@ -227,7 +258,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 => { @@ -235,6 +269,7 @@ export const prepareVaultManagerKit = ( debtMint, collateralBrand, metricsStorageNode, + liquidationsStorageNode, startTimeStamp, storageNode, } = params; @@ -244,7 +279,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 @@ -336,7 +371,9 @@ export const prepareVaultManagerKit = ( getCollateralQuote: M.call().returns(PriceQuoteShape), getPublicFacet: M.call().returns(M.remotable('publicFacet')), lockOraclePrices: M.call().returns(PriceQuoteShape), - liquidateVaults: M.call(AuctionPFShape).returns(M.promise()), + liquidateVaults: M.call(AuctionPFShape, TimestampShape).returns( + M.promise(), + ), }), }, initState, @@ -649,6 +686,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. @@ -1115,8 +1294,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 { @@ -1179,11 +1361,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( @@ -1197,6 +1380,31 @@ 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. + await null; + const [ + { userSeatPromise, deposited }, + liquidationVisibilityWriters, + auctionSchedule, + ] = ( + await Promise.allSettled([ + makeDeposit, + helper.makeLiquidationVisibilityWriters(timestamp), + schedulesP, + ]) + ) + .filter(result => result.status === 'fulfilled') + // @ts-expect-error + .map(result => result.value); + + 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. @@ -1227,6 +1435,28 @@ export const prepareVaultManagerKit = ( totalDebt, vaultsInPlan, }); + + void helper.writeLiqVisibility( + liquidationVisibilityWriters, + harden([ + [ + 'writeAuctionResults', + { + plan, + totalCollateral, + totalDebt, + auctionSchedule, + }, + ], + [ + 'writePostAuction', + { + plan, + vaultsInPlan, + }, + ], + ]), + ); } catch (err) { console.error('🚨 Error distributing proceeds:', err); } @@ -1265,16 +1495,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 00000000000..0f4e228a50b --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/assertions.js @@ -0,0 +1,258 @@ +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) => { + const collateralAmount = await E(vault).getCollateralAmount(); + + t.deepEqual(collateralAmount, t.context.aeth.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) => { + const notification = await E(vaultNotifier).getUpdateSince(); + const lockedAmount = notification.value.locked; + + t.deepEqual(lockedAmount, t.context.aeth.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) => { + 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(t.context.aeth.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/snapshots/test-liquidationVisibility.js.md b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md new file mode 100644 index 00000000000..fa9e2107b77 --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md @@ -0,0 +1,236 @@ +# 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-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 00000000000..bf87f7d5f97 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 00000000000..0dd53228a53 --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js @@ -0,0 +1,780 @@ +// @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'; + +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 { run, aeth, bundleCache, bundles, installation } = + await setupBasics(zoe); + + 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, + run, + }; + + trace(t, 'CONTEXT'); +}); + +// Liquidation ends with a happy path +test('liq-result-scenario-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); + + // 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); + 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); + 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()); + await assertVaultCollateral(t, vault, 0n); + 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, + }, + }); + + // Create snapshot of the storage node + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 1 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}`, + }); +}); + +// We'll make a loan, and trigger liquidation via price changes. The interest +// rate is 40%. The liquidation margin is 105%. The priceAuthority will +// initially quote 10:1 Run:Aeth, and drop to 7:1. The loan will initially be +// overcollateralized 100%. Alice will withdraw enough of the overage that +// she'll get caught when prices drop. +// A bidder will buy at the 65% level, so there will be a shortfall. +test('liq-result-scenario-2', 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, + }); + + // ALICE's loan //////////////////////////////////////////// + + // 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)); + + await assertVaultDebtSnapshot(t, aliceNotifier, aliceWantMinted); + trace(t, 'alice reduce collateral'); + await collateralManagerTracker.assertChange({ + totalCollateral: { value: 700n }, + }); + + // TODO: UNCOMMENT THIS WHEN SOURCE IS READY + 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); + + // TODO: 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); + + // TODO: 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: [], + }); + + // FIXME: https://github.com/Jorge-Lopes/agoric-sdk-liquidation-visibility/issues/3#issuecomment-1905488335 + 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, + }, + }); + + // TODO: Snapshot here + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 2 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}`, + }); +}); + +test('liq-result-scenario-3', 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, + }); + + // ALICE takes out a loan //////////////////////// + + // a loan 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(); + + // TODO: 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); + + // TODO: 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, + }, + }); + + // TODO: PostAuction 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, + }, + ], + ], + }); + + // TODO: AuctionResults here + 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, + }, + }); + + // TODO: Snapshot here + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 3 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}`, + }); +}); 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 00000000000..02d58055885 --- /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 00000000000..da576f86acc --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/tools.js @@ -0,0 +1,429 @@ +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 { 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, + setupElectorateReserveAndAuction, +} from '../vaultFactory/vaultFactoryUtils.js'; +import { subscriptionTracker, vaultManagerMetricsTracker } from '../metrics.js'; +import { startVaultFactory } from '../../src/proposals/econ-behaviors.js'; + +const 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 => { + 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 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, + 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] + */ +export const setupServices = async ( + t, + priceOrList, + unitAmountIn, + timer = buildManualTimer(), + quoteInterval = 1n, + auctionParams = {}, +) => { + const { + zoe, + run, + aeth, + interestTiming, + minInitialDebt, + referencedUi, + rates, + } = t.context; + + t.context.timer = timer; + + const { space, priceAuthorityAdmin, aethTestPriceAuthority } = + await setupElectorateReserveAndAuction( + t, + // @ts-expect-error inconsistent types with withAmountUtils + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + auctionParams, + ); + + 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, + ); + + /** @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, + * AuctioneerKit, + * ManualPriceAuthority, + * CollateralManager, + * chainStorage, + * board, + * ]} + */ + const [ + governorInstance, + vaultFactory, // creator + vfPublic, + aethVaultManager, + auctioneerKit, + priceAuthority, + aethCollateralManager, + chainStorage, + board, + ] = await Promise.all([ + E(consume.agoricNames).lookup('instance', 'VaultFactoryGovernor'), + vaultFactoryCreatorFacetP, + E.get(consume.vaultFactoryKit).publicFacet, + aethVaultManagerP, + consume.auctioneerKit, + /** @type {Promise} */ (consume.priceAuthority), + E(aethVaultManagerP).getPublicFacet(), + 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, + }, + }; + + await E(auctioneerKit.creatorFacet).addBrand(aeth.issuer, 'Aeth'); + + return { + zoe, + timer, + space, + governor: g, + vaultFactory: v, + runKit: { issuer: run.issuer, brand: run.brand }, + priceAuthority, + reserveKit, + auctioneerKit, + priceAuthorityAdmin, + aethTestPriceAuthority, + 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; +};