From d86fc9959192799e9b55278ee26f88829ef21764 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Wed, 17 May 2023 17:48:50 -0700 Subject: [PATCH] fix(vaults): return correct collateral after liquidation The debt to be subtracted from the vault's value is the current debt level divided by the realized price. fixes: #7779 refs: #7346, 7767 --- .../inter-protocol/src/auction/auctionBook.js | 13 +- .../src/proposals/econ-behaviors.js | 1 + .../src/vaultFactory/vaultManager.js | 33 ++- .../test/vaultFactory/test-vaultFactory.js | 2 +- .../vaultFactory/test-vaultLiquidation.js | 250 +++++++++++++++++- .../test/vaultFactory/vaultFactoryUtils.js | 9 +- .../bootstrapTests/test-vaults-upgrade.js | 8 +- 7 files changed, 281 insertions(+), 35 deletions(-) diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 31ebcffa2e9..7d4fcfc4112 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -311,12 +311,6 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { ? [proceedsLimit, floorDivideBy(proceedsLimit, curAuctionPrice)] : [minProceedsTarget, initialCollateralTarget]; - trace('settle', { - collateralTarget, - proceedsTarget, - remainingProceedsGoal, - }); - const { Collateral } = seat.getProposal().want; if (Collateral && AmountMath.isGTE(Collateral, collateralTarget)) { seat.exit('unable to satisfy want'); @@ -336,6 +330,13 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { proceedsTarget, ); } + + trace('settle', { + collateralTarget, + proceedsTarget, + remainingProceedsGoal: this.state.remainingProceedsGoal, + }); + return collateralTarget; }, diff --git a/packages/inter-protocol/src/proposals/econ-behaviors.js b/packages/inter-protocol/src/proposals/econ-behaviors.js index 5f3bc52e461..7e3d8a8418a 100644 --- a/packages/inter-protocol/src/proposals/econ-behaviors.js +++ b/packages/inter-protocol/src/proposals/econ-behaviors.js @@ -19,6 +19,7 @@ const trace = makeTracer('RunEconBehaviors', false); export const SECONDS_PER_HOUR = 60n * 60n; export const SECONDS_PER_DAY = 24n * SECONDS_PER_HOUR; +export const SECONDS_PER_WEEK = 7n * SECONDS_PER_DAY; /** * @typedef {import('../vaultFactory/vaultFactory.js').VaultFactoryContract['publicFacet']} VaultFactoryPublicFacet diff --git a/packages/inter-protocol/src/vaultFactory/vaultManager.js b/packages/inter-protocol/src/vaultFactory/vaultManager.js index 48505fb85c4..3e95b100687 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -37,6 +37,7 @@ import { import { TransferPartShape } from '@agoric/zoe/src/contractSupport/atomicTransfer.js'; import { atomicRearrange, + ceilDivideBy, ceilMultiplyBy, floorDivideBy, floorMultiplyBy, @@ -639,15 +640,20 @@ export const prepareVaultManagerKit = ( const { state, facets } = this; const { collateralBrand, debtBrand, liquidatingVaults } = this.state; + const { Collateral: collateralProceeds } = proceeds; + /** @type {Amount<'nat'>} */ + const collateralSold = AmountMath.subtract( + totalCollateral, + collateralProceeds, + ); state.totalCollateralSold = AmountMath.add( state.totalCollateralSold, - AmountMath.subtract(totalCollateral, proceeds.Collateral), + collateralSold, ); const mintedProceeds = proceeds.Minted || AmountMath.makeEmpty(debtBrand); const accounting = liquidationResults(totalDebt, mintedProceeds); - const { Collateral: collateralProceeds } = proceeds; /** @type {Array} */ const vaultsToLiquidate = []; @@ -670,7 +676,7 @@ export const prepareVaultManagerKit = ( ), ); - const debtPortion = makeRatioFromAmounts(totalPenalty, totalDebt); + const price = makeRatioFromAmounts(mintedProceeds, collateralSold); // Liquidation.md describes how to process liquidation proceeds const bestToWorst = [...vaultData.entries()].reverse(); if (AmountMath.isEmpty(accounting.shortfall)) { @@ -702,8 +708,10 @@ export const prepareVaultManagerKit = ( debtAmount, vault.getCurrentDebt(), ); - const vaultDebt = floorMultiplyBy(debtAmount, debtPortion); - const collatPostDebt = AmountMath.subtract(vCollat, vaultDebt); + const debtInCollateral = ceilDivideBy(debtAmount, price); + const collatPostDebt = AmountMath.isGTE(vCollat, debtInCollateral) + ? AmountMath.subtract(vCollat, debtInCollateral) + : AmountMath.makeEmptyFromAmount(vCollat); if (!AmountMath.isEmpty(leftToStage)) { const collat = AmountMath.min(leftToStage, collatPostDebt); leftToStage = AmountMath.subtract(leftToStage, collat); @@ -851,10 +859,13 @@ export const prepareVaultManagerKit = ( /** @type {Array<[Vault, { collateralAmount: Amount<'nat'>, debtAmount: Amount<'nat'>}]>} */ for (const [vault, balance] of bestToWorst) { const { collateralAmount: vCollat, debtAmount } = balance; - const vaultDebt = floorMultiplyBy(debtAmount, debtPortion); - const collatPostDebt = AmountMath.subtract(vCollat, vaultDebt); + const debtInCollateral = ceilDivideBy(debtAmount, price); + const collatPostDebt = AmountMath.isGTE(vCollat, debtInCollateral) + ? AmountMath.subtract(vCollat, debtInCollateral) + : AmountMath.makeEmptyFromAmount(vCollat); if ( reconstituteVaults && + !AmountMath.isEmpty(collatPostDebt) && AmountMath.isGTE(collatRemaining, collatPostDebt) && AmountMath.isGTE(totalDebt, debtAmount) ) { @@ -862,14 +873,16 @@ export const prepareVaultManagerKit = ( collatRemaining, collatPostDebt, ); - shortfallToReserve = AmountMath.subtract( + shortfallToReserve = AmountMath.isGTE( shortfallToReserve, debtAmount, - ); + ) + ? AmountMath.subtract(shortfallToReserve, debtAmount) + : AmountMath.makeEmptyFromAmount(shortfallToReserve); const seat = vault.getVaultSeat(); // must reinstate after atomicRearrange(), so we record them. vaultsToReinstate.push(vault); - reduceCollateral(vaultDebt); + reduceCollateral(debtInCollateral); transfers.push([liqSeat, seat, { Collateral: collatPostDebt }]); } else { reconstituteVaults = false; diff --git a/packages/inter-protocol/test/vaultFactory/test-vaultFactory.js b/packages/inter-protocol/test/vaultFactory/test-vaultFactory.js index a3b85f6a145..141f137ed88 100644 --- a/packages/inter-protocol/test/vaultFactory/test-vaultFactory.js +++ b/packages/inter-protocol/test/vaultFactory/test-vaultFactory.js @@ -158,7 +158,7 @@ const setupServices = async ( priceOrList, quoteInterval, unitAmountIn, - startFrequency, + { StartFrequency: startFrequency }, ); const { consume, produce } = space; diff --git a/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js b/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js index 0094f992a46..971e79ae945 100644 --- a/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js +++ b/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js @@ -127,7 +127,9 @@ test.before(async t => { * @param {import('@agoric/time/src/types').TimerService} timer * @param {RelativeTime} quoteInterval * @param {bigint} runInitialLiquidity - * @param {bigint} [startFrequency] + * @param {object} actionParams + * @param {bigint} [actionParams.startFrequency] + * @param {bigint} [actionParams.discountStep] */ const setupServices = async ( t, @@ -136,7 +138,10 @@ const setupServices = async ( timer = buildManualTimer(), quoteInterval = 1n, runInitialLiquidity, - startFrequency = undefined, + { startFrequency, discountStep } = { + startFrequency: undefined, + discountStep: undefined, + }, ) => { const { zoe, run, aeth, interestTiming, minInitialDebt, endorsedUi, rates } = t.context; @@ -150,7 +155,7 @@ const setupServices = async ( priceOrList, quoteInterval, unitAmountIn, - startFrequency, + { startFrequency, discountStep }, ); const { consume } = space; @@ -260,6 +265,24 @@ const bid = async (t, zoe, auctioneerKit, aeth, bidAmount, desired) => { return bidderSeat; }; +const bidPrice = async ( + t, + zoe, + auctioneerKit, + aeth, + bidAmount, + desired, + offerPrice, +) => { + 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 }, + ); + return bidderSeat; +}; + const bidDiscount = async ( t, zoe, @@ -1853,7 +1876,7 @@ test('reinstate vault', async t => { liquidatingDebt: { value: 0n }, liquidatingCollateral: { value: 0n }, totalDebt: { value: 158n }, - totalCollateral: { value: 44n }, + totalCollateral: { value: 10n }, totalProceedsReceived: { value: 34n }, totalShortfallReceived: { value: 66n }, totalCollateralSold: { value: 8n }, @@ -1869,7 +1892,7 @@ test('reinstate vault', async t => { // Reduce Bob's collateral by liquidation penalty const recoveredBobCollateral = AmountMath.subtract( bobCollateralAmount, - aeth.make(4n), + aeth.make(38n), ); bobUpdate = await E(bobNotifier).getUpdateSince(); t.is(bobUpdate.value.vaultState, Phase.ACTIVE); @@ -1883,7 +1906,7 @@ test('reinstate vault', async t => { const m = await subscriptionTracker(t, metricsTopic); await m.assertLike({ allocations: { - Aeth: aeth.make(4n), + Aeth: aeth.make(38n), Fee: run.makeEmpty(), }, }); @@ -2183,6 +2206,215 @@ test('Bug 7422 vault reinstated with no assets', async t => { }); }); +test('Bug 7346 excess collateral to holder', async t => { + const { zoe, aeth, run, rates: defaultRates } = t.context; + + const rates = harden({ + ...defaultRates, + liquidationPenalty: makeRatio(1n, run.brand), + liquidationMargin: run.makeRatio(150n), + mintFee: run.makeRatio(50n, 10_000n), + }); + t.context.rates = rates; + + const manualTimer = buildManualTimer(); + const services = await setupServices( + t, + makeRatio(1234n, run.brand, 100n, aeth.brand), + aeth.make(1000n), + manualTimer, + SECONDS_PER_WEEK, + 500n, + { discountStep: 500n }, + ); + + const { + vaultFactory: { aethVaultManager, aethCollateralManager }, + auctioneerKit: auctKit, + priceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + } = services; + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const cm = await E(aethVaultManager).getPublicFacet(); + const aethVaultMetrics = await vaultManagerMetricsTracker(t, cm); + await aethVaultMetrics.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), + }); + + const openVault = (collateral, want) => + E(zoe).offer( + E(aethCollateralManager).makeVaultInvitation(), + harden({ + give: { Collateral: collateral }, + want: { Minted: want }, + }), + harden({ + Collateral: aeth.mint.mintPayment(collateral), + }), + ); + + const aliceWantMinted = run.make(100_000n); + const collateral = aeth.make(15_000n); + /** @type {UserSeat} */ + const aliceVaultSeat = await openVault(collateral, aliceWantMinted); + const { + vault: aliceVault, + publicNotifiers: { vault: aliceNotifier }, + } = await legacyOfferResult(aliceVaultSeat); + let aliceUpdate = await E(aliceNotifier).getUpdateSince(); + t.is(aliceUpdate.value.vaultState, Phase.ACTIVE); + await aethVaultMetrics.assertChange({ + numActiveVaults: 1, + totalCollateral: { value: 15_000n }, + totalDebt: { value: 100_500n }, + }); + + const bobWantMinted = run.make(103_000n); + /** @type {UserSeat} */ + const bobVaultSeat = await openVault(collateral, bobWantMinted); + const { + vault: bobVault, + publicNotifiers: { vault: bobNotifier }, + } = await legacyOfferResult(bobVaultSeat); + const bobUpdate = await E(bobNotifier).getUpdateSince(); + t.is(bobUpdate.value.vaultState, Phase.ACTIVE); + + await aethVaultMetrics.assertChange({ + numActiveVaults: 2, + totalCollateral: { value: 30_000n }, + totalDebt: { value: 204_015n }, + }); + + const carolWantMinted = run.make(105_000n); + /** @type {UserSeat} */ + const carolVaultSeat = await openVault(collateral, carolWantMinted); + const { + vault: carolVault, + publicNotifiers: { vault: carolNotifier }, + } = await legacyOfferResult(carolVaultSeat); + const carolUpdate = await E(carolNotifier).getUpdateSince(); + t.is(carolUpdate.value.vaultState, Phase.ACTIVE); + await aethVaultMetrics.assertChange({ + numActiveVaults: 3, + totalCollateral: { value: 45_000n }, + totalDebt: { value: 309_540n }, + }); + + const { Minted: aliceLentAmount } = await E( + aliceVaultSeat, + ).getFinalAllocation(); + const aliceProceeds = await E(aliceVaultSeat).getPayouts(); + t.deepEqual(aliceLentAmount, aliceWantMinted, 'received 95 Minted'); + + const aliceRunLent = await aliceProceeds.Minted; + t.deepEqual(await E(run.issuer).getAmountOf(aliceRunLent), aliceWantMinted); + + // BIDDERs place BIDs ////////////////////////// + const bidderSeat1 = await bidDiscount( + t, + zoe, + auctKit, + aeth, + run.make(80_000n), + aeth.make(1000_000n), + makeRatio(90n, run.brand), + ); + const bidderSeat2 = await bidPrice( + t, + zoe, + auctKit, + aeth, + run.make(90_000n), + aeth.make(100_000n), + makeRatio(900n, run.brand, 100n, aeth.brand), + ); + const bidderSeat3 = await bidDiscount( + t, + zoe, + auctKit, + aeth, + run.make(150_000n), + aeth.make(1000_000n), + makeRatio(85n, run.brand), + ); + + // price falls + // @ts-expect-error setupServices() should return the right type + await priceAuthority.setPrice(makeRatio(9990n, run.brand, 1000n, aeth.brand)); + await eventLoopIteration(); + + const { startTime } = await startAuctionClock(auctKit, manualTimer); + + await setClockAndAdvanceNTimes(manualTimer, 10n, startTime, 2n); + + await aethVaultMetrics.assertChange({ + liquidatingDebt: { value: 309_540n }, + liquidatingCollateral: { value: 45_000n }, + numActiveVaults: 0, + numLiquidatingVaults: 3, + }); + + aliceUpdate = await E(aliceNotifier).getUpdateSince(); + t.is(aliceUpdate.value.vaultState, Phase.LIQUIDATED); + + await aethVaultMetrics.assertChange({ + liquidatingDebt: { value: 0n }, + totalDebt: { value: 0n }, + liquidatingCollateral: { value: 0n }, + totalCollateral: { value: 0n }, + totalCollateralSold: { value: 36_451n }, + totalProceedsReceived: { value: 309_540n }, + numLiquidatingVaults: 0, + numLiquidationsCompleted: 3, + }); + + t.deepEqual(await E(aliceVault).getCollateralAmount(), aeth.make(3_165n)); + t.deepEqual(await E(aliceVault).getCurrentDebt(), run.makeEmpty()); + t.deepEqual(await E(bobVault).getCollateralAmount(), aeth.make(2810n)); + t.deepEqual(await E(bobVault).getCurrentDebt(), run.makeEmpty()); + t.deepEqual(await E(carolVault).getCollateralAmount(), aeth.make(2_264n)); + t.deepEqual(await E(carolVault).getCurrentDebt(), run.makeEmpty()); + + t.false(await E(bidderSeat3).hasExited()); + await E(bidderSeat3).tryExit(); + t.true(await E(bidderSeat3).hasExited()); + await assertBidderPayout(t, bidderSeat3, run, 10_460n, aeth, 0n); + t.true(await E(bidderSeat1).hasExited()); + await assertBidderPayout(t, bidderSeat1, run, 0n, aeth, 9421n); + + t.true(await E(bidderSeat2).hasExited()); + await assertBidderPayout(t, bidderSeat2, run, 0n, aeth, 10598n); + + const metricsTopic = await E.get(E(reservePublicFacet).getPublicTopics()) + .metrics; + const m = await subscriptionTracker(t, metricsTopic); + + await m.assertState({ + ...reserveInitialState(run.makeEmpty()), + shortfallBalance: run.makeEmpty(), + allocations: { + Aeth: aeth.make(310n), + Fee: run.makeEmpty(), + }, + }); +}); + test('refund to one of two loans', async t => { const { zoe, aeth, run } = t.context; @@ -2341,7 +2573,7 @@ test('refund to one of two loans', async t => { const finalNotification = await E(aliceVaultNotifier).getUpdateSince(); t.is(finalNotification.value.vaultState, Phase.LIQUIDATED); - t.deepEqual(finalNotification.value.locked, aeth.make(0n)); + t.deepEqual(finalNotification.value.locked, aeth.make(9n)); t.is(debtAmountAfter.value, 0n); const totalWantMinted = AmountMath.add(aliceWantMinted, bobWantMinted); @@ -2363,14 +2595,14 @@ test('refund to one of two loans', async t => { const alicePayouts = await E(aliceCloseSeat).getPayouts(); const aliceCollOut = await aeth.issuer.getAmountOf(alicePayouts.Collateral); t.falsy(alicePayouts.Minted); - t.deepEqual(aliceCollOut, aeth.make(0n)); + t.deepEqual(aliceCollOut, aeth.make(9n)); t.deepEqual(await E(aliceVault).getCollateralAmount(), aeth.makeEmpty()); // bob got something const bobPayouts = await E(bobCloseSeat).getPayouts(); const bobCollOut = await aeth.issuer.getAmountOf(bobPayouts.Collateral); t.falsy(bobPayouts.Minted); - t.deepEqual(bobCollOut, aeth.make(19n)); + t.deepEqual(bobCollOut, aeth.make(10n)); t.deepEqual(await E(bobVault).getCollateralAmount(), aeth.makeEmpty()); await E(bidderSeat).tryExit(); diff --git a/packages/inter-protocol/test/vaultFactory/vaultFactoryUtils.js b/packages/inter-protocol/test/vaultFactory/vaultFactoryUtils.js index 98380cd637a..7a4587e8e56 100644 --- a/packages/inter-protocol/test/vaultFactory/vaultFactoryUtils.js +++ b/packages/inter-protocol/test/vaultFactory/vaultFactoryUtils.js @@ -8,6 +8,7 @@ import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority import { makeScriptedPriceAuthority } from '@agoric/zoe/tools/scriptedPriceAuthority.js'; import { E } from '@endo/eventual-send'; import { + SECONDS_PER_WEEK, setupReserve, startAuctioneer, } from '../../src/proposals/econ-behaviors.js'; @@ -58,7 +59,7 @@ export const defaultParamValues = debtBrand => * @param {Array | Ratio} priceOrList * @param {RelativeTime} quoteInterval * @param {Amount | undefined} unitAmountIn - * @param {bigint} [startFrequency] + * @param {Pick} actionParams */ export const setupElectorateReserveAndAuction = async ( t, @@ -67,7 +68,7 @@ export const setupElectorateReserveAndAuction = async ( priceOrList, quoteInterval, unitAmountIn, - startFrequency = undefined, + { StartFrequency = SECONDS_PER_WEEK, DiscountStep = 2000n }, ) => { const { zoe, @@ -104,11 +105,11 @@ export const setupElectorateReserveAndAuction = async ( space.produce.priceAuthority.resolve(pa); const auctionParams = { - StartFrequency: startFrequency || 7n * 24n * 3600n, + StartFrequency, ClockStep: 2n, StartingRate: 10500n, LowestRate: 5500n, - DiscountStep: 2000n, + DiscountStep, AuctionStartDelay: 10n, PriceLockPeriod: 3n, }; diff --git a/packages/vats/test/bootstrapTests/test-vaults-upgrade.js b/packages/vats/test/bootstrapTests/test-vaults-upgrade.js index f723c69561b..bfd3b3114a5 100644 --- a/packages/vats/test/bootstrapTests/test-vaults-upgrade.js +++ b/packages/vats/test/bootstrapTests/test-vaults-upgrade.js @@ -421,13 +421,11 @@ test.serial('force liquidation', async t => { numLiquidatingVaults: 1, }); - // after this the liquidation aborts due to the shortfall reporter being broken after upgrade. - // it has to be repaired by governance until https://github.com/Agoric/agoric-sdk/issues/5200 await advanceTime(1, 'hours'); t.like(readCollateralMetrics(0), { - numActiveVaults: 2, - numLiquidatingVaults: 0, - numLiquidationsAborted: 1, + numActiveVaults: 1, + numLiquidatingVaults: 1, + numLiquidationsAborted: 0, numLiquidationsCompleted: 0, }); });