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, }); }