From 8604b011b072d7bef43df59c075bcff9582b8804 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Tue, 9 Jan 2024 15:13:17 -0800 Subject: [PATCH] feat: auctioneer detects failing priceAuthority; requests new one (#8691) * feat: auctioneer/vaults detect failing priceAuthority & request new If the quoteNotifier is broken, set updatingOracleQuote to null. If updatingOracleQuote is null when attempting to capture the oracle price, restart observeQuoteNotifier Vaults detects failing priceAuthority and requests new one (#8696) * feat: make vaultManager robust against failing priceAuthority * chore: fix types, drop expect-error --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../builders/scripts/vats/restart-vats.js | 2 + .../inter-protocol/src/auction/auctionBook.js | 92 ++-- .../src/vaultFactory/vaultManager.js | 123 ++++- .../test/auction/test-auctionBook.js | 11 +- .../test/auction/test-auctionContract.js | 104 ++++- .../test/vaultFactory/driver.js | 47 +- .../test-replacePriceAuthority.js | 431 ++++++++++++++++++ .../test/vaultFactory/test-vaultFactory.js | 77 ++-- .../vaultFactory/test-vaultLiquidation.js | 120 +++-- .../test/vaultFactory/vaultFactoryUtils.js | 29 +- .../zoe/src/contractSupport/priceAuthority.js | 37 +- packages/zoe/tools/manualPriceAuthority.js | 10 +- 12 files changed, 904 insertions(+), 179 deletions(-) create mode 100644 packages/inter-protocol/test/vaultFactory/test-replacePriceAuthority.js diff --git a/packages/builders/scripts/vats/restart-vats.js b/packages/builders/scripts/vats/restart-vats.js index 27d7a1d8287..a8dbf8ffe9d 100644 --- a/packages/builders/scripts/vats/restart-vats.js +++ b/packages/builders/scripts/vats/restart-vats.js @@ -9,6 +9,8 @@ export const defaultProposalBuilder = async () => { 'feeDistributor', // skip so vaultManager can have prices upon restart; these have been tested as restartable 'scaledPriceAuthority-ATOM', + // If this is killed, and the above is left alive, quoteNotifier throws + 'ATOM-USD_price_feed', ]; return harden({ diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 672c3ce7fe9..dbd4ca0e421 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -2,7 +2,7 @@ import '@agoric/governance/exported.js'; import '@agoric/zoe/exported.js'; import '@agoric/zoe/src/contracts/exported.js'; -import { AmountMath } from '@agoric/ertp'; +import { AmountMath, RatioShape } from '@agoric/ertp'; import { mustMatch } from '@agoric/store'; import { M, prepareExoClassKit } from '@agoric/vat-data'; @@ -124,7 +124,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { bidHoldingSeat: M.any(), bidAmountShape: M.any(), priceAuthority: M.any(), - updatingOracleQuote: M.any(), + updatingOracleQuote: M.or(RatioShape, M.null()), bookDataKit: M.any(), priceBook: M.any(), scaledBidBook: M.any(), @@ -147,11 +147,6 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { */ (bidBrand, collateralBrand, pAuthority, node) => { assertAllDefined({ bidBrand, collateralBrand, pAuthority }); - const zeroBid = makeEmpty(bidBrand); - const zeroRatio = makeRatioFromAmounts( - zeroBid, - AmountMath.make(collateralBrand, 1n), - ); // these don't have to be durable, since we're currently assuming that upgrade // from a quiescent state is sufficient. When the auction is quiescent, there @@ -188,7 +183,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { bidAmountShape, priceAuthority: pAuthority, - updatingOracleQuote: zeroRatio, + updatingOracleQuote: /** @type {Ratio | null} */ (null), bookDataKit, @@ -468,6 +463,48 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { }); return state.bookDataKit.recorder.write(bookData); }, + observeQuoteNotifier() { + const { state, facets } = this; + const { collateralBrand, bidBrand, priceAuthority } = state; + + trace('observing'); + + void E.when( + E(collateralBrand).getDisplayInfo(), + ({ decimalPlaces = DEFAULT_DECIMALS }) => { + const quoteNotifier = E(priceAuthority).makeQuoteNotifier( + AmountMath.make(collateralBrand, 10n ** BigInt(decimalPlaces)), + bidBrand, + ); + void observeNotifier(quoteNotifier, { + updateState: quote => { + trace( + `BOOK notifier ${priceFrom(quote).numerator.value}/${ + priceFrom(quote).denominator.value + }`, + ); + state.updatingOracleQuote = priceFrom(quote); + }, + fail: reason => { + trace( + `Failure from quoteNotifier (${reason}) setting to null`, + ); + // lack of quote will trigger restart + state.updatingOracleQuote = null; + }, + finish: done => { + trace( + `quoteNotifier invoked finish(${done}). setting quote to null`, + ); + // lack of quote will trigger restart + state.updatingOracleQuote = null; + }, + }); + }, + ); + + void facets.helper.publishBookData(); + }, }, self: { /** @@ -630,6 +667,12 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { const { facets, state } = this; trace(`capturing oracle price `, state.updatingOracleQuote); + if (!state.updatingOracleQuote) { + // if the price has feed has died, try restarting it. + facets.helper.observeQuoteNotifier(); + return; + } + state.capturedPriceForRound = state.updatingOracleQuote; void facets.helper.publishBookData(); }, @@ -729,37 +772,8 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { finish: ({ state, facets }) => { const { collateralBrand, bidBrand, priceAuthority } = state; assertAllDefined({ collateralBrand, bidBrand, priceAuthority }); - void E.when( - E(collateralBrand).getDisplayInfo(), - ({ decimalPlaces = DEFAULT_DECIMALS }) => { - // TODO(#6946) use this to keep a current price that can be published in state. - const quoteNotifier = E(priceAuthority).makeQuoteNotifier( - AmountMath.make(collateralBrand, 10n ** BigInt(decimalPlaces)), - bidBrand, - ); - void observeNotifier(quoteNotifier, { - updateState: quote => { - trace( - `BOOK notifier ${priceFrom(quote).numerator.value}/${ - priceFrom(quote).denominator.value - }`, - ); - state.updatingOracleQuote = priceFrom(quote); - }, - fail: reason => { - throw Error( - `auction observer of ${collateralBrand} failed: ${reason}`, - ); - }, - finish: done => { - throw Error( - `auction observer for ${collateralBrand} died: ${done}`, - ); - }, - }); - }, - ); - void facets.helper.publishBookData(); + + facets.helper.observeQuoteNotifier(); }, stateShape: AuctionBookStateShape, }, diff --git a/packages/inter-protocol/src/vaultFactory/vaultManager.js b/packages/inter-protocol/src/vaultFactory/vaultManager.js index 0cc96982794..3b5413f331d 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -69,6 +69,38 @@ const { details: X, Fail, quote: q } = assert; const trace = makeTracer('VM'); +/** + * Watch a notifier that isn't expected to fail or finish unless the vat hosting + * the notifier is upgraded. This watcher supports that by providing a + * straightforward way to get a replacement if the notifier breaks. + * + * @template T notifier topic + * @template {any[]} [A=unknown[]] arbitrary arguments + * @param {ERef>} notifierP + * @param {import('@agoric/swingset-liveslots').PromiseWatcher} watcher + * @param {A} args + */ +export const watchQuoteNotifier = async (notifierP, watcher, ...args) => { + await undefined; + + let updateCount; + for (;;) { + let value; + try { + ({ value, updateCount } = await E(notifierP).getUpdateSince(updateCount)); + watcher.onFulfilled && watcher.onFulfilled(value, ...args); + } catch (e) { + watcher.onRejected && watcher.onRejected(e, ...args); + break; + } + if (updateCount === undefined) { + watcher.onRejected && + watcher.onRejected(Error('stream finished'), ...args); + break; + } + } +}; + /** @typedef {import('./storeUtils.js').NormalizedDebt} NormalizedDebt */ /** @typedef {import('@agoric/time').RelativeTime} RelativeTime */ @@ -170,7 +202,7 @@ const trace = makeTracer('VM'); * @type {(brand: Brand) => { * prioritizedVaults: ReturnType; * storedQuotesNotifier: import('@agoric/notifier').StoredNotifier; - * storedCollateralQuote: PriceQuote; + * storedCollateralQuote: PriceQuote | null; * }} */ // any b/c will be filled after start() @@ -355,13 +387,7 @@ export const prepareVaultManagerKit = ( start() { const { state, facets } = this; trace(state.collateralBrand, 'helper.start()', state.vaultCounter); - const { - collateralBrand, - collateralUnit, - debtBrand, - storageNode, - unsettledVaults, - } = state; + const { collateralBrand, unsettledVaults } = state; const ephemera = collateralEphemera(collateralBrand); ephemera.prioritizedVaults = makePrioritizedVaults(unsettledVaults); @@ -394,7 +420,17 @@ export const prepareVaultManagerKit = ( }, }); - trace('helper.start() making quoteNotifier from', priceAuthority); + void facets.helper.observeQuoteNotifier(); + + trace('helper.start() done'); + }, + observeQuoteNotifier() { + const { state } = this; + + const { collateralBrand, collateralUnit, debtBrand, storageNode } = + state; + const ephemera = collateralEphemera(collateralBrand); + const quoteNotifier = E(priceAuthority).makeQuoteNotifier( collateralUnit, debtBrand, @@ -404,20 +440,29 @@ export const prepareVaultManagerKit = ( E(storageNode).makeChildNode('quotes'), marshaller, ); - trace('helper.start() awaiting observe storedQuotesNotifier'); + trace( + 'helper.start() awaiting observe storedQuotesNotifier', + collateralBrand, + ); // NB: upon restart, there may not be a price for a while. If manager - // operations are permitted, ones the depend on price information will - // throw. See https://github.com/Agoric/agoric-sdk/issues/4317 - void observeNotifier(quoteNotifier, { - updateState(value) { - trace('storing new quote', value.quoteAmount.value); + // operations are permitted, ones that depend on price information + // will throw. See https://github.com/Agoric/agoric-sdk/issues/4317 + const quoteWatcher = harden({ + onFulfilled(value) { ephemera.storedCollateralQuote = value; }, - fail(reason) { - console.error('quoteNotifier failed to iterate', reason); + onRejected() { + // NOTE: drastic action, if the quoteNotifier fails, we don't know + // the value of the asset, nor do we know how long we'll be in + // ignorance. Best choice is to disable actions that require + // prices and restart when we have a new price. If we restart the + // notifier immediately, we'll trigger an infinite loop, so try + // to restart each time we get a request. + + ephemera.storedCollateralQuote = null; }, }); - trace('helper.start() done'); + void watchQuoteNotifier(quoteNotifier, quoteWatcher); }, /** @param {Timestamp} updateTime */ async chargeAllVaults(updateTime) { @@ -785,10 +830,15 @@ export const prepareVaultManagerKit = ( * @param {Amount<'nat'>} collateralAmount */ maxDebtFor(collateralAmount) { - const { collateralBrand } = this.state; + const { state, facets } = this; + const { collateralBrand } = state; const { storedCollateralQuote } = collateralEphemera(collateralBrand); - if (!storedCollateralQuote) - throw Fail`maxDebtFor called before a collateral quote was available`; + if (!storedCollateralQuote) { + facets.helper.observeQuoteNotifier(); + + // it might take an arbitrary amount of time to get a new quote + throw Fail`maxDebtFor called before a collateral quote was available for ${collateralBrand}`; + } // use the lower price to prevent vault adjustments that put them imminently underwater const collateralPrice = minimumPrice( storedCollateralQuote, @@ -1025,11 +1075,17 @@ export const prepareVaultManagerKit = ( }, getCollateralQuote() { + const { state, facets } = this; const { storedCollateralQuote } = collateralEphemera( - this.state.collateralBrand, + state.collateralBrand, ); - if (!storedCollateralQuote) + if (!storedCollateralQuote) { + facets.helper.observeQuoteNotifier(); + + // it might take an arbitrary amount of time to get a new quote throw Fail`getCollateralQuote called before a collateral quote was available`; + } + return storedCollateralQuote; }, @@ -1042,8 +1098,13 @@ export const prepareVaultManagerKit = ( const { storedCollateralQuote } = collateralEphemera( state.collateralBrand, ); - if (!storedCollateralQuote) + if (!storedCollateralQuote) { + facets.helper.observeQuoteNotifier(); + + // it might take an arbitrary amount of time to get a new quote throw Fail`lockOraclePrices called before a collateral quote was available for ${state.collateralBrand}`; + } + trace( `lockOraclePrices`, getAmountIn(storedCollateralQuote), @@ -1078,6 +1139,15 @@ export const prepareVaultManagerKit = ( return; } + const { storedCollateralQuote: collateralQuoteBefore } = + collateralEphemera(this.state.collateralBrand); + if (!collateralQuoteBefore) { + console.error( + 'Skipping liquidation because collateralQuote is missing', + ); + return; + } + const { prioritizedVaults } = collateralEphemera(collateralBrand); prioritizedVaults || Fail`prioritizedVaults missing from ephemera`; @@ -1141,7 +1211,10 @@ export const prepareVaultManagerKit = ( const { plan, vaultsInPlan } = helper.planProceedsDistribution( proceeds, totalDebt, - storedCollateralQuote, + // If a quote was available at the start of liquidation, but is no + // longer, using the earlier price is better than failing to + // distribute proceeds + storedCollateralQuote || collateralQuoteBefore, vaultData, totalCollateral, ); diff --git a/packages/inter-protocol/test/auction/test-auctionBook.js b/packages/inter-protocol/test/auction/test-auctionBook.js index b288460dc81..84670e77472 100644 --- a/packages/inter-protocol/test/auction/test-auctionBook.js +++ b/packages/inter-protocol/test/auction/test-auctionBook.js @@ -80,16 +80,19 @@ const assembleAuctionBook = async basics => { test('states', async t => { const basics = await setupBasics(); - const { moolaKit, simoleanKit } = basics; - const { book } = await assembleAuctionBook(basics); + const { moolaKit, moola, simoleanKit, simoleans } = basics; + const { pa, book } = await assembleAuctionBook(basics); + + pa.setPrice(makeRatioFromAmounts(moola(9n), simoleans(10n))); + await eventLoopIteration(); book.captureOraclePriceForRound(); book.setStartingRate(makeRatio(90n, moolaKit.brand, 100n)); t.deepEqual( book.getCurrentPrice(), makeRatioFromAmounts( - AmountMath.makeEmpty(moolaKit.brand), - AmountMath.make(simoleanKit.brand, 100n), + AmountMath.make(moolaKit.brand, 81_000_000_000n), + AmountMath.make(simoleanKit.brand, 100_000_000_000n), ), ); }); diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js index b624307a9be..08c74be5eb1 100644 --- a/packages/inter-protocol/test/auction/test-auctionContract.js +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -58,7 +58,7 @@ const test = anyTest; const trace = makeTracer('Test AuctContract', false); -const defaultParams = { +const defaultParams = harden({ StartFrequency: 40n, ClockStep: 5n, StartingRate: 10500n, @@ -66,7 +66,7 @@ const defaultParams = { DiscountStep: 2000n, AuctionStartDelay: 10n, PriceLockPeriod: 3n, -}; +}); const makeTestContext = async () => { const { zoe, feeMintAccessP } = await setUpZoeForTest(); @@ -144,7 +144,12 @@ export const setupServices = async (t, params = defaultParams) => { */ const makeAuctionDriver = async (t, params = defaultParams) => { const { zoe, bid } = t.context; - /** @type {MapStore void }>} */ + /** + * @type {MapStore< + * Brand, + * import('@agoric/zoe/tools/manualPriceAuthority.js').ManualPriceAuthority + * >} + */ const priceAuthorities = makeScalarMapStore(); const { space, timer, registry } = await setupServices(t, params); @@ -346,6 +351,19 @@ const makeAuctionDriver = async (t, params = defaultParams) => { const reserveCF = E.get(reserveKit).creatorFacet; return E.get(E(reserveCF).getAllocations())[keyword]; }, + async replacePriceAuthority(brandIn, brandOut, initialPrice) { + priceAuthorities.get(brandIn).disable(); + + const newPa = makeManualPriceAuthority({ + actualBrandIn: brandIn, + actualBrandOut: brandOut, + timer, + initialPrice, + }); + priceAuthorities.set(brandIn, newPa); + + await E(registry).registerPriceAuthority(newPa, brandIn, brandOut, true); + }, }; }; @@ -1188,7 +1206,7 @@ test.serial('add assets to open auction', async t => { test.serial('multiple collaterals', async t => { const { collateral, bid } = t.context; - const params = defaultParams; + const params = { ...defaultParams }; params.LowestRate = 2500n; const driver = await makeAuctionDriver(t, params); @@ -1421,7 +1439,7 @@ test.serial('time jumps forward', async t => { const schedules = await driver.getSchedule(); t.is(schedules.nextAuctionSchedule?.startTime.absValue, 1570n); t.is(schedules.liveAuctionSchedule?.startTime.absValue, 1530n); - t.is(schedules.liveAuctionSchedule?.endTime.absValue, 1550n); + t.is(schedules.liveAuctionSchedule?.endTime.absValue, 1545n); }); // serial because dynamicConfig is shared across tests @@ -1479,7 +1497,8 @@ test.serial('add collateral type during auction', async t => { await driver.advanceTo(185n, 'wait'); await scheduleTracker.assertChange({ - nextDescendingStepTime: { absValue: 190n }, + activeStartTime: null, + nextDescendingStepTime: { absValue: 210n }, }); t.true(await E(seat).hasExited()); @@ -1491,7 +1510,76 @@ test.serial('add collateral type during auction', async t => { await assertPayouts(t, liqSeat, bid, collateral, 231n, 800n); await scheduleTracker.assertChange({ - activeStartTime: null, - nextDescendingStepTime: { absValue: 210n }, + activeStartTime: TimeMath.coerceTimestampRecord(210n, timerBrand), + nextStartTime: { absValue: 250n }, + nextDescendingStepTime: { absValue: 215n }, + }); +}); + +// serial because dynamicConfig is shared across tests +test.serial('replace priceAuthority', async t => { + const { collateral, bid } = t.context; + const driver = await makeAuctionDriver(t); + + await driver.setupCollateralAuction(collateral, collateral.make(100n)); + await driver.updatePriceAuthority( + makeRatioFromAmounts(bid.make(20n), collateral.make(10n)), + ); + await eventLoopIteration(); + + // invalidate the old PA that the auction is relying on. + await driver.replacePriceAuthority( + collateral.brand, + bid.brand, + makeRatioFromAmounts(bid.make(15n), collateral.make(10n)), + ); + // provide new price via the new authority. The auction must switch to see it + await driver.updatePriceAuthority( + makeRatioFromAmounts(bid.make(5n), collateral.make(10n)), + ); + + const seat = await driver.bidForCollateralSeat( + bid.make(125n), + collateral.make(100n), + ); + t.is(await E(seat).getOfferResult(), 'Your bid has been accepted'); + + // The driver must be advanced to 167 to stop at lockTime, so the price is + // locked, else the goods won't be sold at auction. + await driver.advanceTo(167n); + await driver.advanceTo(170n); + let schedules = await driver.getSchedule(); + assert(schedules.liveAuctionSchedule); + t.is(schedules.liveAuctionSchedule.startTime.absValue, 170n); + t.is(schedules.liveAuctionSchedule.endTime.absValue, 185n); + + await driver.advanceTo(175n); + await driver.advanceTo(180n); + await driver.advanceTo(185n); + await driver.advanceTo(200n); + + await eventLoopIteration(); + + await driver.advanceTo(207n); + await driver.advanceTo(210n); + await driver.advanceTo(215n); + await driver.advanceTo(220n); + + await driver.advanceTo(230n); + await driver.depositCollateral(collateral.make(400n), collateral, { + goal: bid.make(200n), }); + + await driver.advanceTo(247n); + await driver.advanceTo(250n); + await driver.advanceTo(255n); + await driver.advanceTo(260n); + await driver.advanceTo(265n); + + schedules = await driver.getSchedule(); + + t.is(schedules.nextAuctionSchedule?.startTime.absValue, 290n); + t.true(await E(seat).hasExited()); + + await assertPayouts(t, seat, bid, collateral, 72n, 100n); }); diff --git a/packages/inter-protocol/test/vaultFactory/driver.js b/packages/inter-protocol/test/vaultFactory/driver.js index b0fdb36c2fd..89485787db7 100644 --- a/packages/inter-protocol/test/vaultFactory/driver.js +++ b/packages/inter-protocol/test/vaultFactory/driver.js @@ -6,6 +6,7 @@ import { makeNotifierFromSubscriber } from '@agoric/notifier'; import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; import { ceilMultiplyBy, + makeRatio, makeRatioFromAmounts, } from '@agoric/zoe/src/contractSupport/index.js'; import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; @@ -15,6 +16,9 @@ import { deeplyFulfilled } from '@endo/marshal'; import { NonNullish } from '@agoric/assert'; import { eventLoopIteration } from '@agoric/notifier/tools/testSupports.js'; +import { providePriceAuthorityRegistry } from '@agoric/zoe/tools/priceAuthorityRegistry.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data/src/index.js'; + import { setupReserve, startAuctioneer, @@ -215,15 +219,27 @@ const setupServices = async (t, initialPrice, priceBase) => { const { consume, produce } = space; t.context.consume = consume; - // Cheesy hack for easy use of manual price authority - const priceAuthority = makeManualPriceAuthority({ + // priceAuthorityReg is the registry, which contains and multiplexes multiple + // individual priceAuthorities, including aethManualPA. + // priceAuthorityAdmin supports registering more individual priceAuthorities + // with the registry. + const aethManualPA = makeManualPriceAuthority({ actualBrandIn: aeth.brand, actualBrandOut: run.brand, initialPrice: makeRatioFromAmounts(initialPrice, priceBase), timer, quoteIssuerKit: makeIssuerKit('quote', AssetKind.SET), }); - produce.priceAuthority.resolve(priceAuthority); + const baggage = makeScalarBigMapStore('baggage'); + const { priceAuthority: priceAuthorityReg, adminFacet: priceAuthorityAdmin } = + providePriceAuthorityRegistry(baggage); + await E(priceAuthorityAdmin).registerPriceAuthority( + aethManualPA, + aeth.brand, + run.brand, + ); + + produce.priceAuthority.resolve(priceAuthorityReg); const { installation: { produce: iProduce }, @@ -276,6 +292,7 @@ const setupServices = async (t, initialPrice, priceBase) => { return { zoe, + timer, governor: { governorInstance, governorPublicFacet: E(zoe).getPublicFacet(governorInstance), @@ -288,7 +305,9 @@ const setupServices = async (t, initialPrice, priceBase) => { vfPublic, aethVaultManager, }, - priceAuthority, + priceAuthority: priceAuthorityReg, + priceAuthorityAdmin, + aethManualPA, }; }; @@ -307,7 +326,7 @@ export const makeManagerDriver = async ( const { zoe, aeth, run } = t.context; const { vaultFactory: { lender, vaultFactory, vfPublic }, - priceAuthority, + aethManualPA, timer, } = services; const publicTopics = await E(lender).getPublicTopics(); @@ -450,6 +469,22 @@ export const makeManagerDriver = async ( addVaultType: async keyword => { /** @type {IssuerKit<'nat'>} */ const kit = makeIssuerKit(keyword.toLowerCase()); + + // for now, this priceAuthority never reports prices, but having one is + // sufficient to get a vaultManager running. + const pa = makeManualPriceAuthority({ + actualBrandIn: kit.brand, + actualBrandOut: run.brand, + timer, + initialPrice: makeRatio(100n, run.brand, 100n, kit.brand), + }); + + await services.priceAuthorityAdmin.registerPriceAuthority( + pa, + kit.brand, + run.brand, + ); + const manager = await E(vaultFactory).addVaultType( kit.issuer, keyword, @@ -477,7 +512,7 @@ export const makeManagerDriver = async ( }); }, /** @param {Amount<'nat'>} p */ - setPrice: p => priceAuthority.setPrice(makeRatioFromAmounts(p, priceBase)), + setPrice: p => aethManualPA.setPrice(makeRatioFromAmounts(p, priceBase)), // XXX the paramPath should be implied by the object `setGovernedParam` is being called on. // e.g. the manager driver should know the paramPath is `{ key: { collateralBrand: aeth.brand } }` // and the director driver should `{ key: 'governedParams }` diff --git a/packages/inter-protocol/test/vaultFactory/test-replacePriceAuthority.js b/packages/inter-protocol/test/vaultFactory/test-replacePriceAuthority.js new file mode 100644 index 00000000000..bd731ec655b --- /dev/null +++ b/packages/inter-protocol/test/vaultFactory/test-replacePriceAuthority.js @@ -0,0 +1,431 @@ +import '@agoric/zoe/exported.js'; +import { test as unknownTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { allValues, makeTracer, objectMap } from '@agoric/internal'; +import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; +import { makeRatio } from '@agoric/zoe/src/contractSupport/index.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import { E } from '@endo/eventual-send'; +import { deeplyFulfilled } from '@endo/marshal'; +import { TimeMath } from '@agoric/time'; +import { providePriceAuthorityRegistry } from '@agoric/zoe/tools/priceAuthorityRegistry.js'; +import { makeScalarMapStore } from '@agoric/vat-data/src/index.js'; +import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; +import { makeNotifierFromAsyncIterable, subscribeEach } from '@agoric/notifier'; + +import { + installPuppetGovernance, + produceInstallations, + setupBootstrap, + setUpZoeForTest, + withAmountUtils, +} from '../supports.js'; +import { startEconomicCommittee } from '../../src/proposals/startEconCommittee.js'; +import { + setupReserve, + startAuctioneer, + SECONDS_PER_DAY as ONE_DAY, + SECONDS_PER_HOUR as ONE_HOUR, + SECONDS_PER_WEEK as ONE_WEEK, + startVaultFactory, +} from '../../src/proposals/econ-behaviors.js'; + +import '../../src/vaultFactory/types.js'; +import { defaultParamValues } from './vaultFactoryUtils.js'; + +/** + * @typedef {Record & { + * aeth: IssuerKit & import('../supports.js').AmountUtils; + * run: IssuerKit & import('../supports.js').AmountUtils; + * bundleCache: Awaited>; + * rates: VaultManagerParamValues; + * interestTiming: InterestTiming; + * zoe: ZoeService; + * }} Context + */ + +/** @type {import('ava').TestFn} */ + +const test = unknownTest; + +const contractRoots = { + faucet: './test/vaultFactory/faucet.js', + VaultFactory: './src/vaultFactory/vaultFactory.js', + reserve: './src/reserve/assetReserve.js', + auctioneer: './src/auction/auctioneer.js', +}; + +/** @typedef {import('../../src/vaultFactory/vaultFactory').VaultFactoryContract} VFC */ + +const trace = makeTracer('Test replc PriceAuthority', false); + +// Define locally to test that vaultFactory uses these values +export const Phase = /** @type {const} */ ({ + ACTIVE: 'active', + LIQUIDATING: 'liquidating', + CLOSED: 'closed', + LIQUIDATED: 'liquidated', + TRANSFER: 'transfer', +}); + +test.before(async t => { + const { zoe, feeMintAccessP } = await setUpZoeForTest(); + 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/'); // package-relative + // note that the liquidation might be a different bundle name + 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)); + + const feeMintAccess = await feeMintAccessP; + const contextPs = { + zoe, + feeMintAccess, + bundles, + installation, + electorateTerms: undefined, + interestTiming: { + chargingPeriod: 2n, + recordingPeriod: 6n, + }, + minInitialDebt: 50n, + referencedUi: undefined, + rates: defaultParamValues(run.brand), + }; + const frozenCtx = await deeplyFulfilled(harden(contextPs)); + t.context = { + ...frozenCtx, + bundleCache, + aeth, + run, + }; + trace(t, '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 + */ +export const setupElectorateReserveAndAuction = async ( + t, + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + { + StartFrequency = ONE_WEEK, + DiscountStep = 2000n, + LowestRate = 5500n, + ClockStep = 2n, + StartingRate = 10_500n, + AuctionStartDelay = 10n, + PriceLockPeriod = 3n, + }, +) => { + 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); + + /** @type {import('@agoric/vat-data').Baggage} */ + const paBaggage = makeScalarMapStore(); + const { priceAuthority, adminFacet: registry } = + providePriceAuthorityRegistry(paBaggage); + space.produce.priceAuthority.resolve(priceAuthority); + + const pa = makeManualPriceAuthority({ + actualBrandIn: aeth.brand, + actualBrandOut: run.brand, + timer, + initialPrice: makeRatio(1000n, run.brand, 100n, aeth.brand), + }); + await E(registry).registerPriceAuthority(pa, aeth.brand, run.brand, true); + + const auctionParams = { + StartFrequency, + ClockStep, + StartingRate, + LowestRate, + DiscountStep, + AuctionStartDelay, + PriceLockPeriod, + }; + + await startAuctioneer(space, { auctionParams }); + return { space, thePriceAuthority: pa, registry }; +}; + +/** + * NOTE: called separately by each test so zoe/priceAuthority don't interfere + * + * @param {import('ava').ExecutionContext} t + * @param {NatValue[] | Ratio} priceOrList + * @param {Amount | undefined} unitAmountIn + * @param {import('@agoric/time').TimerService} timer + * @param {RelativeTime} quoteInterval + * @param {bigint} stableInitialLiquidity + * @param {Partial} [auctionParams] + */ +const setupServices = async ( + t, + priceOrList, + unitAmountIn, + timer = buildManualTimer(), + quoteInterval = 1n, + stableInitialLiquidity, + auctionParams = {}, +) => { + const { + zoe, + run, + aeth, + interestTiming, + minInitialDebt, + referencedUi, + rates, + } = t.context; + t.context.timer = timer; + + const { space, thePriceAuthority, registry } = + await setupElectorateReserveAndAuction( + t, + // @ts-expect-error inconsistent types with withAmountUtils + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + auctionParams, + ); + + const { consume } = space; + + const { + 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; + /** @type {Promise} */ + const vaultFactoryCreatorFacetP = E.get(consume.vaultFactoryKit).creatorFacet; + const reserveCreatorFacet = E.get(consume.reserveKit).creatorFacet; + const reservePublicFacet = E.get(consume.reserveKit).publicFacet; + // XXX just pass through reserveKit from the space + const reserveKit = { reserveCreatorFacet, reservePublicFacet }; + + // Add a vault that will lend on aeth collateral + /** @type {Promise} */ + 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 */ + /** + * @type {[ + * any, + * VaultFactoryCreatorFacet, + * VFC['publicFacet'], + * VaultManager, + * AuctioneerKit, + * ManualPriceAuthority, + * CollateralManager, + * ]} + */ + const [ + governorInstance, + vaultFactory, // creator + vfPublic, + aethVaultManager, + auctioneerKit, + priceAuthority, + aethCollateralManager, + ] = 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(), + ]); + 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, + governor: g, + vaultFactory: v, + runKit: { issuer: run.issuer, brand: run.brand }, + priceAuthority, + reserveKit, + auctioneerKit, + thePriceAuthority, + registry, + }; +}; + +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) { + trace('advancing clock to ', currentTime); + 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. +const startAuctionClock = async (auctioneerKit, manualTimer) => { + const schedule = await E(auctioneerKit.creatorFacet).getSchedule(); + const priceDelay = await E(auctioneerKit.publicFacet).getPriceLockPeriod(); + const { startTime, startDelay } = 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 }; +}; + +test('replace priceAuthority', async t => { + const { aeth, run, rates: defaultRates } = t.context; + + // Interest is charged daily, and auctions are every week, so we'll charge + // interest a few times before the second auction. + t.context.interestTiming = { + chargingPeriod: ONE_DAY, + recordingPeriod: ONE_DAY, + }; + + // Add a vaultManager with 10000 aeth collateral at a 200 aeth/Minted rate + const rates = harden({ + ...defaultRates, + // charge 200% interest + interestRate: run.makeRatio(200n), + liquidationMargin: run.makeRatio(103n), + }); + t.context.rates = rates; + + // charge interest on every tick + const manualTimer = buildManualTimer(); + const services = await setupServices( + t, + makeRatio(100n, run.brand, 10n, aeth.brand), + aeth.make(1n), + manualTimer, + ONE_WEEK, + 500n, + { StartFrequency: ONE_HOUR }, + ); + + const { + auctioneerKit, + reserveKit: { reserveCreatorFacet }, + } = services; + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + // initial loan ///////////////////////////////////// + const { aethVaultManager } = services.vaultFactory; + + const publicFacet = await E(aethVaultManager).getPublicFacet(); + const publicTopics = await E(publicFacet).getPublicTopics(); + const subscription = subscribeEach(publicTopics.metrics.subscriber); + const aethVaultNotifier = await makeNotifierFromAsyncIterable(subscription); + + const newPa = makeManualPriceAuthority({ + actualBrandIn: aeth.brand, + actualBrandOut: run.brand, + timer: t.context.timer, + initialPrice: makeRatio(70n, run.brand, 10n, aeth.brand), + }); + await E(services.registry).registerPriceAuthority( + newPa, + aeth.brand, + run.brand, + true, + ); + services.thePriceAuthority.disable(); + + await eventLoopIteration(); + + let update = await E(aethVaultNotifier).getUpdateSince(); + let startTime; + ({ startTime } = await startAuctionClock(auctioneerKit, manualTimer)); + await setClockAndAdvanceNTimes(manualTimer, 2n, startTime, 2n); + + await eventLoopIteration(); + ({ startTime } = await startAuctionClock(auctioneerKit, manualTimer)); + await setClockAndAdvanceNTimes(manualTimer, 2n, startTime, 2n); + + update = await E(aethVaultNotifier).getUpdateSince(update.updateCount); + t.deepEqual( + update.value.lockedQuote?.numerator, + AmountMath.make(aeth.brand, 1000_000n), + ); +}); diff --git a/packages/inter-protocol/test/vaultFactory/test-vaultFactory.js b/packages/inter-protocol/test/vaultFactory/test-vaultFactory.js index f8e134481ef..b6cc4e94a02 100644 --- a/packages/inter-protocol/test/vaultFactory/test-vaultFactory.js +++ b/packages/inter-protocol/test/vaultFactory/test-vaultFactory.js @@ -20,7 +20,6 @@ import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority import { documentStorageSchema } from '@agoric/governance/tools/storageDoc.js'; import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; -import { makeScriptedPriceAuthority } from '@agoric/zoe/tools/scriptedPriceAuthority.js'; import { E } from '@endo/eventual-send'; import { deeplyFulfilled } from '@endo/marshal'; import { calculateCurrentDebt } from '../../src/interest-math.js'; @@ -156,44 +155,25 @@ const setupServices = async ( const runPayment = await getRunFromFaucet(t, stableInitialLiquidity); trace(t, 'faucet', { stableInitialLiquidity, runPayment }); - const { space } = await setupElectorateReserveAndAuction( - t, - // @ts-expect-error inconsistent types with withAmountUtils - run, - aeth, - priceOrList, - quoteInterval, - unitAmountIn, - { StartFrequency: startFrequency }, - ); - - const { consume, produce } = space; - - const quoteIssuerKit = makeIssuerKit('quote', AssetKind.SET); - // Cheesy hack for easy use of manual price authority - const pa = 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, - }); - produce.priceAuthority.resolve(pa); + const { space, priceAuthorityAdmin, aethTestPriceAuthority } = + await setupElectorateReserveAndAuction( + t, + // @ts-expect-error inconsistent types with withAmountUtils + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + { StartFrequency: startFrequency }, + ); + + const { consume } = space; const { installation: { produce: iProduce }, } = space; iProduce.VaultFactory.resolve(t.context.installation.VaultFactory); + iProduce.liquidate.resolve(t.context.installation.liquidate); await startVaultFactory( space, @@ -267,15 +247,38 @@ const setupServices = async ( return { zoe, + timer, governor: g, vaultFactory: v, runKit: { issuer: run.issuer, brand: run.brand }, - priceAuthority, reserveKit, space, + priceAuthorityAdmin, + aethTestPriceAuthority, }; }; +const addPriceAuthority = async (collateralIssuerKit, services) => { + const { priceAuthorityAdmin, timer, runKit } = services; + + const pa = makeManualPriceAuthority({ + actualBrandIn: collateralIssuerKit.brand, + actualBrandOut: runKit.brand, + timer, + initialPrice: makeRatio( + 100n, + runKit.brand, + 100n, + collateralIssuerKit.brand, + ), + }); + await E(priceAuthorityAdmin).registerPriceAuthority( + pa, + collateralIssuerKit.brand, + runKit.brand, + ); +}; + test('first', async t => { const { aeth, run, zoe, rates } = t.context; t.context.interestTiming = { @@ -1574,6 +1577,7 @@ test('addVaultType: invalid args do not modify state', async t => { ); const { vaultFactory } = services.vaultFactory; + await addPriceAuthority(chit, services); const failsForSameReason = async p => p @@ -1607,6 +1611,7 @@ test('addVaultType: extra, unexpected params', async t => { ); const { vaultFactory } = services.vaultFactory; + await addPriceAuthority(chit, services); const params = { ...defaultParamValues(aeth.brand), shoeSize: 10 }; const extraParams = { ...params, shoeSize: 10 }; @@ -1654,6 +1659,8 @@ test('director notifiers', async t => { // add a vault type const chit = makeIssuerKit('chit'); + await addPriceAuthority(chit, services); + await E(vaultFactory).addVaultType( chit.issuer, 'Chit', diff --git a/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js b/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js index 2240f539d3f..50d4030f733 100644 --- a/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js +++ b/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js @@ -151,16 +151,17 @@ const setupServices = async ( } = t.context; t.context.timer = timer; - const { space } = await setupElectorateReserveAndAuction( - t, - // @ts-expect-error inconsistent types with withAmountUtils - run, - aeth, - priceOrList, - quoteInterval, - unitAmountIn, - auctionParams, - ); + const { space, priceAuthorityAdmin, aethTestPriceAuthority } = + await setupElectorateReserveAndAuction( + t, + // @ts-expect-error inconsistent types with withAmountUtils + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + auctionParams, + ); const { consume } = space; @@ -247,12 +248,15 @@ const setupServices = async ( return { zoe, + timer, governor: g, vaultFactory: v, runKit: { issuer: run.issuer, brand: run.brand }, priceAuthority, reserveKit, auctioneerKit, + priceAuthorityAdmin, + aethTestPriceAuthority, }; }; @@ -374,7 +378,7 @@ test('price drop', async t => { const { vaultFactory: { vaultFactory, aethCollateralManager }, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, auctioneerKit, } = services; @@ -435,9 +439,11 @@ test('price drop', async t => { aeth.make(400n), 'vault holds 11 Collateral', ); - trace(t, 'pa2', priceAuthority); + trace(t, 'pa2', { aethPriceAuthority: aethTestPriceAuthority }); - await priceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(40n, run.brand, 10n, aeth.brand), + ); trace(t, 'price dropped a little'); notification = await E(vaultNotifier).getUpdateSince(); t.is(notification.value.vaultState, Phase.ACTIVE); @@ -537,7 +543,7 @@ test('price falls precipitously', async t => { const { reserveKit: { reserveCreatorFacet, reservePublicFacet }, auctioneerKit, - priceAuthority, + aethTestPriceAuthority, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -581,7 +587,7 @@ test('price falls precipitously', async t => { 'vault holds 4 Collateral', ); - priceAuthority.setPrice(makeRatio(130n, run.brand, 1n, aeth.brand)); + aethTestPriceAuthority.setPrice(makeRatio(130n, run.brand, 1n, aeth.brand)); await eventLoopIteration(); const { startTime, time } = await startAuctionClock( @@ -687,7 +693,7 @@ test('liquidate two loans', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, auctioneerKit, } = services; @@ -860,7 +866,9 @@ test('liquidate two loans', async t => { totalCollateral: { value: 800n }, }); - await E(priceAuthority).setPrice(makeRatio(70n, run.brand, 10n, aeth.brand)); + await E(aethTestPriceAuthority).setPrice( + makeRatio(70n, run.brand, 10n, aeth.brand), + ); trace(t, 'changed price to 7 RUN/Aeth'); // A BIDDER places a BID ////////////////////////// @@ -1041,7 +1049,7 @@ test('sell goods at auction', async t => { const { auctioneerKit, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet }, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -1167,7 +1175,9 @@ test('sell goods at auction', async t => { t.is(aliceUpdate.value.vaultState, Phase.ACTIVE); // price falls - await priceAuthority.setPrice(makeRatio(70n, run.brand, 10n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(70n, run.brand, 10n, aeth.brand), + ); await eventLoopIteration(); // Bob's loan is now 777 Minted (including interest) on 100 Aeth, with the price @@ -1213,7 +1223,7 @@ test('collect fees from loan', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, auctioneerKit, } = services; @@ -1347,10 +1357,14 @@ test('collect fees from loan', async t => { t.deepEqual(aliceUpdate.value.debtSnapshot.debt, aliceRunDebtLevel); trace(t, 'alice reduce collateral'); - await E(priceAuthority).setPrice(makeRatio(7n, run.brand, 1n, aeth.brand)); + await E(aethTestPriceAuthority).setPrice( + makeRatio(7n, run.brand, 1n, aeth.brand), + ); trace(t, 'changed price to 7'); - await priceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(40n, run.brand, 10n, aeth.brand), + ); trace(t, 'price dropped a little'); notification = await E(aliceNotifier).getUpdateSince(); t.is(notification.value.vaultState, Phase.ACTIVE); @@ -1463,7 +1477,7 @@ test('Auction sells all collateral w/shortfall', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, auctioneerKit, } = services; @@ -1589,7 +1603,9 @@ test('Auction sells all collateral w/shortfall', async t => { totalCollateral: { value: 700n }, }); - await E(priceAuthority).setPrice(makeRatio(70n, run.brand, 10n, aeth.brand)); + await E(aethTestPriceAuthority).setPrice( + makeRatio(70n, run.brand, 10n, aeth.brand), + ); trace(t, 'changed price to 7 RUN/Aeth'); // A BIDDER places a BID ////////////////////////// @@ -1676,7 +1692,7 @@ test('liquidation Margin matters', async t => { const { auctioneerKit, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet }, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -1730,7 +1746,9 @@ test('liquidation Margin matters', async t => { const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); // price falls to 10.00. notice that no liquidation takes place. - await priceAuthority.setPrice(makeRatio(1000n, run.brand, 100n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(1000n, run.brand, 100n, aeth.brand), + ); await eventLoopIteration(); let { startTime } = await startAuctionClock(auctioneerKit, manualTimer); @@ -1741,7 +1759,9 @@ test('liquidation Margin matters', async t => { t.is(aliceUpdate.value.vaultState, Phase.ACTIVE); // price falls to 9.99. Now it liquidates. - await priceAuthority.setPrice(makeRatio(999n, run.brand, 100n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(999n, run.brand, 100n, aeth.brand), + ); await eventLoopIteration(); ({ startTime } = await startAuctionClock(auctioneerKit, manualTimer)); @@ -1780,7 +1800,7 @@ test('reinstate vault', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, auctioneerKit, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -1902,7 +1922,9 @@ test('reinstate vault', async t => { const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); // price falls - await priceAuthority.setPrice(makeRatio(400n, run.brand, 100n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(400n, run.brand, 100n, aeth.brand), + ); await eventLoopIteration(); const { startTime } = await startAuctionClock(auctioneerKit, manualTimer); @@ -1993,7 +2015,7 @@ test('auction locks low price', async t => { const { reserveKit: { reserveCreatorFacet }, auctioneerKit, - priceAuthority, + aethTestPriceAuthority, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); trace('addIssuer awaited'); @@ -2001,7 +2023,9 @@ test('auction locks low price', async t => { const wanted = 500n; // Lock in a low price (zero) - priceAuthority.setPrice(makeRatio(0n, run.brand, baseCollateral, aeth.brand)); + aethTestPriceAuthority.setPrice( + makeRatio(0n, run.brand, baseCollateral, aeth.brand), + ); await eventLoopIteration(); const schedule = await E(auctioneerKit.creatorFacet).getSchedule(); @@ -2034,7 +2058,7 @@ test('auction locks low price', async t => { ); // Bump back up to a high price - priceAuthority.setPrice( + aethTestPriceAuthority.setPrice( makeRatio(100n * wanted, run.brand, baseCollateral, aeth.brand), ); @@ -2081,7 +2105,7 @@ test('Bug 7422 vault reinstated with no assets', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, auctioneerKit: auctKit, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -2204,7 +2228,9 @@ test('Bug 7422 vault reinstated with no assets', async t => { ); // price falls - await priceAuthority.setPrice(makeRatio(999n, run.brand, 1000n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(999n, run.brand, 1000n, aeth.brand), + ); await eventLoopIteration(); const { startTime } = await startAuctionClock(auctKit, manualTimer); @@ -2315,7 +2341,7 @@ test('Bug 7346 excess collateral to holder', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, auctioneerKit: auctKit, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -2453,7 +2479,7 @@ test('Bug 7346 excess collateral to holder', async t => { // price falls const newPrice = makeRatio(9990n, run.brand, 1000n, aeth.brand); - await priceAuthority.setPrice(newPrice); + await aethTestPriceAuthority.setPrice(newPrice); await eventLoopIteration(); const { startTime } = await startAuctionClock(auctKit, manualTimer); @@ -2580,7 +2606,7 @@ test('refund to one of two loans', async t => { const { vaultFactory: { vaultFactory, aethCollateralManager }, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, auctioneerKit, } = services; @@ -2640,7 +2666,9 @@ test('refund to one of two loans', async t => { t.truthy(AmountMath.isEqual(lentAmount, aliceWantMinted)); t.deepEqual(await E(aliceVault).getCollateralAmount(), aeth.make(400n)); - await priceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(40n, run.brand, 10n, aeth.brand), + ); aliceNotification = await E(aliceVaultNotifier).getUpdateSince(); t.is(aliceNotification.value.vaultState, Phase.ACTIVE); @@ -2767,7 +2795,7 @@ test('Bug 7784 reconstitute both', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, auctioneerKit: auctKit, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -2882,7 +2910,9 @@ test('Bug 7784 reconstitute both', async t => { ); // price falls - await priceAuthority.setPrice(makeRatio(9990n, run.brand, 1000n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(9990n, run.brand, 1000n, aeth.brand), + ); await eventLoopIteration(); const { startTime } = await startAuctionClock(auctKit, manualTimer); @@ -2984,7 +3014,7 @@ test('Bug 7796 missing lockedPrice', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, auctioneerKit: auctKit, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -3141,7 +3171,7 @@ test('Bug 7796 missing lockedPrice', async t => { trace('ADVANCING', now); // price falls const newPrice = makeRatio(9990n, run.brand, 1000n, aeth.brand); - await priceAuthority.setPrice(newPrice); + await aethTestPriceAuthority.setPrice(newPrice); await eventLoopIteration(); now = await setClockAndAdvanceNTimes( @@ -3262,7 +3292,7 @@ test('Bug 7851 & no bidders', async t => { const { vaultFactory: { aethVaultManager, aethCollateralManager }, auctioneerKit: auctKit, - priceAuthority, + aethTestPriceAuthority, reserveKit: { reserveCreatorFacet, reservePublicFacet }, } = services; await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); @@ -3339,7 +3369,9 @@ test('Bug 7851 & no bidders', async t => { }); // price falls - await priceAuthority.setPrice(makeRatio(9990n, run.brand, 1000n, aeth.brand)); + await aethTestPriceAuthority.setPrice( + makeRatio(9990n, run.brand, 1000n, aeth.brand), + ); await eventLoopIteration(); await setClockAndAdvanceNTimes( diff --git a/packages/inter-protocol/test/vaultFactory/vaultFactoryUtils.js b/packages/inter-protocol/test/vaultFactory/vaultFactoryUtils.js index 16592960c9f..c3ef7e5de69 100644 --- a/packages/inter-protocol/test/vaultFactory/vaultFactoryUtils.js +++ b/packages/inter-protocol/test/vaultFactory/vaultFactoryUtils.js @@ -4,6 +4,8 @@ import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; import { makeNotifierFromSubscriber } from '@agoric/notifier'; import { makeRatio } from '@agoric/zoe/src/contractSupport/index.js'; import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data/src/index.js'; +import { providePriceAuthorityRegistry } from '@agoric/zoe/tools/priceAuthorityRegistry.js'; import { makeScriptedPriceAuthority } from '@agoric/zoe/tools/scriptedPriceAuthority.js'; import { E } from '@endo/eventual-send'; @@ -96,8 +98,13 @@ export const setupElectorateReserveAndAuction = async ( await setupReserve(space); const quoteIssuerKit = makeIssuerKit('quote', AssetKind.SET); - // Cheesy hack for easy use of manual price authority - const pa = Array.isArray(priceOrList) + // 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, @@ -114,7 +121,16 @@ export const setupElectorateReserveAndAuction = async ( timer, quoteIssuerKit, }); - space.produce.priceAuthority.resolve(pa); + const baggage = makeScalarBigMapStore('baggage'); + const { priceAuthority: priceAuthorityReg, adminFacet: priceAuthorityAdmin } = + providePriceAuthorityRegistry(baggage); + await E(priceAuthorityAdmin).registerPriceAuthority( + aethTestPriceAuthority, + aeth.brand, + run.brand, + ); + + space.produce.priceAuthority.resolve(priceAuthorityReg); const auctionParams = { StartFrequency, @@ -127,7 +143,12 @@ export const setupElectorateReserveAndAuction = async ( }; await startAuctioneer(space, { auctionParams }); - return { space }; + return { + space, + priceAuthority: priceAuthorityReg, + priceAuthorityAdmin, + aethTestPriceAuthority, + }; }; /** diff --git a/packages/zoe/src/contractSupport/priceAuthority.js b/packages/zoe/src/contractSupport/priceAuthority.js index 3bc8680c90f..02ac349adf3 100644 --- a/packages/zoe/src/contractSupport/priceAuthority.js +++ b/packages/zoe/src/contractSupport/priceAuthority.js @@ -1,7 +1,6 @@ /* eslint @typescript-eslint/no-floating-promises: "warn" */ import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; -import { assert, q, Fail } from '@agoric/assert'; import { makePromiseKit } from '@endo/promise-kit'; import { AmountMath, AmountShape, BrandShape } from '@agoric/ertp'; import { makeNotifier } from '@agoric/notifier'; @@ -9,6 +8,8 @@ import { makeTracer } from '@agoric/internal'; import { TimestampShape } from '@agoric/time'; import { M } from '@agoric/store'; +const { quote: q, Fail } = assert; + const trace = makeTracer('PA', false); /** @@ -68,7 +69,7 @@ export const PriceAuthorityI = M.interface('PriceAuthority', { * @param {Brand<'nat'>} opts.actualBrandOut * @returns {PriceAuthorityKit} */ -export function makeOnewayPriceAuthorityKit(opts) { +export const makeOnewayPriceAuthorityKit = opts => { const { timer, createQuote, @@ -80,7 +81,7 @@ export function makeOnewayPriceAuthorityKit(opts) { let haveFirstQuote = false; - E(notifier) + void E(notifier) .getUpdateSince() .then(_ => (haveFirstQuote = true)); @@ -108,7 +109,8 @@ export function makeOnewayPriceAuthorityKit(opts) { * * @param {CompareAmount} compareAmountsFn */ - const makeQuoteWhenOut = compareAmountsFn => + const makeQuoteWhenOut = + compareAmountsFn => /** * Return a quote when triggerWhen is true of the arguments. * @@ -116,7 +118,7 @@ export function makeOnewayPriceAuthorityKit(opts) { * @param {Amount} amountOutLimit the value to compare with the output * of calcAmountTrigger */ - async function quoteWhenOutTrigger(amountIn, amountOutLimit) { + async (amountIn, amountOutLimit) => { amountIn = AmountMath.coerce(actualBrandIn, amountIn); amountOutLimit = AmountMath.coerce(actualBrandOut, amountOutLimit); @@ -169,12 +171,13 @@ export function makeOnewayPriceAuthorityKit(opts) { * * @param {CompareAmount} compareAmountsFn */ - const makeMutableQuote = compareAmountsFn => + const makeMutableQuote = + compareAmountsFn => /** * @param {Amount<'nat'>} amountIn * @param {Amount<'nat'>} amountOutLimit */ - async function mutableQuoteWhenOutTrigger(amountIn, amountOutLimit) { + async (amountIn, amountOutLimit) => { AmountMath.coerce(actualBrandIn, amountIn); AmountMath.coerce(actualBrandOut, amountOutLimit); @@ -272,12 +275,20 @@ export function makeOnewayPriceAuthorityKit(opts) { const record = await E(notifier).getUpdateSince(updateCount); // We create a quote inline. - const quote = createQuote(calcAmountOut => ({ - amountIn, - amountOut: calcAmountOut(amountIn), - })); + let quote; + // createQuote can throw if priceAuthority is replaced. + // eslint-disable-next-line no-useless-catch + try { + quote = createQuote(calcAmountOut => ({ + amountIn, + amountOut: calcAmountOut(amountIn), + })); + } catch (e) { + // fall through + } + if (!quote) { - throw Fail`createQuote returned falsey`; + throw Fail`createQuote returned nothing`; } const value = await quote; @@ -368,4 +379,4 @@ export function makeOnewayPriceAuthorityKit(opts) { }); return { priceAuthority, adminFacet: { fireTriggers } }; -} +}; diff --git a/packages/zoe/tools/manualPriceAuthority.js b/packages/zoe/tools/manualPriceAuthority.js index 74a8d12ae36..93ffdc64e54 100644 --- a/packages/zoe/tools/manualPriceAuthority.js +++ b/packages/zoe/tools/manualPriceAuthority.js @@ -18,7 +18,7 @@ import { * @param {Ratio} options.initialPrice * @param {import('@agoric/time').TimerService} options.timer * @param {IssuerKit<'set'>} [options.quoteIssuerKit] - * @returns {PriceAuthority & { setPrice: (Ratio) => void }} + * @returns {PriceAuthority & { setPrice: (Ratio) => void; disable: () => void }} */ export function makeManualPriceAuthority(options) { const { @@ -32,6 +32,7 @@ export function makeManualPriceAuthority(options) { /** @type {Ratio} */ let currentPrice = initialPrice; + let disabled = false; const { notifier, updater } = makeNotifierKit(); updater.updateState(currentPrice); @@ -52,6 +53,9 @@ export function makeManualPriceAuthority(options) { }; function createQuote(priceQuery) { + if (disabled) { + throw Error('disabled'); + } const quote = priceQuery(calcAmountOut, calcAmountIn); if (!quote) { return undefined; @@ -86,6 +90,10 @@ export function makeManualPriceAuthority(options) { updater.updateState(currentPrice); fireTriggers(createQuote); }, + disable: () => { + disabled = true; + updater.updateState(false); + }, ...priceAuthority, }); }