From 0de1e9ba6e370dcd61ccaaa906528a84a034717d Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Thu, 22 Dec 2022 14:48:23 -0800 Subject: [PATCH 01/20] feat(auction): add an auctioneer to manage vault liquiditation Separate pieces include a scheduler that manages the phases of an auction, the AuctionBook that holds onto offers to buy, and the auctioneer, which accepts good for sale and has the APIs. Params are managed via governance. The classes are not durable. This is DRAFT, for early review. The changes to VaultFactory to make use of this approach to liquidation rather than the AMM are in a separate PR, which will be available before this PR is finalized. closes: #6992 --- packages/governance/src/constants.js | 3 + .../src/contractGovernance/assertions.js | 23 + .../src/contractGovernance/paramManager.js | 31 + .../contractGovernance/typedParamManager.js | 2 + packages/governance/src/contractHelper.js | 11 + packages/governance/src/types-ambient.js | 3 + .../scripts/add-collateral-core.js | 12 + .../scripts/deploy-contracts.js | 1 + packages/inter-protocol/scripts/init-core.js | 1 + .../inter-protocol/src/auction/auctionBook.js | 319 +++++++++ .../inter-protocol/src/auction/auctioneer.js | 262 ++++++++ .../src/auction/discountBook.js | 112 ++++ packages/inter-protocol/src/auction/params.js | 220 +++++++ .../inter-protocol/src/auction/scheduler.js | 206 ++++++ .../src/auction/sortedOffers.js | 144 +++++ packages/inter-protocol/src/auction/util.js | 30 + .../src/proposals/core-proposal.js | 25 + .../src/proposals/econ-behaviors.js | 135 +++- .../inter-protocol/src/proposals/startPSM.js | 4 + .../inter-protocol/src/vaultFactory/params.js | 3 + .../src/vaultFactory/prioritizedVaults.js | 5 - .../src/vaultFactory/storeUtils.js | 4 +- .../test/auction/test-auctionBook.js | 225 +++++++ .../test/auction/test-auctionContract.js | 605 ++++++++++++++++++ .../test/auction/test-scheduler.js | 521 +++++++++++++++ .../test/auction/test-sortedOffers.js | 122 ++++ packages/inter-protocol/test/auction/tools.js | 98 +++ .../test/swingsetTests/setup.js | 8 +- packages/time/src/typeGuards.js | 3 +- packages/vats/decentral-psm-config.json | 3 + packages/vats/src/core/types.js | 4 +- packages/vats/src/core/utils.js | 2 + packages/zoe/src/contractSupport/index.js | 4 + .../zoe/src/contractSupport/priceQuote.js | 1 + 34 files changed, 3137 insertions(+), 15 deletions(-) create mode 100644 packages/inter-protocol/src/auction/auctionBook.js create mode 100644 packages/inter-protocol/src/auction/auctioneer.js create mode 100644 packages/inter-protocol/src/auction/discountBook.js create mode 100644 packages/inter-protocol/src/auction/params.js create mode 100644 packages/inter-protocol/src/auction/scheduler.js create mode 100644 packages/inter-protocol/src/auction/sortedOffers.js create mode 100644 packages/inter-protocol/src/auction/util.js create mode 100644 packages/inter-protocol/test/auction/test-auctionBook.js create mode 100644 packages/inter-protocol/test/auction/test-auctionContract.js create mode 100644 packages/inter-protocol/test/auction/test-scheduler.js create mode 100644 packages/inter-protocol/test/auction/test-sortedOffers.js create mode 100644 packages/inter-protocol/test/auction/tools.js diff --git a/packages/governance/src/constants.js b/packages/governance/src/constants.js index 7fa2b609003..f58ea05a660 100644 --- a/packages/governance/src/constants.js +++ b/packages/governance/src/constants.js @@ -15,6 +15,9 @@ export const ParamTypes = /** @type {const} */ ({ RATIO: 'ratio', STRING: 'string', PASSABLE_RECORD: 'record', + TIMER_SERVICE: 'timerService', + TIMESTAMP: 'timestamp', + RELATIVE_TIME: 'relativeTime', UNKNOWN: 'unknown', }); diff --git a/packages/governance/src/contractGovernance/assertions.js b/packages/governance/src/contractGovernance/assertions.js index b062bb88823..2ef4a2d64ee 100644 --- a/packages/governance/src/contractGovernance/assertions.js +++ b/packages/governance/src/contractGovernance/assertions.js @@ -41,9 +41,32 @@ const makeAssertBrandedRatio = (name, modelRatio) => { }; harden(makeAssertBrandedRatio); +const assertRelativeTime = value => { + isRemotable(value.timerBrand) || Fail`relativeTime must have a brand`; + typeof value.relValue === 'bigint' || Fail`must have a relValue field`; +}; +harden(assertRelativeTime); + +const assertTimestamp = value => { + isRemotable(value.timerBrand) || Fail`timestamp must have a brand`; + typeof value.absValue === 'bigint' || Fail`must have an absValue field`; +}; +harden(assertTimestamp); + +const makeAssertTimerService = name => { + return timerService => { + typeof timerService === 'object' || + Fail`value for ${name} must be a TimerService, was ${timerService}`; + }; +}; +harden(makeAssertTimerService); + export { makeLooksLikeBrand, makeAssertInstallation, makeAssertInstance, makeAssertBrandedRatio, + assertRelativeTime, + assertTimestamp, + makeAssertTimerService, }; diff --git a/packages/governance/src/contractGovernance/paramManager.js b/packages/governance/src/contractGovernance/paramManager.js index 426abfcc0e9..79746474430 100644 --- a/packages/governance/src/contractGovernance/paramManager.js +++ b/packages/governance/src/contractGovernance/paramManager.js @@ -8,10 +8,13 @@ import { assertAllDefined } from '@agoric/internal'; import { ParamTypes } from '../constants.js'; import { + assertTimestamp, + assertRelativeTime, makeAssertBrandedRatio, makeAssertInstallation, makeAssertInstance, makeLooksLikeBrand, + makeAssertTimerService, } from './assertions.js'; import { CONTRACT_ELECTORATE } from './governParam.js'; @@ -44,6 +47,9 @@ const assertElectorateMatches = (paramManager, governedParams) => { * @property {(name: string, value: Ratio) => ParamManagerBuilder} addRatio * @property {(name: string, value: import('@endo/marshal').CopyRecord) => ParamManagerBuilder} addRecord * @property {(name: string, value: string) => ParamManagerBuilder} addString + * @property {(name: string, value: import('@agoric/time/src/types').TimerService) => ParamManagerBuilder} addTimerService + * @property {(name: string, value: import('@agoric/time/src/types').Timestamp) => ParamManagerBuilder} addTimestamp + * @property {(name: string, value: import('@agoric/time/src/types').RelativeTime) => ParamManagerBuilder} addRelativeTime * @property {(name: string, value: any) => ParamManagerBuilder} addUnknown * @property {() => AnyParamManager} build */ @@ -184,6 +190,25 @@ const makeParamManagerBuilder = (publisherKit, zoe) => { return builder; }; + /** @type {(name: string, value: import('@agoric/time/src/types').TimerService, builder: ParamManagerBuilder) => ParamManagerBuilder} */ + const addTimerService = (name, value, builder) => { + const assertTimerService = makeAssertTimerService(name); + buildCopyParam(name, value, assertTimerService, ParamTypes.TIMER_SERVICE); + return builder; + }; + + /** @type {(name: string, value: import('@agoric/time/src/types').Timestamp, builder: ParamManagerBuilder) => ParamManagerBuilder} */ + const addTimestamp = (name, value, builder) => { + buildCopyParam(name, value, assertTimestamp, ParamTypes.TIMESTAMP); + return builder; + }; + + /** @type {(name: string, value: import('@agoric/time/src/types').RelativeTime, builder: ParamManagerBuilder) => ParamManagerBuilder} */ + const addRelativeTime = (name, value, builder) => { + buildCopyParam(name, value, assertRelativeTime, ParamTypes.RELATIVE_TIME); + return builder; + }; + /** @type {(name: string, value: any, builder: ParamManagerBuilder) => ParamManagerBuilder} */ const addUnknown = (name, value, builder) => { const assertUnknown = _v => true; @@ -356,6 +381,9 @@ const makeParamManagerBuilder = (publisherKit, zoe) => { getRatio: name => getTypedParam(ParamTypes.RATIO, name), getRecord: name => getTypedParam(ParamTypes.PASSABLE_RECORD, name), getString: name => getTypedParam(ParamTypes.STRING, name), + getTimerService: name => getTypedParam(ParamTypes.TIMER_SERVICE, name), + getTimestamp: name => getTypedParam(ParamTypes.TIMESTAMP, name), + getRelativeTime: name => getTypedParam(ParamTypes.RELATIVE_TIME, name), getUnknown: name => getTypedParam(ParamTypes.UNKNOWN, name), getVisibleValue, getInternalParamValue, @@ -379,6 +407,9 @@ const makeParamManagerBuilder = (publisherKit, zoe) => { addRatio: (n, v) => addRatio(n, v, builder), addRecord: (n, v) => addRecord(n, v, builder), addString: (n, v) => addString(n, v, builder), + addTimerService: (n, v) => addTimerService(n, v, builder), + addRelativeTime: (n, v) => addRelativeTime(n, v, builder), + addTimestamp: (n, v) => addTimestamp(n, v, builder), build, }; return builder; diff --git a/packages/governance/src/contractGovernance/typedParamManager.js b/packages/governance/src/contractGovernance/typedParamManager.js index 0234ba06871..a87768bf66a 100644 --- a/packages/governance/src/contractGovernance/typedParamManager.js +++ b/packages/governance/src/contractGovernance/typedParamManager.js @@ -66,6 +66,8 @@ const isAsync = { * | ST<'nat'> * | ST<'ratio'> * | ST<'string'> + * | ST<'timestamp'> + * | ST<'relativeTime'> * | ST<'unknown'>} SyncSpecTuple * * @typedef {['invitation', Invitation]} AsyncSpecTuple diff --git a/packages/governance/src/contractHelper.js b/packages/governance/src/contractHelper.js index 62b2a4d3f25..8ec6165232f 100644 --- a/packages/governance/src/contractHelper.js +++ b/packages/governance/src/contractHelper.js @@ -4,6 +4,11 @@ import { getMethodNames, objectMap } from '@agoric/internal'; import { ignoreContext } from '@agoric/vat-data'; import { keyEQ, M } from '@agoric/store'; import { AmountShape, BrandShape } from '@agoric/ertp'; +import { + RelativeTimeShape, + TimestampShape, + TimerServiceShape, +} from '@agoric/time'; import { assertElectorateMatches } from './contractGovernance/paramManager.js'; import { makeParamManagerFromTerms } from './contractGovernance/typedParamManager.js'; @@ -23,6 +28,9 @@ const publicMixinAPI = harden({ getNat: M.call().returns(M.bigint()), getRatio: M.call().returns(M.record()), getString: M.call().returns(M.string()), + getTimerService: M.call().returns(TimerServiceShape), + getTimestamp: M.call().returns(TimestampShape), + getRelativeTime: M.call().returns(RelativeTimeShape), getUnknown: M.call().returns(M.any()), }); @@ -51,6 +59,9 @@ const facetHelpers = (zcf, paramManager) => { getNat: paramManager.getNat, getRatio: paramManager.getRatio, getString: paramManager.getString, + getTimerService: paramManager.getTimerService, + getTimestamp: paramManager.getTimestamp, + getRelativeTime: paramManager.getRelativeTime, getUnknown: paramManager.getUnknown, }; diff --git a/packages/governance/src/types-ambient.js b/packages/governance/src/types-ambient.js index 9b8f0889bfc..aafc9cab80d 100644 --- a/packages/governance/src/types-ambient.js +++ b/packages/governance/src/types-ambient.js @@ -427,6 +427,9 @@ * @property {(name: string) => bigint} getNat * @property {(name: string) => Ratio} getRatio * @property {(name: string) => string} getString + * @property {(name: string) => import('@agoric/time/src/types').TimerService} getTimerService + * @property {(name: string) => import('@agoric/time/src/types').Timestamp} getTimestamp + * @property {(name: string) => import('@agoric/time/src/types').RelativeTime} getRelativeTime * @property {(name: string) => any} getUnknown * @property {(name: string, proposedValue: ParamValue) => ParamValue} getVisibleValue - for * most types, the visible value is the same as proposedValue. For Invitations diff --git a/packages/inter-protocol/scripts/add-collateral-core.js b/packages/inter-protocol/scripts/add-collateral-core.js index bc12fd21c33..77189de9c11 100644 --- a/packages/inter-protocol/scripts/add-collateral-core.js +++ b/packages/inter-protocol/scripts/add-collateral-core.js @@ -72,6 +72,18 @@ export const psmGovernanceBuilder = async ({ psm: publishRef( install('../src/psm/psm.js', '../bundles/bundle-psm.js'), ), + vaults: publishRef( + install( + '../src/vaultFactory/vaultFactory.js', + '../bundles/bundle-vaults.js', + ), + ), + auction: publishRef( + install( + '../src/auction/auctioneer.js', + '../bundles/bundle-auction.js', + ), + ), econCommitteeCharter: publishRef( install( '../src/econCommitteeCharter.js', diff --git a/packages/inter-protocol/scripts/deploy-contracts.js b/packages/inter-protocol/scripts/deploy-contracts.js index 65c90565217..42496ca4c47 100644 --- a/packages/inter-protocol/scripts/deploy-contracts.js +++ b/packages/inter-protocol/scripts/deploy-contracts.js @@ -13,6 +13,7 @@ const contractRefs = [ '../bundles/bundle-vaultFactory.js', '../bundles/bundle-reserve.js', '../bundles/bundle-psm.js', + '../bundles/bundle-auction.js', '../../vats/bundles/bundle-mintHolder.js', ]; const contractRoots = contractRefs.map(ref => diff --git a/packages/inter-protocol/scripts/init-core.js b/packages/inter-protocol/scripts/init-core.js index a937a039acc..98b191164dc 100644 --- a/packages/inter-protocol/scripts/init-core.js +++ b/packages/inter-protocol/scripts/init-core.js @@ -36,6 +36,7 @@ const installKeyGroups = { ], }, main: { + auction: ['../src/auction/auctioneer.js', '../bundles/bundle-auction.js'], vaultFactory: [ '../src/vaultFactory/vaultFactory.js', '../bundles/bundle-vaultFactory.js', diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js new file mode 100644 index 00000000000..99b3a272f8c --- /dev/null +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -0,0 +1,319 @@ +import '@agoric/zoe/exported.js'; +import '@agoric/zoe/src/contracts/exported.js'; +import '@agoric/governance/exported.js'; + +import { M, makeScalarBigMapStore, provide } from '@agoric/vat-data'; +import { AmountMath, AmountShape } from '@agoric/ertp'; +import { Far } from '@endo/marshal'; +import { mustMatch } from '@agoric/store'; +import { observeNotifier } from '@agoric/notifier'; + +import { + atomicRearrange, + ceilMultiplyBy, + floorDivideBy, + makeRatioFromAmounts, + multiplyRatios, + ratioGTE, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { TimeMath } from '@agoric/time'; +import { E } from '@endo/captp'; +import { makeTracer } from '@agoric/internal'; + +import { makeDiscountBook, makePriceBook } from './discountBook.js'; +import { + AuctionState, + isDiscountedPriceHigher, + makeRatioPattern, +} from './util.js'; +import { keyToTime } from './sortedOffers.js'; + +const { Fail } = assert; + +/** + * @file The book represents the collateral-specific state of an ongoing + * auction. It holds the book, the lockedPrice, and the collateralSeat that has + * the allocation of assets for sale. + * + * The book contains orders for a particular collateral. It holds two kinds of + * orders: one has a price in terms of a Currency amount, the other is priced as + * a discount (or markup) from the most recent oracle price. + * + * Offers can be added in three ways. When the auction is not active, prices are + * automatically added to the appropriate collection. If a new offer is at or + * above the current price of an active auction, it will be settled immediately. + * If the offer is below the current price, it will be added, and settled when + * the price reaches that level. + */ + +const trace = makeTracer('AucBook', false); + +const priceFrom = quote => + makeRatioFromAmounts( + quote.quoteAmount.value[0].amountOut, + quote.quoteAmount.value[0].amountIn, + ); + +/** @typedef {import('@agoric/vat-data').Baggage} Baggage */ + +export const makeAuctionBook = async ( + baggage, + zcf, + currencyBrand, + collateralBrand, + priceAuthority, +) => { + const makeZeroRatio = () => + makeRatioFromAmounts( + AmountMath.makeEmpty(currencyBrand), + AmountMath.make(collateralBrand, 1n), + ); + const BidSpecShape = M.or( + { + want: AmountShape, + offerPrice: makeRatioPattern(currencyBrand, collateralBrand), + }, + { + want: AmountShape, + offerDiscount: makeRatioPattern(currencyBrand, currencyBrand), + }, + ); + + let assetsForSale = AmountMath.makeEmpty(collateralBrand); + const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); + const { zcfSeat: currencySeat } = zcf.makeEmptySeatKit(); + + let lockedPrice = makeZeroRatio(); + let updatingOracleQuote = makeZeroRatio(); + E.when(E(collateralBrand).getDisplayInfo(), ({ decimalPlaces = 9n }) => { + // TODO(CTH) use this to keep a current price that can be published in state. + const quoteNotifier = E(priceAuthority).makeQuoteNotifier( + AmountMath.make(collateralBrand, 10n ** decimalPlaces), + currencyBrand, + ); + + observeNotifier(quoteNotifier, { + updateState: quote => { + trace(`BOOK notifier ${priceFrom(quote).numerator.value}`); + return (updatingOracleQuote = priceFrom(quote)); + }, + fail: reason => { + throw Error(`auction observer of ${collateralBrand} failed: ${reason}`); + }, + finish: done => { + throw Error(`auction observer for ${collateralBrand} died: ${done}`); + }, + }); + }); + + let curAuctionPrice = makeZeroRatio(); + + const discountBook = provide(baggage, 'discountBook', () => { + const discountStore = makeScalarBigMapStore('orderedVaultStore', { + durable: true, + }); + return makeDiscountBook(discountStore, currencyBrand, collateralBrand); + }); + + const priceBook = provide(baggage, 'sortedOffers', () => { + const priceStore = makeScalarBigMapStore('orderedVaultStore', { + durable: true, + }); + return makePriceBook(priceStore, currencyBrand, collateralBrand); + }); + + const settle = (seat, collateralWanted) => { + const { Currency: currencyAvailable } = seat.getCurrentAllocation(); + const { Collateral: collateralAvailable } = + collateralSeat.getCurrentAllocation(); + if (!collateralAvailable || AmountMath.isEmpty(collateralAvailable)) { + return AmountMath.makeEmptyFromAmount(collateralWanted); + } + + /** @type {Amount<'nat'>} */ + const collateralTarget = AmountMath.min( + collateralWanted, + collateralAvailable, + ); + + const currencyNeeded = ceilMultiplyBy(collateralTarget, curAuctionPrice); + if (AmountMath.isEmpty(currencyNeeded)) { + seat.fail('price fell to zero'); + return AmountMath.makeEmptyFromAmount(collateralWanted); + } + + let collateralRecord; + let currencyRecord; + if (AmountMath.isGTE(currencyAvailable, currencyNeeded)) { + collateralRecord = { + Collateral: collateralTarget, + }; + currencyRecord = { + Currency: currencyNeeded, + }; + } else { + const affordableCollateral = floorDivideBy( + currencyAvailable, + curAuctionPrice, + ); + collateralRecord = { + Collateral: affordableCollateral, + }; + currencyRecord = { + Currency: currencyAvailable, + }; + } + + trace('settle', { currencyRecord, collateralRecord }); + + atomicRearrange( + zcf, + harden([ + [collateralSeat, seat, collateralRecord], + [seat, currencySeat, currencyRecord], + ]), + ); + return collateralRecord.Collateral; + }; + + const isActive = auctionState => auctionState === AuctionState.ACTIVE; + + const acceptOffer = (seat, price, want, timestamp, auctionState) => { + trace('acceptPrice'); + // Offer has ZcfSeat, offerArgs (w/price) and timeStamp + + let collateralSold = AmountMath.makeEmptyFromAmount(want); + if (isActive(auctionState) && ratioGTE(price, curAuctionPrice)) { + collateralSold = settle(seat, want); + + if (AmountMath.isEmpty(seat.getCurrentAllocation().Currency)) { + seat.exit(); + return; + } + } + + const stillWant = AmountMath.subtract(want, collateralSold); + if (!AmountMath.isEmpty(stillWant)) { + priceBook.add(seat, price, stillWant, timestamp); + } else { + seat.exit(); + } + }; + + const acceptDiscountOffer = ( + seat, + discount, + want, + timestamp, + auctionState, + ) => { + trace('accept discount'); + let collateralSold = AmountMath.makeEmptyFromAmount(want); + + if ( + isActive(auctionState) && + isDiscountedPriceHigher(discount, curAuctionPrice, lockedPrice) + ) { + collateralSold = settle(seat, want); + if (AmountMath.isEmpty(seat.getCurrentAllocation().Currency)) { + seat.exit(); + return; + } + } + + const stillWant = AmountMath.subtract(want, collateralSold); + if (!AmountMath.isEmpty(stillWant)) { + discountBook.add(seat, discount, stillWant, timestamp); + } else { + seat.exit(); + } + }; + + return Far('AuctionBook', { + addAssets(assetAmount, sourceSeat) { + trace('add assets'); + assetsForSale = AmountMath.add(assetsForSale, assetAmount); + atomicRearrange( + zcf, + harden([[sourceSeat, collateralSeat, { Collateral: assetAmount }]]), + ); + }, + settleAtNewRate(reduction) { + curAuctionPrice = multiplyRatios(reduction, lockedPrice); + + const pricedOffers = priceBook.offersAbove(curAuctionPrice); + const discOffers = discountBook.offersAbove(reduction); + + // requested price or discount gives no priority beyond specifying which + // round the order will be service in. + const prioritizedOffers = [...pricedOffers, ...discOffers].sort( + ([a], [b]) => TimeMath.compareAbs(keyToTime(a), keyToTime(b)), + ); + + trace(`settling`, pricedOffers.length, discOffers.length); + prioritizedOffers.forEach(([key, { seat, price: p, wanted }]) => { + const collateralSold = settle(seat, wanted); + + if (AmountMath.isEmpty(seat.getCurrentAllocation().Currency)) { + seat.exit(); + if (p) { + priceBook.delete(key); + } else { + discountBook.delete(key); + } + } else if (!AmountMath.isEmpty(collateralSold)) { + if (p) { + priceBook.updateReceived(key, collateralSold); + } else { + discountBook.updateReceived(key, collateralSold); + } + } + }); + }, + getCurrentPrice() { + return curAuctionPrice; + }, + hasOrders() { + return discountBook.hasOrders() || priceBook.hasOrders(); + }, + lockOraclePriceForRound() { + trace(`locking `, updatingOracleQuote); + lockedPrice = updatingOracleQuote; + }, + + setStartingRate(rate) { + trace('set startPrice', lockedPrice); + curAuctionPrice = multiplyRatios(lockedPrice, rate); + }, + addOffer(bidSpec, seat, auctionState) { + mustMatch(bidSpec, BidSpecShape); + + if (bidSpec.offerPrice) { + return acceptOffer( + seat, + bidSpec.offerPrice, + bidSpec.want, + 0n, + auctionState, + ); + } else if (bidSpec.offerDiscount) { + return acceptDiscountOffer( + seat, + bidSpec.offerDiscount, + bidSpec.want, + 2n, + auctionState, + ); + } else { + throw Fail`Offer was neither a price nor a discount`; + } + }, + getSeats() { + return { collateralSeat, currencySeat }; + }, + exitAllSeats() { + priceBook.exitAllSeats(); + discountBook.exitAllSeats(); + }, + }); +}; diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js new file mode 100644 index 00000000000..2eb9c90fd1e --- /dev/null +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -0,0 +1,262 @@ +import '@agoric/zoe/exported.js'; +import '@agoric/zoe/src/contracts/exported.js'; +import '@agoric/governance/exported.js'; + +import { Far } from '@endo/marshal'; +import { E } from '@endo/eventual-send'; +import { M, makeScalarBigMapStore, provide } from '@agoric/vat-data'; +import { AmountMath } from '@agoric/ertp'; +import { + atomicRearrange, + makeRatioFromAmounts, + makeRatio, + natSafeMath, + floorMultiplyBy, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { AmountKeywordRecordShape } from '@agoric/zoe/src/typeGuards.js'; +import { handleParamGovernance } from '@agoric/governance'; +import { makeTracer } from '@agoric/internal'; + +import { makeAuctionBook } from './auctionBook.js'; +import { BASIS_POINTS } from './util.js'; +import { makeScheduler } from './scheduler.js'; +import { auctioneerParamTypes } from './params.js'; + +/** @typedef {import('@agoric/vat-data').Baggage} Baggage */ + +const { Fail, quote: q } = assert; + +const trace = makeTracer('Auction', true); + +const makeBPRatio = (rate, currencyBrand, collateralBrand = currencyBrand) => + makeRatioFromAmounts( + AmountMath.make(currencyBrand, rate), + AmountMath.make(collateralBrand, 10000n), + ); + +/** + * @param {ZCF & {timerService: import('@agoric/time/src/types').TimerService, priceAuthority: PriceAuthority}>} zcf + * @param {{initialPoserInvitation: Invitation, storageNode: StorageNode, marshaller: Marshaller}} privateArgs + * @param {Baggage} baggage + */ +export const start = async (zcf, privateArgs, baggage) => { + const { brands, timerService: timer, priceAuthority } = zcf.getTerms(); + timer || Fail`Timer must be in Auctioneer terms`; + const timerBrand = await E(timer).getTimerBrand(); + + const books = provide(baggage, 'auctionBooks', () => + makeScalarBigMapStore('orderedVaultStore', { + durable: true, + }), + ); + const deposits = provide(baggage, 'deposits', () => + makeScalarBigMapStore('deposits', { + durable: true, + }), + ); + const addDeposit = (seat, amount) => { + if (deposits.has(amount.brand)) { + const depositListForBrand = deposits.get(amount.brand); + deposits.set( + amount.brand, + harden([...depositListForBrand, { seat, amount }]), + ); + } else { + deposits.init(amount.brand, harden([{ seat, amount }])); + } + }; + + // could be above or below 100%. in basis points + let currentDiscountRate; + + const distributeProceeds = () => { + // assert collaterals in map match known collaterals + for (const brand of deposits.keys()) { + const book = books.get(brand); + const { collateralSeat, currencySeat } = book.getSeats(); + + const depositsForBrand = deposits.get(brand); + if (depositsForBrand.length === 1) { + // send it all to the one + const liqSeat = depositsForBrand[0].seat; + atomicRearrange( + zcf, + harden([ + [collateralSeat, liqSeat, collateralSeat.getCurrentAllocation()], + ]), + ); + liqSeat.exit(); + } else { + const totalDeposits = depositsForBrand.reduce((prev, { amount }) => { + return AmountMath.add(prev, amount); + }, AmountMath.makeEmpty(brand)); + const curCollateral = + depositsForBrand[0].seat.getCurrentAllocation().Collateral; + if (AmountMath.isEmpty(curCollateral)) { + const currencyRaised = currencySeat.getCurrentAllocation().Currency; + for (const { seat, amount } of deposits.get(brand).values()) { + const payment = floorMultiplyBy( + amount, + makeRatioFromAmounts(currencyRaised, totalDeposits), + ); + atomicRearrange( + zcf, + harden([[currencySeat, seat, { Currency: payment }]]), + ); + seat.exit(); + } + // TODO(cth) sweep away dust + } else { + Fail`Split up incomplete sale`; + } + } + } + }; + const releaseSeats = () => { + for (const brand of deposits.keys()) { + books.get(brand).exitAllSeats(); + } + }; + + const { publicMixin, creatorMixin, makeFarGovernorFacet, params } = + await handleParamGovernance( + zcf, + privateArgs.initialPoserInvitation, + // @ts-expect-error XXX How to type this? + auctioneerParamTypes, + privateArgs.storageNode, + privateArgs.marshaller, + ); + + const tradeEveryBook = () => { + const discountRatio = makeRatio( + currentDiscountRate, + brands.Currency, + BASIS_POINTS, + ); + + [...books.entries()].forEach(([_collateralBrand, book]) => { + book.settleAtNewRate(discountRatio); + }); + }; + + const driver = Far('Auctioneer', { + descendingStep: () => { + trace('descent'); + + natSafeMath.isGTE(currentDiscountRate, params.getDiscountStep()) || + Fail`rates must fall ${currentDiscountRate}`; + + currentDiscountRate = natSafeMath.subtract( + currentDiscountRate, + params.getDiscountStep(), + ); + + tradeEveryBook(); + + if (!natSafeMath.isGTE(currentDiscountRate, params.getDiscountStep())) { + // end trading + } + }, + finalize: () => { + trace('finalize'); + distributeProceeds(); + releaseSeats(); + }, + startRound() { + trace('startRound'); + + currentDiscountRate = params.getStartingRate(); + [...books.entries()].forEach(([_collateralBrand, book]) => { + book.lockOraclePriceForRound(); + book.setStartingRate(makeBPRatio(currentDiscountRate, brands.Currency)); + }); + + tradeEveryBook(); + }, + }); + + // @ts-expect-error types are correct. How to convince TS? + const scheduler = await makeScheduler(driver, timer, params, timerBrand); + const depositOfferHandler = zcfSeat => { + const { Collateral: collateralAmount } = zcfSeat.getCurrentAllocation(); + const book = books.get(collateralAmount.brand); + trace(`deposited ${q(collateralAmount)}`); + book.addAssets(collateralAmount, zcfSeat); + addDeposit(zcfSeat, collateralAmount); + return 'deposited'; + }; + + const getDepositInvitation = () => + zcf.makeInvitation(depositOfferHandler, 'deposit Collateral'); + + const publicFacet = Far('publicFacet', { + getBidInvitation(collateralBrand) { + const newBidHandler = (zcfSeat, bidSpec) => { + if (books.has(collateralBrand)) { + const auctionBook = books.get(collateralBrand); + auctionBook.addOffer(bidSpec, zcfSeat, scheduler.getAuctionState()); + return 'Your offer has been received'; + } else { + zcfSeat.exit(`No book for brand ${collateralBrand}`); + return 'Your offer was refused'; + } + }; + + return zcf.makeInvitation( + newBidHandler, + 'new bid', + {}, + harden({ + give: AmountKeywordRecordShape, + want: AmountKeywordRecordShape, + // XXX is there a standard Exit Pattern? + exit: M.any(), + }), + ); + }, + getSchedules() { + return E(scheduler).getSchedule(); + }, + getDepositInvitation, + ...publicMixin, + ...params, + }); + + const limitedCreatorFacet = Far('creatorFacet', { + async addBrand(issuer, collateralBrand, kwd) { + if (!baggage.has(kwd)) { + baggage.init(kwd, makeScalarBigMapStore(kwd, { durable: true })); + } + const newBook = await makeAuctionBook( + baggage.get(kwd), + zcf, + brands.Currency, + collateralBrand, + priceAuthority, + ); + zcf.saveIssuer(issuer, kwd); + books.init(collateralBrand, newBook); + }, + // TODO (cth) if it's in public, doesn't also need to be in creatorFacet. + getDepositInvitation, + getSchedule() { + return E(scheduler).getSchedule(); + }, + ...creatorMixin, + }); + + const governorFacet = makeFarGovernorFacet(limitedCreatorFacet); + + return { publicFacet, creatorFacet: governorFacet }; +}; + +/** @typedef {ContractOf} AuctioneerContract */ +/** @typedef {AuctioneerContract['publicFacet']} AuctioneerPublicFacet */ +/** @typedef {AuctioneerContract['creatorFacet']} AuctioneerCreatorFacet */ diff --git a/packages/inter-protocol/src/auction/discountBook.js b/packages/inter-protocol/src/auction/discountBook.js new file mode 100644 index 00000000000..abfb94d8736 --- /dev/null +++ b/packages/inter-protocol/src/auction/discountBook.js @@ -0,0 +1,112 @@ +// book of offers to buy liquidating vaults with prices in terms of discount +// from the current oracle price. + +import { Far } from '@endo/marshal'; +import { mustMatch, M } from '@agoric/store'; +import { AmountMath } from '@agoric/ertp'; + +import { + toDiscountedRateOfferKey, + toPriceOfferKey, + toPriceComparator, + toDiscountComparator, +} from './sortedOffers.js'; +import { makeRatioPattern } from './util.js'; + +// multiple offers might be provided with the same timestamp (since the time +// granularity is limited to blocks), so we increment with each offer for +// uniqueness. +let mostRecentTimestamp = 0n; +const makeNextTimestamp = () => { + return timestamp => { + if (timestamp > mostRecentTimestamp) { + mostRecentTimestamp = timestamp; + return timestamp; + } + mostRecentTimestamp += 1n; + return mostRecentTimestamp; + }; +}; +const nextTimestamp = makeNextTimestamp(); + +// prices in this book are expressed as percentage of full price. .4 is 60% off. +// 1.1 is 10% above par. +export const makeDiscountBook = (store, currencyBrand, collateralBrand) => { + return Far('discountBook ', { + add(seat, discount, wanted, proposedTimestamp) { + // TODO(cth) mustMatch(discount, DISCOUNT_PATTERN); + + const time = nextTimestamp(proposedTimestamp); + const key = toDiscountedRateOfferKey(discount, time); + const empty = AmountMath.makeEmpty(collateralBrand); + const bidderRecord = { seat, discount, wanted, time, received: empty }; + store.init(key, harden(bidderRecord)); + return key; + }, + offersAbove(discount) { + return [...store.entries(M.gte(toDiscountComparator(discount)))]; + }, + hasOrders() { + return store.getSize() > 0; + }, + delete(key) { + store.delete(key); + }, + updateReceived(key, sold) { + const oldRec = store.get(key); + store.set( + key, + harden({ ...oldRec, received: AmountMath.add(oldRec.received, sold) }), + ); + }, + exitAllSeats() { + for (const { seat } of store.entries()) { + if (!seat.hasExited()) { + seat.exit(); + } + } + }, + }); +}; + +export const makePriceBook = (store, currencyBrand, collateralBrand) => { + const RATIO_PATTERN = makeRatioPattern(currencyBrand, collateralBrand); + return Far('discountBook ', { + add(seat, price, wanted, proposedTimestamp) { + mustMatch(price, RATIO_PATTERN); + + const time = nextTimestamp(proposedTimestamp); + const key = toPriceOfferKey(price, time); + const empty = AmountMath.makeEmpty(collateralBrand); + const bidderRecord = { seat, price, wanted, time, received: empty }; + store.init(key, harden(bidderRecord)); + return key; + }, + offersAbove(price) { + return [...store.entries(M.gte(toPriceComparator(price)))]; + }, + firstOffer() { + return [...store.keys()][0]; + }, + hasOrders() { + return store.getSize() > 0; + }, + delete(key) { + store.delete(key); + }, + updateReceived(key, sold) { + const oldRec = store.get(key); + store.set( + key, + harden({ ...oldRec, received: AmountMath.add(oldRec.received, sold) }), + ); + }, + exitAllSeats() { + for (const [_, { seat }] of store.entries()) { + if (!seat.hasExited()) { + seat.exit(); + } + } + }, + }); +}; diff --git a/packages/inter-protocol/src/auction/params.js b/packages/inter-protocol/src/auction/params.js new file mode 100644 index 00000000000..98a19f7f768 --- /dev/null +++ b/packages/inter-protocol/src/auction/params.js @@ -0,0 +1,220 @@ +import { + CONTRACT_ELECTORATE, + makeParamManager, + ParamTypes, +} from '@agoric/governance'; +import { objectMap } from '@agoric/internal'; +import { TimerBrandShape, TimeMath } from '@agoric/time'; +import { M } from '@agoric/store'; + +/** @typedef {import('@agoric/governance/src/contractGovernance/typedParamManager.js').AsyncSpecTuple} AsyncSpecTuple */ +/** @typedef {import('@agoric/governance/src/contractGovernance/typedParamManager.js').SyncSpecTuple} SyncSpecTuple */ + +// TODO duplicated with zoe/src/TypeGuards.js +export const InvitationShape = M.remotable('Invitation'); + +// The auction will start at AUCTION_START_DELAY seconds after a multiple of +// START_FREQUENCY, with the price at STARTING_RATE. Every CLOCK_STEP, the price +// will be reduced by DISCOUNT_STEP, as long as the rate is at or above +// LOWEST_RATE, or until START_FREQUENCY has elapsed. + +// in seconds, how often to start an auction +export const START_FREQUENCY = 'StartFrequency'; +// in seconds, how often to reduce the price +export const CLOCK_STEP = 'ClockStep'; +// discount or markup for starting price in basis points. 9999 = 1 bp discount +export const STARTING_RATE = 'StartingRate'; +// A limit below which the price will not be discounted. +export const LOWEST_RATE = 'LowestRate'; +// amount to reduce prices each time step in bp, as % of the start price +export const DISCOUNT_STEP = 'DiscountStep'; +// VaultManagers liquidate vaults at a frequency configured by START_FREQUENCY. +// Auctions start this long after the hour to give vaults time to finish. +export const AUCTION_START_DELAY = 'AuctionStartDelay'; +// Basis Points to charge in penalty against vaults that are liquidated. Notice +// that if the penalty is less than the LOWEST_RATE discount, vault holders +// could buy their assets back at an advantageous price. +export const LIQUIDATION_PENALTY = 'LiquidationPenalty'; + +// /////// used by VaultDirector ///////////////////// +// time before each auction that the prices are locked. +export const PRICE_LOCK_PERIOD = 'PriceLockPeriod'; + +const RelativeTimePattern = { relValue: M.nat(), timerBrand: TimerBrandShape }; + +export const auctioneerParamPattern = M.splitRecord({ + [CONTRACT_ELECTORATE]: InvitationShape, + [START_FREQUENCY]: RelativeTimePattern, + [CLOCK_STEP]: RelativeTimePattern, + [STARTING_RATE]: M.nat(), + [LOWEST_RATE]: M.nat(), + [DISCOUNT_STEP]: M.nat(), + [AUCTION_START_DELAY]: RelativeTimePattern, + [PRICE_LOCK_PERIOD]: RelativeTimePattern, +}); + +export const auctioneerParamTypes = harden({ + [CONTRACT_ELECTORATE]: ParamTypes.INVITATION, + [START_FREQUENCY]: ParamTypes.RELATIVE_TIME, + [CLOCK_STEP]: ParamTypes.RELATIVE_TIME, + [STARTING_RATE]: ParamTypes.NAT, + [LOWEST_RATE]: ParamTypes.NAT, + [DISCOUNT_STEP]: ParamTypes.NAT, + [AUCTION_START_DELAY]: ParamTypes.RELATIVE_TIME, + [PRICE_LOCK_PERIOD]: ParamTypes.RELATIVE_TIME, +}); + +/** + * @param {object} initial + * @param {Amount} initial.electorateInvitationAmount + * @param {RelativeTime} initial.startFreq + * @param {RelativeTime} initial.clockStep + * @param {bigint} initial.startingRate + * @param {bigint} initial.lowestRate + * @param {bigint} initial.discountStep + * @param {RelativeTime} initial.auctionStartDelay + * @param {RelativeTime} initial.priceLockPeriod + * @param {import('@agoric/time/src/types').TimerBrand} initial.timerBrand + */ +export const makeAuctioneerParams = ({ + electorateInvitationAmount, + startFreq, + clockStep, + lowestRate, + startingRate, + discountStep, + auctionStartDelay, + priceLockPeriod, + timerBrand, +}) => { + return harden({ + [CONTRACT_ELECTORATE]: { + type: ParamTypes.INVITATION, + value: electorateInvitationAmount, + }, + [START_FREQUENCY]: { + type: ParamTypes.RELATIVE_TIME, + value: TimeMath.toRel(startFreq, timerBrand), + }, + [CLOCK_STEP]: { + type: ParamTypes.RELATIVE_TIME, + value: TimeMath.toRel(clockStep, timerBrand), + }, + [AUCTION_START_DELAY]: { + type: ParamTypes.RELATIVE_TIME, + value: TimeMath.toRel(auctionStartDelay, timerBrand), + }, + [PRICE_LOCK_PERIOD]: { + type: ParamTypes.RELATIVE_TIME, + value: TimeMath.toRel(priceLockPeriod, timerBrand), + }, + [STARTING_RATE]: { type: ParamTypes.NAT, value: startingRate }, + [LOWEST_RATE]: { type: ParamTypes.NAT, value: lowestRate }, + [DISCOUNT_STEP]: { type: ParamTypes.NAT, value: discountStep }, + }); +}; +harden(makeAuctioneerParams); + +export const toParamValueMap = typedDescriptions => { + return objectMap(typedDescriptions, value => { + return value; + }); +}; + +/** + * @param {import('@agoric/notifier').StoredPublisherKit} publisherKit + * @param {ZoeService} zoe + * @param {object} initial + * @param {Amount} initial.electorateInvitationAmount + * @param {RelativeTime} initial.startFreq + * @param {RelativeTime} initial.clockStep + * @param {bigint} initial.startingRate + * @param {bigint} initial.lowestRate + * @param {bigint} initial.discountStep + * @param {RelativeTime} initial.auctionStartDelay + * @param {RelativeTime} initial.priceLockPeriod + * @param {import('@agoric/time/src/types').TimerBrand} initial.timerBrand + */ +export const makeAuctioneerParamManager = (publisherKit, zoe, initial) => { + return makeParamManager( + publisherKit, + { + [CONTRACT_ELECTORATE]: [ + ParamTypes.INVITATION, + initial[CONTRACT_ELECTORATE], + ], + // @ts-expect-error type confusion + [START_FREQUENCY]: [ParamTypes.RELATIVE_TIME, initial[START_FREQUENCY]], + // @ts-expect-error type confusion + [CLOCK_STEP]: [ParamTypes.RELATIVE_TIME, initial[CLOCK_STEP]], + [STARTING_RATE]: [ParamTypes.NAT, initial[STARTING_RATE]], + [LOWEST_RATE]: [ParamTypes.NAT, initial[LOWEST_RATE]], + [DISCOUNT_STEP]: [ParamTypes.NAT, initial[DISCOUNT_STEP]], + // @ts-expect-error type confusion + [AUCTION_START_DELAY]: [ + ParamTypes.RELATIVE_TIME, + initial[AUCTION_START_DELAY], + ], + // @ts-expect-error type confusion + [PRICE_LOCK_PERIOD]: [ + ParamTypes.RELATIVE_TIME, + initial[PRICE_LOCK_PERIOD], + ], + }, + zoe, + ); +}; +harden(makeAuctioneerParamManager); + +/** + * @param {{storageNode: ERef, marshaller: ERef}} caps + * @param {{ + * electorateInvitationAmount: Amount, + * priceAuthority: ERef, + * timer: ERef, + * startFreq: RelativeTime, + * clockStep: RelativeTime, + * discountStep: bigint, + * startingRate: bigint, + * lowestRate: bigint, + * auctionStartDelay: RelativeTime, + * priceLockPeriod: RelativeTime, + * timerBrand: import('@agoric/time/src/types').TimerBrand, + * }} opts + */ +export const makeGovernedTerms = ( + { storageNode: _storageNode, marshaller: _marshaller }, + { + electorateInvitationAmount, + priceAuthority, + timer, + startFreq, + clockStep, + lowestRate, + startingRate, + discountStep, + auctionStartDelay, + priceLockPeriod, + timerBrand, + }, +) => { + // TODO(CTH) use storageNode and Marshaller + return harden({ + priceAuthority, + timerService: timer, + governedParams: makeAuctioneerParams({ + electorateInvitationAmount, + startFreq, + clockStep, + startingRate, + lowestRate, + discountStep, + auctionStartDelay, + priceLockPeriod, + timerBrand, + }), + }); +}; +harden(makeGovernedTerms); + +/** @typedef {ReturnType} AuctionParamManaager */ diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js new file mode 100644 index 00000000000..dd9fba8b59f --- /dev/null +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -0,0 +1,206 @@ +import { E } from '@endo/eventual-send'; +import { TimeMath } from '@agoric/time'; +import { Far } from '@endo/marshal'; +import { natSafeMath } from '@agoric/zoe/src/contractSupport/index.js'; +import { makeTracer } from '@agoric/internal'; +import { AuctionState } from './util.js'; + +const { Fail } = assert; +const { subtract, multiply, floorDivide } = natSafeMath; + +const trace = makeTracer('SCHED', false); + +/** + * @file The scheduler is presumed to be quiescent between auction rounds. Each + * Auction round consists of a sequence of steps with decreasing prices. There + * should always be a next schedule, but between rounds, liveSchedule is null. + * + * The lock period that the liquidators use might start before the previous + * round has finished, so we need to scheduled the next round each time an + * auction starts. This means if the scheduling parameters change, it'll be a + * full cycle before we switch. Otherwise, the vaults wouldn't know when to + * start their lock period. + */ + +const makeCancelToken = () => { + let tokenCount = 1; + return Far(`cancelToken${(tokenCount += 1)}`, {}); +}; + +/** + * @typedef {object} AuctionDriver + * @property {() => void} descendingStep + * @property {() => void} finalize + * @property {() => void} startRound + */ + +/** + * @param {AuctionDriver} auctionDriver + * @param {import('@agoric/time/src/types').TimerService} timer + * @param {Awaited} params + * @param {import('@agoric/time/src/types').TimerBrand} timerBrand + */ +export const makeScheduler = async ( + auctionDriver, + timer, + params, + timerBrand, +) => { + // live version is non-null when an auction is active. + let liveSchedule; + // Next should always be defined after initialization unless it's paused + let nextSchedule; + const stepCancelToken = makeCancelToken(); + + // XXX why can't it be @type {AuctionState}? + /** @type {'active' | 'waiting'} */ + let auctionState = AuctionState.WAITING; + + const computeRoundTiming = baseTime => { + // currently a TimeValue; hopefully a TimeRecord soon + /** @type {RelativeTime} */ + // @ts-expect-error cast + const freq = params.getStartFrequency(); + /** @type {RelativeTime} */ + // @ts-expect-error cast + const clockStep = params.getClockStep(); + /** @type {NatValue} */ + // @ts-expect-error cast + const startingRate = params.getStartingRate(); + /** @type {NatValue} */ + // @ts-expect-error cast + const discountStep = params.getDiscountStep(); + /** @type {RelativeTime} */ + // @ts-expect-error cast + const lockPeriod = params.getPriceLockPeriod(); + /** @type {NatValue} */ + // @ts-expect-error cast + const lowestRate = params.getLowestRate(); + + /** @type {RelativeTime} */ + // @ts-expect-error cast + const startDelay = params.getAuctionStartDelay(); + TimeMath.compareRel(freq, startDelay) > 0 || + Fail`startFrequency must exceed startDelay, ${freq}, ${startDelay}`; + TimeMath.compareRel(freq, lockPeriod) > 0 || + Fail`startFrequency must exceed lock period, ${freq}, ${lockPeriod}`; + + const rateChange = subtract(startingRate, lowestRate); + const requestedSteps = floorDivide(rateChange, discountStep); + requestedSteps > 0n || + Fail`discountStep ${discountStep} too large for requested rates`; + const duration = TimeMath.multiplyRelNat(clockStep, requestedSteps); + + TimeMath.compareRel(duration, freq) < 0 || + Fail`Frequency ${freq} must exceed duration ${duration}`; + const steps = TimeMath.divideRelRel(duration, clockStep); + steps > 0n || + Fail`clockStep ${clockStep} too long for auction duration ${duration}`; + const endRate = subtract(startingRate, multiply(steps, discountStep)); + + const actualDuration = TimeMath.multiplyRelNat(clockStep, steps); + // computed start is baseTime + freq - (now mod freq). if there are hourly + // starts, we add an hour to the current time, and subtract now mod freq. + // Then we add the delay + const startTime = TimeMath.addAbsRel( + TimeMath.addAbsRel( + baseTime, + TimeMath.subtractRelRel(freq, TimeMath.modAbsRel(baseTime, freq)), + ), + startDelay, + ); + const endTime = TimeMath.addAbsRel(startTime, actualDuration); + + const next = { startTime, endTime, steps, endRate, startDelay, clockStep }; + return harden(next); + }; + + const clockTick = (timeValue, schedule) => { + const time = TimeMath.toAbs(timeValue, timerBrand); + + trace('clockTick', schedule.startTime, time); + if (TimeMath.compareAbs(time, schedule.startTime) >= 0) { + if (auctionState !== AuctionState.ACTIVE) { + auctionState = AuctionState.ACTIVE; + auctionDriver.startRound(); + } else { + auctionDriver.descendingStep(); + } + } + + if (TimeMath.compareAbs(time, schedule.endTime) >= 0) { + trace('LastStep', time); + auctionState = AuctionState.WAITING; + + auctionDriver.finalize(); + const afterNow = TimeMath.addAbsRel(time, TimeMath.toRel(1n, timerBrand)); + nextSchedule = computeRoundTiming(afterNow); + liveSchedule = undefined; + + E(timer).cancel(stepCancelToken); + } + }; + + const scheduleRound = (schedule, time) => { + trace('nextRound', time); + + const { startTime } = schedule; + trace('START ', startTime); + + const startDelay = + TimeMath.compareAbs(startTime, time) > 0 + ? TimeMath.subtractAbsAbs(startTime, time) + : TimeMath.subtractAbsAbs(startTime, startTime); + + E(timer).repeatAfter( + startDelay, + schedule.clockStep, + Far('SchedulerWaker', { + wake(t) { + clockTick(t, schedule); + }, + }), + stepCancelToken, + ); + }; + + const scheduleNextRound = start => { + console.log(`SCHED nextRound`); + E(timer).setWakeup( + start, + Far('SchedulerWaker', { + wake(time) { + // eslint-disable-next-line no-use-before-define + startAuction(time); + }, + }), + ); + }; + + const startAuction = async time => { + !liveSchedule || Fail`can't start an auction round while one is active`; + + liveSchedule = nextSchedule; + const after = TimeMath.addAbsRel( + liveSchedule.startTime, + TimeMath.toRel(1n, timerBrand), + ); + nextSchedule = computeRoundTiming(after); + scheduleRound(liveSchedule, time); + scheduleNextRound(TimeMath.toAbs(nextSchedule.startTime)); + }; + + const baseNow = await E(timer).getCurrentTimestamp(); + const now = TimeMath.toAbs(baseNow, timerBrand); + nextSchedule = computeRoundTiming(now); + scheduleNextRound(nextSchedule.startTime); + + return Far('scheduler', { + getSchedule: () => + harden({ + liveAuctionSchedule: liveSchedule, + nextAuctionSchedule: nextSchedule, + }), + getAuctionState: () => auctionState, + }); +}; diff --git a/packages/inter-protocol/src/auction/sortedOffers.js b/packages/inter-protocol/src/auction/sortedOffers.js new file mode 100644 index 00000000000..dcc7f8446c1 --- /dev/null +++ b/packages/inter-protocol/src/auction/sortedOffers.js @@ -0,0 +1,144 @@ +import { makeRatio } from '@agoric/zoe/src/contractSupport/index.js'; +import { TimeMath } from '@agoric/time'; + +import { decodeNumber, encodeNumber } from '../vaultFactory/storeUtils.js'; + +// We want earlier times to sort the same direction as higher prices, so we +// subtract the timestamp from millisecond time in the year 2286. This works for +// timestamps in seconds or millis. The alternative considered was inverting, +// but floats don't have enough resolution to convert back to the same timestamp +// This will work just fine for at least 250 years. And notice that these +// timestamps are used for sorting during an auction and don't need to be stored +// long-term. We could safely subtract from a timestamp that's now + 1 month. +const FarFuture = 10000000000000n; +const encodeTimestamp = t => FarFuture - t; + +/** + * Prices might be more or less than one. + * + * @param {Ratio} price price quote in IST/Collateral + * @returns {number} + */ +const priceAsFloat = price => { + const n = Number(price.numerator.value); + const d = Number(price.denominator.value); + return n / d; +}; + +/** + * Prices might be more or less than one. + * + * @param {Ratio} discount price quote in IST/IST + * @returns {number} + */ +const rateAsFloat = discount => { + const n = Number(discount.numerator.value); + const d = Number(discount.denominator.value); + return n / d; +}; + +export const toPriceComparator = offerPrice => { + assert(offerPrice); + const mostSignificantPart = encodeNumber(priceAsFloat(offerPrice)); + return `${mostSignificantPart}:`; +}; + +/** + * Sorts by ratio in descending price. + * + * @param {Ratio} offerPrice IST/collateral + * @param {Timestamp} offerTime + * @returns {string} lexically sortable string in which highest price is first, + * ties will be broken by time of offer + */ +export const toPriceOfferKey = (offerPrice, offerTime) => { + assert(offerPrice); + assert(offerTime); + // until DB supports composite keys, copy its method for turning numbers to DB + // entry keys + const mostSignificantPart = encodeNumber(priceAsFloat(offerPrice)); + return `${mostSignificantPart}:${encodeTimestamp(offerTime)}`; +}; + +const priceRatioFromFloat = (floatPrice, numBrand, denomBrand, useDecimals) => { + const denominatorValue = 10 ** useDecimals; + return makeRatio( + BigInt(Math.trunc(decodeNumber(floatPrice) * denominatorValue)), + numBrand, + BigInt(denominatorValue), + denomBrand, + ); +}; + +const discountRatioFromFloat = ( + floatDiscount, + numBrand, + denomBrand, + useDecimals, +) => { + const denominatorValue = 10 ** useDecimals; + return makeRatio( + BigInt(Math.trunc(decodeNumber(floatDiscount) * denominatorValue)), + numBrand, + BigInt(denominatorValue), + denomBrand, + ); +}; + +/** + * fromPriceOfferKey is only used for diagnostics. + * + * @param {string} key + * @param {Brand} numBrand + * @param {Brand} denomBrand + * @param {number} useDecimals + * @returns {[normalizedPrice: Ratio, offerTime: Timestamp]} + */ +export const fromPriceOfferKey = (key, numBrand, denomBrand, useDecimals) => { + const [pricePart, timePart] = key.split(':'); + return [ + priceRatioFromFloat(pricePart, numBrand, denomBrand, useDecimals), + BigInt(encodeTimestamp(BigInt(timePart))), + ]; +}; + +export const toDiscountComparator = rate => { + assert(rate); + const mostSignificantPart = encodeNumber(rateAsFloat(rate)); + return `${mostSignificantPart}:`; +}; + +/** + * Sorts offers expressed as percentage of the current oracle price. + * + * @param {Ratio} rate + * @param {Timestamp} offerTime + * @returns {string} lexically sortable string in which highest price is first, + * ties will be broken by time of offer + */ +export const toDiscountedRateOfferKey = (rate, offerTime) => { + assert(rate); + assert(offerTime); + // until DB supports composite keys, copy its method for turning numbers to DB + // entry keys + const mostSignificantPart = encodeNumber(rateAsFloat(rate)); + return `${mostSignificantPart}:${encodeTimestamp(offerTime)}`; +}; + +/** + * fromDiscountedRateOfferKey is only used for diagnostics. + * + * @param {string} key + * @param {Brand} brand + * @param {number} useDecimals + * @returns {[normalizedPrice: Ratio, offerTime: Timestamp]} + */ +export const fromDiscountedRateOfferKey = (key, brand, useDecimals) => { + const [discountPart, timePart] = key.split(':'); + return [ + discountRatioFromFloat(discountPart, brand, brand, useDecimals), + BigInt(encodeTimestamp(BigInt(timePart))), + ]; +}; + +export const keyToTime = key => TimeMath.toAbs(Number(key.split(':')[1])); diff --git a/packages/inter-protocol/src/auction/util.js b/packages/inter-protocol/src/auction/util.js new file mode 100644 index 00000000000..2c008bce9ee --- /dev/null +++ b/packages/inter-protocol/src/auction/util.js @@ -0,0 +1,30 @@ +import { M } from '@agoric/store'; +import { + multiplyRatios, + ratioGTE, +} from '@agoric/zoe/src/contractSupport/index.js'; + +export const DiscountOfferShape = M.any(); +export const PriceOfferShape = M.any(); + +export const BASIS_POINTS = 10000n; + +/** + * Constants for Auction State. + * + * @type {{ ACTIVE: 'active', WAITING: 'waiting' }} + */ +export const AuctionState = { + ACTIVE: 'active', + WAITING: 'waiting', +}; + +export const makeRatioPattern = (nBrand, dBrand) => { + return harden({ + numerator: { brand: nBrand, value: M.nat() }, + denominator: { brand: dBrand, value: M.nat() }, + }); +}; + +export const isDiscountedPriceHigher = (discount, currentPrice, oracleQuote) => + ratioGTE(multiplyRatios(oracleQuote, discount), currentPrice); diff --git a/packages/inter-protocol/src/proposals/core-proposal.js b/packages/inter-protocol/src/proposals/core-proposal.js index 212f1e93804..4025a646a03 100644 --- a/packages/inter-protocol/src/proposals/core-proposal.js +++ b/packages/inter-protocol/src/proposals/core-proposal.js @@ -22,6 +22,7 @@ const SHARED_MAIN_MANIFEST = harden({ priceAuthority: 'priceAuthority', economicCommitteeCreatorFacet: 'economicCommittee', reserveKit: 'reserve', + auction: 'auction', }, produce: { vaultFactoryKit: 'VaultFactory' }, brand: { consume: { [Stable.symbol]: 'zoe' } }, @@ -35,6 +36,7 @@ const SHARED_MAIN_MANIFEST = harden({ instance: { consume: { reserve: 'reserve', + auction: 'auction', }, produce: { VaultFactory: 'VaultFactory', @@ -73,6 +75,28 @@ const SHARED_MAIN_MANIFEST = harden({ }, }, }, + + [econBehaviors.startAuction.name]: { + consume: { + zoe: 'zoe', + board: 'board', + chainTimerService: 'timer', + priceAuthority: 'priceAuthority', + chainStorage: true, + economicCommitteeCreatorFacet: 'economicCommittee', + }, + produce: { auctionKit: 'auction' }, + instance: { + produce: { auction: 'auction' }, + }, + installation: { + consume: { auctionInstallation: 'zoe' }, + contractGovernor: 'zoe', + }, + issuer: { + consume: { [Stable.symbol]: 'zoe' }, + }, + }, }); const REWARD_MANIFEST = harden({ @@ -165,6 +189,7 @@ export const getManifestForMain = ( manifest: SHARED_MAIN_MANIFEST, installations: { VaultFactory: restoreRef(installKeys.vaultFactory), + auction: restoreRef(installKeys.auction), feeDistributor: restoreRef(installKeys.feeDistributor), reserve: restoreRef(installKeys.reserve), }, diff --git a/packages/inter-protocol/src/proposals/econ-behaviors.js b/packages/inter-protocol/src/proposals/econ-behaviors.js index 5a2c25ff5b5..263f7dcbea0 100644 --- a/packages/inter-protocol/src/proposals/econ-behaviors.js +++ b/packages/inter-protocol/src/proposals/econ-behaviors.js @@ -13,6 +13,7 @@ import { LienBridgeId, makeStakeReporter } from '../my-lien.js'; import { makeReserveTerms } from '../reserve/params.js'; import { makeStakeFactoryTerms } from '../stakeFactory/params.js'; import { makeGovernedTerms } from '../vaultFactory/params.js'; +import { makeGovernedTerms as makeGovernedATerms } from '../auction/params.js'; const trace = makeTracer('RunEconBehaviors', false); @@ -26,6 +27,8 @@ const BASIS_POINTS = 10_000n; * @typedef {import('../stakeFactory/stakeFactory.js').StakeFactoryPublic} StakeFactoryPublic * @typedef {import('../reserve/assetReserve.js').GovernedAssetReserveFacetAccess} GovernedAssetReserveFacetAccess * @typedef {import('../vaultFactory/vaultFactory.js').VaultFactoryContract['publicFacet']} VaultFactoryPublicFacet + * @typedef {import('../auction/auctioneer.js').AuctioneerPublicFacet} AuctioneerPublicFacet + * @typedef {import('../auction/auctioneer.js').AuctioneerCreatorFacet} AuctioneerCreatorFacet */ /** @@ -69,6 +72,12 @@ const BASIS_POINTS = 10_000n; * governorCreatorFacet: GovernedContractFacetAccess, * adminFacet: AdminFacet, * }, + * auctionKit: { + * publicFacet: AuctioneerPublicFacet, + * creatorFacet: AuctioneerCreatorFacet, + * governorCreatorFacet: GovernedContractFacetAccess<{},{}>, + * adminFacet: AdminFacet, + * } * minInitialDebt: NatValue, * }>} EconomyBootstrapSpace */ @@ -204,7 +213,7 @@ export const startVaultFactory = async ( }, instance: { produce: instanceProduce, - consume: { reserve: reserveInstance }, + consume: { auction: auctionInstance, reserve: reserveInstance }, }, installation: { consume: { @@ -247,6 +256,7 @@ export const startVaultFactory = async ( const centralBrand = await centralBrandP; const reservePublicFacet = await E(zoe).getPublicFacet(reserveInstance); + const auctionPublicFacet = await E(zoe).getPublicFacet(auctionInstance); const storageNode = await makeStorageNodeChild(chainStorage, STORAGE_PATH); const marshaller = await E(board).getReadonlyMarshaller(); @@ -262,6 +272,7 @@ export const startVaultFactory = async ( bootstrapPaymentValue: 0n, shortfallInvitationAmount, endorsedUi, + auctionPublicFacet, }, ); @@ -479,6 +490,128 @@ export const startLienBridge = async ({ lienBridge.resolve(reporter); }; +/** + * @param {EconomyBootstrapPowers} powers + * @param {object} config + * @param {any} [config.auctionParams] + */ +export const startAuction = async ( + { + consume: { + zoe, + board, + chainTimerService, + priceAuthority, + chainStorage, + economicCommitteeCreatorFacet: electorateCreatorFacet, + }, + produce: { auctionKit }, + instance: { + produce: { auction: auctionInstance }, + }, + installation: { + consume: { + auction: auctionInstallation, + contractGovernor: contractGovernorInstallation, + }, + }, + issuer: { + consume: { [Stable.symbol]: runIssuerP }, + }, + }, + { + auctionParams = { + startFreq: 3600n, + clockStep: 3n * 60n, + startingRate: 10500n, + lowestRate: 4500n, + discountStep: 500n, + auctionStartDelay: 2n, + priceLockPeriod: 3n, + }, + } = {}, +) => { + trace('startAuction'); + const STORAGE_PATH = 'auction'; + + const poserInvitationP = E(electorateCreatorFacet).getPoserInvitation(); + + const [initialPoserInvitation, electorateInvitationAmount, runIssuer] = + await Promise.all([ + poserInvitationP, + E(E(zoe).getInvitationIssuer()).getAmountOf(poserInvitationP), + runIssuerP, + ]); + + const timerBrand = await E(chainTimerService).getTimerBrand(); + + const storageNode = await makeStorageNodeChild(chainStorage, STORAGE_PATH); + const marshaller = await E(board).getReadonlyMarshaller(); + + const auctionTerms = makeGovernedATerms( + { storageNode, marshaller }, + { + priceAuthority, + timer: chainTimerService, + startFreq: auctionParams.startFreq, + clockStep: auctionParams.clockStep, + lowestRate: auctionParams.lowestRate, + startingRate: auctionParams.startingRate, + discountStep: auctionParams.discountStep, + auctionStartDelay: auctionParams.auctionStartDelay, + priceLockPeriod: auctionParams.priceLockPeriod, + electorateInvitationAmount, + timerBrand, + }, + ); + + const governorTerms = await deeplyFulfilledObject( + harden({ + timer: chainTimerService, + governedContractInstallation: auctionInstallation, + governed: { + terms: auctionTerms, + issuerKeywordRecord: { Currency: runIssuer }, + storageNode, + marshaller, + }, + }), + ); + + /** @type {{ publicFacet: GovernorPublic, creatorFacet: GovernedContractFacetAccess, adminFacet: AdminFacet}} */ + const governorStartResult = await E(zoe).startInstance( + contractGovernorInstallation, + undefined, + governorTerms, + harden({ + electorateCreatorFacet, + governed: { + initialPoserInvitation, + storageNode, + marshaller, + }, + }), + ); + + const [governedInstance, governedCreatorFacet, governedPublicFacet] = + await Promise.all([ + E(governorStartResult.creatorFacet).getInstance(), + E(governorStartResult.creatorFacet).getCreatorFacet(), + E(governorStartResult.creatorFacet).getPublicFacet(), + ]); + + auctionKit.resolve( + harden({ + creatorFacet: governedCreatorFacet, + governorCreatorFacet: governorStartResult.creatorFacet, + adminFacet: governorStartResult.adminFacet, + publicFacet: governedPublicFacet, + }), + ); + + auctionInstance.resolve(governedInstance); +}; + /** * @typedef {EconomyBootstrapPowers & PromiseSpaceOf<{ * client: ClientManager, diff --git a/packages/inter-protocol/src/proposals/startPSM.js b/packages/inter-protocol/src/proposals/startPSM.js index 6d2f8628409..100c5226f98 100644 --- a/packages/inter-protocol/src/proposals/startPSM.js +++ b/packages/inter-protocol/src/proposals/startPSM.js @@ -282,6 +282,7 @@ export const installGovAndPSMContracts = async ({ contractGovernor, committee, binaryVoteCounter, + auction, psm, econCommitteeCharter, }, @@ -298,6 +299,7 @@ export const installGovAndPSMContracts = async ({ contractGovernor, committee, binaryVoteCounter, + auction, psm, econCommitteeCharter, }).map(async ([name, producer]) => { @@ -325,6 +327,7 @@ export const PSM_GOV_MANIFEST = { contractGovernor: 'zoe', committee: 'zoe', binaryVoteCounter: 'zoe', + auction: 'zoe', psm: 'zoe', econCommitteeCharter: 'zoe', }, @@ -420,6 +423,7 @@ export const getManifestForPsm = ( return { manifest: PSM_MANIFEST, installations: { + auction: restoreRef(installKeys.auctioneer), psm: restoreRef(installKeys.psm), mintHolder: restoreRef(installKeys.mintHolder), }, diff --git a/packages/inter-protocol/src/vaultFactory/params.js b/packages/inter-protocol/src/vaultFactory/params.js index 6487e644f11..d762604b5ed 100644 --- a/packages/inter-protocol/src/vaultFactory/params.js +++ b/packages/inter-protocol/src/vaultFactory/params.js @@ -140,6 +140,7 @@ harden(makeVaultDirectorParamManager); * reservePublicFacet: AssetReservePublicFacet, * loanTiming: LoanTiming, * shortfallInvitationAmount: Amount, + * auctionPublicFacet: import('../auction/auctioneer.js').AuctioneerPublicFacet, * endorsedUi?: string, * }} opts */ @@ -147,6 +148,7 @@ export const makeGovernedTerms = ( { storageNode, marshaller }, { bootstrapPaymentValue, + auctionPublicFacet, electorateInvitationAmount, loanTiming, minInitialDebt, @@ -173,6 +175,7 @@ export const makeGovernedTerms = ( return harden({ priceAuthority, + auctionPublicFacet, loanTimingParams, reservePublicFacet, timerService: timer, diff --git a/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js b/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js index 04279d86bae..d9f844f2752 100644 --- a/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js +++ b/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js @@ -74,11 +74,6 @@ export const makePrioritizedVaults = (store, higherHighestCb = () => {}) => { /** @type {string | undefined} */ let firstKey; - // Check if this ratio of debt to collateral would be the highest known. If - // so, reset our highest and invoke the callback. This can be called on new - // vaults and when we get a state update for a vault changing balances. - /** @param {Ratio} collateralToDebt */ - /** * Ratio of the least-collateralized vault, if there is one. * diff --git a/packages/inter-protocol/src/vaultFactory/storeUtils.js b/packages/inter-protocol/src/vaultFactory/storeUtils.js index 22e4cd43b3a..0077c51787e 100644 --- a/packages/inter-protocol/src/vaultFactory/storeUtils.js +++ b/packages/inter-protocol/src/vaultFactory/storeUtils.js @@ -44,7 +44,7 @@ const decodeData = makeDecodePassable(); * @param {number} n * @returns {string} */ -const encodeNumber = n => { +export const encodeNumber = n => { assert.typeof(n, 'number'); return encodeData(n); }; @@ -53,7 +53,7 @@ const encodeNumber = n => { * @param {string} encoded * @returns {number} */ -const decodeNumber = encoded => { +export const decodeNumber = encoded => { const result = decodeData(encoded); assert.typeof(result, 'number'); return result; diff --git a/packages/inter-protocol/test/auction/test-auctionBook.js b/packages/inter-protocol/test/auction/test-auctionBook.js new file mode 100644 index 00000000000..3594dabd089 --- /dev/null +++ b/packages/inter-protocol/test/auction/test-auctionBook.js @@ -0,0 +1,225 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { AmountMath } from '@agoric/ertp'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { setupZCFTest } from '@agoric/zoe/test/unitTests/zcf/setupZcfTest.js'; +import { + makeRatio, + makeRatioFromAmounts, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { makeOffer } from '@agoric/zoe/test/unitTests/makeOffer.js'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; +import { eventLoopIteration } from '@agoric/notifier/tools/testSupports.js'; + +import { setup } from '../../../zoe/test/unitTests/setupBasicMints.js'; +import { makeAuctionBook } from '../../src/auction/auctionBook.js'; +import { AuctionState } from '../../src/auction/util.js'; + +const buildManualPriceAuthority = initialPrice => + makeManualPriceAuthority({ + actualBrandIn: initialPrice.denominator.brand, + actualBrandOut: initialPrice.numerator.brand, + timer: buildManualTimer(), + initialPrice, + }); + +test('states', async t => { + const { moolaKit, moola, simoleanKit, simoleans } = setup(); + + const { zcf } = await setupZCFTest(); + await zcf.saveIssuer(moolaKit.issuer, 'Moola'); + await zcf.saveIssuer(simoleanKit.issuer, 'Sim'); + const baggage = makeScalarBigMapStore('zcfBaggage', { durable: true }); + + const initialPrice = makeRatioFromAmounts(moola(20n), simoleans(100n)); + const pa = buildManualPriceAuthority(initialPrice); + const auct = await makeAuctionBook( + baggage, + zcf, + moolaKit.brand, + simoleanKit.brand, + pa, + ); + t.deepEqual( + auct.getCurrentPrice(), + makeRatioFromAmounts( + AmountMath.makeEmpty(moolaKit.brand), + AmountMath.make(simoleanKit.brand, 1n), + ), + ); + auct.setStartingRate(makeRatio(90n, moolaKit.brand, 100n)); +}); + +const makeSeatWithAssets = async (zoe, zcf, giveAmount, giveKwd, issuerKit) => { + const payment = issuerKit.mint.mintPayment(giveAmount); + const { zcfSeat } = await makeOffer( + zoe, + zcf, + { give: { [giveKwd]: giveAmount } }, + { [giveKwd]: payment }, + ); + return zcfSeat; +}; + +test('acceptOffer fakeSeat', async t => { + const { moolaKit, moola, simoleans, simoleanKit } = setup(); + + const { zoe, zcf } = await setupZCFTest(); + await zcf.saveIssuer(moolaKit.issuer, 'Moola'); + await zcf.saveIssuer(simoleanKit.issuer, 'Sim'); + + const payment = moolaKit.mint.mintPayment(moola(100n)); + + const { zcfSeat } = await makeOffer( + zoe, + zcf, + { give: { Bid: moola(100n) }, want: { Ask: simoleans(0n) } }, + { Bid: payment }, + ); + const baggage = makeScalarBigMapStore('zcfBaggage', { durable: true }); + const donorSeat = await makeSeatWithAssets( + zoe, + zcf, + simoleans(500n), + 'Collateral', + simoleanKit, + ); + + const initialPrice = makeRatioFromAmounts(moola(20n), simoleans(100n)); + const pa = buildManualPriceAuthority(initialPrice); + + const book = await makeAuctionBook( + baggage, + zcf, + moolaKit.brand, + simoleanKit.brand, + pa, + ); + pa.setPrice(makeRatioFromAmounts(moola(11n), simoleans(10n))); + await eventLoopIteration(); + + book.addAssets(AmountMath.make(simoleanKit.brand, 123n), donorSeat); + book.lockOraclePriceForRound(); + book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + + book.addOffer( + harden({ + offerPrice: makeRatioFromAmounts(moola(10n), simoleans(100n)), + want: simoleans(50n), + }), + zcfSeat, + AuctionState.ACTIVE, + ); + + t.true(book.hasOrders()); +}); + +test('getOffers to a price limit', async t => { + const { moolaKit, moola, simoleanKit, simoleans } = setup(); + + const { zoe, zcf } = await setupZCFTest(); + await zcf.saveIssuer(moolaKit.issuer, 'Moola'); + await zcf.saveIssuer(simoleanKit.issuer, 'Sim'); + + const baggage = makeScalarBigMapStore('zcfBaggage', { durable: true }); + + const initialPrice = makeRatioFromAmounts(moola(20n), simoleans(100n)); + const pa = buildManualPriceAuthority(initialPrice); + + const donorSeat = await makeSeatWithAssets( + zoe, + zcf, + simoleans(500n), + 'Collateral', + simoleanKit, + ); + + const book = await makeAuctionBook( + baggage, + zcf, + moolaKit.brand, + simoleanKit.brand, + pa, + ); + pa.setPrice(makeRatioFromAmounts(moola(11n), simoleans(10n))); + await eventLoopIteration(); + + book.addAssets(AmountMath.make(simoleanKit.brand, 123n), donorSeat); + const zcfSeat = await makeSeatWithAssets( + zoe, + zcf, + moola(100n), + 'Bid', + moolaKit, + ); + + book.lockOraclePriceForRound(); + book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + + book.addOffer( + harden({ + offerDiscount: makeRatioFromAmounts(moola(10n), moola(100n)), + want: simoleans(50n), + }), + zcfSeat, + AuctionState.ACTIVE, + ); + + t.true(book.hasOrders()); +}); + +test('getOffers w/discount', async t => { + const { moolaKit, moola, simoleanKit, simoleans } = setup(); + + const { zoe, zcf } = await setupZCFTest(); + await zcf.saveIssuer(moolaKit.issuer, 'Moola'); + await zcf.saveIssuer(simoleanKit.issuer, 'Sim'); + + const baggage = makeScalarBigMapStore('zcfBaggage', { durable: true }); + + const donorSeat = await makeSeatWithAssets( + zoe, + zcf, + simoleans(500n), + 'Collateral', + simoleanKit, + ); + + const initialPrice = makeRatioFromAmounts(moola(20n), simoleans(100n)); + const pa = buildManualPriceAuthority(initialPrice); + + const book = await makeAuctionBook( + baggage, + zcf, + moolaKit.brand, + simoleanKit.brand, + pa, + ); + + pa.setPrice(makeRatioFromAmounts(moola(11n), simoleans(10n))); + await eventLoopIteration(); + book.addAssets(AmountMath.make(simoleanKit.brand, 123n), donorSeat); + + book.lockOraclePriceForRound(); + book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + + const zcfSeat = await makeSeatWithAssets( + zoe, + zcf, + moola(100n), + 'Bid', + moolaKit, + ); + + book.addOffer( + harden({ + offerDiscount: makeRatioFromAmounts(moola(10n), moola(100n)), + want: simoleans(50n), + }), + zcfSeat, + AuctionState.ACTIVE, + ); + + t.true(book.hasOrders()); +}); diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js new file mode 100644 index 00000000000..ee44cdea0df --- /dev/null +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -0,0 +1,605 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import '@agoric/zoe/exported.js'; + +import { makeIssuerKit } from '@agoric/ertp'; +import { E } from '@endo/eventual-send'; +import { makeBoard } from '@agoric/vats/src/lib-board.js'; +import { + makeRatioFromAmounts, + makeRatio, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import { deeplyFulfilled } from '@endo/marshal'; +import { eventLoopIteration } from '@agoric/notifier/tools/testSupports.js'; +import { makePriceAuthorityRegistry } from '@agoric/zoe/tools/priceAuthorityRegistry.js'; +import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; +import { assertPayoutAmount } from '@agoric/zoe/test/zoeTestHelpers.js'; +import { makeScalarMapStore } from '@agoric/vat-data/src/index.js'; +import { makeTracer } from '@agoric/internal'; + +import { + makeMockChainStorageRoot, + setUpZoeForTest, + withAmountUtils, +} from '../supports.js'; +import { makeAuctioneerParams } from '../../src/auction/params.js'; +import { getInvitation, setUpInstallations } from './tools.js'; + +/** @type {import('ava').TestFn>>} */ +const test = anyTest; + +const trace = makeTracer('Test AuctContract', false); + +const defaultParams = { + startFreq: 40n, + clockStep: 5n, + startingRate: 10500n, + lowestRate: 4500n, + discountStep: 2000n, + auctionStartDelay: 10n, + priceLockPeriod: 3n, +}; + +const makeTestContext = async () => { + const { zoe } = await setUpZoeForTest(); + + const currency = withAmountUtils(makeIssuerKit('Currency')); + const collateral = withAmountUtils(makeIssuerKit('Collateral')); + + const installs = await deeplyFulfilled(setUpInstallations(zoe)); + + trace('makeContext'); + return { + zoe: await zoe, + installs, + currency, + collateral, + }; +}; + +test.before(async t => { + t.context = await makeTestContext(); +}); + +const dynamicConfig = async (t, params) => { + const { zoe, installs } = t.context; + + const { fakeInvitationAmount, fakeInvitationPayment } = await getInvitation( + zoe, + installs, + ); + const manualTimer = buildManualTimer(); + await manualTimer.advanceTo(140n); + const timerBrand = await manualTimer.getTimerBrand(); + + const { priceAuthority, adminFacet: registry } = makePriceAuthorityRegistry(); + + const governedParams = makeAuctioneerParams({ + electorateInvitationAmount: fakeInvitationAmount, + ...params, + timerBrand, + }); + + const terms = { + timerService: manualTimer, + governedParams, + priceAuthority, + }; + + return { terms, governedParams, fakeInvitationPayment, registry }; +}; + +/** + * @param {import('ava').ExecutionContext>>} t + * @param {{}} [customTerms] + * @param {any} [params] + */ +const makeAuctionDriver = async (t, customTerms, params = defaultParams) => { + const { zoe, installs, currency } = t.context; + const { terms, fakeInvitationPayment, registry } = await dynamicConfig( + t, + params, + ); + const { timerService } = terms; + const priceAuthorities = makeScalarMapStore(); + + // Each driver needs its own to avoid state pollution between tests + const mockChainStorage = makeMockChainStorageRoot(); + + const pubsubTerms = harden({ + storageNode: mockChainStorage.makeChildNode('thisPsm'), + marshaller: makeBoard().getReadonlyMarshaller(), + }); + + /** @type {Awaited>} */ + const { creatorFacet: GCF } = await E(zoe).startInstance( + installs.governor, + harden({}), + harden({ + governedContractInstallation: installs.auctioneer, + governed: { + issuerKeywordRecord: { + Currency: currency.issuer, + }, + terms: { ...terms, ...customTerms, ...pubsubTerms }, + }, + }), + { governed: { initialPoserInvitation: fakeInvitationPayment } }, + ); + // @ts-expect-error XXX Fix types + const publicFacet = E(GCF).getPublicFacet(); + // @ts-expect-error XXX Fix types + const creatorFacet = E(GCF).getCreatorFacet(); + + /** + * @param {Amount<'nat'>} giveCurrency + * @param {Amount<'nat'>} wantCollateral + * @param {Ratio} [discount] + */ + const bidForCollateralSeat = async ( + giveCurrency, + wantCollateral, + discount = undefined, + ) => { + const bidInvitation = E(publicFacet).getBidInvitation(wantCollateral.brand); + const proposal = harden({ + give: { Currency: giveCurrency }, + // IF we had multiples, the buyer could express a want. + // want: { Collateral: wantCollateral }, + }); + const payment = harden({ + Currency: currency.mint.mintPayment(giveCurrency), + }); + const offerArgs = + discount && discount.numerator.brand === discount.denominator.brand + ? { want: wantCollateral, offerDiscount: discount } + : { + want: wantCollateral, + offerPrice: + discount || + harden(makeRatioFromAmounts(giveCurrency, wantCollateral)), + }; + return E(zoe).offer(bidInvitation, proposal, payment, harden(offerArgs)); + }; + + const depositCollateral = async (collateralAmount, issuerKit) => { + const collateralPayment = E(issuerKit.mint).mintPayment( + harden(collateralAmount), + ); + const seat = E(zoe).offer( + E(creatorFacet).getDepositInvitation(), + harden({ + give: { Collateral: collateralAmount }, + }), + harden({ Collateral: collateralPayment }), + ); + await eventLoopIteration(); + + return seat; + }; + + const setupCollateralAuction = async (issuerKit, collateralAmount) => { + const collateralBrand = collateralAmount.brand; + + const pa = makeManualPriceAuthority({ + actualBrandIn: collateralBrand, + actualBrandOut: currency.brand, + timer: timerService, + initialPrice: makeRatio(100n, currency.brand, 100n, collateralBrand), + }); + priceAuthorities.init(collateralBrand, pa); + registry.registerPriceAuthority(pa, collateralBrand, currency.brand); + + await E(creatorFacet).addBrand( + issuerKit.issuer, + collateralBrand, + collateralBrand.getAllegedName(), + ); + return depositCollateral(collateralAmount, issuerKit); + }; + + return { + mockChainStorage, + publicFacet, + creatorFacet, + + /** @type {(subpath: string) => object} */ + getStorageChildBody(subpath) { + return mockChainStorage.getBody( + `mockChainStorageRoot.thisPsm.${subpath}`, + ); + }, + + async bidForCollateralPayouts(giveCurrency, wantCollateral, discount) { + const seat = bidForCollateralSeat(giveCurrency, wantCollateral, discount); + return E(seat).getPayouts(); + }, + async bidForCollateralSeat(giveCurrency, wantCollateral, discount) { + return bidForCollateralSeat(giveCurrency, wantCollateral, discount); + }, + setupCollateralAuction, + advanceTo(time) { + timerService.advanceTo(time); + }, + async updatePriceAuthority(newPrice) { + priceAuthorities.get(newPrice.denominator.brand).setPrice(newPrice); + await eventLoopIteration(); + }, + depositCollateral, + getSchedule() { + return E(creatorFacet).getSchedule(); + }, + }; +}; + +const assertPayouts = async ( + t, + seat, + currency, + collateral, + currencyValue, + collateralValue, +) => { + const { Collateral: collateralPayout, Currency: currencyPayout } = await E( + seat, + ).getPayouts(); + + if (!currencyPayout) { + currencyValue === 0n || + t.fail( + `currencyValue must be zero when no currency is paid out ${collateralValue}`, + ); + } else { + await assertPayoutAmount( + t, + currency.issuer, + currencyPayout, + currency.make(currencyValue), + 'currency payout', + ); + } + + if (!collateralPayout) { + collateralValue === 0n || + t.fail( + `collateralValue must be zero when no collateral is paid out ${collateralValue}`, + ); + } else { + await assertPayoutAmount( + t, + collateral.issuer, + collateralPayout, + collateral.make(collateralValue), + 'collateral payout', + ); + } +}; + +test('priced bid recorded', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + await driver.setupCollateralAuction(collateral, collateral.make(1000n)); + + const seat = await driver.bidForCollateralSeat( + currency.make(100n), + collateral.make(200n), + ); + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); +}); + +test('discount bid recorded', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + await driver.setupCollateralAuction(collateral, collateral.make(1000n)); + + const seat = await driver.bidForCollateralSeat( + currency.make(20n), + collateral.make(200n), + makeRatioFromAmounts(currency.make(10n), currency.make(100n)), + ); + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); +}); + +test('priced bid settled', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + await driver.setupCollateralAuction(collateral, collateral.make(1000n)); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + const schedules = await driver.getSchedule(); + t.is(schedules.nextAuctionSchedule.startTime.absValue, 170n); + + await driver.advanceTo(170n); + + const seat = await driver.bidForCollateralSeat( + currency.make(250n), + collateral.make(200n), + ); + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + + await assertPayouts(t, seat, currency, collateral, 19n, 200n); +}); + +test('discount bid settled', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + await driver.setupCollateralAuction(collateral, collateral.make(1000n)); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const schedules = await driver.getSchedule(); + t.is(schedules.nextAuctionSchedule.startTime.absValue, 170n); + await driver.advanceTo(170n); + + const seat = await driver.bidForCollateralSeat( + currency.make(250n), + collateral.make(200n), + makeRatioFromAmounts(currency.make(120n), currency.make(100n)), + ); + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + await driver.advanceTo(180n); + + // 250 - 200 * (1.1 * 1.05) + await assertPayouts(t, seat, currency, collateral, 250n - 231n, 200n); +}); + +// serial because dynamicConfig is shared across tests +test.serial('priced bid insufficient collateral added', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + await driver.setupCollateralAuction(collateral, collateral.make(20n)); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const schedules = await driver.getSchedule(); + t.is(schedules.nextAuctionSchedule.startTime.absValue, 170n); + t.is(schedules.nextAuctionSchedule.endTime.absValue, 185n); + await driver.advanceTo(170n); + + const seat = await driver.bidForCollateralSeat( + currency.make(240n), + collateral.make(200n), + ); + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + t.false(await E(seat).hasExited()); + + await driver.advanceTo(185n); + t.true(await E(seat).hasExited()); + + // 240n - 20n * (115n / 100n) + await assertPayouts(t, seat, currency, collateral, 216n, 20n); +}); + +// serial because dynamicConfig is shared across tests +test.serial('priced bid recorded then settled with price drop', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + await driver.setupCollateralAuction(collateral, collateral.make(1000n)); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const seat = await driver.bidForCollateralSeat( + currency.make(116n), + collateral.make(100n), + ); + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + + await driver.advanceTo(170n); + const schedules = await driver.getSchedule(); + t.is(schedules.liveAuctionSchedule.startTime.absValue, 170n); + t.is(schedules.liveAuctionSchedule.endTime.absValue, 185n); + + await driver.advanceTo(184n); + await driver.advanceTo(185n); + t.true(await E(seat).hasExited()); + await driver.advanceTo(190n); + + await assertPayouts(t, seat, currency, collateral, 0n, 100n); +}); + +test('priced bid settled auction price below bid', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + await driver.setupCollateralAuction(collateral, collateral.make(1000n)); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const schedules = await driver.getSchedule(); + t.is(schedules.nextAuctionSchedule.startTime.absValue, 170n); + await driver.advanceTo(170n); + + // overbid for current price + const seat = await driver.bidForCollateralSeat( + currency.make(2240n), + collateral.make(200n), + ); + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + + t.true(await E(seat).hasExited()); + await driver.advanceTo(185n); + + await assertPayouts(t, seat, currency, collateral, 2009n, 200n); +}); + +// serial because dynamicConfig is shared across tests +test.serial('complete auction liquidator gets proceeds', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + const liqSeat = await driver.setupCollateralAuction( + collateral, + collateral.make(1000n), + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const result = await E(liqSeat).getOfferResult(); + t.is(result, 'deposited'); + + await driver.advanceTo(170n); + const seat = await driver.bidForCollateralSeat( + // 1.1 * 1.05 * 200 + currency.make(231n), + collateral.make(200n), + ); + + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + await assertPayouts(t, seat, currency, collateral, 0n, 200n); + + await driver.advanceTo(185n); + + await assertPayouts(t, liqSeat, currency, collateral, 0n, 800n); +}); + +test('add assets to open auction', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + const liqSeat = await driver.setupCollateralAuction( + collateral, + collateral.make(1000n), + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const result = await E(liqSeat).getOfferResult(); + t.is(result, 'deposited'); + + const bidderSeat1 = await driver.bidForCollateralSeat( + // 1.1 * 1.05 * 200 + currency.make(231n), + collateral.make(200n), + ); + t.is(await E(bidderSeat1).getOfferResult(), 'Your offer has been received'); + + await driver.advanceTo(170n); + + const liqSeat2 = driver.depositCollateral(collateral.make(2000n), collateral); + const resultL2 = await E(liqSeat2).getOfferResult(); + t.is(resultL2, 'deposited'); + + const bidderSeat2 = await driver.bidForCollateralSeat( + // 1.1 * 1.05 * 200 + currency.make(300n), + collateral.make(500n), + ); + t.is(await E(bidderSeat2).getOfferResult(), 'Your offer has been received'); + + await driver.advanceTo(180n); + await assertPayouts(t, bidderSeat1, currency, collateral, 0n, 200n); + + await driver.advanceTo(190n); + await assertPayouts(t, liqSeat, currency, collateral, 231n / 3n, 0n); + await assertPayouts(t, liqSeat2, currency, collateral, (2n * 231n) / 3n, 0n); +}); + +// collateral quote is 1.1. asset quote is .25. 1000 C, and 500 A available. +// Prices will start with a 1.05 multiplier, and fall by .2 at each of 4 steps, +// so prices will be 1.05, .85, .65, .45, and .25. +// +// serial because dynamicConfig is shared across tests +test.serial('multiple collaterals', async t => { + const { collateral, currency } = t.context; + + const params = defaultParams; + params.lowestRate = 2500n; + + const driver = await makeAuctionDriver(t, {}, params); + const asset = withAmountUtils(makeIssuerKit('Asset')); + + const collatLiqSeat = await driver.setupCollateralAuction( + collateral, + collateral.make(1000n), + ); + const assetLiqSeat = await driver.setupCollateralAuction( + asset, + asset.make(500n), + ); + + t.is(await E(collatLiqSeat).getOfferResult(), 'deposited'); + t.is(await E(assetLiqSeat).getOfferResult(), 'deposited'); + + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(25n), asset.make(100n)), + ); + + // offers 290 for up to 300 at 1.1 * .875, so will trigger at the first discount + const bidderSeat1C = await driver.bidForCollateralSeat( + currency.make(265n), + collateral.make(300n), + makeRatioFromAmounts(currency.make(950n), collateral.make(1000n)), + ); + t.is(await E(bidderSeat1C).getOfferResult(), 'Your offer has been received'); + + // offers up to 500 for 2000 at 1.1 * 75%, so will trigger at second discount step + const bidderSeat2C = await driver.bidForCollateralSeat( + currency.make(500n), + collateral.make(2000n), + makeRatioFromAmounts(currency.make(75n), currency.make(100n)), + ); + t.is(await E(bidderSeat2C).getOfferResult(), 'Your offer has been received'); + + // offers 50 for 200 at .25 * 50% discount, so triggered at third step + const bidderSeat1A = await driver.bidForCollateralSeat( + currency.make(23n), + asset.make(200n), + makeRatioFromAmounts(currency.make(50n), currency.make(100n)), + ); + t.is(await E(bidderSeat1A).getOfferResult(), 'Your offer has been received'); + + // offers 100 for 300 at .25 * 33%, so triggered at fourth step + const bidderSeat2A = await driver.bidForCollateralSeat( + currency.make(19n), + asset.make(300n), + makeRatioFromAmounts(currency.make(100n), asset.make(1000n)), + ); + t.is(await E(bidderSeat2A).getOfferResult(), 'Your offer has been received'); + + const schedules = await driver.getSchedule(); + t.is(schedules.nextAuctionSchedule.startTime.absValue, 170n); + + await driver.advanceTo(150n); + await driver.advanceTo(170n); + await eventLoopIteration(); + await driver.advanceTo(175n); + + t.true(await E(bidderSeat1C).hasExited()); + + await assertPayouts(t, bidderSeat1C, currency, collateral, 0n, 283n); + t.false(await E(bidderSeat2C).hasExited()); + + await driver.advanceTo(180n); + t.true(await E(bidderSeat2C).hasExited()); + await assertPayouts(t, bidderSeat2C, currency, collateral, 0n, 699n); + t.false(await E(bidderSeat1A).hasExited()); + + await driver.advanceTo(185n); + t.true(await E(bidderSeat1A).hasExited()); + await assertPayouts(t, bidderSeat1A, currency, asset, 0n, 200n); + t.false(await E(bidderSeat2A).hasExited()); + + await driver.advanceTo(190n); + t.true(await E(bidderSeat2A).hasExited()); + await assertPayouts(t, bidderSeat2A, currency, asset, 0n, 300n); +}); + +test.todo('bids that are satisfied over more than one phase'); +test.todo('auction runs out of collateral with remaining bids'); diff --git a/packages/inter-protocol/test/auction/test-scheduler.js b/packages/inter-protocol/test/auction/test-scheduler.js new file mode 100644 index 00000000000..bd713e95846 --- /dev/null +++ b/packages/inter-protocol/test/auction/test-scheduler.js @@ -0,0 +1,521 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import { TimeMath } from '@agoric/time'; +import { setupZCFTest } from '@agoric/zoe/test/unitTests/zcf/setupZcfTest.js'; +import { eventLoopIteration } from '@agoric/zoe/tools/eventLoopIteration.js'; + +import { makeScheduler } from '../../src/auction/scheduler.js'; +import { + makeAuctioneerParamManager, + makeAuctioneerParams, +} from '../../src/auction/params.js'; +import { + getInvitation, + makeDefaultParams, + makeFakeAuctioneer, + makePublisherFromFakes, + setUpInstallations, +} from './tools.js'; + +/** @typedef {import('@agoric/time/src/types').TimerService} TimerService */ + +test('schedule start to finish', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + auctionStartDelay: 1n, + startFreq: 10n, + priceLockPeriod: 5n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + let now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + const scheduler = await makeScheduler( + fakeAuctioneer, + timer, + paramManager, + timer.getTimerBrand(), + ); + const schedule = scheduler.getSchedule(); + t.deepEqual(schedule.liveAuctionSchedule, undefined); + const firstSchedule = { + startTime: TimeMath.toAbs(131n, timerBrand), + endTime: TimeMath.toAbs(135n, timerBrand), + steps: 2n, + endRate: 6500n, + startDelay: TimeMath.toRel(1n, timerBrand), + clockStep: TimeMath.toRel(2n, timerBrand), + }; + t.deepEqual(schedule.nextAuctionSchedule, firstSchedule); + + t.false(fakeAuctioneer.getState().final); + t.is(fakeAuctioneer.getState().step, 0); + t.false(fakeAuctioneer.getState().final); + + await timer.advanceTo((now += 1n)); + + t.is(fakeAuctioneer.getState().step, 0); + t.false(fakeAuctioneer.getState().final); + + now = 131n; + await timer.advanceTo(now); + await eventLoopIteration(); + + const schedule2 = scheduler.getSchedule(); + t.deepEqual(schedule2.liveAuctionSchedule, firstSchedule); + t.deepEqual(schedule2.nextAuctionSchedule, { + startTime: TimeMath.toAbs(141n, timerBrand), + endTime: TimeMath.toAbs(145n, timerBrand), + steps: 2n, + endRate: 6500n, + startDelay: TimeMath.toRel(1n, timerBrand), + clockStep: TimeMath.toRel(2n, timerBrand), + }); + + t.is(fakeAuctioneer.getState().step, 1); + t.false(fakeAuctioneer.getState().final); + + // xxx I shouldn't have to tick twice. + await timer.advanceTo((now += 1n)); + await timer.advanceTo((now += 1n)); + + t.is(fakeAuctioneer.getState().step, 2); + t.false(fakeAuctioneer.getState().final); + + // final step + await timer.advanceTo((now += 1n)); + await timer.advanceTo((now += 1n)); + + t.is(fakeAuctioneer.getState().step, 3); + t.true(fakeAuctioneer.getState().final); + + // Auction finished, nothing else happens + await timer.advanceTo((now += 1n)); + await timer.advanceTo((now += 1n)); + + t.is(fakeAuctioneer.getState().step, 3); + t.true(fakeAuctioneer.getState().final); + + t.deepEqual(fakeAuctioneer.getStartRounds(), [0]); + + const finalSchedule = scheduler.getSchedule(); + t.deepEqual(finalSchedule.liveAuctionSchedule, undefined); + const secondSchedule = { + startTime: TimeMath.toAbs(141n, timerBrand), + endTime: TimeMath.toAbs(145n, timerBrand), + steps: 2n, + endRate: 6500n, + startDelay: TimeMath.toRel(1n, timerBrand), + clockStep: TimeMath.toRel(2n, timerBrand), + }; + t.deepEqual(finalSchedule.nextAuctionSchedule, secondSchedule); + + now = 140n; + await timer.advanceTo(now); + + t.deepEqual(finalSchedule.liveAuctionSchedule, undefined); + t.deepEqual(finalSchedule.nextAuctionSchedule, secondSchedule); + + await timer.advanceTo((now += 1n)); + await eventLoopIteration(); + + const schedule3 = scheduler.getSchedule(); + t.deepEqual(schedule3.liveAuctionSchedule, secondSchedule); + t.deepEqual(schedule3.nextAuctionSchedule, { + startTime: TimeMath.toAbs(151n, timerBrand), + endTime: TimeMath.toAbs(155n, timerBrand), + steps: 2n, + endRate: 6500n, + startDelay: TimeMath.toRel(1n, timerBrand), + clockStep: TimeMath.toRel(2n, timerBrand), + }); + + t.is(fakeAuctioneer.getState().step, 4); + t.false(fakeAuctioneer.getState().final); + + // xxx I shouldn't have to tick twice. + await timer.advanceTo((now += 1n)); + await timer.advanceTo((now += 1n)); + + t.is(fakeAuctioneer.getState().step, 5); + t.false(fakeAuctioneer.getState().final); + + // final step + await timer.advanceTo((now += 1n)); + await timer.advanceTo((now += 1n)); + + t.is(fakeAuctioneer.getState().step, 6); + t.true(fakeAuctioneer.getState().final); + + // Auction finished, nothing else happens + await timer.advanceTo((now += 1n)); + await timer.advanceTo((now += 1n)); + + t.is(fakeAuctioneer.getState().step, 6); + t.true(fakeAuctioneer.getState().final); + + t.deepEqual(fakeAuctioneer.getStartRounds(), [0, 3]); +}); + +test('lowest >= starting', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + lowestRate: 110n, + startingRate: 105n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + const now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { message: '-5 is negative' }, + ); +}); + +test('zero time for auction', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + startFreq: 2n, + clockStep: 3n, + auctionStartDelay: 1n, + priceLockPeriod: 1n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + const now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { message: /Frequency .* must exceed duration/ }, + ); +}); + +test('discountStep 0', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + discountStep: 0n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + const now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { message: 'Division by zero' }, + ); +}); + +test('discountStep larger than starting rate', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + startingRate: 10100n, + discountStep: 10500n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + const now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { message: /discountStep .* too large for requested rates/ }, + ); +}); + +test('start Freq 0', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + startFreq: 0n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + const now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { message: /startFrequency must exceed startDelay.*0n.*10n.*/ }, + ); +}); + +test('delay > freq', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + auctionStartDelay: 40n, + startFreq: 20n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + const now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { message: /startFrequency must exceed startDelay.*\[20n\].*\[40n\].*/ }, + ); +}); + +test('lockPeriod > freq', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + priceLockPeriod: 7200n, + startFreq: 3600n, + auctionStartDelay: 500n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + const now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { + message: /startFrequency must exceed lock period.*\[3600n\].*\[7200n\].*/, + }, + ); +}); + +// if duration = frequency, we'll start every other freq. +test('duration = freq', async t => { + const { zoe } = await setupZCFTest(); + const installations = await setUpInstallations(zoe); + /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + const timer = buildManualTimer(); + const timerBrand = await timer.getTimerBrand(); + + const fakeAuctioneer = makeFakeAuctioneer(); + const { fakeInvitationPayment } = await getInvitation(zoe, installations); + const publisherKit = makePublisherFromFakes(); + + let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + defaultParams = { + ...defaultParams, + priceLockPeriod: 20n, + startFreq: 360n, + auctionStartDelay: 5n, + clockStep: 60n, + startingRate: 100n, + lowestRate: 40n, + discountStep: 10n, + }; + const params = makeAuctioneerParams(defaultParams); + const params2 = {}; + for (const key of Object.keys(params)) { + const { value } = params[key]; + params2[key] = value; + } + + const now = 127n; + await timer.advanceTo(now); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { + message: /Frequency .* must exceed duration .*/, + }, + ); +}); diff --git a/packages/inter-protocol/test/auction/test-sortedOffers.js b/packages/inter-protocol/test/auction/test-sortedOffers.js new file mode 100644 index 00000000000..02674909859 --- /dev/null +++ b/packages/inter-protocol/test/auction/test-sortedOffers.js @@ -0,0 +1,122 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { + ratiosSame, + makeRatioFromAmounts, + quantize, +} from '@agoric/zoe/src/contractSupport/index.js'; + +import { setup } from '../../../zoe/test/unitTests/setupBasicMints.js'; +import { + fromPriceOfferKey, + toPriceOfferKey, + toDiscountedRateOfferKey, + fromDiscountedRateOfferKey, +} from '../../src/auction/sortedOffers.js'; + +const DEC25 = 1671993996n; +const DEC26 = 1672080396n; + +test('toKey price', t => { + const { moola, simoleans } = setup(); + const priceA = makeRatioFromAmounts(moola(4001n), simoleans(100n)); + const priceB = makeRatioFromAmounts(moola(4000n), simoleans(100n)); + const priceC = makeRatioFromAmounts(moola(41n), simoleans(1000n)); + const priceD = makeRatioFromAmounts(moola(40n), simoleans(1000n)); + + const keyA25 = toPriceOfferKey(priceA, DEC25); + const keyB25 = toPriceOfferKey(priceB, DEC25); + const keyC25 = toPriceOfferKey(priceC, DEC25); + const keyD25 = toPriceOfferKey(priceD, DEC25); + const keyA26 = toPriceOfferKey(priceA, DEC26); + const keyB26 = toPriceOfferKey(priceB, DEC26); + const keyC26 = toPriceOfferKey(priceC, DEC26); + const keyD26 = toPriceOfferKey(priceD, DEC26); + t.true(keyA25 > keyB25); + t.true(keyA25 > keyA26); + t.true(keyB25 > keyC25); + t.true(keyB25 > keyB26); + t.true(keyC25 > keyD25); + t.true(keyC25 > keyC26); + t.true(keyD25 > keyD26); +}); + +test('toKey discount', t => { + const { moola } = setup(); + const discountA = makeRatioFromAmounts(moola(5n), moola(100n)); + const discountB = makeRatioFromAmounts(moola(55n), moola(1000n)); + const discountC = makeRatioFromAmounts(moola(6n), moola(100n)); + const discountD = makeRatioFromAmounts(moola(10n), moola(100n)); + + const keyA25 = toDiscountedRateOfferKey(discountA, DEC25); + const keyB25 = toDiscountedRateOfferKey(discountB, DEC25); + const keyC25 = toDiscountedRateOfferKey(discountC, DEC25); + const keyD25 = toDiscountedRateOfferKey(discountD, DEC25); + const keyA26 = toDiscountedRateOfferKey(discountA, DEC26); + const keyB26 = toDiscountedRateOfferKey(discountB, DEC26); + const keyC26 = toDiscountedRateOfferKey(discountC, DEC26); + const keyD26 = toDiscountedRateOfferKey(discountD, DEC26); + t.true(keyB25 > keyA25); + t.true(keyA25 > keyA26); + t.true(keyC25 > keyB25); + t.true(keyB25 > keyB26); + t.true(keyD25 > keyC25); + t.true(keyC25 > keyC26); + t.true(keyD25 > keyD26); +}); + +test('fromKey Price', t => { + const { moola, moolaKit, simoleans, simoleanKit } = setup(); + const { brand: moolaBrand } = moolaKit; + const { brand: simBrand } = simoleanKit; + const priceA = makeRatioFromAmounts(moola(4000n), simoleans(100n)); + const priceB = makeRatioFromAmounts(moola(40n), simoleans(1000n)); + + const keyA25 = toPriceOfferKey(priceA, DEC25); + const keyB25 = toPriceOfferKey(priceB, DEC25); + + const [priceAOut, timeA] = fromPriceOfferKey(keyA25, moolaBrand, simBrand, 9); + const [priceBOut, timeB] = fromPriceOfferKey(keyB25, moolaBrand, simBrand, 9); + const N = 10n ** 9n; + t.true( + ratiosSame(priceAOut, makeRatioFromAmounts(moola(40n * N), simoleans(N))), + ); + t.true( + ratiosSame( + priceBOut, + quantize(makeRatioFromAmounts(moola(40n), simoleans(1000n)), N), + ), + ); + t.is(timeA, DEC25); + t.is(timeB, DEC25); +}); + +test('fromKey discount', t => { + const { moola, moolaKit } = setup(); + const { brand: moolaBrand } = moolaKit; + const fivePercent = makeRatioFromAmounts(moola(5n), moola(100n)); + const discountA = fivePercent; + const fivePointFivePercent = makeRatioFromAmounts(moola(55n), moola(1000n)); + const discountB = fivePointFivePercent; + + const keyA25 = toDiscountedRateOfferKey(discountA, DEC25); + const keyB25 = toDiscountedRateOfferKey(discountB, DEC25); + + const [discountAOut, timeA] = fromDiscountedRateOfferKey( + keyA25, + moolaBrand, + 9, + ); + const [discountBOut, timeB] = fromDiscountedRateOfferKey( + keyB25, + moolaBrand, + 9, + ); + t.deepEqual(quantize(discountAOut, 10000n), quantize(fivePercent, 10000n)); + t.deepEqual( + quantize(discountBOut, 10000n), + quantize(fivePointFivePercent, 10000n), + ); + t.is(timeA, DEC25); + t.is(timeB, DEC25); +}); diff --git a/packages/inter-protocol/test/auction/tools.js b/packages/inter-protocol/test/auction/tools.js new file mode 100644 index 00000000000..41db877e0d7 --- /dev/null +++ b/packages/inter-protocol/test/auction/tools.js @@ -0,0 +1,98 @@ +import { makeLoopback } from '@endo/captp'; +import { Far } from '@endo/marshal'; +import { E } from '@endo/eventual-send'; +import { makeStoredPublisherKit } from '@agoric/notifier'; +import { makeZoeKit } from '@agoric/zoe'; +import { objectMap } from '@agoric/internal'; +import { makeFakeVatAdmin } from '@agoric/zoe/tools/fakeVatAdmin.js'; +import { makeMockChainStorageRoot } from '@agoric/internal/src/storage-test-utils.js'; +import { makeFakeMarshaller } from '@agoric/notifier/tools/testSupports.js'; +import { GOVERNANCE_STORAGE_KEY } from '@agoric/governance/src/contractHelper.js'; +import contractGovernorBundle from '@agoric/governance/bundles/bundle-contractGovernor.js'; +import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; + +import { resolve as importMetaResolve } from 'import-meta-resolve'; +import * as Collect from '../../src/collect.js'; + +export const setUpInstallations = async zoe => { + const autoRefund = '@agoric/zoe/src/contracts/automaticRefund.js'; + const autoRefundUrl = await importMetaResolve(autoRefund, import.meta.url); + const autoRefundPath = new URL(autoRefundUrl).pathname; + + const bundleCache = await unsafeMakeBundleCache('./bundles/'); // package-relative + const bundles = await Collect.allValues({ + // could be called fakeCommittee. It's used as a source of invitations only + autoRefund: bundleCache.load(autoRefundPath, 'autoRefund'), + auctioneer: bundleCache.load('./src/auction/auctioneer.js', 'auctioneer'), + governor: contractGovernorBundle, + }); + return objectMap(bundles, bundle => E(zoe).install(bundle)); +}; + +export const makeDefaultParams = (invitation, timerBrand) => + harden({ + electorateInvitationAmount: invitation, + startFreq: 60n, + clockStep: 2n, + startingRate: 10500n, + lowestRate: 5500n, + discountStep: 2000n, + auctionStartDelay: 10n, + priceLockPeriod: 3n, + timerBrand, + }); + +export const makeFakeAuctioneer = () => { + const state = { step: 0, final: false }; + const startRounds = []; + + return Far('FakeAuctioneer', { + descendingStep: () => { + state.step += 1; + }, + finalize: () => (state.final = true), + getState: () => state, + startRound: () => { + startRounds.push(state.step); + state.step += 1; + state.final = false; + }, + getStartRounds: () => startRounds, + }); +}; + +/** + * Returns promises for `zoe` and the `feeMintAccess`. + * + * @param {() => void} setJig + */ +export const setUpZoeForTest = async (setJig = () => {}) => { + const { makeFar } = makeLoopback('zoeTest'); + + const { zoeService } = await makeFar( + makeZoeKit(makeFakeVatAdmin(setJig).admin, undefined), + ); + return zoeService; +}; + +// contract governor wants a committee invitation. give it a random invitation +export const getInvitation = async (zoe, installations) => { + const autoRefundFacets = await E(zoe).startInstance(installations.autoRefund); + const invitationP = E(autoRefundFacets.publicFacet).makeInvitation(); + const [fakeInvitationPayment, fakeInvitationAmount] = await Promise.all([ + invitationP, + E(E(zoe).getInvitationIssuer()).getAmountOf(invitationP), + ]); + return { fakeInvitationPayment, fakeInvitationAmount }; +}; + +/** @returns {import('@agoric/notifier').StoredPublisherKit} */ +export const makePublisherFromFakes = () => { + const storageRoot = makeMockChainStorageRoot(); + + return makeStoredPublisherKit( + storageRoot, + makeFakeMarshaller(), + GOVERNANCE_STORAGE_KEY, + ); +}; diff --git a/packages/inter-protocol/test/swingsetTests/setup.js b/packages/inter-protocol/test/swingsetTests/setup.js index 8e2825768ba..ed28f8d3597 100644 --- a/packages/inter-protocol/test/swingsetTests/setup.js +++ b/packages/inter-protocol/test/swingsetTests/setup.js @@ -2,10 +2,10 @@ import { E } from '@endo/eventual-send'; import { makeIssuerKit, AmountMath } from '@agoric/ertp'; import { makeRatio } from '@agoric/zoe/src/contractSupport/index.js'; -import buildManualTimer from '@agoric/zoe/tools/manualTimer'; -import { makeGovernedTerms as makeVaultFactoryTerms } from '../../src/vaultFactory/params'; -import { ammMock } from './mockAmm'; -import { liquidationDetailTerms } from '../../src/vaultFactory/liquidation'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { makeGovernedTerms as makeVaultFactoryTerms } from '../../src/vaultFactory/params.js'; +import { ammMock } from './mockAmm.js'; +import { liquidationDetailTerms } from '../../src/vaultFactory/liquidation.js'; const ONE_DAY = 24n * 60n * 60n; const SECONDS_PER_HOUR = 60n * 60n; diff --git a/packages/time/src/typeGuards.js b/packages/time/src/typeGuards.js index 7001100ab18..7279e2e5e18 100644 --- a/packages/time/src/typeGuards.js +++ b/packages/time/src/typeGuards.js @@ -1,8 +1,9 @@ import { M } from '@agoric/store'; -export const TimerBrandShape = M.remotable(); +export const TimerBrandShape = M.remotable('TimerBrand'); export const TimestampValueShape = M.nat(); export const RelativeTimeValueShape = M.nat(); // Should we allow negatives? +export const TimerServiceShape = M.remotable('TimerService'); export const TimestampRecordShape = harden({ timerBrand: TimerBrandShape, diff --git a/packages/vats/decentral-psm-config.json b/packages/vats/decentral-psm-config.json index 859b5dc7ba7..526148c58e4 100644 --- a/packages/vats/decentral-psm-config.json +++ b/packages/vats/decentral-psm-config.json @@ -45,6 +45,9 @@ "binaryVoteCounter": { "sourceSpec": "@agoric/governance/src/binaryVoteCounter.js" }, + "auction": { + "sourceSpec": "@agoric/inter-protocol/src/auction/auctioneer.js" + }, "psm": { "sourceSpec": "@agoric/inter-protocol/src/psm/psm.js" }, diff --git a/packages/vats/src/core/types.js b/packages/vats/src/core/types.js index d264ab95161..bb026dbbecd 100644 --- a/packages/vats/src/core/types.js +++ b/packages/vats/src/core/types.js @@ -137,13 +137,13 @@ * TokenKeyword | 'Invitation' | 'Attestation' | 'AUSD', * installation: | * 'centralSupply' | 'mintHolder' | - * 'walletFactory' | 'provisionPool' | + * 'walletFactory' | 'provisionPool' | 'auction' | * 'feeDistributor' | * 'contractGovernor' | 'committee' | 'noActionElectorate' | 'binaryVoteCounter' | * 'VaultFactory' | 'liquidate' | 'stakeFactory' | * 'Pegasus' | 'reserve' | 'psm' | 'econCommitteeCharter' | 'priceAggregator', * instance: | - * 'economicCommittee' | 'feeDistributor' | + * 'economicCommittee' | 'feeDistributor' | 'auction' | * 'VaultFactory' | 'VaultFactoryGovernor' | * 'stakeFactory' | 'stakeFactoryGovernor' | * 'econCommitteeCharter' | diff --git a/packages/vats/src/core/utils.js b/packages/vats/src/core/utils.js index 2161a21c1c8..19019da63e2 100644 --- a/packages/vats/src/core/utils.js +++ b/packages/vats/src/core/utils.js @@ -47,6 +47,7 @@ export const agoricNamesReserved = harden({ noActionElectorate: 'no action electorate', binaryVoteCounter: 'binary vote counter', VaultFactory: 'vault factory', + auction: 'auctioneer', feeDistributor: 'fee distributor', liquidate: 'liquidate', stakeFactory: 'stakeFactory', @@ -61,6 +62,7 @@ export const agoricNamesReserved = harden({ VaultFactory: 'vault factory', feeDistributor: 'fee distributor', Treasury: 'Treasury', // for compatibility + auction: 'auctioneer', VaultFactoryGovernor: 'vault factory governor', stakeFactory: 'stakeFactory', stakeFactoryGovernor: 'stakeFactory governor', diff --git a/packages/zoe/src/contractSupport/index.js b/packages/zoe/src/contractSupport/index.js index 9ca5d3a16bc..b23f37d839f 100644 --- a/packages/zoe/src/contractSupport/index.js +++ b/packages/zoe/src/contractSupport/index.js @@ -52,4 +52,8 @@ export { oneMinus, addRatios, multiplyRatios, + ratiosSame, + quantize, + ratioGTE, + subtractRatios, } from './ratio.js'; diff --git a/packages/zoe/src/contractSupport/priceQuote.js b/packages/zoe/src/contractSupport/priceQuote.js index 3ba149e36ac..6229faaf318 100644 --- a/packages/zoe/src/contractSupport/priceQuote.js +++ b/packages/zoe/src/contractSupport/priceQuote.js @@ -29,6 +29,7 @@ export const getTimestamp = quote => getPriceDescription(quote).timestamp; /** @param {Brand<'nat'>} brand */ export const unitAmount = async brand => { // Brand methods are remote + // FIXME: round trip to brand whenever unitAmount is needed? Cache displayInfo const displayInfo = await E(brand).getDisplayInfo(); const decimalPlaces = displayInfo.decimalPlaces ?? 0; return AmountMath.make(brand, 10n ** Nat(decimalPlaces)); From 3d96042546e0f5908b479e2f79908e70bdbcdc5e Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Sun, 19 Feb 2023 15:54:08 -0800 Subject: [PATCH 02/20] refactor: cleanups from working on vault liquidation --- .../inter-protocol/src/auction/auctionBook.js | 51 +++- .../inter-protocol/src/auction/auctioneer.js | 117 ++++--- .../src/auction/discountBook.js | 2 +- packages/inter-protocol/src/auction/params.js | 2 +- .../inter-protocol/src/auction/scheduler.js | 2 +- .../test/auction/test-auctionContract.js | 286 ++++++++++++++++-- 6 files changed, 366 insertions(+), 94 deletions(-) diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 99b3a272f8c..cbb0849ae22 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -86,7 +86,7 @@ export const makeAuctionBook = async ( let lockedPrice = makeZeroRatio(); let updatingOracleQuote = makeZeroRatio(); E.when(E(collateralBrand).getDisplayInfo(), ({ decimalPlaces = 9n }) => { - // TODO(CTH) use this to keep a current price that can be published in state. + // TODO(#6946) use this to keep a current price that can be published in state. const quoteNotifier = E(priceAuthority).makeQuoteNotifier( AmountMath.make(collateralBrand, 10n ** decimalPlaces), currencyBrand, @@ -94,7 +94,11 @@ export const makeAuctionBook = async ( observeNotifier(quoteNotifier, { updateState: quote => { - trace(`BOOK notifier ${priceFrom(quote).numerator.value}`); + trace( + `BOOK notifier ${priceFrom(quote).numerator.value}/${ + priceFrom(quote).denominator.value + }`, + ); return (updatingOracleQuote = priceFrom(quote)); }, fail: reason => { @@ -122,6 +126,15 @@ export const makeAuctionBook = async ( return makePriceBook(priceStore, currencyBrand, collateralBrand); }); + const removeFromOneBook = (isPriceBook, key) => { + if (isPriceBook) { + priceBook.delete(key); + } else { + discountBook.delete(key); + } + }; + + // Settle with seat. The caller is responsible for updating the book, if any. const settle = (seat, collateralWanted) => { const { Currency: currencyAvailable } = seat.getCurrentAllocation(); const { Collateral: collateralAvailable } = @@ -178,6 +191,7 @@ export const makeAuctionBook = async ( const isActive = auctionState => auctionState === AuctionState.ACTIVE; + // accept new offer. const acceptOffer = (seat, price, want, timestamp, auctionState) => { trace('acceptPrice'); // Offer has ZcfSeat, offerArgs (w/price) and timeStamp @@ -194,12 +208,14 @@ export const makeAuctionBook = async ( const stillWant = AmountMath.subtract(want, collateralSold); if (!AmountMath.isEmpty(stillWant)) { + trace('added Offer ', price, stillWant.value); priceBook.add(seat, price, stillWant, timestamp); } else { seat.exit(); } }; + // accept new discount offer. const acceptDiscountOffer = ( seat, discount, @@ -252,20 +268,23 @@ export const makeAuctionBook = async ( trace(`settling`, pricedOffers.length, discOffers.length); prioritizedOffers.forEach(([key, { seat, price: p, wanted }]) => { - const collateralSold = settle(seat, wanted); - - if (AmountMath.isEmpty(seat.getCurrentAllocation().Currency)) { - seat.exit(); - if (p) { - priceBook.delete(key); - } else { - discountBook.delete(key); - } - } else if (!AmountMath.isEmpty(collateralSold)) { - if (p) { - priceBook.updateReceived(key, collateralSold); - } else { - discountBook.updateReceived(key, collateralSold); + if (seat.hasExited()) { + removeFromOneBook(p, key); + } else { + const collateralSold = settle(seat, wanted); + + if ( + AmountMath.isEmpty(seat.getCurrentAllocation().Currency) || + AmountMath.isGTE(seat.getCurrentAllocation().Collateral, wanted) + ) { + seat.exit(); + removeFromOneBook(p, key); + } else if (!AmountMath.isGTE(collateralSold, wanted)) { + if (p) { + priceBook.updateReceived(key, collateralSold); + } else { + discountBook.updateReceived(key, collateralSold); + } } } }); diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index 2eb9c90fd1e..24eff8dcf19 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -12,6 +12,7 @@ import { makeRatio, natSafeMath, floorMultiplyBy, + provideEmptySeat, } from '@agoric/zoe/src/contractSupport/index.js'; import { AmountKeywordRecordShape } from '@agoric/zoe/src/typeGuards.js'; import { handleParamGovernance } from '@agoric/governance'; @@ -26,7 +27,7 @@ import { auctioneerParamTypes } from './params.js'; const { Fail, quote: q } = assert; -const trace = makeTracer('Auction', true); +const trace = makeTracer('Auction', false); const makeBPRatio = (rate, currencyBrand, collateralBrand = currencyBrand) => makeRatioFromAmounts( @@ -60,23 +61,26 @@ export const start = async (zcf, privateArgs, baggage) => { durable: true, }), ); + const brandToKeyword = provide(baggage, 'brandToKeyword', () => + makeScalarBigMapStore('deposits', { + durable: true, + }), + ); + + const reserveFunds = provideEmptySeat(zcf, baggage, 'collateral'); + const addDeposit = (seat, amount) => { - if (deposits.has(amount.brand)) { - const depositListForBrand = deposits.get(amount.brand); - deposits.set( - amount.brand, - harden([...depositListForBrand, { seat, amount }]), - ); - } else { - deposits.init(amount.brand, harden([{ seat, amount }])); - } + const depositListForBrand = deposits.get(amount.brand); + deposits.set( + amount.brand, + harden([...depositListForBrand, { seat, amount }]), + ); }; // could be above or below 100%. in basis points let currentDiscountRate; const distributeProceeds = () => { - // assert collaterals in map match known collaterals for (const brand of deposits.keys()) { const book = books.get(brand); const { collateralSeat, currencySeat } = book.getSeats(); @@ -85,44 +89,64 @@ export const start = async (zcf, privateArgs, baggage) => { if (depositsForBrand.length === 1) { // send it all to the one const liqSeat = depositsForBrand[0].seat; + atomicRearrange( zcf, harden([ [collateralSeat, liqSeat, collateralSeat.getCurrentAllocation()], + [currencySeat, liqSeat, currencySeat.getCurrentAllocation()], ]), ); liqSeat.exit(); - } else { - const totalDeposits = depositsForBrand.reduce((prev, { amount }) => { + deposits.set(brand, []); + } else if (depositsForBrand.length > 1) { + const totCollDeposited = depositsForBrand.reduce((prev, { amount }) => { return AmountMath.add(prev, amount); }, AmountMath.makeEmpty(brand)); - const curCollateral = - depositsForBrand[0].seat.getCurrentAllocation().Collateral; - if (AmountMath.isEmpty(curCollateral)) { - const currencyRaised = currencySeat.getCurrentAllocation().Currency; - for (const { seat, amount } of deposits.get(brand).values()) { - const payment = floorMultiplyBy( - amount, - makeRatioFromAmounts(currencyRaised, totalDeposits), - ); - atomicRearrange( - zcf, - harden([[currencySeat, seat, { Currency: payment }]]), - ); - seat.exit(); - } - // TODO(cth) sweep away dust - } else { - Fail`Split up incomplete sale`; + + const collatRaise = collateralSeat.getCurrentAllocation().Collateral; + const currencyRaise = currencySeat.getCurrentAllocation().Currency; + + const collShare = makeRatioFromAmounts(collatRaise, totCollDeposited); + const currShare = makeRatioFromAmounts(currencyRaise, totCollDeposited); + /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} */ + const transfers = []; + let currencyLeft = currencyRaise; + let collateralLeft = collatRaise; + + // each depositor gets as share that equals their amount deposited + // divided by the total deposited multplied by the currency and + // collateral being distributed. + for (const { seat, amount } of deposits.get(brand).values()) { + const currPortion = floorMultiplyBy(amount, currShare); + currencyLeft = AmountMath.subtract(currencyLeft, currPortion); + const collPortion = floorMultiplyBy(amount, collShare); + collateralLeft = AmountMath.subtract(collateralLeft, collPortion); + transfers.push([currencySeat, seat, { Currency: currPortion }]); + transfers.push([collateralSeat, seat, { Collateral: collPortion }]); + } + + // TODO The leftovers should go to the reserve, and should be visible. + const keyword = brandToKeyword.get(brand); + transfers.push([ + currencySeat, + reserveFunds, + { Currency: currencyLeft }, + ]); + transfers.push([ + collateralSeat, + reserveFunds, + { Collateral: collateralLeft }, + { [keyword]: collateralLeft }, + ]); + atomicRearrange(zcf, harden(transfers)); + + for (const { seat } of depositsForBrand) { + seat.exit(); } } } }; - const releaseSeats = () => { - for (const brand of deposits.keys()) { - books.get(brand).exitAllSeats(); - } - }; const { publicMixin, creatorMixin, makeFarGovernorFacet, params } = await handleParamGovernance( @@ -159,15 +183,10 @@ export const start = async (zcf, privateArgs, baggage) => { ); tradeEveryBook(); - - if (!natSafeMath.isGTE(currentDiscountRate, params.getDiscountStep())) { - // end trading - } }, finalize: () => { trace('finalize'); distributeProceeds(); - releaseSeats(); }, startRound() { trace('startRound'); @@ -231,9 +250,12 @@ export const start = async (zcf, privateArgs, baggage) => { const limitedCreatorFacet = Far('creatorFacet', { async addBrand(issuer, collateralBrand, kwd) { - if (!baggage.has(kwd)) { - baggage.init(kwd, makeScalarBigMapStore(kwd, { durable: true })); - } + zcf.assertUniqueKeyword(kwd); + !baggage.has(kwd) || + Fail`cannot add brand with keyword ${kwd}. it's in use`; + + zcf.saveIssuer(issuer, kwd); + baggage.init(kwd, makeScalarBigMapStore(kwd, { durable: true })); const newBook = await makeAuctionBook( baggage.get(kwd), zcf, @@ -241,10 +263,11 @@ export const start = async (zcf, privateArgs, baggage) => { collateralBrand, priceAuthority, ); - zcf.saveIssuer(issuer, kwd); + deposits.init(collateralBrand, harden([])); books.init(collateralBrand, newBook); + brandToKeyword.init(collateralBrand, kwd); }, - // TODO (cth) if it's in public, doesn't also need to be in creatorFacet. + // XXX if it's in public, doesn't also need to be in creatorFacet. getDepositInvitation, getSchedule() { return E(scheduler).getSchedule(); @@ -260,3 +283,5 @@ export const start = async (zcf, privateArgs, baggage) => { /** @typedef {ContractOf} AuctioneerContract */ /** @typedef {AuctioneerContract['publicFacet']} AuctioneerPublicFacet */ /** @typedef {AuctioneerContract['creatorFacet']} AuctioneerCreatorFacet */ + +export const AuctionPFShape = M.remotable('Auction Public Facet'); diff --git a/packages/inter-protocol/src/auction/discountBook.js b/packages/inter-protocol/src/auction/discountBook.js index abfb94d8736..b337098833b 100644 --- a/packages/inter-protocol/src/auction/discountBook.js +++ b/packages/inter-protocol/src/auction/discountBook.js @@ -34,7 +34,7 @@ const nextTimestamp = makeNextTimestamp(); export const makeDiscountBook = (store, currencyBrand, collateralBrand) => { return Far('discountBook ', { add(seat, discount, wanted, proposedTimestamp) { - // TODO(cth) mustMatch(discount, DISCOUNT_PATTERN); + // XXX mustMatch(discount, DISCOUNT_PATTERN); const time = nextTimestamp(proposedTimestamp); const key = toDiscountedRateOfferKey(discount, time); diff --git a/packages/inter-protocol/src/auction/params.js b/packages/inter-protocol/src/auction/params.js index 98a19f7f768..2bdecd522c8 100644 --- a/packages/inter-protocol/src/auction/params.js +++ b/packages/inter-protocol/src/auction/params.js @@ -198,7 +198,7 @@ export const makeGovernedTerms = ( timerBrand, }, ) => { - // TODO(CTH) use storageNode and Marshaller + // XXX use storageNode and Marshaller return harden({ priceAuthority, timerService: timer, diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index dd9fba8b59f..e3d1c756673 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -165,7 +165,7 @@ export const makeScheduler = async ( }; const scheduleNextRound = start => { - console.log(`SCHED nextRound`); + trace(`SCHED nextRound`, start); E(timer).setWakeup( start, Far('SchedulerWaker', { diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js index ee44cdea0df..b485ada55b7 100644 --- a/packages/inter-protocol/test/auction/test-auctionContract.js +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -136,18 +136,25 @@ const makeAuctionDriver = async (t, customTerms, params = defaultParams) => { * @param {Amount<'nat'>} giveCurrency * @param {Amount<'nat'>} wantCollateral * @param {Ratio} [discount] + * @param {ExitRule} [exitRule] */ const bidForCollateralSeat = async ( giveCurrency, wantCollateral, discount = undefined, + exitRule = undefined, ) => { const bidInvitation = E(publicFacet).getBidInvitation(wantCollateral.brand); - const proposal = harden({ + const rawProposal = { give: { Currency: giveCurrency }, - // IF we had multiples, the buyer could express a want. + // IF we had multiples, the buyer could express an offer-safe want. // want: { Collateral: wantCollateral }, - }); + }; + if (exitRule) { + rawProposal.exit = exitRule; + } + const proposal = harden(rawProposal); + const payment = harden({ Currency: currency.mint.mintPayment(giveCurrency), }); @@ -215,21 +222,27 @@ const makeAuctionDriver = async (t, customTerms, params = defaultParams) => { const seat = bidForCollateralSeat(giveCurrency, wantCollateral, discount); return E(seat).getPayouts(); }, - async bidForCollateralSeat(giveCurrency, wantCollateral, discount) { - return bidForCollateralSeat(giveCurrency, wantCollateral, discount); + async bidForCollateralSeat(giveCurrency, wantCollateral, discount, exit) { + return bidForCollateralSeat(giveCurrency, wantCollateral, discount, exit); }, setupCollateralAuction, - advanceTo(time) { - timerService.advanceTo(time); + async advanceTo(time) { + await timerService.advanceTo(time); }, async updatePriceAuthority(newPrice) { priceAuthorities.get(newPrice.denominator.brand).setPrice(newPrice); await eventLoopIteration(); }, depositCollateral, + async getLockPeriod() { + return E(publicFacet).getPriceLockPeriod(); + }, getSchedule() { return E(creatorFacet).getSchedule(); }, + getTimerService() { + return timerService; + }, }; }; @@ -363,16 +376,22 @@ test.serial('priced bid insufficient collateral added', async t => { const schedules = await driver.getSchedule(); t.is(schedules.nextAuctionSchedule.startTime.absValue, 170n); t.is(schedules.nextAuctionSchedule.endTime.absValue, 185n); - await driver.advanceTo(170n); + await driver.advanceTo(167n); const seat = await driver.bidForCollateralSeat( currency.make(240n), collateral.make(200n), + undefined, + { afterDeadline: { timer: driver.getTimerService(), deadline: 185n } }, ); + await driver.advanceTo(170n); t.is(await E(seat).getOfferResult(), 'Your offer has been received'); t.false(await E(seat).hasExited()); + await driver.advanceTo(175n); + await driver.advanceTo(180n); await driver.advanceTo(185n); + t.true(await E(seat).hasExited()); // 240n - 20n * (115n / 100n) @@ -450,25 +469,228 @@ test.serial('complete auction liquidator gets proceeds', async t => { const result = await E(liqSeat).getOfferResult(); t.is(result, 'deposited'); - await driver.advanceTo(170n); + await driver.advanceTo(167n); const seat = await driver.bidForCollateralSeat( // 1.1 * 1.05 * 200 currency.make(231n), collateral.make(200n), ); - t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + t.false(await E(seat).hasExited()); + + await driver.advanceTo(170n); + await eventLoopIteration(); + + await driver.advanceTo(175n); + await eventLoopIteration(); + + await driver.advanceTo(180n); + await eventLoopIteration(); + + await driver.advanceTo(185n); + await eventLoopIteration(); + + t.true(await E(seat).hasExited()); + await assertPayouts(t, seat, currency, collateral, 0n, 200n); + await assertPayouts(t, liqSeat, currency, collateral, 231n, 800n); +}); + +// serial because dynamicConfig is shared across tests +test.serial('multiple Depositors, not all assets are sold', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + const liqSeatA = await driver.setupCollateralAuction( + collateral, + collateral.make(1000n), + ); + const liqSeatB = await driver.depositCollateral( + collateral.make(500n), + collateral, + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const result = await E(liqSeatA).getOfferResult(); + t.is(result, 'deposited'); + + await driver.advanceTo(167n); + const seat = await driver.bidForCollateralSeat( + currency.make(1200n), + collateral.make(1000n), + ); + + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + t.false(await E(seat).hasExited()); + + await driver.advanceTo(170n); + await eventLoopIteration(); + await driver.advanceTo(175n); + await eventLoopIteration(); + await driver.advanceTo(180n); + await eventLoopIteration(); + await driver.advanceTo(185n); + await eventLoopIteration(); + + t.true(await E(seat).hasExited()); + + // 1500 Collateral was put up for auction by two bidders (1000 and 500). One + // bidder offered 1200 currency for 1000 collateral. So one seller gets 66% of + // the proceeds, and the other 33%. The price authority quote was 110, and the + // goods were sold in the first auction round at 105%. So the proceeds were + // 1155. The bidder gets 45 currency back. The two sellers split 1155 and the + // 500 returned collateral. The auctioneer sets the remainder aside. + await assertPayouts(t, seat, currency, collateral, 45n, 1000n); + await assertPayouts(t, liqSeatA, currency, collateral, 770n, 333n); + await assertPayouts(t, liqSeatB, currency, collateral, 385n, 166n); +}); + +// serial because dynamicConfig is shared across tests +test.serial('multiple Depositors, all assets are sold', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + const liqSeatA = await driver.setupCollateralAuction( + collateral, + collateral.make(1000n), + ); + const liqSeatB = await driver.depositCollateral( + collateral.make(500n), + collateral, + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const result = await E(liqSeatA).getOfferResult(); + t.is(result, 'deposited'); + + await driver.advanceTo(167n); + const seat = await driver.bidForCollateralSeat( + currency.make(1800n), + collateral.make(1500n), + ); + + t.is(await E(seat).getOfferResult(), 'Your offer has been received'); + t.false(await E(seat).hasExited()); + + await driver.advanceTo(170n); + await eventLoopIteration(); + await driver.advanceTo(175n); + await eventLoopIteration(); + await driver.advanceTo(180n); + await eventLoopIteration(); + await driver.advanceTo(185n); + await eventLoopIteration(); + + t.true(await E(seat).hasExited()); + + // 1500 Collateral was put up for auction by two bidders (1000 and 500). One + // bidder offered 1800 currency for all the collateral. The sellers get 66% + // and 33% of the proceeds. The price authority quote was 110, and the goods + // were sold in the first auction round at 105%. So the proceeds were + // 1733 The bidder gets 67 currency back. The two sellers split 1733. The + // auctioneer sets the remainder aside. + await assertPayouts(t, seat, currency, collateral, 67n, 1500n); + await assertPayouts(t, liqSeatA, currency, collateral, 1155n, 0n); + await assertPayouts(t, liqSeatB, currency, collateral, 577n, 0n); +}); + +// serial because dynamicConfig is shared across tests +test.serial('onDemand exit', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + const liqSeat = await driver.setupCollateralAuction( + collateral, + collateral.make(100n), + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const result = await E(liqSeat).getOfferResult(); + t.is(result, 'deposited'); + + await driver.advanceTo(167n); + const exitingSeat = await driver.bidForCollateralSeat( + currency.make(250n), + collateral.make(200n), + undefined, + { onDemand: null }, + ); + + t.is(await E(exitingSeat).getOfferResult(), 'Your offer has been received'); + t.false(await E(exitingSeat).hasExited()); + + await driver.advanceTo(170n); + await eventLoopIteration(); + await driver.advanceTo(175n); + await eventLoopIteration(); + await driver.advanceTo(180n); + await eventLoopIteration(); await driver.advanceTo(185n); + await eventLoopIteration(); + + t.false(await E(exitingSeat).hasExited()); + + await E(exitingSeat).tryExit(); + t.true(await E(exitingSeat).hasExited()); - await assertPayouts(t, liqSeat, currency, collateral, 0n, 800n); + await assertPayouts(t, exitingSeat, currency, collateral, 134n, 100n); + await assertPayouts(t, liqSeat, currency, collateral, 116n, 0n); +}); + +// serial because dynamicConfig is shared across tests +test.serial('onDeadline exit', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + const liqSeat = await driver.setupCollateralAuction( + collateral, + collateral.make(100n), + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const result = await E(liqSeat).getOfferResult(); + t.is(result, 'deposited'); + + await driver.advanceTo(167n); + const exitingSeat = await driver.bidForCollateralSeat( + currency.make(250n), + collateral.make(200n), + undefined, + { afterDeadline: { timer: driver.getTimerService(), deadline: 185n } }, + ); + + t.is(await E(exitingSeat).getOfferResult(), 'Your offer has been received'); + t.false(await E(exitingSeat).hasExited()); + + await driver.advanceTo(170n); + await eventLoopIteration(); + await driver.advanceTo(175n); + await eventLoopIteration(); + await driver.advanceTo(180n); + await eventLoopIteration(); + await driver.advanceTo(185n); + await eventLoopIteration(); + + t.true(await E(exitingSeat).hasExited()); + + await assertPayouts(t, exitingSeat, currency, collateral, 134n, 100n); + await assertPayouts(t, liqSeat, currency, collateral, 116n, 0n); }); test('add assets to open auction', async t => { const { collateral, currency } = t.context; const driver = await makeAuctionDriver(t); + // One seller deposits 1000 collateral const liqSeat = await driver.setupCollateralAuction( collateral, collateral.make(1000n), @@ -480,32 +702,41 @@ test('add assets to open auction', async t => { const result = await E(liqSeat).getOfferResult(); t.is(result, 'deposited'); + // bids for half of 1000 + 2000 collateral. const bidderSeat1 = await driver.bidForCollateralSeat( - // 1.1 * 1.05 * 200 - currency.make(231n), - collateral.make(200n), + // 1.1 * 1.05 * 1500 + currency.make(1733n), + collateral.make(1500n), ); t.is(await E(bidderSeat1).getOfferResult(), 'Your offer has been received'); - await driver.advanceTo(170n); + // price lock period before auction start + await driver.advanceTo(167n); - const liqSeat2 = driver.depositCollateral(collateral.make(2000n), collateral); + // another seller deposits 2000 + const liqSeat2 = await driver.depositCollateral( + collateral.make(2000n), + collateral, + ); const resultL2 = await E(liqSeat2).getOfferResult(); t.is(resultL2, 'deposited'); - const bidderSeat2 = await driver.bidForCollateralSeat( - // 1.1 * 1.05 * 200 - currency.make(300n), - collateral.make(500n), - ); - t.is(await E(bidderSeat2).getOfferResult(), 'Your offer has been received'); - await driver.advanceTo(180n); - await assertPayouts(t, bidderSeat1, currency, collateral, 0n, 200n); + + // bidder gets collateral + await assertPayouts(t, bidderSeat1, currency, collateral, 0n, 1500n); await driver.advanceTo(190n); - await assertPayouts(t, liqSeat, currency, collateral, 231n / 3n, 0n); - await assertPayouts(t, liqSeat2, currency, collateral, (2n * 231n) / 3n, 0n); + // sellers split proceeds and refund 2:1 + await assertPayouts(t, liqSeat, currency, collateral, 1733n / 3n, 500n); + await assertPayouts( + t, + liqSeat2, + currency, + collateral, + (2n * 1733n) / 3n, + 1000n, + ); }); // collateral quote is 1.1. asset quote is .25. 1000 C, and 500 A available. @@ -600,6 +831,3 @@ test.serial('multiple collaterals', async t => { t.true(await E(bidderSeat2A).hasExited()); await assertPayouts(t, bidderSeat2A, currency, asset, 0n, 300n); }); - -test.todo('bids that are satisfied over more than one phase'); -test.todo('auction runs out of collateral with remaining bids'); From 64ee0bd10d4e226d8b964b8bff9c835f912c5ed9 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Wed, 22 Feb 2023 16:46:59 -0800 Subject: [PATCH 03/20] chore: cleanups suggested in review --- packages/governance/src/constants.js | 1 - .../src/contractGovernance/assertions.js | 17 +-- .../src/contractGovernance/paramManager.js | 11 -- packages/governance/src/contractHelper.js | 12 +- packages/governance/src/types-ambient.js | 10 +- .../scripts/add-collateral-core.js | 4 +- .../scripts/deploy-contracts.js | 2 +- packages/inter-protocol/scripts/init-core.js | 5 +- .../inter-protocol/src/auction/auctionBook.js | 78 +++++++------ .../inter-protocol/src/auction/auctioneer.js | 53 ++++----- .../src/auction/discountBook.js | 54 ++++----- packages/inter-protocol/src/auction/params.js | 65 +++++------ .../inter-protocol/src/auction/scheduler.js | 11 +- .../src/auction/sortedOffers.js | 108 ++++++++---------- packages/inter-protocol/src/auction/util.js | 24 +++- .../test/auction/test-sortedOffers.js | 1 + packages/inter-protocol/test/auction/tools.js | 2 +- packages/zoe/src/contractSupport/index.js | 1 + .../zoe/src/contractSupport/priceQuote.js | 1 - packages/zoe/src/contractSupport/ratio.js | 12 ++ 20 files changed, 222 insertions(+), 250 deletions(-) diff --git a/packages/governance/src/constants.js b/packages/governance/src/constants.js index f58ea05a660..53ec759c703 100644 --- a/packages/governance/src/constants.js +++ b/packages/governance/src/constants.js @@ -15,7 +15,6 @@ export const ParamTypes = /** @type {const} */ ({ RATIO: 'ratio', STRING: 'string', PASSABLE_RECORD: 'record', - TIMER_SERVICE: 'timerService', TIMESTAMP: 'timestamp', RELATIVE_TIME: 'relativeTime', UNKNOWN: 'unknown', diff --git a/packages/governance/src/contractGovernance/assertions.js b/packages/governance/src/contractGovernance/assertions.js index 2ef4a2d64ee..1e2ede685bb 100644 --- a/packages/governance/src/contractGovernance/assertions.js +++ b/packages/governance/src/contractGovernance/assertions.js @@ -1,5 +1,7 @@ import { isRemotable } from '@endo/marshal'; import { assertIsRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { mustMatch } from '@agoric/store'; +import { RelativeTimeRecordShape, TimestampRecordShape } from '@agoric/time'; const { Fail } = assert; @@ -42,25 +44,15 @@ const makeAssertBrandedRatio = (name, modelRatio) => { harden(makeAssertBrandedRatio); const assertRelativeTime = value => { - isRemotable(value.timerBrand) || Fail`relativeTime must have a brand`; - typeof value.relValue === 'bigint' || Fail`must have a relValue field`; + mustMatch(value, RelativeTimeRecordShape); }; harden(assertRelativeTime); const assertTimestamp = value => { - isRemotable(value.timerBrand) || Fail`timestamp must have a brand`; - typeof value.absValue === 'bigint' || Fail`must have an absValue field`; + mustMatch(value, TimestampRecordShape, 'timestamp'); }; harden(assertTimestamp); -const makeAssertTimerService = name => { - return timerService => { - typeof timerService === 'object' || - Fail`value for ${name} must be a TimerService, was ${timerService}`; - }; -}; -harden(makeAssertTimerService); - export { makeLooksLikeBrand, makeAssertInstallation, @@ -68,5 +60,4 @@ export { makeAssertBrandedRatio, assertRelativeTime, assertTimestamp, - makeAssertTimerService, }; diff --git a/packages/governance/src/contractGovernance/paramManager.js b/packages/governance/src/contractGovernance/paramManager.js index 79746474430..8a38e0d4fc2 100644 --- a/packages/governance/src/contractGovernance/paramManager.js +++ b/packages/governance/src/contractGovernance/paramManager.js @@ -14,7 +14,6 @@ import { makeAssertInstallation, makeAssertInstance, makeLooksLikeBrand, - makeAssertTimerService, } from './assertions.js'; import { CONTRACT_ELECTORATE } from './governParam.js'; @@ -47,7 +46,6 @@ const assertElectorateMatches = (paramManager, governedParams) => { * @property {(name: string, value: Ratio) => ParamManagerBuilder} addRatio * @property {(name: string, value: import('@endo/marshal').CopyRecord) => ParamManagerBuilder} addRecord * @property {(name: string, value: string) => ParamManagerBuilder} addString - * @property {(name: string, value: import('@agoric/time/src/types').TimerService) => ParamManagerBuilder} addTimerService * @property {(name: string, value: import('@agoric/time/src/types').Timestamp) => ParamManagerBuilder} addTimestamp * @property {(name: string, value: import('@agoric/time/src/types').RelativeTime) => ParamManagerBuilder} addRelativeTime * @property {(name: string, value: any) => ParamManagerBuilder} addUnknown @@ -190,13 +188,6 @@ const makeParamManagerBuilder = (publisherKit, zoe) => { return builder; }; - /** @type {(name: string, value: import('@agoric/time/src/types').TimerService, builder: ParamManagerBuilder) => ParamManagerBuilder} */ - const addTimerService = (name, value, builder) => { - const assertTimerService = makeAssertTimerService(name); - buildCopyParam(name, value, assertTimerService, ParamTypes.TIMER_SERVICE); - return builder; - }; - /** @type {(name: string, value: import('@agoric/time/src/types').Timestamp, builder: ParamManagerBuilder) => ParamManagerBuilder} */ const addTimestamp = (name, value, builder) => { buildCopyParam(name, value, assertTimestamp, ParamTypes.TIMESTAMP); @@ -381,7 +372,6 @@ const makeParamManagerBuilder = (publisherKit, zoe) => { getRatio: name => getTypedParam(ParamTypes.RATIO, name), getRecord: name => getTypedParam(ParamTypes.PASSABLE_RECORD, name), getString: name => getTypedParam(ParamTypes.STRING, name), - getTimerService: name => getTypedParam(ParamTypes.TIMER_SERVICE, name), getTimestamp: name => getTypedParam(ParamTypes.TIMESTAMP, name), getRelativeTime: name => getTypedParam(ParamTypes.RELATIVE_TIME, name), getUnknown: name => getTypedParam(ParamTypes.UNKNOWN, name), @@ -407,7 +397,6 @@ const makeParamManagerBuilder = (publisherKit, zoe) => { addRatio: (n, v) => addRatio(n, v, builder), addRecord: (n, v) => addRecord(n, v, builder), addString: (n, v) => addString(n, v, builder), - addTimerService: (n, v) => addTimerService(n, v, builder), addRelativeTime: (n, v) => addRelativeTime(n, v, builder), addTimestamp: (n, v) => addTimestamp(n, v, builder), build, diff --git a/packages/governance/src/contractHelper.js b/packages/governance/src/contractHelper.js index 8ec6165232f..fe19d7b6c79 100644 --- a/packages/governance/src/contractHelper.js +++ b/packages/governance/src/contractHelper.js @@ -4,11 +4,7 @@ import { getMethodNames, objectMap } from '@agoric/internal'; import { ignoreContext } from '@agoric/vat-data'; import { keyEQ, M } from '@agoric/store'; import { AmountShape, BrandShape } from '@agoric/ertp'; -import { - RelativeTimeShape, - TimestampShape, - TimerServiceShape, -} from '@agoric/time'; +import { RelativeTimeRecordShape, TimestampRecordShape } from '@agoric/time'; import { assertElectorateMatches } from './contractGovernance/paramManager.js'; import { makeParamManagerFromTerms } from './contractGovernance/typedParamManager.js'; @@ -28,9 +24,8 @@ const publicMixinAPI = harden({ getNat: M.call().returns(M.bigint()), getRatio: M.call().returns(M.record()), getString: M.call().returns(M.string()), - getTimerService: M.call().returns(TimerServiceShape), - getTimestamp: M.call().returns(TimestampShape), - getRelativeTime: M.call().returns(RelativeTimeShape), + getTimestamp: M.call().returns(TimestampRecordShape), + getRelativeTime: M.call().returns(RelativeTimeRecordShape), getUnknown: M.call().returns(M.any()), }); @@ -59,7 +54,6 @@ const facetHelpers = (zcf, paramManager) => { getNat: paramManager.getNat, getRatio: paramManager.getRatio, getString: paramManager.getString, - getTimerService: paramManager.getTimerService, getTimestamp: paramManager.getTimestamp, getRelativeTime: paramManager.getRelativeTime, getUnknown: paramManager.getUnknown, diff --git a/packages/governance/src/types-ambient.js b/packages/governance/src/types-ambient.js index aafc9cab80d..8085da02d8c 100644 --- a/packages/governance/src/types-ambient.js +++ b/packages/governance/src/types-ambient.js @@ -31,7 +31,8 @@ /** * @typedef { Amount | Brand | Installation | Instance | bigint | - * Ratio | string | unknown } ParamValue + * Ratio | string | import('@agoric/time/src/types').TimestampRecord | + * import('@agoric/time/src/types').RelativeTimeRecord | unknown } ParamValue */ // XXX better to use the manifest constant ParamTypes @@ -47,6 +48,8 @@ * T extends 'nat' ? bigint : * T extends 'ratio' ? Ratio : * T extends 'string' ? string : + * T extends 'timestamp' ? import('@agoric/time/src/types').TimestampRecord : + * T extends 'relativeTime' ? import('@agoric/time/src/types').RelativeTimeRecord : * T extends 'unknown' ? unknown : * never * } ParamValueForType @@ -427,9 +430,8 @@ * @property {(name: string) => bigint} getNat * @property {(name: string) => Ratio} getRatio * @property {(name: string) => string} getString - * @property {(name: string) => import('@agoric/time/src/types').TimerService} getTimerService - * @property {(name: string) => import('@agoric/time/src/types').Timestamp} getTimestamp - * @property {(name: string) => import('@agoric/time/src/types').RelativeTime} getRelativeTime + * @property {(name: string) => import('@agoric/time/src/types').TimestampRecord} getTimestamp + * @property {(name: string) => import('@agoric/time/src/types').RelativeTimeRecord} getRelativeTime * @property {(name: string) => any} getUnknown * @property {(name: string, proposedValue: ParamValue) => ParamValue} getVisibleValue - for * most types, the visible value is the same as proposedValue. For Invitations diff --git a/packages/inter-protocol/scripts/add-collateral-core.js b/packages/inter-protocol/scripts/add-collateral-core.js index 77189de9c11..2b8d130cb91 100644 --- a/packages/inter-protocol/scripts/add-collateral-core.js +++ b/packages/inter-protocol/scripts/add-collateral-core.js @@ -75,13 +75,13 @@ export const psmGovernanceBuilder = async ({ vaults: publishRef( install( '../src/vaultFactory/vaultFactory.js', - '../bundles/bundle-vaults.js', + '../bundles/bundle-vaultFactory.js', ), ), auction: publishRef( install( '../src/auction/auctioneer.js', - '../bundles/bundle-auction.js', + '../bundles/bundle-auctioneer.js', ), ), econCommitteeCharter: publishRef( diff --git a/packages/inter-protocol/scripts/deploy-contracts.js b/packages/inter-protocol/scripts/deploy-contracts.js index 42496ca4c47..3f363bafcf5 100644 --- a/packages/inter-protocol/scripts/deploy-contracts.js +++ b/packages/inter-protocol/scripts/deploy-contracts.js @@ -13,7 +13,7 @@ const contractRefs = [ '../bundles/bundle-vaultFactory.js', '../bundles/bundle-reserve.js', '../bundles/bundle-psm.js', - '../bundles/bundle-auction.js', + '../bundles/bundle-auctioneer.js', '../../vats/bundles/bundle-mintHolder.js', ]; const contractRoots = contractRefs.map(ref => diff --git a/packages/inter-protocol/scripts/init-core.js b/packages/inter-protocol/scripts/init-core.js index 98b191164dc..0574e6327e8 100644 --- a/packages/inter-protocol/scripts/init-core.js +++ b/packages/inter-protocol/scripts/init-core.js @@ -36,7 +36,10 @@ const installKeyGroups = { ], }, main: { - auction: ['../src/auction/auctioneer.js', '../bundles/bundle-auction.js'], + auction: [ + '../src/auction/auctioneer.js', + '../bundles/bundle-auctioneer.js', + ], vaultFactory: [ '../src/vaultFactory/vaultFactory.js', '../bundles/bundle-vaultFactory.js', diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index cbb0849ae22..2b38a6cf726 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -16,7 +16,6 @@ import { multiplyRatios, ratioGTE, } from '@agoric/zoe/src/contractSupport/index.js'; -import { TimeMath } from '@agoric/time'; import { E } from '@endo/captp'; import { makeTracer } from '@agoric/internal'; @@ -24,9 +23,9 @@ import { makeDiscountBook, makePriceBook } from './discountBook.js'; import { AuctionState, isDiscountedPriceHigher, - makeRatioPattern, + makeBrandedRatioPattern, + priceFrom, } from './util.js'; -import { keyToTime } from './sortedOffers.js'; const { Fail } = assert; @@ -35,9 +34,11 @@ const { Fail } = assert; * auction. It holds the book, the lockedPrice, and the collateralSeat that has * the allocation of assets for sale. * - * The book contains orders for a particular collateral. It holds two kinds of - * orders: one has a price in terms of a Currency amount, the other is priced as - * a discount (or markup) from the most recent oracle price. + * The book contains orders for the collateral. It holds two kinds of + * orders: + * - Prices express the bid in terms of a Currency amount + * - Discount express the bid in terms of a discount (or markup) from the + * most recent oracle price. * * Offers can be added in three ways. When the auction is not active, prices are * automatically added to the appropriate collection. If a new offer is at or @@ -48,12 +49,6 @@ const { Fail } = assert; const trace = makeTracer('AucBook', false); -const priceFrom = quote => - makeRatioFromAmounts( - quote.quoteAmount.value[0].amountOut, - quote.quoteAmount.value[0].amountIn, - ); - /** @typedef {import('@agoric/vat-data').Baggage} Baggage */ export const makeAuctionBook = async ( @@ -71,11 +66,11 @@ export const makeAuctionBook = async ( const BidSpecShape = M.or( { want: AmountShape, - offerPrice: makeRatioPattern(currencyBrand, collateralBrand), + offerPrice: makeBrandedRatioPattern(currencyBrand, collateralBrand), }, { want: AmountShape, - offerDiscount: makeRatioPattern(currencyBrand, currencyBrand), + offerDiscount: makeBrandedRatioPattern(currencyBrand, currencyBrand), }, ); @@ -83,7 +78,7 @@ export const makeAuctionBook = async ( const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); const { zcfSeat: currencySeat } = zcf.makeEmptySeatKit(); - let lockedPrice = makeZeroRatio(); + let lockedPriceForRound = makeZeroRatio(); let updatingOracleQuote = makeZeroRatio(); E.when(E(collateralBrand).getDisplayInfo(), ({ decimalPlaces = 9n }) => { // TODO(#6946) use this to keep a current price that can be published in state. @@ -191,8 +186,16 @@ export const makeAuctionBook = async ( const isActive = auctionState => auctionState === AuctionState.ACTIVE; - // accept new offer. - const acceptOffer = (seat, price, want, timestamp, auctionState) => { + /** + * Accept an offer expressed as a price. If the auction is active, attempt to + * buy collateral. If any of the offer remains add it to the book. + * + * @param {ZCFSeat} seat + * @param {Ratio} price + * @param {Amount} want + * @param {AuctionState} auctionState + */ + const acceptPriceOffer = (seat, price, want, auctionState) => { trace('acceptPrice'); // Offer has ZcfSeat, offerArgs (w/price) and timeStamp @@ -209,26 +212,29 @@ export const makeAuctionBook = async ( const stillWant = AmountMath.subtract(want, collateralSold); if (!AmountMath.isEmpty(stillWant)) { trace('added Offer ', price, stillWant.value); - priceBook.add(seat, price, stillWant, timestamp); + priceBook.add(seat, price, stillWant); } else { seat.exit(); } }; - // accept new discount offer. - const acceptDiscountOffer = ( - seat, - discount, - want, - timestamp, - auctionState, - ) => { + /** + * Accept an offer expressed as a discount (or markup). If the auction is + * active, attempt to buy collateral. If any of the offer remains add it to + * the book. + * + * @param {ZCFSeat} seat + * @param {Ratio} discount + * @param {Amount} want + * @param {AuctionState} auctionState + */ + const acceptDiscountOffer = (seat, discount, want, auctionState) => { trace('accept discount'); let collateralSold = AmountMath.makeEmptyFromAmount(want); if ( isActive(auctionState) && - isDiscountedPriceHigher(discount, curAuctionPrice, lockedPrice) + isDiscountedPriceHigher(discount, curAuctionPrice, lockedPriceForRound) ) { collateralSold = settle(seat, want); if (AmountMath.isEmpty(seat.getCurrentAllocation().Currency)) { @@ -239,7 +245,7 @@ export const makeAuctionBook = async ( const stillWant = AmountMath.subtract(want, collateralSold); if (!AmountMath.isEmpty(stillWant)) { - discountBook.add(seat, discount, stillWant, timestamp); + discountBook.add(seat, discount, stillWant); } else { seat.exit(); } @@ -255,16 +261,14 @@ export const makeAuctionBook = async ( ); }, settleAtNewRate(reduction) { - curAuctionPrice = multiplyRatios(reduction, lockedPrice); + curAuctionPrice = multiplyRatios(reduction, lockedPriceForRound); const pricedOffers = priceBook.offersAbove(curAuctionPrice); const discOffers = discountBook.offersAbove(reduction); // requested price or discount gives no priority beyond specifying which // round the order will be service in. - const prioritizedOffers = [...pricedOffers, ...discOffers].sort( - ([a], [b]) => TimeMath.compareAbs(keyToTime(a), keyToTime(b)), - ); + const prioritizedOffers = [...pricedOffers, ...discOffers].sort(); trace(`settling`, pricedOffers.length, discOffers.length); prioritizedOffers.forEach(([key, { seat, price: p, wanted }]) => { @@ -297,22 +301,21 @@ export const makeAuctionBook = async ( }, lockOraclePriceForRound() { trace(`locking `, updatingOracleQuote); - lockedPrice = updatingOracleQuote; + lockedPriceForRound = updatingOracleQuote; }, setStartingRate(rate) { - trace('set startPrice', lockedPrice); - curAuctionPrice = multiplyRatios(lockedPrice, rate); + trace('set startPrice', lockedPriceForRound); + curAuctionPrice = multiplyRatios(lockedPriceForRound, rate); }, addOffer(bidSpec, seat, auctionState) { mustMatch(bidSpec, BidSpecShape); if (bidSpec.offerPrice) { - return acceptOffer( + return acceptPriceOffer( seat, bidSpec.offerPrice, bidSpec.want, - 0n, auctionState, ); } else if (bidSpec.offerDiscount) { @@ -320,7 +323,6 @@ export const makeAuctionBook = async ( seat, bidSpec.offerDiscount, bidSpec.want, - 2n, auctionState, ); } else { diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index 24eff8dcf19..dd682ec75fe 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -4,7 +4,12 @@ import '@agoric/governance/exported.js'; import { Far } from '@endo/marshal'; import { E } from '@endo/eventual-send'; -import { M, makeScalarBigMapStore, provide } from '@agoric/vat-data'; +import { + M, + makeScalarBigMapStore, + provide, + provideDurableMapStore, +} from '@agoric/vat-data'; import { AmountMath } from '@agoric/ertp'; import { atomicRearrange, @@ -14,9 +19,9 @@ import { floorMultiplyBy, provideEmptySeat, } from '@agoric/zoe/src/contractSupport/index.js'; -import { AmountKeywordRecordShape } from '@agoric/zoe/src/typeGuards.js'; import { handleParamGovernance } from '@agoric/governance'; import { makeTracer } from '@agoric/internal'; +import { FullProposalShape } from '@agoric/zoe/src/typeGuards.js'; import { makeAuctionBook } from './auctionBook.js'; import { BASIS_POINTS } from './util.js'; @@ -51,16 +56,8 @@ export const start = async (zcf, privateArgs, baggage) => { timer || Fail`Timer must be in Auctioneer terms`; const timerBrand = await E(timer).getTimerBrand(); - const books = provide(baggage, 'auctionBooks', () => - makeScalarBigMapStore('orderedVaultStore', { - durable: true, - }), - ); - const deposits = provide(baggage, 'deposits', () => - makeScalarBigMapStore('deposits', { - durable: true, - }), - ); + const books = provideDurableMapStore(baggage, 'auctionBooks'); + const deposits = provideDurableMapStore(baggage, 'deposits'); const brandToKeyword = provide(baggage, 'brandToKeyword', () => makeScalarBigMapStore('deposits', { durable: true, @@ -77,8 +74,9 @@ export const start = async (zcf, privateArgs, baggage) => { ); }; - // could be above or below 100%. in basis points - let currentDiscountRate; + // Called "discount" rate even though it can be above or below 100%. + /** @type {NatValue} */ + let currentDiscountRateBP; const distributeProceeds = () => { for (const brand of deposits.keys()) { @@ -160,7 +158,7 @@ export const start = async (zcf, privateArgs, baggage) => { const tradeEveryBook = () => { const discountRatio = makeRatio( - currentDiscountRate, + currentDiscountRateBP, brands.Currency, BASIS_POINTS, ); @@ -171,14 +169,14 @@ export const start = async (zcf, privateArgs, baggage) => { }; const driver = Far('Auctioneer', { - descendingStep: () => { - trace('descent'); + reducePriceAndTrade: () => { + trace('reducePriceAndTrade'); - natSafeMath.isGTE(currentDiscountRate, params.getDiscountStep()) || - Fail`rates must fall ${currentDiscountRate}`; + natSafeMath.isGTE(currentDiscountRateBP, params.getDiscountStep()) || + Fail`rates must fall ${currentDiscountRateBP}`; - currentDiscountRate = natSafeMath.subtract( - currentDiscountRate, + currentDiscountRateBP = natSafeMath.subtract( + currentDiscountRateBP, params.getDiscountStep(), ); @@ -191,10 +189,12 @@ export const start = async (zcf, privateArgs, baggage) => { startRound() { trace('startRound'); - currentDiscountRate = params.getStartingRate(); + currentDiscountRateBP = params.getStartingRate(); [...books.entries()].forEach(([_collateralBrand, book]) => { book.lockOraclePriceForRound(); - book.setStartingRate(makeBPRatio(currentDiscountRate, brands.Currency)); + book.setStartingRate( + makeBPRatio(currentDiscountRateBP, brands.Currency), + ); }); tradeEveryBook(); @@ -232,12 +232,7 @@ export const start = async (zcf, privateArgs, baggage) => { newBidHandler, 'new bid', {}, - harden({ - give: AmountKeywordRecordShape, - want: AmountKeywordRecordShape, - // XXX is there a standard Exit Pattern? - exit: M.any(), - }), + FullProposalShape, ); }, getSchedules() { diff --git a/packages/inter-protocol/src/auction/discountBook.js b/packages/inter-protocol/src/auction/discountBook.js index b337098833b..eef73c18828 100644 --- a/packages/inter-protocol/src/auction/discountBook.js +++ b/packages/inter-protocol/src/auction/discountBook.js @@ -2,44 +2,37 @@ // from the current oracle price. import { Far } from '@endo/marshal'; -import { mustMatch, M } from '@agoric/store'; +import { M, mustMatch } from '@agoric/store'; import { AmountMath } from '@agoric/ertp'; import { + toDiscountComparator, toDiscountedRateOfferKey, + toPartialOfferKey, toPriceOfferKey, - toPriceComparator, - toDiscountComparator, } from './sortedOffers.js'; -import { makeRatioPattern } from './util.js'; +import { makeBrandedRatioPattern } from './util.js'; -// multiple offers might be provided with the same timestamp (since the time -// granularity is limited to blocks), so we increment with each offer for -// uniqueness. -let mostRecentTimestamp = 0n; -const makeNextTimestamp = () => { - return timestamp => { - if (timestamp > mostRecentTimestamp) { - mostRecentTimestamp = timestamp; - return timestamp; - } - mostRecentTimestamp += 1n; - return mostRecentTimestamp; - }; +// multiple offers might be provided at the same time (since the time +// granularity is limited to blocks), so we increment a sequenceNumber with each +// offer for uniqueness. +let latestSequenceNumber = 0n; +const nextSequenceNumber = () => { + latestSequenceNumber += 1n; + return latestSequenceNumber; }; -const nextTimestamp = makeNextTimestamp(); // prices in this book are expressed as percentage of full price. .4 is 60% off. // 1.1 is 10% above par. export const makeDiscountBook = (store, currencyBrand, collateralBrand) => { return Far('discountBook ', { - add(seat, discount, wanted, proposedTimestamp) { + add(seat, discount, wanted) { // XXX mustMatch(discount, DISCOUNT_PATTERN); - const time = nextTimestamp(proposedTimestamp); - const key = toDiscountedRateOfferKey(discount, time); + const seqNum = nextSequenceNumber(); + const key = toDiscountedRateOfferKey(discount, seqNum); const empty = AmountMath.makeEmpty(collateralBrand); - const bidderRecord = { seat, discount, wanted, time, received: empty }; + const bidderRecord = { seat, discount, wanted, seqNum, received: empty }; store.init(key, harden(bidderRecord)); return key; }, @@ -70,23 +63,20 @@ export const makeDiscountBook = (store, currencyBrand, collateralBrand) => { }; export const makePriceBook = (store, currencyBrand, collateralBrand) => { - const RATIO_PATTERN = makeRatioPattern(currencyBrand, collateralBrand); + const RATIO_PATTERN = makeBrandedRatioPattern(currencyBrand, collateralBrand); return Far('discountBook ', { - add(seat, price, wanted, proposedTimestamp) { + add(seat, price, wanted) { mustMatch(price, RATIO_PATTERN); - const time = nextTimestamp(proposedTimestamp); - const key = toPriceOfferKey(price, time); + const seqNum = nextSequenceNumber(); + const key = toPriceOfferKey(price, seqNum); const empty = AmountMath.makeEmpty(collateralBrand); - const bidderRecord = { seat, price, wanted, time, received: empty }; + const bidderRecord = { seat, price, wanted, seqNum, received: empty }; store.init(key, harden(bidderRecord)); return key; }, offersAbove(price) { - return [...store.entries(M.gte(toPriceComparator(price)))]; - }, - firstOffer() { - return [...store.keys()][0]; + return [...store.entries(M.gte(toPartialOfferKey(price)))]; }, hasOrders() { return store.getSize() > 0; @@ -102,7 +92,7 @@ export const makePriceBook = (store, currencyBrand, collateralBrand) => { ); }, exitAllSeats() { - for (const [_, { seat }] of store.entries()) { + for (const { seat } of store.values()) { if (!seat.hasExited()) { seat.exit(); } diff --git a/packages/inter-protocol/src/auction/params.js b/packages/inter-protocol/src/auction/params.js index 2bdecd522c8..675dbf17d3e 100644 --- a/packages/inter-protocol/src/auction/params.js +++ b/packages/inter-protocol/src/auction/params.js @@ -3,8 +3,11 @@ import { makeParamManager, ParamTypes, } from '@agoric/governance'; -import { objectMap } from '@agoric/internal'; -import { TimerBrandShape, TimeMath } from '@agoric/time'; +import { + TimerBrandShape, + TimeMath, + RelativeTimeValueShape, +} from '@agoric/time'; import { M } from '@agoric/store'; /** @typedef {import('@agoric/governance/src/contractGovernance/typedParamManager.js').AsyncSpecTuple} AsyncSpecTuple */ @@ -13,44 +16,52 @@ import { M } from '@agoric/store'; // TODO duplicated with zoe/src/TypeGuards.js export const InvitationShape = M.remotable('Invitation'); -// The auction will start at AUCTION_START_DELAY seconds after a multiple of -// START_FREQUENCY, with the price at STARTING_RATE. Every CLOCK_STEP, the price -// will be reduced by DISCOUNT_STEP, as long as the rate is at or above -// LOWEST_RATE, or until START_FREQUENCY has elapsed. - -// in seconds, how often to start an auction +/** + * The auction will start at AUCTION_START_DELAY seconds after a multiple of + * START_FREQUENCY, with the price at STARTING_RATE. Every CLOCK_STEP, the price + * will be reduced by DISCOUNT_STEP, as long as the rate is at or above + * LOWEST_RATE, or until START_FREQUENCY has elapsed. in seconds, how often to + * start an auction + */ export const START_FREQUENCY = 'StartFrequency'; -// in seconds, how often to reduce the price +/** in seconds, how often to reduce the price */ export const CLOCK_STEP = 'ClockStep'; -// discount or markup for starting price in basis points. 9999 = 1 bp discount +/** discount or markup for starting price in basis points. 9999 = 1bp discount */ export const STARTING_RATE = 'StartingRate'; -// A limit below which the price will not be discounted. +/** A limit below which the price will not be discounted. */ export const LOWEST_RATE = 'LowestRate'; -// amount to reduce prices each time step in bp, as % of the start price +/** amount to reduce prices each time step in bp, as % of the start price */ export const DISCOUNT_STEP = 'DiscountStep'; -// VaultManagers liquidate vaults at a frequency configured by START_FREQUENCY. -// Auctions start this long after the hour to give vaults time to finish. +/** + * VaultManagers liquidate vaults at a frequency configured by START_FREQUENCY. + * Auctions start this long after the hour to give vaults time to finish. + */ export const AUCTION_START_DELAY = 'AuctionStartDelay'; -// Basis Points to charge in penalty against vaults that are liquidated. Notice -// that if the penalty is less than the LOWEST_RATE discount, vault holders -// could buy their assets back at an advantageous price. +/** + * Basis Points to charge in penalty against vaults that are liquidated. Notice + * that if the penalty is less than the LOWEST_RATE discount, vault holders + * could buy their assets back at an advantageous price. + */ export const LIQUIDATION_PENALTY = 'LiquidationPenalty'; // /////// used by VaultDirector ///////////////////// // time before each auction that the prices are locked. export const PRICE_LOCK_PERIOD = 'PriceLockPeriod'; -const RelativeTimePattern = { relValue: M.nat(), timerBrand: TimerBrandShape }; +export const RelativeTimeRecordShape = harden({ + timerBrand: TimerBrandShape, + relValue: RelativeTimeValueShape, +}); export const auctioneerParamPattern = M.splitRecord({ [CONTRACT_ELECTORATE]: InvitationShape, - [START_FREQUENCY]: RelativeTimePattern, - [CLOCK_STEP]: RelativeTimePattern, + [START_FREQUENCY]: RelativeTimeRecordShape, + [CLOCK_STEP]: RelativeTimeRecordShape, [STARTING_RATE]: M.nat(), [LOWEST_RATE]: M.nat(), [DISCOUNT_STEP]: M.nat(), - [AUCTION_START_DELAY]: RelativeTimePattern, - [PRICE_LOCK_PERIOD]: RelativeTimePattern, + [AUCTION_START_DELAY]: RelativeTimeRecordShape, + [PRICE_LOCK_PERIOD]: RelativeTimeRecordShape, }); export const auctioneerParamTypes = harden({ @@ -115,12 +126,6 @@ export const makeAuctioneerParams = ({ }; harden(makeAuctioneerParams); -export const toParamValueMap = typedDescriptions => { - return objectMap(typedDescriptions, value => { - return value; - }); -}; - /** * @param {import('@agoric/notifier').StoredPublisherKit} publisherKit * @param {ZoeService} zoe @@ -143,19 +148,15 @@ export const makeAuctioneerParamManager = (publisherKit, zoe, initial) => { ParamTypes.INVITATION, initial[CONTRACT_ELECTORATE], ], - // @ts-expect-error type confusion [START_FREQUENCY]: [ParamTypes.RELATIVE_TIME, initial[START_FREQUENCY]], - // @ts-expect-error type confusion [CLOCK_STEP]: [ParamTypes.RELATIVE_TIME, initial[CLOCK_STEP]], [STARTING_RATE]: [ParamTypes.NAT, initial[STARTING_RATE]], [LOWEST_RATE]: [ParamTypes.NAT, initial[LOWEST_RATE]], [DISCOUNT_STEP]: [ParamTypes.NAT, initial[DISCOUNT_STEP]], - // @ts-expect-error type confusion [AUCTION_START_DELAY]: [ ParamTypes.RELATIVE_TIME, initial[AUCTION_START_DELAY], ], - // @ts-expect-error type confusion [PRICE_LOCK_PERIOD]: [ ParamTypes.RELATIVE_TIME, initial[PRICE_LOCK_PERIOD], diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index e3d1c756673..2194f8fe954 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -29,7 +29,7 @@ const makeCancelToken = () => { /** * @typedef {object} AuctionDriver - * @property {() => void} descendingStep + * @property {() => void} reducePriceAndTrade * @property {() => void} finalize * @property {() => void} startRound */ @@ -59,26 +59,19 @@ export const makeScheduler = async ( const computeRoundTiming = baseTime => { // currently a TimeValue; hopefully a TimeRecord soon /** @type {RelativeTime} */ - // @ts-expect-error cast const freq = params.getStartFrequency(); /** @type {RelativeTime} */ - // @ts-expect-error cast const clockStep = params.getClockStep(); /** @type {NatValue} */ - // @ts-expect-error cast const startingRate = params.getStartingRate(); /** @type {NatValue} */ - // @ts-expect-error cast const discountStep = params.getDiscountStep(); /** @type {RelativeTime} */ - // @ts-expect-error cast const lockPeriod = params.getPriceLockPeriod(); /** @type {NatValue} */ - // @ts-expect-error cast const lowestRate = params.getLowestRate(); /** @type {RelativeTime} */ - // @ts-expect-error cast const startDelay = params.getAuctionStartDelay(); TimeMath.compareRel(freq, startDelay) > 0 || Fail`startFrequency must exceed startDelay, ${freq}, ${startDelay}`; @@ -124,7 +117,7 @@ export const makeScheduler = async ( auctionState = AuctionState.ACTIVE; auctionDriver.startRound(); } else { - auctionDriver.descendingStep(); + auctionDriver.reducePriceAndTrade(); } } diff --git a/packages/inter-protocol/src/auction/sortedOffers.js b/packages/inter-protocol/src/auction/sortedOffers.js index dcc7f8446c1..5b959625357 100644 --- a/packages/inter-protocol/src/auction/sortedOffers.js +++ b/packages/inter-protocol/src/auction/sortedOffers.js @@ -1,8 +1,14 @@ -import { makeRatio } from '@agoric/zoe/src/contractSupport/index.js'; -import { TimeMath } from '@agoric/time'; +import { + makeRatio, + coerceToNumber, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { M, mustMatch } from '@agoric/store'; +import { RatioShape } from '@agoric/ertp'; import { decodeNumber, encodeNumber } from '../vaultFactory/storeUtils.js'; +const { Fail } = assert; + // We want earlier times to sort the same direction as higher prices, so we // subtract the timestamp from millisecond time in the year 2286. This works for // timestamps in seconds or millis. The alternative considered was inverting, @@ -11,77 +17,58 @@ import { decodeNumber, encodeNumber } from '../vaultFactory/storeUtils.js'; // timestamps are used for sorting during an auction and don't need to be stored // long-term. We could safely subtract from a timestamp that's now + 1 month. const FarFuture = 10000000000000n; -const encodeTimestamp = t => FarFuture - t; -/** - * Prices might be more or less than one. - * - * @param {Ratio} price price quote in IST/Collateral - * @returns {number} - */ -const priceAsFloat = price => { - const n = Number(price.numerator.value); - const d = Number(price.denominator.value); - return n / d; -}; +/** @type {(s: bigint) => bigint} */ +const encodeSequenceNumber = s => FarFuture - s; /** - * Prices might be more or less than one. + * Return a sort key that will compare based only on price. Price is the prefix + * of the complete sort key, which is sufficient to find offers below a cutoff. * - * @param {Ratio} discount price quote in IST/IST - * @returns {number} + * @param {Ratio} offerPrice */ -const rateAsFloat = discount => { - const n = Number(discount.numerator.value); - const d = Number(discount.denominator.value); - return n / d; -}; - -export const toPriceComparator = offerPrice => { +export const toPartialOfferKey = offerPrice => { assert(offerPrice); - const mostSignificantPart = encodeNumber(priceAsFloat(offerPrice)); + const mostSignificantPart = encodeNumber(coerceToNumber(offerPrice)); return `${mostSignificantPart}:`; }; /** - * Sorts by ratio in descending price. + * Return a sort key that distinguishes by Price and sequence number * * @param {Ratio} offerPrice IST/collateral - * @param {Timestamp} offerTime + * @param {bigint} sequenceNumber * @returns {string} lexically sortable string in which highest price is first, - * ties will be broken by time of offer + * ties will be broken by sequenceNumber of offer */ -export const toPriceOfferKey = (offerPrice, offerTime) => { - assert(offerPrice); - assert(offerTime); +export const toPriceOfferKey = (offerPrice, sequenceNumber) => { + mustMatch(offerPrice, RatioShape); + offerPrice.numerator.brand !== offerPrice.denominator.brand || + Fail`offer prices must have different numerator and denominator`; + mustMatch(sequenceNumber, M.nat()); + // until DB supports composite keys, copy its method for turning numbers to DB // entry keys - const mostSignificantPart = encodeNumber(priceAsFloat(offerPrice)); - return `${mostSignificantPart}:${encodeTimestamp(offerTime)}`; + const mostSignificantPart = encodeNumber(coerceToNumber(offerPrice)); + return `${mostSignificantPart}:${encodeSequenceNumber(sequenceNumber)}`; }; const priceRatioFromFloat = (floatPrice, numBrand, denomBrand, useDecimals) => { const denominatorValue = 10 ** useDecimals; return makeRatio( - BigInt(Math.trunc(decodeNumber(floatPrice) * denominatorValue)), + BigInt(Math.round(decodeNumber(floatPrice) * denominatorValue)), numBrand, BigInt(denominatorValue), denomBrand, ); }; -const discountRatioFromFloat = ( - floatDiscount, - numBrand, - denomBrand, - useDecimals, -) => { +const discountRatioFromFloat = (floatDiscount, numBrand, useDecimals) => { const denominatorValue = 10 ** useDecimals; return makeRatio( - BigInt(Math.trunc(decodeNumber(floatDiscount) * denominatorValue)), + BigInt(Math.round(decodeNumber(floatDiscount) * denominatorValue)), numBrand, BigInt(denominatorValue), - denomBrand, ); }; @@ -92,37 +79,40 @@ const discountRatioFromFloat = ( * @param {Brand} numBrand * @param {Brand} denomBrand * @param {number} useDecimals - * @returns {[normalizedPrice: Ratio, offerTime: Timestamp]} + * @returns {[normalizedPrice: Ratio, sequenceNumber: bigint]} */ export const fromPriceOfferKey = (key, numBrand, denomBrand, useDecimals) => { - const [pricePart, timePart] = key.split(':'); + const [pricePart, sequenceNumberPart] = key.split(':'); return [ priceRatioFromFloat(pricePart, numBrand, denomBrand, useDecimals), - BigInt(encodeTimestamp(BigInt(timePart))), + encodeSequenceNumber(BigInt(sequenceNumberPart)), ]; }; export const toDiscountComparator = rate => { assert(rate); - const mostSignificantPart = encodeNumber(rateAsFloat(rate)); + const mostSignificantPart = encodeNumber(coerceToNumber(rate)); return `${mostSignificantPart}:`; }; /** * Sorts offers expressed as percentage of the current oracle price. * - * @param {Ratio} rate - * @param {Timestamp} offerTime + * @param {Ratio} rate discount/markup rate expressed as a ratio IST/IST + * @param {bigint} sequenceNumber * @returns {string} lexically sortable string in which highest price is first, - * ties will be broken by time of offer + * ties will be broken by sequenceNumber of offer */ -export const toDiscountedRateOfferKey = (rate, offerTime) => { - assert(rate); - assert(offerTime); +export const toDiscountedRateOfferKey = (rate, sequenceNumber) => { + mustMatch(rate, RatioShape); + rate.numerator.brand === rate.denominator.brand || + Fail`discount rate must have the same numerator and denominator`; + mustMatch(sequenceNumber, M.nat()); + // until DB supports composite keys, copy its method for turning numbers to DB // entry keys - const mostSignificantPart = encodeNumber(rateAsFloat(rate)); - return `${mostSignificantPart}:${encodeTimestamp(offerTime)}`; + const mostSignificantPart = encodeNumber(coerceToNumber(rate)); + return `${mostSignificantPart}:${encodeSequenceNumber(sequenceNumber)}`; }; /** @@ -131,14 +121,12 @@ export const toDiscountedRateOfferKey = (rate, offerTime) => { * @param {string} key * @param {Brand} brand * @param {number} useDecimals - * @returns {[normalizedPrice: Ratio, offerTime: Timestamp]} + * @returns {[normalizedPrice: Ratio, sequenceNumber: bigint]} */ export const fromDiscountedRateOfferKey = (key, brand, useDecimals) => { - const [discountPart, timePart] = key.split(':'); + const [discountPart, sequenceNumberPart] = key.split(':'); return [ - discountRatioFromFloat(discountPart, brand, brand, useDecimals), - BigInt(encodeTimestamp(BigInt(timePart))), + discountRatioFromFloat(discountPart, brand, useDecimals), + encodeSequenceNumber(BigInt(sequenceNumberPart)), ]; }; - -export const keyToTime = key => TimeMath.toAbs(Number(key.split(':')[1])); diff --git a/packages/inter-protocol/src/auction/util.js b/packages/inter-protocol/src/auction/util.js index 2c008bce9ee..3031b077c14 100644 --- a/packages/inter-protocol/src/auction/util.js +++ b/packages/inter-protocol/src/auction/util.js @@ -1,12 +1,10 @@ import { M } from '@agoric/store'; import { + makeRatioFromAmounts, multiplyRatios, ratioGTE, } from '@agoric/zoe/src/contractSupport/index.js'; -export const DiscountOfferShape = M.any(); -export const PriceOfferShape = M.any(); - export const BASIS_POINTS = 10000n; /** @@ -19,12 +17,26 @@ export const AuctionState = { WAITING: 'waiting', }; -export const makeRatioPattern = (nBrand, dBrand) => { +export const makeBrandedRatioPattern = (nBrand, dBrand) => { return harden({ numerator: { brand: nBrand, value: M.nat() }, denominator: { brand: dBrand, value: M.nat() }, }); }; -export const isDiscountedPriceHigher = (discount, currentPrice, oracleQuote) => - ratioGTE(multiplyRatios(oracleQuote, discount), currentPrice); +/** + * TRUE if the discount(/markup) applied to the price is higher than the quote. + * + * @param {Ratio} discount + * @param {Ratio} currentPrice + * @param {Ratio} oraclePrice + */ +export const isDiscountedPriceHigher = (discount, currentPrice, oraclePrice) => + ratioGTE(multiplyRatios(oraclePrice, discount), currentPrice); + +/** @type {(PriceQuote) => Ratio} */ +export const priceFrom = quote => + makeRatioFromAmounts( + quote.quoteAmount.value[0].amountOut, + quote.quoteAmount.value[0].amountIn, + ); diff --git a/packages/inter-protocol/test/auction/test-sortedOffers.js b/packages/inter-protocol/test/auction/test-sortedOffers.js index 02674909859..91fba960241 100644 --- a/packages/inter-protocol/test/auction/test-sortedOffers.js +++ b/packages/inter-protocol/test/auction/test-sortedOffers.js @@ -14,6 +14,7 @@ import { fromDiscountedRateOfferKey, } from '../../src/auction/sortedOffers.js'; +// these used to be timestamps, but now they're bigInts const DEC25 = 1671993996n; const DEC26 = 1672080396n; diff --git a/packages/inter-protocol/test/auction/tools.js b/packages/inter-protocol/test/auction/tools.js index 41db877e0d7..78428976d58 100644 --- a/packages/inter-protocol/test/auction/tools.js +++ b/packages/inter-protocol/test/auction/tools.js @@ -47,7 +47,7 @@ export const makeFakeAuctioneer = () => { const startRounds = []; return Far('FakeAuctioneer', { - descendingStep: () => { + reducePriceAndTrade: () => { state.step += 1; }, finalize: () => (state.final = true), diff --git a/packages/zoe/src/contractSupport/index.js b/packages/zoe/src/contractSupport/index.js index b23f37d839f..4831fa7d41e 100644 --- a/packages/zoe/src/contractSupport/index.js +++ b/packages/zoe/src/contractSupport/index.js @@ -56,4 +56,5 @@ export { quantize, ratioGTE, subtractRatios, + coerceToNumber, } from './ratio.js'; diff --git a/packages/zoe/src/contractSupport/priceQuote.js b/packages/zoe/src/contractSupport/priceQuote.js index 6229faaf318..3ba149e36ac 100644 --- a/packages/zoe/src/contractSupport/priceQuote.js +++ b/packages/zoe/src/contractSupport/priceQuote.js @@ -29,7 +29,6 @@ export const getTimestamp = quote => getPriceDescription(quote).timestamp; /** @param {Brand<'nat'>} brand */ export const unitAmount = async brand => { // Brand methods are remote - // FIXME: round trip to brand whenever unitAmount is needed? Cache displayInfo const displayInfo = await E(brand).getDisplayInfo(); const decimalPlaces = displayInfo.decimalPlaces ?? 0; return AmountMath.make(brand, 10n ** Nat(decimalPlaces)); diff --git a/packages/zoe/src/contractSupport/ratio.js b/packages/zoe/src/contractSupport/ratio.js index f6a94810b30..68c0de198ec 100644 --- a/packages/zoe/src/contractSupport/ratio.js +++ b/packages/zoe/src/contractSupport/ratio.js @@ -391,3 +391,15 @@ export const assertParsableNumber = specimen => { const match = `${specimen}`.match(NUMERIC_RE); match || Fail`Invalid numeric data: ${specimen}`; }; + +/** + * Ratios might be greater or less than one. + * + * @param {Ratio} ratio + * @returns {number} + */ +export const coerceToNumber = ratio => { + const n = Number(ratio.numerator.value); + const d = Number(ratio.denominator.value); + return n / d; +}; From 5a96e6f52480e04efa3844ddba076fc06c8aa5c3 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Mon, 27 Feb 2023 17:36:13 -0800 Subject: [PATCH 04/20] refactor: simplify keys in sortOffers --- .../src/auction/sortedOffers.js | 58 +++++++++---------- .../src/vaultFactory/storeUtils.js | 8 +-- .../test/auction/test-sortedOffers.js | 16 ++--- 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/packages/inter-protocol/src/auction/sortedOffers.js b/packages/inter-protocol/src/auction/sortedOffers.js index 5b959625357..89dfeb53ff1 100644 --- a/packages/inter-protocol/src/auction/sortedOffers.js +++ b/packages/inter-protocol/src/auction/sortedOffers.js @@ -5,21 +5,19 @@ import { import { M, mustMatch } from '@agoric/store'; import { RatioShape } from '@agoric/ertp'; -import { decodeNumber, encodeNumber } from '../vaultFactory/storeUtils.js'; +import { decodeData, encodeData } from '../vaultFactory/storeUtils.js'; const { Fail } = assert; -// We want earlier times to sort the same direction as higher prices, so we -// subtract the timestamp from millisecond time in the year 2286. This works for -// timestamps in seconds or millis. The alternative considered was inverting, -// but floats don't have enough resolution to convert back to the same timestamp -// This will work just fine for at least 250 years. And notice that these -// timestamps are used for sorting during an auction and don't need to be stored -// long-term. We could safely subtract from a timestamp that's now + 1 month. -const FarFuture = 10000000000000n; - -/** @type {(s: bigint) => bigint} */ -const encodeSequenceNumber = s => FarFuture - s; +/** + * @file we use a floating point representation of the price or rate as the + * first part of the key in the store. The second part is the sequence number of + * the bid, but it doesn't matter for sorting. When we retrieve multiple bids, + * it's only by bid value, so we don't care how the sequence numbers sort. + * + * We take advantage of the fact that encodeData takes a passable and turns it + * into a sort key. Arrays of passable data sort like composite keys. + */ /** * Return a sort key that will compare based only on price. Price is the prefix @@ -29,8 +27,8 @@ const encodeSequenceNumber = s => FarFuture - s; */ export const toPartialOfferKey = offerPrice => { assert(offerPrice); - const mostSignificantPart = encodeNumber(coerceToNumber(offerPrice)); - return `${mostSignificantPart}:`; + const mostSignificantPart = coerceToNumber(offerPrice); + return encodeData(harden([mostSignificantPart, 0n])); }; /** @@ -47,26 +45,24 @@ export const toPriceOfferKey = (offerPrice, sequenceNumber) => { Fail`offer prices must have different numerator and denominator`; mustMatch(sequenceNumber, M.nat()); - // until DB supports composite keys, copy its method for turning numbers to DB - // entry keys - const mostSignificantPart = encodeNumber(coerceToNumber(offerPrice)); - return `${mostSignificantPart}:${encodeSequenceNumber(sequenceNumber)}`; + const mostSignificantPart = coerceToNumber(offerPrice); + return encodeData(harden([mostSignificantPart, sequenceNumber])); }; const priceRatioFromFloat = (floatPrice, numBrand, denomBrand, useDecimals) => { const denominatorValue = 10 ** useDecimals; return makeRatio( - BigInt(Math.round(decodeNumber(floatPrice) * denominatorValue)), + BigInt(Math.round(floatPrice * denominatorValue)), numBrand, BigInt(denominatorValue), denomBrand, ); }; -const discountRatioFromFloat = (floatDiscount, numBrand, useDecimals) => { +const discountRatioFromKey = (floatDiscount, numBrand, useDecimals) => { const denominatorValue = 10 ** useDecimals; return makeRatio( - BigInt(Math.round(decodeNumber(floatDiscount) * denominatorValue)), + BigInt(Math.round(floatDiscount * denominatorValue)), numBrand, BigInt(denominatorValue), ); @@ -82,17 +78,17 @@ const discountRatioFromFloat = (floatDiscount, numBrand, useDecimals) => { * @returns {[normalizedPrice: Ratio, sequenceNumber: bigint]} */ export const fromPriceOfferKey = (key, numBrand, denomBrand, useDecimals) => { - const [pricePart, sequenceNumberPart] = key.split(':'); + const [pricePart, sequenceNumberPart] = decodeData(key); return [ priceRatioFromFloat(pricePart, numBrand, denomBrand, useDecimals), - encodeSequenceNumber(BigInt(sequenceNumberPart)), + sequenceNumberPart, ]; }; export const toDiscountComparator = rate => { assert(rate); - const mostSignificantPart = encodeNumber(coerceToNumber(rate)); - return `${mostSignificantPart}:`; + const mostSignificantPart = coerceToNumber(rate); + return encodeData(harden([mostSignificantPart, 0n])); }; /** @@ -109,10 +105,8 @@ export const toDiscountedRateOfferKey = (rate, sequenceNumber) => { Fail`discount rate must have the same numerator and denominator`; mustMatch(sequenceNumber, M.nat()); - // until DB supports composite keys, copy its method for turning numbers to DB - // entry keys - const mostSignificantPart = encodeNumber(coerceToNumber(rate)); - return `${mostSignificantPart}:${encodeSequenceNumber(sequenceNumber)}`; + const mostSignificantPart = coerceToNumber(rate); + return encodeData(harden([mostSignificantPart, sequenceNumber])); }; /** @@ -124,9 +118,9 @@ export const toDiscountedRateOfferKey = (rate, sequenceNumber) => { * @returns {[normalizedPrice: Ratio, sequenceNumber: bigint]} */ export const fromDiscountedRateOfferKey = (key, brand, useDecimals) => { - const [discountPart, sequenceNumberPart] = key.split(':'); + const [discountPart, sequenceNumberPart] = decodeData(key); return [ - discountRatioFromFloat(discountPart, brand, useDecimals), - encodeSequenceNumber(BigInt(sequenceNumberPart)), + discountRatioFromKey(discountPart, brand, useDecimals), + sequenceNumberPart, ]; }; diff --git a/packages/inter-protocol/src/vaultFactory/storeUtils.js b/packages/inter-protocol/src/vaultFactory/storeUtils.js index 0077c51787e..49cb9303ad5 100644 --- a/packages/inter-protocol/src/vaultFactory/storeUtils.js +++ b/packages/inter-protocol/src/vaultFactory/storeUtils.js @@ -27,7 +27,7 @@ import { * @param {PureData} key * @returns {string} */ -const encodeData = makeEncodePassable(); +export const encodeData = makeEncodePassable(); // `makeDecodePassable` has three named options: // `decodeRemotable`, `decodeError`, and `decodePromise`. @@ -38,13 +38,13 @@ const encodeData = makeEncodePassable(); * @param {string} encoded * @returns {PureData} */ -const decodeData = makeDecodePassable(); +export const decodeData = makeDecodePassable(); /** * @param {number} n * @returns {string} */ -export const encodeNumber = n => { +const encodeNumber = n => { assert.typeof(n, 'number'); return encodeData(n); }; @@ -53,7 +53,7 @@ export const encodeNumber = n => { * @param {string} encoded * @returns {number} */ -export const decodeNumber = encoded => { +const decodeNumber = encoded => { const result = decodeData(encoded); assert.typeof(result, 'number'); return result; diff --git a/packages/inter-protocol/test/auction/test-sortedOffers.js b/packages/inter-protocol/test/auction/test-sortedOffers.js index 91fba960241..1791ef178be 100644 --- a/packages/inter-protocol/test/auction/test-sortedOffers.js +++ b/packages/inter-protocol/test/auction/test-sortedOffers.js @@ -34,12 +34,12 @@ test('toKey price', t => { const keyC26 = toPriceOfferKey(priceC, DEC26); const keyD26 = toPriceOfferKey(priceD, DEC26); t.true(keyA25 > keyB25); - t.true(keyA25 > keyA26); + t.true(keyA26 > keyA25); t.true(keyB25 > keyC25); - t.true(keyB25 > keyB26); + t.true(keyB26 > keyB25); t.true(keyC25 > keyD25); - t.true(keyC25 > keyC26); - t.true(keyD25 > keyD26); + t.true(keyC26 > keyC25); + t.true(keyD26 > keyD25); }); test('toKey discount', t => { @@ -58,12 +58,12 @@ test('toKey discount', t => { const keyC26 = toDiscountedRateOfferKey(discountC, DEC26); const keyD26 = toDiscountedRateOfferKey(discountD, DEC26); t.true(keyB25 > keyA25); - t.true(keyA25 > keyA26); + t.true(keyA26 > keyA25); t.true(keyC25 > keyB25); - t.true(keyB25 > keyB26); + t.true(keyB26 > keyB25); t.true(keyD25 > keyC25); - t.true(keyC25 > keyC26); - t.true(keyD25 > keyD26); + t.true(keyC26 > keyC25); + t.true(keyD26 > keyD25); }); test('fromKey Price', t => { From e5ff84d2ebbde08d76b85dcdf3dc262a0478c130 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Tue, 28 Feb 2023 11:21:55 -0800 Subject: [PATCH 05/20] chore: cosmetic cleanups from review --- .../inter-protocol/src/auction/auctionBook.js | 65 +++++++++---------- .../inter-protocol/src/auction/auctioneer.js | 22 ++++--- .../auction/{discountBook.js => offerBook.js} | 38 ++++++----- packages/inter-protocol/src/auction/params.js | 11 +--- .../inter-protocol/src/auction/scheduler.js | 2 +- .../src/auction/sortedOffers.js | 28 ++++---- packages/inter-protocol/src/auction/util.js | 6 +- .../src/vaultFactory/prioritizedVaults.js | 5 ++ .../test/auction/test-auctionBook.js | 4 +- .../test/auction/test-auctionContract.js | 2 +- .../test/auction/test-sortedOffers.js | 36 ++++------ packages/inter-protocol/test/auction/tools.js | 5 +- packages/zoe/src/contractSupport/index.js | 2 +- packages/zoe/src/contractSupport/ratio.js | 2 +- 14 files changed, 112 insertions(+), 116 deletions(-) rename packages/inter-protocol/src/auction/{discountBook.js => offerBook.js} (72%) diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 2b38a6cf726..40793749a3b 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -19,10 +19,10 @@ import { import { E } from '@endo/captp'; import { makeTracer } from '@agoric/internal'; -import { makeDiscountBook, makePriceBook } from './discountBook.js'; +import { makeScaledBidBook, makePriceBook } from './offerBook.js'; import { AuctionState, - isDiscountedPriceHigher, + isScaledBidPriceHigher, makeBrandedRatioPattern, priceFrom, } from './util.js'; @@ -37,7 +37,7 @@ const { Fail } = assert; * The book contains orders for the collateral. It holds two kinds of * orders: * - Prices express the bid in terms of a Currency amount - * - Discount express the bid in terms of a discount (or markup) from the + * - Scaled bid express the bid in terms of a discount (or markup) from the * most recent oracle price. * * Offers can be added in three ways. When the auction is not active, prices are @@ -58,11 +58,10 @@ export const makeAuctionBook = async ( collateralBrand, priceAuthority, ) => { - const makeZeroRatio = () => - makeRatioFromAmounts( - AmountMath.makeEmpty(currencyBrand), - AmountMath.make(collateralBrand, 1n), - ); + const zeroRatio = makeRatioFromAmounts( + AmountMath.makeEmpty(currencyBrand), + AmountMath.make(collateralBrand, 1n), + ); const BidSpecShape = M.or( { want: AmountShape, @@ -70,7 +69,7 @@ export const makeAuctionBook = async ( }, { want: AmountShape, - offerDiscount: makeBrandedRatioPattern(currencyBrand, currencyBrand), + offerBidScaling: makeBrandedRatioPattern(currencyBrand, currencyBrand), }, ); @@ -78,8 +77,8 @@ export const makeAuctionBook = async ( const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); const { zcfSeat: currencySeat } = zcf.makeEmptySeatKit(); - let lockedPriceForRound = makeZeroRatio(); - let updatingOracleQuote = makeZeroRatio(); + let lockedPriceForRound = zeroRatio; + let updatingOracleQuote = zeroRatio; E.when(E(collateralBrand).getDisplayInfo(), ({ decimalPlaces = 9n }) => { // TODO(#6946) use this to keep a current price that can be published in state. const quoteNotifier = E(priceAuthority).makeQuoteNotifier( @@ -105,17 +104,17 @@ export const makeAuctionBook = async ( }); }); - let curAuctionPrice = makeZeroRatio(); + let curAuctionPrice = zeroRatio; - const discountBook = provide(baggage, 'discountBook', () => { - const discountStore = makeScalarBigMapStore('orderedVaultStore', { + const scaledBidBook = provide(baggage, 'scaledBidBook', () => { + const scaledBidStore = makeScalarBigMapStore('scaledBidBookStore', { durable: true, }); - return makeDiscountBook(discountStore, currencyBrand, collateralBrand); + return makeScaledBidBook(scaledBidStore, currencyBrand, collateralBrand); }); const priceBook = provide(baggage, 'sortedOffers', () => { - const priceStore = makeScalarBigMapStore('orderedVaultStore', { + const priceStore = makeScalarBigMapStore('sortedOffersStore', { durable: true, }); return makePriceBook(priceStore, currencyBrand, collateralBrand); @@ -125,7 +124,7 @@ export const makeAuctionBook = async ( if (isPriceBook) { priceBook.delete(key); } else { - discountBook.delete(key); + scaledBidBook.delete(key); } }; @@ -224,17 +223,17 @@ export const makeAuctionBook = async ( * the book. * * @param {ZCFSeat} seat - * @param {Ratio} discount + * @param {Ratio} bidScaling * @param {Amount} want * @param {AuctionState} auctionState */ - const acceptDiscountOffer = (seat, discount, want, auctionState) => { - trace('accept discount'); + const acceptScaledBidOffer = (seat, bidScaling, want, auctionState) => { + trace('accept scaled bid offer'); let collateralSold = AmountMath.makeEmptyFromAmount(want); if ( isActive(auctionState) && - isDiscountedPriceHigher(discount, curAuctionPrice, lockedPriceForRound) + isScaledBidPriceHigher(bidScaling, curAuctionPrice, lockedPriceForRound) ) { collateralSold = settle(seat, want); if (AmountMath.isEmpty(seat.getCurrentAllocation().Currency)) { @@ -245,7 +244,7 @@ export const makeAuctionBook = async ( const stillWant = AmountMath.subtract(want, collateralSold); if (!AmountMath.isEmpty(stillWant)) { - discountBook.add(seat, discount, stillWant); + scaledBidBook.add(seat, bidScaling, stillWant); } else { seat.exit(); } @@ -264,14 +263,14 @@ export const makeAuctionBook = async ( curAuctionPrice = multiplyRatios(reduction, lockedPriceForRound); const pricedOffers = priceBook.offersAbove(curAuctionPrice); - const discOffers = discountBook.offersAbove(reduction); + const discOffers = scaledBidBook.offersAbove(reduction); - // requested price or discount gives no priority beyond specifying which + // requested price or bid scaling gives no priority beyond specifying which // round the order will be service in. const prioritizedOffers = [...pricedOffers, ...discOffers].sort(); trace(`settling`, pricedOffers.length, discOffers.length); - prioritizedOffers.forEach(([key, { seat, price: p, wanted }]) => { + for (const [key, { seat, price: p, wanted }] of prioritizedOffers) { if (seat.hasExited()) { removeFromOneBook(p, key); } else { @@ -287,17 +286,17 @@ export const makeAuctionBook = async ( if (p) { priceBook.updateReceived(key, collateralSold); } else { - discountBook.updateReceived(key, collateralSold); + scaledBidBook.updateReceived(key, collateralSold); } } } - }); + } }, getCurrentPrice() { return curAuctionPrice; }, hasOrders() { - return discountBook.hasOrders() || priceBook.hasOrders(); + return scaledBidBook.hasOrders() || priceBook.hasOrders(); }, lockOraclePriceForRound() { trace(`locking `, updatingOracleQuote); @@ -318,15 +317,15 @@ export const makeAuctionBook = async ( bidSpec.want, auctionState, ); - } else if (bidSpec.offerDiscount) { - return acceptDiscountOffer( + } else if (bidSpec.offerBidScaling) { + return acceptScaledBidOffer( seat, - bidSpec.offerDiscount, + bidSpec.offerBidScaling, bidSpec.want, auctionState, ); } else { - throw Fail`Offer was neither a price nor a discount`; + throw Fail`Offer was neither a price nor a scaled bid`; } }, getSeats() { @@ -334,7 +333,7 @@ export const makeAuctionBook = async ( }, exitAllSeats() { priceBook.exitAllSeats(); - discountBook.exitAllSeats(); + scaledBidBook.exitAllSeats(); }, }); }; diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index dd682ec75fe..6a2dcbeafe4 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -7,7 +7,6 @@ import { E } from '@endo/eventual-send'; import { M, makeScalarBigMapStore, - provide, provideDurableMapStore, } from '@agoric/vat-data'; import { AmountMath } from '@agoric/ertp'; @@ -47,8 +46,15 @@ const makeBPRatio = (rate, currencyBrand, collateralBrand = currencyBrand) => * StartingRate: 'nat', * lowestRate: 'nat', * DiscountStep: 'nat', - * }> & {timerService: import('@agoric/time/src/types').TimerService, priceAuthority: PriceAuthority}>} zcf - * @param {{initialPoserInvitation: Invitation, storageNode: StorageNode, marshaller: Marshaller}} privateArgs + * }> & { + * timerService: import('@agoric/time/src/types').TimerService, + * priceAuthority: PriceAuthority + * }>} zcf + * @param {{ + * initialPoserInvitation: Invitation, + * storageNode: StorageNode, + * marshaller: Marshaller + * }} privateArgs * @param {Baggage} baggage */ export const start = async (zcf, privateArgs, baggage) => { @@ -58,11 +64,7 @@ export const start = async (zcf, privateArgs, baggage) => { const books = provideDurableMapStore(baggage, 'auctionBooks'); const deposits = provideDurableMapStore(baggage, 'deposits'); - const brandToKeyword = provide(baggage, 'brandToKeyword', () => - makeScalarBigMapStore('deposits', { - durable: true, - }), - ); + const brandToKeyword = provideDurableMapStore(baggage, 'brandToKeyword'); const reserveFunds = provideEmptySeat(zcf, baggage, 'collateral'); @@ -157,14 +159,14 @@ export const start = async (zcf, privateArgs, baggage) => { ); const tradeEveryBook = () => { - const discountRatio = makeRatio( + const bidScalingRatio = makeRatio( currentDiscountRateBP, brands.Currency, BASIS_POINTS, ); [...books.entries()].forEach(([_collateralBrand, book]) => { - book.settleAtNewRate(discountRatio); + book.settleAtNewRate(bidScalingRatio); }); }; diff --git a/packages/inter-protocol/src/auction/discountBook.js b/packages/inter-protocol/src/auction/offerBook.js similarity index 72% rename from packages/inter-protocol/src/auction/discountBook.js rename to packages/inter-protocol/src/auction/offerBook.js index eef73c18828..0c54b1cc456 100644 --- a/packages/inter-protocol/src/auction/discountBook.js +++ b/packages/inter-protocol/src/auction/offerBook.js @@ -1,13 +1,13 @@ -// book of offers to buy liquidating vaults with prices in terms of discount -// from the current oracle price. +// book of offers to buy liquidating vaults with prices in terms of +// discount/markup from the current oracle price. import { Far } from '@endo/marshal'; import { M, mustMatch } from '@agoric/store'; import { AmountMath } from '@agoric/ertp'; import { - toDiscountComparator, - toDiscountedRateOfferKey, + toBidScalingComparator, + toScaledRateOfferKey, toPartialOfferKey, toPriceOfferKey, } from './sortedOffers.js'; @@ -22,22 +22,28 @@ const nextSequenceNumber = () => { return latestSequenceNumber; }; -// prices in this book are expressed as percentage of full price. .4 is 60% off. -// 1.1 is 10% above par. -export const makeDiscountBook = (store, currencyBrand, collateralBrand) => { - return Far('discountBook ', { - add(seat, discount, wanted) { - // XXX mustMatch(discount, DISCOUNT_PATTERN); +// prices in this book are expressed as percentage of the full oracle price +// snapshot taken when the auction started. .4 is 60% off. 1.1 is 10% above par. +export const makeScaledBidBook = (store, currencyBrand, collateralBrand) => { + return Far('scaledBidBook ', { + add(seat, bidScaling, wanted) { + // XXX mustMatch(bidScaling, BID_SCALING_PATTERN); const seqNum = nextSequenceNumber(); - const key = toDiscountedRateOfferKey(discount, seqNum); + const key = toScaledRateOfferKey(bidScaling, seqNum); const empty = AmountMath.makeEmpty(collateralBrand); - const bidderRecord = { seat, discount, wanted, seqNum, received: empty }; + const bidderRecord = { + seat, + bidScaling, + wanted, + seqNum, + received: empty, + }; store.init(key, harden(bidderRecord)); return key; }, - offersAbove(discount) { - return [...store.entries(M.gte(toDiscountComparator(discount)))]; + offersAbove(bidScaling) { + return [...store.entries(M.gte(toBidScalingComparator(bidScaling)))]; }, hasOrders() { return store.getSize() > 0; @@ -62,9 +68,11 @@ export const makeDiscountBook = (store, currencyBrand, collateralBrand) => { }); }; +// prices in this book are actual prices expressed in terms of currency amount +// and collateral amount. export const makePriceBook = (store, currencyBrand, collateralBrand) => { const RATIO_PATTERN = makeBrandedRatioPattern(currencyBrand, collateralBrand); - return Far('discountBook ', { + return Far('priceBook ', { add(seat, price, wanted) { mustMatch(price, RATIO_PATTERN); diff --git a/packages/inter-protocol/src/auction/params.js b/packages/inter-protocol/src/auction/params.js index 675dbf17d3e..f1df93a2690 100644 --- a/packages/inter-protocol/src/auction/params.js +++ b/packages/inter-protocol/src/auction/params.js @@ -3,11 +3,7 @@ import { makeParamManager, ParamTypes, } from '@agoric/governance'; -import { - TimerBrandShape, - TimeMath, - RelativeTimeValueShape, -} from '@agoric/time'; +import { TimeMath, RelativeTimeRecordShape } from '@agoric/time'; import { M } from '@agoric/store'; /** @typedef {import('@agoric/governance/src/contractGovernance/typedParamManager.js').AsyncSpecTuple} AsyncSpecTuple */ @@ -48,11 +44,6 @@ export const LIQUIDATION_PENALTY = 'LiquidationPenalty'; // time before each auction that the prices are locked. export const PRICE_LOCK_PERIOD = 'PriceLockPeriod'; -export const RelativeTimeRecordShape = harden({ - timerBrand: TimerBrandShape, - relValue: RelativeTimeValueShape, -}); - export const auctioneerParamPattern = M.splitRecord({ [CONTRACT_ELECTORATE]: InvitationShape, [START_FREQUENCY]: RelativeTimeRecordShape, diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index 2194f8fe954..bd16b313cbf 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -16,7 +16,7 @@ const trace = makeTracer('SCHED', false); * should always be a next schedule, but between rounds, liveSchedule is null. * * The lock period that the liquidators use might start before the previous - * round has finished, so we need to scheduled the next round each time an + * round has finished, so we need to schedule the next round each time an * auction starts. This means if the scheduling parameters change, it'll be a * full cycle before we switch. Otherwise, the vaults wouldn't know when to * start their lock period. diff --git a/packages/inter-protocol/src/auction/sortedOffers.js b/packages/inter-protocol/src/auction/sortedOffers.js index 89dfeb53ff1..7ae3dc61e58 100644 --- a/packages/inter-protocol/src/auction/sortedOffers.js +++ b/packages/inter-protocol/src/auction/sortedOffers.js @@ -1,6 +1,6 @@ import { makeRatio, - coerceToNumber, + ratioToNumber, } from '@agoric/zoe/src/contractSupport/index.js'; import { M, mustMatch } from '@agoric/store'; import { RatioShape } from '@agoric/ertp'; @@ -27,7 +27,7 @@ const { Fail } = assert; */ export const toPartialOfferKey = offerPrice => { assert(offerPrice); - const mostSignificantPart = coerceToNumber(offerPrice); + const mostSignificantPart = ratioToNumber(offerPrice); return encodeData(harden([mostSignificantPart, 0n])); }; @@ -45,7 +45,7 @@ export const toPriceOfferKey = (offerPrice, sequenceNumber) => { Fail`offer prices must have different numerator and denominator`; mustMatch(sequenceNumber, M.nat()); - const mostSignificantPart = coerceToNumber(offerPrice); + const mostSignificantPart = ratioToNumber(offerPrice); return encodeData(harden([mostSignificantPart, sequenceNumber])); }; @@ -59,10 +59,10 @@ const priceRatioFromFloat = (floatPrice, numBrand, denomBrand, useDecimals) => { ); }; -const discountRatioFromKey = (floatDiscount, numBrand, useDecimals) => { +const bidScalingRatioFromKey = (bidScaleFloat, numBrand, useDecimals) => { const denominatorValue = 10 ** useDecimals; return makeRatio( - BigInt(Math.round(floatDiscount * denominatorValue)), + BigInt(Math.round(bidScaleFloat * denominatorValue)), numBrand, BigInt(denominatorValue), ); @@ -85,9 +85,9 @@ export const fromPriceOfferKey = (key, numBrand, denomBrand, useDecimals) => { ]; }; -export const toDiscountComparator = rate => { +export const toBidScalingComparator = rate => { assert(rate); - const mostSignificantPart = coerceToNumber(rate); + const mostSignificantPart = ratioToNumber(rate); return encodeData(harden([mostSignificantPart, 0n])); }; @@ -99,28 +99,28 @@ export const toDiscountComparator = rate => { * @returns {string} lexically sortable string in which highest price is first, * ties will be broken by sequenceNumber of offer */ -export const toDiscountedRateOfferKey = (rate, sequenceNumber) => { +export const toScaledRateOfferKey = (rate, sequenceNumber) => { mustMatch(rate, RatioShape); rate.numerator.brand === rate.denominator.brand || - Fail`discount rate must have the same numerator and denominator`; + Fail`bid scaling rate must have the same numerator and denominator`; mustMatch(sequenceNumber, M.nat()); - const mostSignificantPart = coerceToNumber(rate); + const mostSignificantPart = ratioToNumber(rate); return encodeData(harden([mostSignificantPart, sequenceNumber])); }; /** - * fromDiscountedRateOfferKey is only used for diagnostics. + * fromScaledRateOfferKey is only used for diagnostics. * * @param {string} key * @param {Brand} brand * @param {number} useDecimals * @returns {[normalizedPrice: Ratio, sequenceNumber: bigint]} */ -export const fromDiscountedRateOfferKey = (key, brand, useDecimals) => { - const [discountPart, sequenceNumberPart] = decodeData(key); +export const fromScaledRateOfferKey = (key, brand, useDecimals) => { + const [bidScalingPart, sequenceNumberPart] = decodeData(key); return [ - discountRatioFromKey(discountPart, brand, useDecimals), + bidScalingRatioFromKey(bidScalingPart, brand, useDecimals), sequenceNumberPart, ]; }; diff --git a/packages/inter-protocol/src/auction/util.js b/packages/inter-protocol/src/auction/util.js index 3031b077c14..5f0440241de 100644 --- a/packages/inter-protocol/src/auction/util.js +++ b/packages/inter-protocol/src/auction/util.js @@ -27,12 +27,12 @@ export const makeBrandedRatioPattern = (nBrand, dBrand) => { /** * TRUE if the discount(/markup) applied to the price is higher than the quote. * - * @param {Ratio} discount + * @param {Ratio} bidScaling * @param {Ratio} currentPrice * @param {Ratio} oraclePrice */ -export const isDiscountedPriceHigher = (discount, currentPrice, oraclePrice) => - ratioGTE(multiplyRatios(oraclePrice, discount), currentPrice); +export const isScaledBidPriceHigher = (bidScaling, currentPrice, oraclePrice) => + ratioGTE(multiplyRatios(oraclePrice, bidScaling), currentPrice); /** @type {(PriceQuote) => Ratio} */ export const priceFrom = quote => diff --git a/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js b/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js index d9f844f2752..e094f988435 100644 --- a/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js +++ b/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js @@ -52,6 +52,11 @@ export const currentDebtToCollateral = vault => export const makePrioritizedVaults = (store, higherHighestCb = () => {}) => { const vaults = makeOrderedVaultStore(store); + // Check if this ratio of debt to collateral would be the highest known. If + // so, reset our highest and invoke the callback. This can be called on new + // vaults and when we get a state update for a vault changing balances. + /** @param {Ratio} collateralToDebt */ + /** * Called back when there's a new highestRatio and it's higher than the previous. * diff --git a/packages/inter-protocol/test/auction/test-auctionBook.js b/packages/inter-protocol/test/auction/test-auctionBook.js index 3594dabd089..ade8786deda 100644 --- a/packages/inter-protocol/test/auction/test-auctionBook.js +++ b/packages/inter-protocol/test/auction/test-auctionBook.js @@ -159,7 +159,7 @@ test('getOffers to a price limit', async t => { book.addOffer( harden({ - offerDiscount: makeRatioFromAmounts(moola(10n), moola(100n)), + offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), want: simoleans(50n), }), zcfSeat, @@ -214,7 +214,7 @@ test('getOffers w/discount', async t => { book.addOffer( harden({ - offerDiscount: makeRatioFromAmounts(moola(10n), moola(100n)), + offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), want: simoleans(50n), }), zcfSeat, diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js index b485ada55b7..84603ea14ac 100644 --- a/packages/inter-protocol/test/auction/test-auctionContract.js +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -160,7 +160,7 @@ const makeAuctionDriver = async (t, customTerms, params = defaultParams) => { }); const offerArgs = discount && discount.numerator.brand === discount.denominator.brand - ? { want: wantCollateral, offerDiscount: discount } + ? { want: wantCollateral, offerBidScaling: discount } : { want: wantCollateral, offerPrice: diff --git a/packages/inter-protocol/test/auction/test-sortedOffers.js b/packages/inter-protocol/test/auction/test-sortedOffers.js index 1791ef178be..75a5fc6857b 100644 --- a/packages/inter-protocol/test/auction/test-sortedOffers.js +++ b/packages/inter-protocol/test/auction/test-sortedOffers.js @@ -10,8 +10,8 @@ import { setup } from '../../../zoe/test/unitTests/setupBasicMints.js'; import { fromPriceOfferKey, toPriceOfferKey, - toDiscountedRateOfferKey, - fromDiscountedRateOfferKey, + toScaledRateOfferKey, + fromScaledRateOfferKey, } from '../../src/auction/sortedOffers.js'; // these used to be timestamps, but now they're bigInts @@ -49,14 +49,14 @@ test('toKey discount', t => { const discountC = makeRatioFromAmounts(moola(6n), moola(100n)); const discountD = makeRatioFromAmounts(moola(10n), moola(100n)); - const keyA25 = toDiscountedRateOfferKey(discountA, DEC25); - const keyB25 = toDiscountedRateOfferKey(discountB, DEC25); - const keyC25 = toDiscountedRateOfferKey(discountC, DEC25); - const keyD25 = toDiscountedRateOfferKey(discountD, DEC25); - const keyA26 = toDiscountedRateOfferKey(discountA, DEC26); - const keyB26 = toDiscountedRateOfferKey(discountB, DEC26); - const keyC26 = toDiscountedRateOfferKey(discountC, DEC26); - const keyD26 = toDiscountedRateOfferKey(discountD, DEC26); + const keyA25 = toScaledRateOfferKey(discountA, DEC25); + const keyB25 = toScaledRateOfferKey(discountB, DEC25); + const keyC25 = toScaledRateOfferKey(discountC, DEC25); + const keyD25 = toScaledRateOfferKey(discountD, DEC25); + const keyA26 = toScaledRateOfferKey(discountA, DEC26); + const keyB26 = toScaledRateOfferKey(discountB, DEC26); + const keyC26 = toScaledRateOfferKey(discountC, DEC26); + const keyD26 = toScaledRateOfferKey(discountD, DEC26); t.true(keyB25 > keyA25); t.true(keyA26 > keyA25); t.true(keyC25 > keyB25); @@ -100,19 +100,11 @@ test('fromKey discount', t => { const fivePointFivePercent = makeRatioFromAmounts(moola(55n), moola(1000n)); const discountB = fivePointFivePercent; - const keyA25 = toDiscountedRateOfferKey(discountA, DEC25); - const keyB25 = toDiscountedRateOfferKey(discountB, DEC25); + const keyA25 = toScaledRateOfferKey(discountA, DEC25); + const keyB25 = toScaledRateOfferKey(discountB, DEC25); - const [discountAOut, timeA] = fromDiscountedRateOfferKey( - keyA25, - moolaBrand, - 9, - ); - const [discountBOut, timeB] = fromDiscountedRateOfferKey( - keyB25, - moolaBrand, - 9, - ); + const [discountAOut, timeA] = fromScaledRateOfferKey(keyA25, moolaBrand, 9); + const [discountBOut, timeB] = fromScaledRateOfferKey(keyB25, moolaBrand, 9); t.deepEqual(quantize(discountAOut, 10000n), quantize(fivePercent, 10000n)); t.deepEqual( quantize(discountBOut, 10000n), diff --git a/packages/inter-protocol/test/auction/tools.js b/packages/inter-protocol/test/auction/tools.js index 78428976d58..c6a182bfde7 100644 --- a/packages/inter-protocol/test/auction/tools.js +++ b/packages/inter-protocol/test/auction/tools.js @@ -3,7 +3,7 @@ import { Far } from '@endo/marshal'; import { E } from '@endo/eventual-send'; import { makeStoredPublisherKit } from '@agoric/notifier'; import { makeZoeKit } from '@agoric/zoe'; -import { objectMap } from '@agoric/internal'; +import { objectMap, allValues } from '@agoric/internal'; import { makeFakeVatAdmin } from '@agoric/zoe/tools/fakeVatAdmin.js'; import { makeMockChainStorageRoot } from '@agoric/internal/src/storage-test-utils.js'; import { makeFakeMarshaller } from '@agoric/notifier/tools/testSupports.js'; @@ -12,7 +12,6 @@ import contractGovernorBundle from '@agoric/governance/bundles/bundle-contractGo import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; import { resolve as importMetaResolve } from 'import-meta-resolve'; -import * as Collect from '../../src/collect.js'; export const setUpInstallations = async zoe => { const autoRefund = '@agoric/zoe/src/contracts/automaticRefund.js'; @@ -20,7 +19,7 @@ export const setUpInstallations = async zoe => { const autoRefundPath = new URL(autoRefundUrl).pathname; const bundleCache = await unsafeMakeBundleCache('./bundles/'); // package-relative - const bundles = await Collect.allValues({ + const bundles = await allValues({ // could be called fakeCommittee. It's used as a source of invitations only autoRefund: bundleCache.load(autoRefundPath, 'autoRefund'), auctioneer: bundleCache.load('./src/auction/auctioneer.js', 'auctioneer'), diff --git a/packages/zoe/src/contractSupport/index.js b/packages/zoe/src/contractSupport/index.js index 4831fa7d41e..3e29ff53196 100644 --- a/packages/zoe/src/contractSupport/index.js +++ b/packages/zoe/src/contractSupport/index.js @@ -56,5 +56,5 @@ export { quantize, ratioGTE, subtractRatios, - coerceToNumber, + ratioToNumber, } from './ratio.js'; diff --git a/packages/zoe/src/contractSupport/ratio.js b/packages/zoe/src/contractSupport/ratio.js index 68c0de198ec..17d252455f2 100644 --- a/packages/zoe/src/contractSupport/ratio.js +++ b/packages/zoe/src/contractSupport/ratio.js @@ -398,7 +398,7 @@ export const assertParsableNumber = specimen => { * @param {Ratio} ratio * @returns {number} */ -export const coerceToNumber = ratio => { +export const ratioToNumber = ratio => { const n = Number(ratio.numerator.value); const d = Number(ratio.denominator.value); return n / d; From fcb3056be42fddeb90c71099f3e44bf2a163eb2d Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Tue, 28 Feb 2023 15:27:05 -0800 Subject: [PATCH 06/20] chore: auction was introduced to vaultfactory too early. It will be introduced with #7047 when vaultfactory makes use of the auction for liquidation --- packages/inter-protocol/src/proposals/econ-behaviors.js | 4 +--- packages/inter-protocol/src/vaultFactory/params.js | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/inter-protocol/src/proposals/econ-behaviors.js b/packages/inter-protocol/src/proposals/econ-behaviors.js index 263f7dcbea0..765e06ded12 100644 --- a/packages/inter-protocol/src/proposals/econ-behaviors.js +++ b/packages/inter-protocol/src/proposals/econ-behaviors.js @@ -213,7 +213,7 @@ export const startVaultFactory = async ( }, instance: { produce: instanceProduce, - consume: { auction: auctionInstance, reserve: reserveInstance }, + consume: { reserve: reserveInstance }, }, installation: { consume: { @@ -256,7 +256,6 @@ export const startVaultFactory = async ( const centralBrand = await centralBrandP; const reservePublicFacet = await E(zoe).getPublicFacet(reserveInstance); - const auctionPublicFacet = await E(zoe).getPublicFacet(auctionInstance); const storageNode = await makeStorageNodeChild(chainStorage, STORAGE_PATH); const marshaller = await E(board).getReadonlyMarshaller(); @@ -272,7 +271,6 @@ export const startVaultFactory = async ( bootstrapPaymentValue: 0n, shortfallInvitationAmount, endorsedUi, - auctionPublicFacet, }, ); diff --git a/packages/inter-protocol/src/vaultFactory/params.js b/packages/inter-protocol/src/vaultFactory/params.js index d762604b5ed..6487e644f11 100644 --- a/packages/inter-protocol/src/vaultFactory/params.js +++ b/packages/inter-protocol/src/vaultFactory/params.js @@ -140,7 +140,6 @@ harden(makeVaultDirectorParamManager); * reservePublicFacet: AssetReservePublicFacet, * loanTiming: LoanTiming, * shortfallInvitationAmount: Amount, - * auctionPublicFacet: import('../auction/auctioneer.js').AuctioneerPublicFacet, * endorsedUi?: string, * }} opts */ @@ -148,7 +147,6 @@ export const makeGovernedTerms = ( { storageNode, marshaller }, { bootstrapPaymentValue, - auctionPublicFacet, electorateInvitationAmount, loanTiming, minInitialDebt, @@ -175,7 +173,6 @@ export const makeGovernedTerms = ( return harden({ priceAuthority, - auctionPublicFacet, loanTimingParams, reservePublicFacet, timerService: timer, From 4d07a53fcf77e1d458b11d33d3975a2fb7e2ef1d Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Tue, 28 Feb 2023 15:30:44 -0800 Subject: [PATCH 07/20] chore: correctly revert removal of param declaration It'll be removed in #7074, I think, but for now it like a change in this PR. --- .../src/vaultFactory/prioritizedVaults.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js b/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js index e094f988435..04279d86bae 100644 --- a/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js +++ b/packages/inter-protocol/src/vaultFactory/prioritizedVaults.js @@ -52,11 +52,6 @@ export const currentDebtToCollateral = vault => export const makePrioritizedVaults = (store, higherHighestCb = () => {}) => { const vaults = makeOrderedVaultStore(store); - // Check if this ratio of debt to collateral would be the highest known. If - // so, reset our highest and invoke the callback. This can be called on new - // vaults and when we get a state update for a vault changing balances. - /** @param {Ratio} collateralToDebt */ - /** * Called back when there's a new highestRatio and it's higher than the previous. * @@ -79,6 +74,11 @@ export const makePrioritizedVaults = (store, higherHighestCb = () => {}) => { /** @type {string | undefined} */ let firstKey; + // Check if this ratio of debt to collateral would be the highest known. If + // so, reset our highest and invoke the callback. This can be called on new + // vaults and when we get a state update for a vault changing balances. + /** @param {Ratio} collateralToDebt */ + /** * Ratio of the least-collateralized vault, if there is one. * From c193026223bd1e08c9d404afcc61210918d7f9c6 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Tue, 28 Feb 2023 16:15:34 -0800 Subject: [PATCH 08/20] test: repair dependencies for startAuction --- packages/inter-protocol/src/proposals/core-proposal.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/inter-protocol/src/proposals/core-proposal.js b/packages/inter-protocol/src/proposals/core-proposal.js index 4025a646a03..9bd9703c82f 100644 --- a/packages/inter-protocol/src/proposals/core-proposal.js +++ b/packages/inter-protocol/src/proposals/core-proposal.js @@ -90,8 +90,7 @@ const SHARED_MAIN_MANIFEST = harden({ produce: { auction: 'auction' }, }, installation: { - consume: { auctionInstallation: 'zoe' }, - contractGovernor: 'zoe', + consume: { contractGovernor: 'zoe', auction: 'zoe' }, }, issuer: { consume: { [Stable.symbol]: 'zoe' }, From 8bed4d5ec742e66c827e45d4eada6ac3a9681936 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Wed, 1 Mar 2023 17:55:40 -0800 Subject: [PATCH 09/20] chore: responses to reviews --- .../inter-protocol/src/auction/auctionBook.js | 264 ++++++++++-------- .../inter-protocol/src/auction/auctioneer.js | 116 +++++--- .../inter-protocol/src/auction/offerBook.js | 36 ++- packages/inter-protocol/src/auction/util.js | 16 +- .../test/auction/test-auctionBook.js | 80 +++++- .../test/auction/test-proportionalDist.js | 168 +++++++++++ packages/internal/src/utils.js | 2 + 7 files changed, 503 insertions(+), 179 deletions(-) create mode 100644 packages/inter-protocol/test/auction/test-proportionalDist.js diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 40793749a3b..d72de455202 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -3,7 +3,7 @@ import '@agoric/zoe/src/contracts/exported.js'; import '@agoric/governance/exported.js'; import { M, makeScalarBigMapStore, provide } from '@agoric/vat-data'; -import { AmountMath, AmountShape } from '@agoric/ertp'; +import { AmountMath } from '@agoric/ertp'; import { Far } from '@endo/marshal'; import { mustMatch } from '@agoric/store'; import { observeNotifier } from '@agoric/notifier'; @@ -21,7 +21,6 @@ import { makeTracer } from '@agoric/internal'; import { makeScaledBidBook, makePriceBook } from './offerBook.js'; import { - AuctionState, isScaledBidPriceHigher, makeBrandedRatioPattern, priceFrom, @@ -29,6 +28,8 @@ import { const { Fail } = assert; +const DEFAULT_DECIMALS = 9n; + /** * @file The book represents the collateral-specific state of an ongoing * auction. It holds the book, the lockedPrice, and the collateralSeat that has @@ -37,17 +38,17 @@ const { Fail } = assert; * The book contains orders for the collateral. It holds two kinds of * orders: * - Prices express the bid in terms of a Currency amount - * - Scaled bid express the bid in terms of a discount (or markup) from the + * - Scaled bids express the bid in terms of a discount (or markup) from the * most recent oracle price. * - * Offers can be added in three ways. When the auction is not active, prices are - * automatically added to the appropriate collection. If a new offer is at or - * above the current price of an active auction, it will be settled immediately. - * If the offer is below the current price, it will be added, and settled when - * the price reaches that level. + * Offers can be added in three ways. 1) When the auction is not active, prices + * are automatically added to the appropriate collection. When the auction is + * active, 2) if a new offer is at or above the current price, it will be + * settled immediately; 2) If the offer is below the current price, it will be + * added in the appropriate place and settled when the price reaches that level. */ -const trace = makeTracer('AucBook', false); +const trace = makeTracer('AucBook', true); /** @typedef {import('@agoric/vat-data').Baggage} Baggage */ @@ -62,72 +63,124 @@ export const makeAuctionBook = async ( AmountMath.makeEmpty(currencyBrand), AmountMath.make(collateralBrand, 1n), ); + const [currencyAmountShape, collateralAmountShape] = await Promise.all([ + E(currencyBrand).getAmountShape(), + E(collateralBrand).getAmountShape(), + ]); const BidSpecShape = M.or( { - want: AmountShape, - offerPrice: makeBrandedRatioPattern(currencyBrand, collateralBrand), + want: collateralAmountShape, + offerPrice: makeBrandedRatioPattern( + currencyAmountShape, + collateralAmountShape, + ), }, { - want: AmountShape, - offerBidScaling: makeBrandedRatioPattern(currencyBrand, currencyBrand), + want: collateralAmountShape, + offerBidScaling: makeBrandedRatioPattern( + currencyAmountShape, + currencyAmountShape, + ), }, ); let assetsForSale = AmountMath.makeEmpty(collateralBrand); + + // 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 + // may be offers in the book, but these seats will be empty, with all assets + // returned to the funders. const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); const { zcfSeat: currencySeat } = zcf.makeEmptySeatKit(); let lockedPriceForRound = zeroRatio; let updatingOracleQuote = zeroRatio; - E.when(E(collateralBrand).getDisplayInfo(), ({ decimalPlaces = 9n }) => { - // TODO(#6946) use this to keep a current price that can be published in state. - const quoteNotifier = E(priceAuthority).makeQuoteNotifier( - AmountMath.make(collateralBrand, 10n ** decimalPlaces), - currencyBrand, - ); + 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 ** decimalPlaces), + currencyBrand, + ); - observeNotifier(quoteNotifier, { - updateState: quote => { - trace( - `BOOK notifier ${priceFrom(quote).numerator.value}/${ - priceFrom(quote).denominator.value - }`, - ); - return (updatingOracleQuote = priceFrom(quote)); - }, - fail: reason => { - throw Error(`auction observer of ${collateralBrand} failed: ${reason}`); - }, - finish: done => { - throw Error(`auction observer for ${collateralBrand} died: ${done}`); - }, - }); - }); + observeNotifier(quoteNotifier, { + updateState: quote => { + trace( + `BOOK notifier ${priceFrom(quote).numerator.value}/${ + priceFrom(quote).denominator.value + }`, + ); + return (updatingOracleQuote = priceFrom(quote)); + }, + fail: reason => { + throw Error( + `auction observer of ${collateralBrand} failed: ${reason}`, + ); + }, + finish: done => { + throw Error(`auction observer for ${collateralBrand} died: ${done}`); + }, + }); + }, + ); let curAuctionPrice = zeroRatio; const scaledBidBook = provide(baggage, 'scaledBidBook', () => { + const ratioPattern = makeBrandedRatioPattern( + currencyAmountShape, + currencyAmountShape, + ); const scaledBidStore = makeScalarBigMapStore('scaledBidBookStore', { durable: true, }); - return makeScaledBidBook(scaledBidStore, currencyBrand, collateralBrand); + return makeScaledBidBook(scaledBidStore, ratioPattern, collateralBrand); }); const priceBook = provide(baggage, 'sortedOffers', () => { + const ratioPattern = makeBrandedRatioPattern( + currencyAmountShape, + collateralAmountShape, + ); + const priceStore = makeScalarBigMapStore('sortedOffersStore', { durable: true, }); - return makePriceBook(priceStore, currencyBrand, collateralBrand); + return makePriceBook(priceStore, ratioPattern, collateralBrand); }); - const removeFromOneBook = (isPriceBook, key) => { - if (isPriceBook) { + /** + * remove the key from the appropriate book, indicated by whether the price + * is defined. + * + * @param {string} key + * @param {Ratio | undefined} price + */ + const removeFromItsBook = (key, price) => { + if (price) { priceBook.delete(key); } else { scaledBidBook.delete(key); } }; + /** + * Update the entry in the appropriate book, indicated by whether the price + * is defined. + * + * @param {string} key + * @param {Amount} collateralSold + * @param {Ratio | undefined} price + */ + const updateItsBook = (key, collateralSold, price) => { + if (price) { + priceBook.updateReceived(key, collateralSold); + } else { + scaledBidBook.updateReceived(key, collateralSold); + } + }; + // Settle with seat. The caller is responsible for updating the book, if any. const settle = (seat, collateralWanted) => { const { Currency: currencyAvailable } = seat.getCurrentAllocation(); @@ -149,42 +202,30 @@ export const makeAuctionBook = async ( return AmountMath.makeEmptyFromAmount(collateralWanted); } - let collateralRecord; - let currencyRecord; - if (AmountMath.isGTE(currencyAvailable, currencyNeeded)) { - collateralRecord = { - Collateral: collateralTarget, - }; - currencyRecord = { - Currency: currencyNeeded, - }; - } else { - const affordableCollateral = floorDivideBy( - currencyAvailable, - curAuctionPrice, - ); - collateralRecord = { - Collateral: affordableCollateral, - }; - currencyRecord = { - Currency: currencyAvailable, - }; - } - - trace('settle', { currencyRecord, collateralRecord }); + const affordableAmounts = () => { + if (AmountMath.isGTE(currencyAvailable, currencyNeeded)) { + return [collateralTarget, currencyNeeded]; + } else { + const affordableCollateral = floorDivideBy( + currencyAvailable, + curAuctionPrice, + ); + return [affordableCollateral, currencyAvailable]; + } + }; + const [collateralAmount, currencyAmount] = affordableAmounts(); + trace('settle', { collateralAmount, currencyAmount }); atomicRearrange( zcf, harden([ - [collateralSeat, seat, collateralRecord], - [seat, currencySeat, currencyRecord], + [collateralSeat, seat, { Collateral: collateralAmount }], + [seat, currencySeat, { Currency: currencyAmount }], ]), ); - return collateralRecord.Collateral; + return collateralAmount; }; - const isActive = auctionState => auctionState === AuctionState.ACTIVE; - /** * Accept an offer expressed as a price. If the auction is active, attempt to * buy collateral. If any of the offer remains add it to the book. @@ -192,28 +233,26 @@ export const makeAuctionBook = async ( * @param {ZCFSeat} seat * @param {Ratio} price * @param {Amount} want - * @param {AuctionState} auctionState + * @param {boolean} trySettle */ - const acceptPriceOffer = (seat, price, want, auctionState) => { + const acceptPriceOffer = (seat, price, want, trySettle) => { trace('acceptPrice'); // Offer has ZcfSeat, offerArgs (w/price) and timeStamp - let collateralSold = AmountMath.makeEmptyFromAmount(want); - if (isActive(auctionState) && ratioGTE(price, curAuctionPrice)) { - collateralSold = settle(seat, want); - - if (AmountMath.isEmpty(seat.getCurrentAllocation().Currency)) { - seat.exit(); - return; - } - } + const collateralSold = + trySettle && ratioGTE(price, curAuctionPrice) + ? settle(seat, want) + : AmountMath.makeEmptyFromAmount(want); const stillWant = AmountMath.subtract(want, collateralSold); - if (!AmountMath.isEmpty(stillWant)) { + if ( + AmountMath.isEmpty(stillWant) || + AmountMath.isEmpty(seat.getCurrentAllocation().Currency) + ) { + seat.exit(); + } else { trace('added Offer ', price, stillWant.value); priceBook.add(seat, price, stillWant); - } else { - seat.exit(); } }; @@ -225,28 +264,24 @@ export const makeAuctionBook = async ( * @param {ZCFSeat} seat * @param {Ratio} bidScaling * @param {Amount} want - * @param {AuctionState} auctionState + * @param {boolean} trySettle */ - const acceptScaledBidOffer = (seat, bidScaling, want, auctionState) => { + const acceptScaledBidOffer = (seat, bidScaling, want, trySettle) => { trace('accept scaled bid offer'); - let collateralSold = AmountMath.makeEmptyFromAmount(want); - - if ( - isActive(auctionState) && + const collateralSold = + trySettle && isScaledBidPriceHigher(bidScaling, curAuctionPrice, lockedPriceForRound) - ) { - collateralSold = settle(seat, want); - if (AmountMath.isEmpty(seat.getCurrentAllocation().Currency)) { - seat.exit(); - return; - } - } + ? settle(seat, want) + : AmountMath.makeEmptyFromAmount(want); const stillWant = AmountMath.subtract(want, collateralSold); - if (!AmountMath.isEmpty(stillWant)) { - scaledBidBook.add(seat, bidScaling, stillWant); - } else { + if ( + AmountMath.isEmpty(stillWant) || + AmountMath.isEmpty(seat.getCurrentAllocation().Currency) + ) { seat.exit(); + } else { + scaledBidBook.add(seat, bidScaling, stillWant); } }; @@ -263,16 +298,16 @@ export const makeAuctionBook = async ( curAuctionPrice = multiplyRatios(reduction, lockedPriceForRound); const pricedOffers = priceBook.offersAbove(curAuctionPrice); - const discOffers = scaledBidBook.offersAbove(reduction); + const scaledBidOffers = scaledBidBook.offersAbove(reduction); + trace(`settling`, pricedOffers.length, scaledBidOffers.length); // requested price or bid scaling gives no priority beyond specifying which - // round the order will be service in. - const prioritizedOffers = [...pricedOffers, ...discOffers].sort(); + // round the order will be serviced in. + const prioritizedOffers = [...pricedOffers, ...scaledBidOffers].sort(); - trace(`settling`, pricedOffers.length, discOffers.length); for (const [key, { seat, price: p, wanted }] of prioritizedOffers) { if (seat.hasExited()) { - removeFromOneBook(p, key); + removeFromItsBook(key, p); } else { const collateralSold = settle(seat, wanted); @@ -281,13 +316,9 @@ export const makeAuctionBook = async ( AmountMath.isGTE(seat.getCurrentAllocation().Collateral, wanted) ) { seat.exit(); - removeFromOneBook(p, key); + removeFromItsBook(key, p); } else if (!AmountMath.isGTE(collateralSold, wanted)) { - if (p) { - priceBook.updateReceived(key, collateralSold); - } else { - scaledBidBook.updateReceived(key, collateralSold); - } + updateItsBook(key, collateralSold, p); } } } @@ -307,22 +338,29 @@ export const makeAuctionBook = async ( trace('set startPrice', lockedPriceForRound); curAuctionPrice = multiplyRatios(lockedPriceForRound, rate); }, - addOffer(bidSpec, seat, auctionState) { + addOffer(bidSpec, seat, trySettle) { mustMatch(bidSpec, BidSpecShape); + const { give } = seat.getProposal(); + mustMatch( + give.Currency, + currencyAmountShape, + 'give must include "Currency"', + ); if (bidSpec.offerPrice) { + give.C; return acceptPriceOffer( seat, bidSpec.offerPrice, bidSpec.want, - auctionState, + trySettle, ); } else if (bidSpec.offerBidScaling) { return acceptScaledBidOffer( seat, bidSpec.offerBidScaling, bidSpec.want, - auctionState, + trySettle, ); } else { throw Fail`Offer was neither a price nor a scaled bid`; @@ -337,3 +375,5 @@ export const makeAuctionBook = async ( }, }); }; + +/** @typedef {Awaited>} AuctionBook */ diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index 6a2dcbeafe4..a51a92ee8d0 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -19,11 +19,11 @@ import { provideEmptySeat, } from '@agoric/zoe/src/contractSupport/index.js'; import { handleParamGovernance } from '@agoric/governance'; -import { makeTracer } from '@agoric/internal'; +import { makeTracer, BASIS_POINTS } from '@agoric/internal'; import { FullProposalShape } from '@agoric/zoe/src/typeGuards.js'; import { makeAuctionBook } from './auctionBook.js'; -import { BASIS_POINTS } from './util.js'; +import { AuctionState } from './util.js'; import { makeScheduler } from './scheduler.js'; import { auctioneerParamTypes } from './params.js'; @@ -36,9 +36,69 @@ const trace = makeTracer('Auction', false); const makeBPRatio = (rate, currencyBrand, collateralBrand = currencyBrand) => makeRatioFromAmounts( AmountMath.make(currencyBrand, rate), - AmountMath.make(collateralBrand, 10000n), + AmountMath.make(collateralBrand, BASIS_POINTS), ); +/** + * Return a set of transfers for atomicRearrange() that distribute + * collateralRaised and currencyRaised proportionally to each seat's deposited + * amount. Any uneven split should be allocated to the reserve. + * + * @param {Amount} collateralRaised + * @param {Amount} currencyRaised + * @param {{seat: ZCFSeat, amount: Amount<"nat">}[]} deposits + * @param {ZCFSeat} collateralSeat + * @param {ZCFSeat} currencySeat + * @param {string} collateralKeyword + * @param {ZCFSeat} reserveSeat + * @param {Brand} brand + */ +export const distributeProportionalShares = ( + collateralRaised, + currencyRaised, + deposits, + collateralSeat, + currencySeat, + collateralKeyword, + reserveSeat, + brand, +) => { + const totalCollDeposited = deposits.reduce((prev, { amount }) => { + return AmountMath.add(prev, amount); + }, AmountMath.makeEmpty(brand)); + + const collShare = makeRatioFromAmounts(collateralRaised, totalCollDeposited); + const currShare = makeRatioFromAmounts(currencyRaised, totalCollDeposited); + /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} */ + const transfers = []; + let currencyLeft = currencyRaised; + let collateralLeft = collateralRaised; + + // each depositor gets a share that equals their amount deposited + // divided by the total deposited multiplied by the currency and + // collateral being distributed. + for (const { seat, amount } of deposits.values()) { + const currPortion = floorMultiplyBy(amount, currShare); + currencyLeft = AmountMath.subtract(currencyLeft, currPortion); + const collPortion = floorMultiplyBy(amount, collShare); + collateralLeft = AmountMath.subtract(collateralLeft, collPortion); + transfers.push([currencySeat, seat, { Currency: currPortion }]); + transfers.push([collateralSeat, seat, { Collateral: collPortion }]); + } + + // TODO The leftovers should go to the reserve, and should be visible. + transfers.push([currencySeat, reserveSeat, { Currency: currencyLeft }]); + + // There will be multiple collaterals, so they can't all use the same keyword + transfers.push([ + collateralSeat, + reserveSeat, + { Collateral: collateralLeft }, + { [collateralKeyword]: collateralLeft }, + ]); + return transfers; +}; + /** * @param {ZCF { timer || Fail`Timer must be in Auctioneer terms`; const timerBrand = await E(timer).getTimerBrand(); + /** @type {MapStore} */ const books = provideDurableMapStore(baggage, 'auctionBooks'); + /** @type {MapStore}>>} */ const deposits = provideDurableMapStore(baggage, 'deposits'); + /** @type {MapStore} */ const brandToKeyword = provideDurableMapStore(baggage, 'brandToKeyword'); const reserveFunds = provideEmptySeat(zcf, baggage, 'collateral'); @@ -100,45 +163,18 @@ export const start = async (zcf, privateArgs, baggage) => { liqSeat.exit(); deposits.set(brand, []); } else if (depositsForBrand.length > 1) { - const totCollDeposited = depositsForBrand.reduce((prev, { amount }) => { - return AmountMath.add(prev, amount); - }, AmountMath.makeEmpty(brand)); - const collatRaise = collateralSeat.getCurrentAllocation().Collateral; const currencyRaise = currencySeat.getCurrentAllocation().Currency; - - const collShare = makeRatioFromAmounts(collatRaise, totCollDeposited); - const currShare = makeRatioFromAmounts(currencyRaise, totCollDeposited); - /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} */ - const transfers = []; - let currencyLeft = currencyRaise; - let collateralLeft = collatRaise; - - // each depositor gets as share that equals their amount deposited - // divided by the total deposited multplied by the currency and - // collateral being distributed. - for (const { seat, amount } of deposits.get(brand).values()) { - const currPortion = floorMultiplyBy(amount, currShare); - currencyLeft = AmountMath.subtract(currencyLeft, currPortion); - const collPortion = floorMultiplyBy(amount, collShare); - collateralLeft = AmountMath.subtract(collateralLeft, collPortion); - transfers.push([currencySeat, seat, { Currency: currPortion }]); - transfers.push([collateralSeat, seat, { Collateral: collPortion }]); - } - - // TODO The leftovers should go to the reserve, and should be visible. - const keyword = brandToKeyword.get(brand); - transfers.push([ - currencySeat, - reserveFunds, - { Currency: currencyLeft }, - ]); - transfers.push([ + const transfers = distributeProportionalShares( + collatRaise, + currencyRaise, + depositsForBrand, collateralSeat, + currencySeat, + brandToKeyword.get(brand), reserveFunds, - { Collateral: collateralLeft }, - { [keyword]: collateralLeft }, - ]); + brand, + ); atomicRearrange(zcf, harden(transfers)); for (const { seat } of depositsForBrand) { @@ -205,6 +241,8 @@ export const start = async (zcf, privateArgs, baggage) => { // @ts-expect-error types are correct. How to convince TS? const scheduler = await makeScheduler(driver, timer, params, timerBrand); + const isActive = () => scheduler.getAuctionState() === AuctionState.ACTIVE; + const depositOfferHandler = zcfSeat => { const { Collateral: collateralAmount } = zcfSeat.getCurrentAllocation(); const book = books.get(collateralAmount.brand); @@ -222,7 +260,7 @@ export const start = async (zcf, privateArgs, baggage) => { const newBidHandler = (zcfSeat, bidSpec) => { if (books.has(collateralBrand)) { const auctionBook = books.get(collateralBrand); - auctionBook.addOffer(bidSpec, zcfSeat, scheduler.getAuctionState()); + auctionBook.addOffer(bidSpec, zcfSeat, isActive()); return 'Your offer has been received'; } else { zcfSeat.exit(`No book for brand ${collateralBrand}`); diff --git a/packages/inter-protocol/src/auction/offerBook.js b/packages/inter-protocol/src/auction/offerBook.js index 0c54b1cc456..aa45661ee54 100644 --- a/packages/inter-protocol/src/auction/offerBook.js +++ b/packages/inter-protocol/src/auction/offerBook.js @@ -11,7 +11,8 @@ import { toPartialOfferKey, toPriceOfferKey, } from './sortedOffers.js'; -import { makeBrandedRatioPattern } from './util.js'; + +/** @typedef {import('@agoric/vat-data').Baggage} Baggage */ // multiple offers might be provided at the same time (since the time // granularity is limited to blocks), so we increment a sequenceNumber with each @@ -22,12 +23,22 @@ const nextSequenceNumber = () => { return latestSequenceNumber; }; -// prices in this book are expressed as percentage of the full oracle price -// snapshot taken when the auction started. .4 is 60% off. 1.1 is 10% above par. -export const makeScaledBidBook = (store, currencyBrand, collateralBrand) => { +/** + * Prices in this book are expressed as percentage of the full oracle price + * snapshot taken when the auction started. .4 is 60% off. 1.1 is 10% above par. + * + * @param {Baggage} store + * @param {Pattern} bidScalingPattern + * @param {Brand} collateralBrand + */ +export const makeScaledBidBook = ( + store, + bidScalingPattern, + collateralBrand, +) => { return Far('scaledBidBook ', { add(seat, bidScaling, wanted) { - // XXX mustMatch(bidScaling, BID_SCALING_PATTERN); + mustMatch(bidScaling, bidScalingPattern); const seqNum = nextSequenceNumber(); const key = toScaledRateOfferKey(bidScaling, seqNum); @@ -68,13 +79,18 @@ export const makeScaledBidBook = (store, currencyBrand, collateralBrand) => { }); }; -// prices in this book are actual prices expressed in terms of currency amount -// and collateral amount. -export const makePriceBook = (store, currencyBrand, collateralBrand) => { - const RATIO_PATTERN = makeBrandedRatioPattern(currencyBrand, collateralBrand); +/** + * Prices in this book are actual prices expressed in terms of currency amount + * and collateral amount. + * + * @param {Baggage} store + * @param {Pattern} ratioPattern + * @param {Brand} collateralBrand + */ +export const makePriceBook = (store, ratioPattern, collateralBrand) => { return Far('priceBook ', { add(seat, price, wanted) { - mustMatch(price, RATIO_PATTERN); + mustMatch(price, ratioPattern); const seqNum = nextSequenceNumber(); const key = toPriceOfferKey(price, seqNum); diff --git a/packages/inter-protocol/src/auction/util.js b/packages/inter-protocol/src/auction/util.js index 5f0440241de..5462ca3a0c3 100644 --- a/packages/inter-protocol/src/auction/util.js +++ b/packages/inter-protocol/src/auction/util.js @@ -1,12 +1,9 @@ -import { M } from '@agoric/store'; import { makeRatioFromAmounts, multiplyRatios, ratioGTE, } from '@agoric/zoe/src/contractSupport/index.js'; -export const BASIS_POINTS = 10000n; - /** * Constants for Auction State. * @@ -17,10 +14,17 @@ export const AuctionState = { WAITING: 'waiting', }; -export const makeBrandedRatioPattern = (nBrand, dBrand) => { +/** + * @param {Pattern} numeratorAmountShape + * @param {Pattern} denominatorAmountShape + */ +export const makeBrandedRatioPattern = ( + numeratorAmountShape, + denominatorAmountShape, +) => { return harden({ - numerator: { brand: nBrand, value: M.nat() }, - denominator: { brand: dBrand, value: M.nat() }, + numerator: numeratorAmountShape, + denominator: denominatorAmountShape, }); }; diff --git a/packages/inter-protocol/test/auction/test-auctionBook.js b/packages/inter-protocol/test/auction/test-auctionBook.js index ade8786deda..1c4e30df5d7 100644 --- a/packages/inter-protocol/test/auction/test-auctionBook.js +++ b/packages/inter-protocol/test/auction/test-auctionBook.js @@ -14,7 +14,6 @@ import { eventLoopIteration } from '@agoric/notifier/tools/testSupports.js'; import { setup } from '../../../zoe/test/unitTests/setupBasicMints.js'; import { makeAuctionBook } from '../../src/auction/auctionBook.js'; -import { AuctionState } from '../../src/auction/util.js'; const buildManualPriceAuthority = initialPrice => makeManualPriceAuthority({ @@ -62,21 +61,21 @@ const makeSeatWithAssets = async (zoe, zcf, giveAmount, giveKwd, issuerKit) => { return zcfSeat; }; -test('acceptOffer fakeSeat', async t => { +test('simple addOffer', async t => { const { moolaKit, moola, simoleans, simoleanKit } = setup(); const { zoe, zcf } = await setupZCFTest(); await zcf.saveIssuer(moolaKit.issuer, 'Moola'); await zcf.saveIssuer(simoleanKit.issuer, 'Sim'); - const payment = moolaKit.mint.mintPayment(moola(100n)); - - const { zcfSeat } = await makeOffer( + const zcfSeat = await makeSeatWithAssets( zoe, zcf, - { give: { Bid: moola(100n) }, want: { Ask: simoleans(0n) } }, - { Bid: payment }, + moola(100n), + 'Currency', + moolaKit, ); + const baggage = makeScalarBigMapStore('zcfBaggage', { durable: true }); const donorSeat = await makeSeatWithAssets( zoe, @@ -109,7 +108,7 @@ test('acceptOffer fakeSeat', async t => { want: simoleans(50n), }), zcfSeat, - AuctionState.ACTIVE, + true, ); t.true(book.hasOrders()); @@ -150,7 +149,7 @@ test('getOffers to a price limit', async t => { zoe, zcf, moola(100n), - 'Bid', + 'Currency', moolaKit, ); @@ -163,13 +162,13 @@ test('getOffers to a price limit', async t => { want: simoleans(50n), }), zcfSeat, - AuctionState.ACTIVE, + true, ); t.true(book.hasOrders()); }); -test('getOffers w/discount', async t => { +test('Bad keyword', async t => { const { moolaKit, moola, simoleanKit, simoleans } = setup(); const { zoe, zcf } = await setupZCFTest(); @@ -212,13 +211,70 @@ test('getOffers w/discount', async t => { moolaKit, ); + t.throws( + () => + book.addOffer( + harden({ + offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), + want: simoleans(50n), + }), + zcfSeat, + true, + ), + { message: /give must include "Currency".*/ }, + ); +}); + +test('getOffers w/discount', async t => { + const { moolaKit, moola, simoleanKit, simoleans } = setup(); + + const { zoe, zcf } = await setupZCFTest(); + await zcf.saveIssuer(moolaKit.issuer, 'Moola'); + await zcf.saveIssuer(simoleanKit.issuer, 'Sim'); + + const baggage = makeScalarBigMapStore('zcfBaggage', { durable: true }); + + const donorSeat = await makeSeatWithAssets( + zoe, + zcf, + simoleans(500n), + 'Collateral', + simoleanKit, + ); + + const initialPrice = makeRatioFromAmounts(moola(20n), simoleans(100n)); + const pa = buildManualPriceAuthority(initialPrice); + + const book = await makeAuctionBook( + baggage, + zcf, + moolaKit.brand, + simoleanKit.brand, + pa, + ); + + pa.setPrice(makeRatioFromAmounts(moola(11n), simoleans(10n))); + await eventLoopIteration(); + book.addAssets(AmountMath.make(simoleanKit.brand, 123n), donorSeat); + + book.lockOraclePriceForRound(); + book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + + const zcfSeat = await makeSeatWithAssets( + zoe, + zcf, + moola(100n), + 'Currency', + moolaKit, + ); + book.addOffer( harden({ offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), want: simoleans(50n), }), zcfSeat, - AuctionState.ACTIVE, + true, ); t.true(book.hasOrders()); diff --git a/packages/inter-protocol/test/auction/test-proportionalDist.js b/packages/inter-protocol/test/auction/test-proportionalDist.js new file mode 100644 index 00000000000..c98134a62bb --- /dev/null +++ b/packages/inter-protocol/test/auction/test-proportionalDist.js @@ -0,0 +1,168 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import '@agoric/zoe/exported.js'; + +import { makeIssuerKit } from '@agoric/ertp'; +import { makeTracer } from '@agoric/internal'; + +import { setUpZoeForTest, withAmountUtils } from '../supports.js'; +import { distributeProportionalShares } from '../../src/auction/auctioneer.js'; + +/** @type {import('ava').TestFn>>} */ +const test = anyTest; + +const trace = makeTracer('Test AuctContract', false); + +const makeTestContext = async () => { + const { zoe } = await setUpZoeForTest(); + + const currency = withAmountUtils(makeIssuerKit('Currency')); + const collateral = withAmountUtils(makeIssuerKit('Collateral')); + + trace('makeContext'); + return { + zoe: await zoe, + currency, + collateral, + }; +}; + +test.before(async t => { + t.context = await makeTestContext(); +}); + +const checkProportions = ( + t, + amountsReturned, + rawDeposits, + rawExpected, + kwd = 'ATOM', +) => { + const { collateral, currency } = t.context; + + const rawExp = rawExpected[0]; + t.is(rawDeposits.length, rawExp.length); + + const [collateralReturned, currencyReturned] = amountsReturned; + const fakeCollateralSeat = harden({}); + const fakeCurrencySeat = harden({}); + const fakeReserveSeat = harden({}); + + debugger; + const deposits = []; + const expectedXfer = []; + for (let i = 0; i < rawDeposits.length; i += 1) { + const seat = harden({}); + deposits.push({ seat, amount: collateral.make(rawDeposits[i]) }); + const currencyRecord = { Currency: currency.make(rawExp[i][1]) }; + expectedXfer.push([fakeCurrencySeat, seat, currencyRecord]); + const collateralRecord = { Collateral: collateral.make(rawExp[i][0]) }; + expectedXfer.push([fakeCollateralSeat, seat, collateralRecord]); + } + const expectedLeftovers = rawExpected[1]; + const leftoverCurrency = { Currency: currency.make(expectedLeftovers[1]) }; + expectedXfer.push([fakeCurrencySeat, fakeReserveSeat, leftoverCurrency]); + expectedXfer.push([ + fakeCollateralSeat, + fakeReserveSeat, + { Collateral: collateral.make(expectedLeftovers[0]) }, + { [kwd]: collateral.make(expectedLeftovers[0]) }, + ]); + + const transfers = distributeProportionalShares( + collateral.make(collateralReturned), + currency.make(currencyReturned), + // @ts-expect-error mocks for test + deposits, + fakeCollateralSeat, + fakeCurrencySeat, + 'ATOM', + fakeReserveSeat, + collateral.brand, + ); + + t.deepEqual(transfers, expectedXfer); +}; + +test('distributeProportionalShares', t => { + // received 0 Collateral and 20 Currency from the auction to distribute to one + // vaultManager. Expect the one to get 0 and 20, and no leftovers + checkProportions(t, [0n, 20n], [100n], [[[0n, 20n]], [0n, 0n]]); +}); + +test('proportional simple', t => { + // received 100 Collateral and 2000 Currency from the auction to distribute to + // two depositors in a ratio of 6:1. expect leftovers + checkProportions( + t, + [100n, 2000n], + [100n, 600n], + [ + [ + [14n, 285n], + [85n, 1714n], + ], + [1n, 1n], + ], + ); +}); + +test('proportional three way', t => { + // received 100 Collateral and 2000 Currency from the auction to distribute to + // three depositors in a ratio of 1:3:1. expect no leftovers + checkProportions( + t, + [100n, 2000n], + [100n, 300n, 100n], + [ + [ + [20n, 400n], + [60n, 1200n], + [20n, 400n], + ], + [0n, 0n], + ], + ); +}); + +test('proportional odd ratios, no collateral', t => { + // received 0 Collateral and 2001 Currency from the auction to distribute to + // five depositors in a ratio of 20, 36, 17, 83, 42. expect leftovers + // sum = 198 + checkProportions( + t, + [0n, 2001n], + [20n, 36n, 17n, 83n, 42n], + [ + [ + [0n, 202n], + [0n, 363n], + [0n, 171n], + [0n, 838n], + [0n, 424n], + ], + [0n, 3n], + ], + ); +}); + +test('proportional, no currency', t => { + // received 0 Collateral and 2001 Currency from the auction to distribute to + // five depositors in a ratio of 20, 36, 17, 83, 42. expect leftovers + // sum = 198 + checkProportions( + t, + [20n, 0n], + [20n, 36n, 17n, 83n, 42n], + [ + [ + [2n, 0n], + [3n, 0n], + [1n, 0n], + [8n, 0n], + [4n, 0n], + ], + [2n, 0n], + ], + ); +}); diff --git a/packages/internal/src/utils.js b/packages/internal/src/utils.js index 290aa0648d3..eb693f89e13 100644 --- a/packages/internal/src/utils.js +++ b/packages/internal/src/utils.js @@ -10,6 +10,8 @@ const { ownKeys } = Reflect; const { details: X, quote: q, Fail } = assert; +export const BASIS_POINTS = 10_000n; + /** @template T @typedef {import('@endo/eventual-send').ERef} ERef */ /** From d94b6e66d5303b47c980b14e7406a57b86669e9a Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Thu, 2 Mar 2023 14:28:27 -0800 Subject: [PATCH 10/20] test: refactor proportional distribution test to use macros --- .../test/auction/test-proportionalDist.js | 147 +++++++++--------- 1 file changed, 73 insertions(+), 74 deletions(-) diff --git a/packages/inter-protocol/test/auction/test-proportionalDist.js b/packages/inter-protocol/test/auction/test-proportionalDist.js index c98134a62bb..d3aa23c21d5 100644 --- a/packages/inter-protocol/test/auction/test-proportionalDist.js +++ b/packages/inter-protocol/test/auction/test-proportionalDist.js @@ -48,7 +48,6 @@ const checkProportions = ( const fakeCurrencySeat = harden({}); const fakeReserveSeat = harden({}); - debugger; const deposits = []; const expectedXfer = []; for (let i = 0; i < rawDeposits.length; i += 1) { @@ -84,85 +83,85 @@ const checkProportions = ( t.deepEqual(transfers, expectedXfer); }; -test('distributeProportionalShares', t => { - // received 0 Collateral and 20 Currency from the auction to distribute to one - // vaultManager. Expect the one to get 0 and 20, and no leftovers - checkProportions(t, [0n, 20n], [100n], [[[0n, 20n]], [0n, 0n]]); -}); - -test('proportional simple', t => { - // received 100 Collateral and 2000 Currency from the auction to distribute to - // two depositors in a ratio of 6:1. expect leftovers - checkProportions( - t, - [100n, 2000n], - [100n, 600n], +// Received 0 Collateral and 20 Currency from the auction to distribute to one +// vaultManager. Expect the one to get 0 and 20, and no leftovers +test( + 'distributeProportionalShares', + checkProportions, + [0n, 20n], + [100n], + [[[0n, 20n]], [0n, 0n]], +); + +// received 100 Collateral and 2000 Currency from the auction to distribute to +// two depositors in a ratio of 6:1. expect leftovers +test( + 'proportional simple', + checkProportions, + [100n, 2000n], + [100n, 600n], + [ [ - [ - [14n, 285n], - [85n, 1714n], - ], - [1n, 1n], + [14n, 285n], + [85n, 1714n], ], - ); -}); - -test('proportional three way', t => { - // received 100 Collateral and 2000 Currency from the auction to distribute to - // three depositors in a ratio of 1:3:1. expect no leftovers - checkProportions( - t, - [100n, 2000n], - [100n, 300n, 100n], + [1n, 1n], + ], +); + +// Received 100 Collateral and 2000 Currency from the auction to distribute to +// three depositors in a ratio of 1:3:1. expect no leftovers +test( + 'proportional three way', + checkProportions, + [100n, 2000n], + [100n, 300n, 100n], + [ [ - [ - [20n, 400n], - [60n, 1200n], - [20n, 400n], - ], - [0n, 0n], + [20n, 400n], + [60n, 1200n], + [20n, 400n], ], - ); -}); - -test('proportional odd ratios, no collateral', t => { - // received 0 Collateral and 2001 Currency from the auction to distribute to - // five depositors in a ratio of 20, 36, 17, 83, 42. expect leftovers - // sum = 198 - checkProportions( - t, - [0n, 2001n], - [20n, 36n, 17n, 83n, 42n], + [0n, 0n], + ], +); + +// Received 0 Collateral and 2001 Currency from the auction to distribute to +// five depositors in a ratio of 20, 36, 17, 83, 42. expect leftovers +// sum = 198 +test( + 'proportional odd ratios, no collateral', + checkProportions, + [0n, 2001n], + [20n, 36n, 17n, 83n, 42n], + [ [ - [ - [0n, 202n], - [0n, 363n], - [0n, 171n], - [0n, 838n], - [0n, 424n], - ], - [0n, 3n], + [0n, 202n], + [0n, 363n], + [0n, 171n], + [0n, 838n], + [0n, 424n], ], - ); -}); - -test('proportional, no currency', t => { - // received 0 Collateral and 2001 Currency from the auction to distribute to - // five depositors in a ratio of 20, 36, 17, 83, 42. expect leftovers - // sum = 198 - checkProportions( - t, - [20n, 0n], - [20n, 36n, 17n, 83n, 42n], + [0n, 3n], + ], +); + +// Received 0 Collateral and 2001 Currency from the auction to distribute to +// five depositors in a ratio of 20, 36, 17, 83, 42. expect leftovers +// sum = 198 +test( + 'proportional, no currency', + checkProportions, + [20n, 0n], + [20n, 36n, 17n, 83n, 42n], + [ [ - [ - [2n, 0n], - [3n, 0n], - [1n, 0n], - [8n, 0n], - [4n, 0n], - ], [2n, 0n], + [3n, 0n], + [1n, 0n], + [8n, 0n], + [4n, 0n], ], - ); -}); + [2n, 0n], + ], +); From 2141de986d662bfb0b52eab20bea1325a1987cca Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Thu, 2 Mar 2023 14:35:40 -0800 Subject: [PATCH 11/20] refactor: correct sorting of orders by time Add a test for this case. make all auctionContract tests serial --- .../inter-protocol/src/auction/auctionBook.js | 15 +++- .../test/auction/test-auctionContract.js | 80 +++++++++++++++++-- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index d72de455202..4f8e4c1beab 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -300,11 +300,22 @@ export const makeAuctionBook = async ( const pricedOffers = priceBook.offersAbove(curAuctionPrice); const scaledBidOffers = scaledBidBook.offersAbove(reduction); + const compareValues = (v1, v2) => { + if (v1 < v2) { + return -1; + } else if (v1 === v2) { + return 0; + } else { + assert(v1 > v2); + return 1; + } + }; trace(`settling`, pricedOffers.length, scaledBidOffers.length); // requested price or bid scaling gives no priority beyond specifying which // round the order will be serviced in. - const prioritizedOffers = [...pricedOffers, ...scaledBidOffers].sort(); - + const prioritizedOffers = [...pricedOffers, ...scaledBidOffers].sort( + (a, b) => compareValues(a[1].seqNum, b[1].seqNum), + ); for (const [key, { seat, price: p, wanted }] of prioritizedOffers) { if (seat.hasExited()) { removeFromItsBook(key, p); diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js index 84603ea14ac..eb61a219476 100644 --- a/packages/inter-protocol/test/auction/test-auctionContract.js +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -289,7 +289,7 @@ const assertPayouts = async ( } }; -test('priced bid recorded', async t => { +test.serial('priced bid recorded', async t => { const { collateral, currency } = t.context; const driver = await makeAuctionDriver(t); @@ -302,7 +302,7 @@ test('priced bid recorded', async t => { t.is(await E(seat).getOfferResult(), 'Your offer has been received'); }); -test('discount bid recorded', async t => { +test.serial('discount bid recorded', async t => { const { collateral, currency } = t.context; const driver = await makeAuctionDriver(t); @@ -316,7 +316,7 @@ test('discount bid recorded', async t => { t.is(await E(seat).getOfferResult(), 'Your offer has been received'); }); -test('priced bid settled', async t => { +test.serial('priced bid settled', async t => { const { collateral, currency } = t.context; const driver = await makeAuctionDriver(t); @@ -338,7 +338,7 @@ test('priced bid settled', async t => { await assertPayouts(t, seat, currency, collateral, 19n, 200n); }); -test('discount bid settled', async t => { +test.serial('discount bid settled', async t => { const { collateral, currency } = t.context; const driver = await makeAuctionDriver(t); @@ -427,7 +427,7 @@ test.serial('priced bid recorded then settled with price drop', async t => { await assertPayouts(t, seat, currency, collateral, 0n, 100n); }); -test('priced bid settled auction price below bid', async t => { +test.serial('priced bid settled auction price below bid', async t => { const { collateral, currency } = t.context; const driver = await makeAuctionDriver(t); @@ -686,7 +686,7 @@ test.serial('onDeadline exit', async t => { await assertPayouts(t, liqSeat, currency, collateral, 116n, 0n); }); -test('add assets to open auction', async t => { +test.serial('add assets to open auction', async t => { const { collateral, currency } = t.context; const driver = await makeAuctionDriver(t); @@ -831,3 +831,71 @@ test.serial('multiple collaterals', async t => { t.true(await E(bidderSeat2A).hasExited()); await assertPayouts(t, bidderSeat2A, currency, asset, 0n, 300n); }); + +// serial because dynamicConfig is shared across tests +test.serial('multiple bidders at one auction step', async t => { + const { collateral, currency } = t.context; + const driver = await makeAuctionDriver(t); + + const { nextAuctionSchedule } = await driver.getSchedule(); + + const liqSeat = await driver.setupCollateralAuction( + collateral, + collateral.make(300n), + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + const result = await E(liqSeat).getOfferResult(); + t.is(result, 'deposited'); + + let now = nextAuctionSchedule.startTime.absValue - 3n; + await driver.advanceTo(now); + const seat1 = await driver.bidForCollateralSeat( + // 1.1 * 1.05 * 200 + currency.make(231n), + collateral.make(200n), + ); + t.is(await E(seat1).getOfferResult(), 'Your offer has been received'); + t.false(await E(seat1).hasExited()); + + // higher bid, later + const seat2 = await driver.bidForCollateralSeat( + // 1.1 * 1.05 * 200 + currency.make(232n), + collateral.make(200n), + ); + + now = nextAuctionSchedule.startTime.absValue; + await driver.advanceTo(now); + await eventLoopIteration(); + + now += 5n; + await driver.advanceTo(now); + await eventLoopIteration(); + + now += 5n; + await driver.advanceTo(now); + await eventLoopIteration(); + + now += 5n; + await driver.advanceTo(now); + await eventLoopIteration(); + + now += 5n; + await driver.advanceTo(now); + await eventLoopIteration(); + + t.true(await E(seat1).hasExited()); + t.false(await E(seat2).hasExited()); + await E(seat2).tryExit(); + + t.true(await E(seat2).hasExited()); + + await assertPayouts(t, seat1, currency, collateral, 0n, 200n); + await assertPayouts(t, seat2, currency, collateral, 116n, 100n); + + t.true(await E(liqSeat).hasExited()); + await assertPayouts(t, liqSeat, currency, collateral, 347n, 0n); +}); From bf5e72128fba3f370e515a15507430697a307836 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Thu, 2 Mar 2023 14:33:28 -0800 Subject: [PATCH 12/20] chore: cleanups suggested in review for loops rather than foreach better type extraneous line --- packages/inter-protocol/src/auction/auctionBook.js | 5 ++--- packages/inter-protocol/src/auction/auctioneer.js | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 4f8e4c1beab..76fc8a45618 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -48,7 +48,7 @@ const DEFAULT_DECIMALS = 9n; * added in the appropriate place and settled when the price reaches that level. */ -const trace = makeTracer('AucBook', true); +const trace = makeTracer('AucBook', false); /** @typedef {import('@agoric/vat-data').Baggage} Baggage */ @@ -359,7 +359,6 @@ export const makeAuctionBook = async ( ); if (bidSpec.offerPrice) { - give.C; return acceptPriceOffer( seat, bidSpec.offerPrice, @@ -387,4 +386,4 @@ export const makeAuctionBook = async ( }); }; -/** @typedef {Awaited>} AuctionBook */ +/** @typedef {Awaited>} AuctionBook */ diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index a51a92ee8d0..986d6a00126 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -201,9 +201,9 @@ export const start = async (zcf, privateArgs, baggage) => { BASIS_POINTS, ); - [...books.entries()].forEach(([_collateralBrand, book]) => { + for (const book of books.values()) { book.settleAtNewRate(bidScalingRatio); - }); + } }; const driver = Far('Auctioneer', { @@ -228,12 +228,12 @@ export const start = async (zcf, privateArgs, baggage) => { trace('startRound'); currentDiscountRateBP = params.getStartingRate(); - [...books.entries()].forEach(([_collateralBrand, book]) => { + for (const book of books.values()) { book.lockOraclePriceForRound(); book.setStartingRate( makeBPRatio(currentDiscountRateBP, brands.Currency), ); - }); + } tradeEveryBook(); }, From 7150b7e7e0399f998518c886cc4ebb8e6a0f1dea Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Fri, 3 Mar 2023 15:35:13 -0800 Subject: [PATCH 13/20] chore: clean up scheduler; add test for computeRoundTiming --- .../inter-protocol/src/auction/scheduler.js | 145 +++++++------ .../test/auction/test-computeRoundTiming.js | 201 ++++++++++++++++++ 2 files changed, 284 insertions(+), 62 deletions(-) create mode 100644 packages/inter-protocol/test/auction/test-computeRoundTiming.js diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index bd16b313cbf..8442d65ced3 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -27,6 +27,64 @@ const makeCancelToken = () => { return Far(`cancelToken${(tokenCount += 1)}`, {}); }; +export const computeRoundTiming = (params, baseTime) => { + // currently a TimeValue; hopefully a TimeRecord soon + /** @type {RelativeTime} */ + const freq = params.getStartFrequency(); + /** @type {RelativeTime} */ + const clockStep = params.getClockStep(); + /** @type {NatValue} */ + const startingRate = params.getStartingRate(); + /** @type {NatValue} */ + const discountStep = params.getDiscountStep(); + /** @type {RelativeTime} */ + const lockPeriod = params.getPriceLockPeriod(); + /** @type {NatValue} */ + const lowestRate = params.getLowestRate(); + + /** @type {RelativeTime} */ + const startDelay = params.getAuctionStartDelay(); + TimeMath.compareRel(freq, startDelay) > 0 || + Fail`startFrequency must exceed startDelay, ${freq}, ${startDelay}`; + TimeMath.compareRel(freq, lockPeriod) > 0 || + Fail`startFrequency must exceed lock period, ${freq}, ${lockPeriod}`; + + startingRate > lowestRate || + Fail`startingRate ${startingRate} must be more than ${lowestRate}`; + const rateChange = subtract(startingRate, lowestRate); + const requestedSteps = floorDivide(rateChange, discountStep); + requestedSteps > 0n || + Fail`discountStep ${discountStep} too large for requested rates`; + TimeMath.compareRel(freq, clockStep) >= 0 || + Fail`clockStep ${clockStep} must be shorter than startFrequency ${freq} to allow >1 steps `; + + const requestedDuration = TimeMath.multiplyRelNat(clockStep, requestedSteps); + const targetDuration = + TimeMath.compareRel(requestedDuration, freq) < 0 ? requestedDuration : freq; + const steps = TimeMath.divideRelRel(targetDuration, clockStep); + const duration = TimeMath.multiplyRelNat(clockStep, steps); + + steps > 0n || + Fail`clockStep ${clockStep} too long for auction duration ${duration}`; + const endRate = subtract(startingRate, multiply(steps, discountStep)); + + const actualDuration = TimeMath.multiplyRelNat(clockStep, steps); + // computed start is baseTime + freq - (now mod freq). if there are hourly + // starts, we add an hour to the current time, and subtract now mod freq. + // Then we add the delay + const startTime = TimeMath.addAbsRel( + TimeMath.addAbsRel( + baseTime, + TimeMath.subtractRelRel(freq, TimeMath.modAbsRel(baseTime, freq)), + ), + startDelay, + ); + const endTime = TimeMath.addAbsRel(startTime, actualDuration); + + const next = { startTime, endTime, steps, endRate, startDelay, clockStep }; + return harden(next); +}; + /** * @typedef {object} AuctionDriver * @property {() => void} reducePriceAndTrade @@ -52,62 +110,9 @@ export const makeScheduler = async ( let nextSchedule; const stepCancelToken = makeCancelToken(); - // XXX why can't it be @type {AuctionState}? - /** @type {'active' | 'waiting'} */ + /** @type {typeof AuctionState[keyof typeof AuctionState]} */ let auctionState = AuctionState.WAITING; - const computeRoundTiming = baseTime => { - // currently a TimeValue; hopefully a TimeRecord soon - /** @type {RelativeTime} */ - const freq = params.getStartFrequency(); - /** @type {RelativeTime} */ - const clockStep = params.getClockStep(); - /** @type {NatValue} */ - const startingRate = params.getStartingRate(); - /** @type {NatValue} */ - const discountStep = params.getDiscountStep(); - /** @type {RelativeTime} */ - const lockPeriod = params.getPriceLockPeriod(); - /** @type {NatValue} */ - const lowestRate = params.getLowestRate(); - - /** @type {RelativeTime} */ - const startDelay = params.getAuctionStartDelay(); - TimeMath.compareRel(freq, startDelay) > 0 || - Fail`startFrequency must exceed startDelay, ${freq}, ${startDelay}`; - TimeMath.compareRel(freq, lockPeriod) > 0 || - Fail`startFrequency must exceed lock period, ${freq}, ${lockPeriod}`; - - const rateChange = subtract(startingRate, lowestRate); - const requestedSteps = floorDivide(rateChange, discountStep); - requestedSteps > 0n || - Fail`discountStep ${discountStep} too large for requested rates`; - const duration = TimeMath.multiplyRelNat(clockStep, requestedSteps); - - TimeMath.compareRel(duration, freq) < 0 || - Fail`Frequency ${freq} must exceed duration ${duration}`; - const steps = TimeMath.divideRelRel(duration, clockStep); - steps > 0n || - Fail`clockStep ${clockStep} too long for auction duration ${duration}`; - const endRate = subtract(startingRate, multiply(steps, discountStep)); - - const actualDuration = TimeMath.multiplyRelNat(clockStep, steps); - // computed start is baseTime + freq - (now mod freq). if there are hourly - // starts, we add an hour to the current time, and subtract now mod freq. - // Then we add the delay - const startTime = TimeMath.addAbsRel( - TimeMath.addAbsRel( - baseTime, - TimeMath.subtractRelRel(freq, TimeMath.modAbsRel(baseTime, freq)), - ), - startDelay, - ); - const endTime = TimeMath.addAbsRel(startTime, actualDuration); - - const next = { startTime, endTime, steps, endRate, startDelay, clockStep }; - return harden(next); - }; - const clockTick = (timeValue, schedule) => { const time = TimeMath.toAbs(timeValue, timerBrand); @@ -127,17 +132,17 @@ export const makeScheduler = async ( auctionDriver.finalize(); const afterNow = TimeMath.addAbsRel(time, TimeMath.toRel(1n, timerBrand)); - nextSchedule = computeRoundTiming(afterNow); + nextSchedule = computeRoundTiming(params, afterNow); liveSchedule = undefined; E(timer).cancel(stepCancelToken); } }; - const scheduleRound = (schedule, time) => { + const scheduleRound = time => { trace('nextRound', time); - const { startTime } = schedule; + const { startTime } = liveSchedule; trace('START ', startTime); const startDelay = @@ -147,10 +152,10 @@ export const makeScheduler = async ( E(timer).repeatAfter( startDelay, - schedule.clockStep, + liveSchedule.clockStep, Far('SchedulerWaker', { wake(t) { - clockTick(t, schedule); + clockTick(t, liveSchedule); }, }), stepCancelToken, @@ -178,14 +183,14 @@ export const makeScheduler = async ( liveSchedule.startTime, TimeMath.toRel(1n, timerBrand), ); - nextSchedule = computeRoundTiming(after); - scheduleRound(liveSchedule, time); + nextSchedule = computeRoundTiming(params, after); + scheduleRound(time); scheduleNextRound(TimeMath.toAbs(nextSchedule.startTime)); }; const baseNow = await E(timer).getCurrentTimestamp(); const now = TimeMath.toAbs(baseNow, timerBrand); - nextSchedule = computeRoundTiming(now); + nextSchedule = computeRoundTiming(params, now); scheduleNextRound(nextSchedule.startTime); return Far('scheduler', { @@ -197,3 +202,19 @@ export const makeScheduler = async ( getAuctionState: () => auctionState, }); }; + +/** + * @typedef {object} Schedule + * @property {Timestamp} startTime + * @property {Timestamp} endTime + * @property {bigint} steps + * @property {Ratio} endRate + * @property {RelativeTime} startDelay + * @property {RelativeTime} clockStep + */ + +/** + * @typedef {object} FullSchedule + * @property {Schedule} nextAuctionSchedule + * @property {Schedule} liveAuctionSchedule + */ diff --git a/packages/inter-protocol/test/auction/test-computeRoundTiming.js b/packages/inter-protocol/test/auction/test-computeRoundTiming.js new file mode 100644 index 00000000000..a9b8c206cf5 --- /dev/null +++ b/packages/inter-protocol/test/auction/test-computeRoundTiming.js @@ -0,0 +1,201 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { TimeMath } from '@agoric/time'; +import '@agoric/zoe/exported.js'; +import { computeRoundTiming } from '../../src/auction/scheduler.js'; + +const makeDefaultParams = ({ + freq = 3600, + step = 600, + delay = 300, + discount = 1000n, + lock = 15 * 60, + lowest = 6_500n, +} = {}) => { + /** @type {import('@agoric/time').TimerBrand} */ + // @ts-expect-error mock + const timerBrand = harden({}); + + return { + getStartFrequency: () => TimeMath.toRel(freq, timerBrand), + getClockStep: () => TimeMath.toRel(step, timerBrand), + getStartingRate: () => 10_500n, + getDiscountStep: () => discount, + getPriceLockPeriod: () => TimeMath.toRel(lock, timerBrand), + getLowestRate: () => lowest, + getAuctionStartDelay: () => TimeMath.toRel(delay, timerBrand), + }; +}; + +/** + * @param {any} t + * @param {ReturnType} params + * @param {number} baseTime + * @param {any} rawExpect + */ +const checkSchedule = (t, params, baseTime, rawExpect) => { + /** @type {import('@agoric/time/src/types').TimestampRecord} */ + // @ts-expect-error known for testing + const startFrequency = params.getStartFrequency(); + const brand = startFrequency.timerBrand; + const schedule = computeRoundTiming(params, TimeMath.toAbs(baseTime, brand)); + + const expect = { + startTime: TimeMath.toAbs(rawExpect.startTime, brand), + endTime: TimeMath.toAbs(rawExpect.endTime, brand), + steps: rawExpect.steps, + endRate: rawExpect.endRate, + startDelay: TimeMath.toRel(rawExpect.startDelay, brand), + clockStep: TimeMath.toRel(rawExpect.clockStep, brand), + }; + t.deepEqual(schedule, expect); +}; + +/** + * @param {any} t + * @param {ReturnType} params + * @param {number} baseTime + * @param {any} expectMessage XXX should be {ThrowsExpectation} + */ +const checkScheduleThrows = (t, params, baseTime, expectMessage) => { + /** @type {import('@agoric/time/src/types').TimestampRecord} */ + // @ts-expect-error known for testing + const startFrequency = params.getStartFrequency(); + const brand = startFrequency.timerBrand; + t.throws(() => computeRoundTiming(params, TimeMath.toAbs(baseTime, brand)), { + message: expectMessage, + }); +}; + +// Hourly starts. 4 steps down, 5 price levels. discount steps of 10%. +// 10.5, 9.5, 8.5, 7.5, 6.5. First start is 5 minutes after the hour. +test('simple schedule', checkSchedule, makeDefaultParams(), 100, { + startTime: 3600 + 300, + endTime: 3600 + 4 * 10 * 60 + 300, + steps: 4n, + endRate: 6_500n, + startDelay: 300, + clockStep: 600, +}); + +test( + 'baseTime at a possible start', + checkSchedule, + makeDefaultParams({}), + 3600, + { + startTime: 7200 + 300, + endTime: 7200 + 4 * 10 * 60 + 300, + steps: 4n, + endRate: 6_500n, + startDelay: 300, + clockStep: 600, + }, +); + +// Hourly starts. 8 steps down, 9 price levels. discount steps of 5%. +// First start is 5 minutes after the hour. +test( + 'finer steps', + checkSchedule, + makeDefaultParams({ step: 300, discount: 500n }), + 100, + { + startTime: 3600 + 300, + endTime: 3600 + 8 * 5 * 60 + 300, + steps: 8n, + endRate: 6_500n, + startDelay: 300, + clockStep: 300, + }, +); + +// lock Period too Long +test( + 'long lock period', + checkScheduleThrows, + makeDefaultParams({ lock: 3600 }), + 100, + /startFrequency must exceed lock period/, +); + +test( + 'longer auction than freq', + checkScheduleThrows, + makeDefaultParams({ freq: 500, lock: 300 }), + 100, + /clockStep .* must be shorter than startFrequency /, +); + +test( + 'startDelay too long', + checkScheduleThrows, + makeDefaultParams({ delay: 5000 }), + 100, + /startFrequency must exceed startDelay/, +); + +test( + 'large discount step', + checkScheduleThrows, + makeDefaultParams({ discount: 5000n }), + 100, + /discountStep "\[5000n]" too large for requested rates/, +); + +test( + 'one auction step', + checkSchedule, + makeDefaultParams({ discount: 2001n }), + 100, + { + startTime: 3600 + 300, + endTime: 3600 + 600 + 300, + steps: 1n, + endRate: 10_500n - 2_001n, + startDelay: 300, + clockStep: 600, + }, +); + +test( + 'lowest rate higher than start', + checkScheduleThrows, + makeDefaultParams({ lowest: 10_600n }), + 100, + /startingRate "\[10500n]" must be more than/, +); + +// If the steps are small enough that we can't get to the end_rate, we'll cut +// the auction short when the next auction should start. +test( + 'very small discountStep', + checkSchedule, + makeDefaultParams({ discount: 10n }), + 100, + { + startTime: 3600 + 300, + endTime: 3600 + 6 * 10 * 60 + 300, + steps: 6n, + endRate: 10_500n - 6n * 10n, + startDelay: 300, + clockStep: 600, + }, +); + +// if the discountStep is not a divisor of the price range, we'll end above the +// specified lowestRate. +test( + 'discountStep not a divisor of price range', + checkSchedule, + makeDefaultParams({ discount: 350n }), + 100, + { + startTime: 3600 + 300, + endTime: 3600 + 6 * 10 * 60 + 300, + steps: 6n, + endRate: 10_500n - 6n * 350n, + startDelay: 300, + clockStep: 600, + }, +); From e137415ad134433e4bf846228b20f39e13696cff Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Fri, 3 Mar 2023 16:11:37 -0800 Subject: [PATCH 14/20] chore: minor cleanups from review --- .../inter-protocol/src/auction/auctionBook.js | 11 +---- .../inter-protocol/src/auction/auctioneer.js | 9 ++++ .../inter-protocol/src/auction/offerBook.js | 12 ++++-- packages/inter-protocol/src/auction/params.js | 42 +++++++++---------- .../inter-protocol/src/auction/scheduler.js | 1 + packages/inter-protocol/src/auction/util.js | 8 ++-- .../test/auction/test-proportionalDist.js | 5 +-- 7 files changed, 46 insertions(+), 42 deletions(-) diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 76fc8a45618..9a9801c0393 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -132,10 +132,7 @@ export const makeAuctionBook = async ( currencyAmountShape, currencyAmountShape, ); - const scaledBidStore = makeScalarBigMapStore('scaledBidBookStore', { - durable: true, - }); - return makeScaledBidBook(scaledBidStore, ratioPattern, collateralBrand); + return makeScaledBidBook(baggage, ratioPattern, collateralBrand); }); const priceBook = provide(baggage, 'sortedOffers', () => { @@ -144,10 +141,7 @@ export const makeAuctionBook = async ( collateralAmountShape, ); - const priceStore = makeScalarBigMapStore('sortedOffersStore', { - durable: true, - }); - return makePriceBook(priceStore, ratioPattern, collateralBrand); + return makePriceBook(baggage, ratioPattern, collateralBrand); }); /** @@ -306,7 +300,6 @@ export const makeAuctionBook = async ( } else if (v1 === v2) { return 0; } else { - assert(v1 > v2); return 1; } }; diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index 986d6a00126..d783d3b4997 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -44,6 +44,9 @@ const makeBPRatio = (rate, currencyBrand, collateralBrand = currencyBrand) => * collateralRaised and currencyRaised proportionally to each seat's deposited * amount. Any uneven split should be allocated to the reserve. * + * This function is exported for testability, and is not expected to be used + * outside the contract below. + * * @param {Amount} collateralRaised * @param {Amount} currencyRaised * @param {{seat: ZCFSeat, amount: Amount<"nat">}[]} deposits @@ -284,6 +287,11 @@ export const start = async (zcf, privateArgs, baggage) => { }); const limitedCreatorFacet = Far('creatorFacet', { + /** + * @param {Issuer} issuer + * @param {Brand} collateralBrand + * @param {Keyword} kwd + */ async addBrand(issuer, collateralBrand, kwd) { zcf.assertUniqueKeyword(kwd); !baggage.has(kwd) || @@ -304,6 +312,7 @@ export const start = async (zcf, privateArgs, baggage) => { }, // XXX if it's in public, doesn't also need to be in creatorFacet. getDepositInvitation, + /** @returns {Promise} */ getSchedule() { return E(scheduler).getSchedule(); }, diff --git a/packages/inter-protocol/src/auction/offerBook.js b/packages/inter-protocol/src/auction/offerBook.js index aa45661ee54..7b896ddfb6f 100644 --- a/packages/inter-protocol/src/auction/offerBook.js +++ b/packages/inter-protocol/src/auction/offerBook.js @@ -4,6 +4,7 @@ import { Far } from '@endo/marshal'; import { M, mustMatch } from '@agoric/store'; import { AmountMath } from '@agoric/ertp'; +import { provideDurableMapStore } from '@agoric/vat-data'; import { toBidScalingComparator, @@ -27,15 +28,17 @@ const nextSequenceNumber = () => { * Prices in this book are expressed as percentage of the full oracle price * snapshot taken when the auction started. .4 is 60% off. 1.1 is 10% above par. * - * @param {Baggage} store + * @param {Baggage} baggage * @param {Pattern} bidScalingPattern * @param {Brand} collateralBrand */ export const makeScaledBidBook = ( - store, + baggage, bidScalingPattern, collateralBrand, ) => { + const store = provideDurableMapStore(baggage, 'scaledBidStore'); + return Far('scaledBidBook ', { add(seat, bidScaling, wanted) { mustMatch(bidScaling, bidScalingPattern); @@ -83,11 +86,12 @@ export const makeScaledBidBook = ( * Prices in this book are actual prices expressed in terms of currency amount * and collateral amount. * - * @param {Baggage} store + * @param {Baggage} baggage * @param {Pattern} ratioPattern * @param {Brand} collateralBrand */ -export const makePriceBook = (store, ratioPattern, collateralBrand) => { +export const makePriceBook = (baggage, ratioPattern, collateralBrand) => { + const store = provideDurableMapStore(baggage, 'scaledBidStore'); return Far('priceBook ', { add(seat, price, wanted) { mustMatch(price, ratioPattern); diff --git a/packages/inter-protocol/src/auction/params.js b/packages/inter-protocol/src/auction/params.js index f1df93a2690..a5fccfd2ecb 100644 --- a/packages/inter-protocol/src/auction/params.js +++ b/packages/inter-protocol/src/auction/params.js @@ -13,21 +13,21 @@ import { M } from '@agoric/store'; export const InvitationShape = M.remotable('Invitation'); /** - * The auction will start at AUCTION_START_DELAY seconds after a multiple of - * START_FREQUENCY, with the price at STARTING_RATE. Every CLOCK_STEP, the price - * will be reduced by DISCOUNT_STEP, as long as the rate is at or above - * LOWEST_RATE, or until START_FREQUENCY has elapsed. in seconds, how often to - * start an auction + * In seconds, how often to start an auction. The auction will start at + * AUCTION_START_DELAY seconds after a multiple of START_FREQUENCY, with the + * price at STARTING_RATE_BP. Every CLOCK_STEP, the price will be reduced by + * DISCOUNT_STEP_BP, as long as the rate is at or above LOWEST_RATE_BP, or until + * START_FREQUENCY has elapsed. */ export const START_FREQUENCY = 'StartFrequency'; /** in seconds, how often to reduce the price */ export const CLOCK_STEP = 'ClockStep'; /** discount or markup for starting price in basis points. 9999 = 1bp discount */ -export const STARTING_RATE = 'StartingRate'; +export const STARTING_RATE_BP = 'StartingRate'; /** A limit below which the price will not be discounted. */ -export const LOWEST_RATE = 'LowestRate'; +export const LOWEST_RATE_BP = 'LowestRate'; /** amount to reduce prices each time step in bp, as % of the start price */ -export const DISCOUNT_STEP = 'DiscountStep'; +export const DISCOUNT_STEP_BP = 'DiscountStep'; /** * VaultManagers liquidate vaults at a frequency configured by START_FREQUENCY. * Auctions start this long after the hour to give vaults time to finish. @@ -35,7 +35,7 @@ export const DISCOUNT_STEP = 'DiscountStep'; export const AUCTION_START_DELAY = 'AuctionStartDelay'; /** * Basis Points to charge in penalty against vaults that are liquidated. Notice - * that if the penalty is less than the LOWEST_RATE discount, vault holders + * that if the penalty is less than the LOWEST_RATE_BP discount, vault holders * could buy their assets back at an advantageous price. */ export const LIQUIDATION_PENALTY = 'LiquidationPenalty'; @@ -48,9 +48,9 @@ export const auctioneerParamPattern = M.splitRecord({ [CONTRACT_ELECTORATE]: InvitationShape, [START_FREQUENCY]: RelativeTimeRecordShape, [CLOCK_STEP]: RelativeTimeRecordShape, - [STARTING_RATE]: M.nat(), - [LOWEST_RATE]: M.nat(), - [DISCOUNT_STEP]: M.nat(), + [STARTING_RATE_BP]: M.nat(), + [LOWEST_RATE_BP]: M.nat(), + [DISCOUNT_STEP_BP]: M.nat(), [AUCTION_START_DELAY]: RelativeTimeRecordShape, [PRICE_LOCK_PERIOD]: RelativeTimeRecordShape, }); @@ -59,9 +59,9 @@ export const auctioneerParamTypes = harden({ [CONTRACT_ELECTORATE]: ParamTypes.INVITATION, [START_FREQUENCY]: ParamTypes.RELATIVE_TIME, [CLOCK_STEP]: ParamTypes.RELATIVE_TIME, - [STARTING_RATE]: ParamTypes.NAT, - [LOWEST_RATE]: ParamTypes.NAT, - [DISCOUNT_STEP]: ParamTypes.NAT, + [STARTING_RATE_BP]: ParamTypes.NAT, + [LOWEST_RATE_BP]: ParamTypes.NAT, + [DISCOUNT_STEP_BP]: ParamTypes.NAT, [AUCTION_START_DELAY]: ParamTypes.RELATIVE_TIME, [PRICE_LOCK_PERIOD]: ParamTypes.RELATIVE_TIME, }); @@ -110,9 +110,9 @@ export const makeAuctioneerParams = ({ type: ParamTypes.RELATIVE_TIME, value: TimeMath.toRel(priceLockPeriod, timerBrand), }, - [STARTING_RATE]: { type: ParamTypes.NAT, value: startingRate }, - [LOWEST_RATE]: { type: ParamTypes.NAT, value: lowestRate }, - [DISCOUNT_STEP]: { type: ParamTypes.NAT, value: discountStep }, + [STARTING_RATE_BP]: { type: ParamTypes.NAT, value: startingRate }, + [LOWEST_RATE_BP]: { type: ParamTypes.NAT, value: lowestRate }, + [DISCOUNT_STEP_BP]: { type: ParamTypes.NAT, value: discountStep }, }); }; harden(makeAuctioneerParams); @@ -141,9 +141,9 @@ export const makeAuctioneerParamManager = (publisherKit, zoe, initial) => { ], [START_FREQUENCY]: [ParamTypes.RELATIVE_TIME, initial[START_FREQUENCY]], [CLOCK_STEP]: [ParamTypes.RELATIVE_TIME, initial[CLOCK_STEP]], - [STARTING_RATE]: [ParamTypes.NAT, initial[STARTING_RATE]], - [LOWEST_RATE]: [ParamTypes.NAT, initial[LOWEST_RATE]], - [DISCOUNT_STEP]: [ParamTypes.NAT, initial[DISCOUNT_STEP]], + [STARTING_RATE_BP]: [ParamTypes.NAT, initial[STARTING_RATE_BP]], + [LOWEST_RATE_BP]: [ParamTypes.NAT, initial[LOWEST_RATE_BP]], + [DISCOUNT_STEP_BP]: [ParamTypes.NAT, initial[DISCOUNT_STEP_BP]], [AUCTION_START_DELAY]: [ ParamTypes.RELATIVE_TIME, initial[AUCTION_START_DELAY], diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index 8442d65ced3..124e3675be6 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -27,6 +27,7 @@ const makeCancelToken = () => { return Far(`cancelToken${(tokenCount += 1)}`, {}); }; +// exported for testability. export const computeRoundTiming = (params, baseTime) => { // currently a TimeValue; hopefully a TimeRecord soon /** @type {RelativeTime} */ diff --git a/packages/inter-protocol/src/auction/util.js b/packages/inter-protocol/src/auction/util.js index 5462ca3a0c3..3428884e599 100644 --- a/packages/inter-protocol/src/auction/util.js +++ b/packages/inter-protocol/src/auction/util.js @@ -15,8 +15,8 @@ export const AuctionState = { }; /** - * @param {Pattern} numeratorAmountShape - * @param {Pattern} denominatorAmountShape + * @param {{ brand: Brand, value: Pattern }} numeratorAmountShape + * @param {{ brand: Brand, value: Pattern }} denominatorAmountShape */ export const makeBrandedRatioPattern = ( numeratorAmountShape, @@ -29,11 +29,11 @@ export const makeBrandedRatioPattern = ( }; /** - * TRUE if the discount(/markup) applied to the price is higher than the quote. - * * @param {Ratio} bidScaling * @param {Ratio} currentPrice * @param {Ratio} oraclePrice + * @returns {boolean} TRUE iff the discount(/markup) applied to the price is + * higher than the quote. */ export const isScaledBidPriceHigher = (bidScaling, currentPrice, oraclePrice) => ratioGTE(multiplyRatios(oraclePrice, bidScaling), currentPrice); diff --git a/packages/inter-protocol/test/auction/test-proportionalDist.js b/packages/inter-protocol/test/auction/test-proportionalDist.js index d3aa23c21d5..a7ad2310f16 100644 --- a/packages/inter-protocol/test/auction/test-proportionalDist.js +++ b/packages/inter-protocol/test/auction/test-proportionalDist.js @@ -5,7 +5,7 @@ import '@agoric/zoe/exported.js'; import { makeIssuerKit } from '@agoric/ertp'; import { makeTracer } from '@agoric/internal'; -import { setUpZoeForTest, withAmountUtils } from '../supports.js'; +import { withAmountUtils } from '../supports.js'; import { distributeProportionalShares } from '../../src/auction/auctioneer.js'; /** @type {import('ava').TestFn>>} */ @@ -14,14 +14,11 @@ const test = anyTest; const trace = makeTracer('Test AuctContract', false); const makeTestContext = async () => { - const { zoe } = await setUpZoeForTest(); - const currency = withAmountUtils(makeIssuerKit('Currency')); const collateral = withAmountUtils(makeIssuerKit('Collateral')); trace('makeContext'); return { - zoe: await zoe, currency, collateral, }; From ea0d0c41412bfceb49078b6a36c85b004f2ee5d3 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Fri, 3 Mar 2023 16:12:15 -0800 Subject: [PATCH 15/20] chore: rename auctionKit to auctioneerKit --- packages/inter-protocol/src/proposals/core-proposal.js | 4 ++-- .../inter-protocol/src/proposals/econ-behaviors.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/inter-protocol/src/proposals/core-proposal.js b/packages/inter-protocol/src/proposals/core-proposal.js index 9bd9703c82f..842387ebd04 100644 --- a/packages/inter-protocol/src/proposals/core-proposal.js +++ b/packages/inter-protocol/src/proposals/core-proposal.js @@ -76,7 +76,7 @@ const SHARED_MAIN_MANIFEST = harden({ }, }, - [econBehaviors.startAuction.name]: { + [econBehaviors.startAuctioneer.name]: { consume: { zoe: 'zoe', board: 'board', @@ -85,7 +85,7 @@ const SHARED_MAIN_MANIFEST = harden({ chainStorage: true, economicCommitteeCreatorFacet: 'economicCommittee', }, - produce: { auctionKit: 'auction' }, + produce: { auctioneerKit: 'auction' }, instance: { produce: { auction: 'auction' }, }, diff --git a/packages/inter-protocol/src/proposals/econ-behaviors.js b/packages/inter-protocol/src/proposals/econ-behaviors.js index 765e06ded12..ff173614e04 100644 --- a/packages/inter-protocol/src/proposals/econ-behaviors.js +++ b/packages/inter-protocol/src/proposals/econ-behaviors.js @@ -72,7 +72,7 @@ const BASIS_POINTS = 10_000n; * governorCreatorFacet: GovernedContractFacetAccess, * adminFacet: AdminFacet, * }, - * auctionKit: { + * auctioneerKit: { * publicFacet: AuctioneerPublicFacet, * creatorFacet: AuctioneerCreatorFacet, * governorCreatorFacet: GovernedContractFacetAccess<{},{}>, @@ -493,7 +493,7 @@ export const startLienBridge = async ({ * @param {object} config * @param {any} [config.auctionParams] */ -export const startAuction = async ( +export const startAuctioneer = async ( { consume: { zoe, @@ -503,7 +503,7 @@ export const startAuction = async ( chainStorage, economicCommitteeCreatorFacet: electorateCreatorFacet, }, - produce: { auctionKit }, + produce: { auctioneerKit }, instance: { produce: { auction: auctionInstance }, }, @@ -529,7 +529,7 @@ export const startAuction = async ( }, } = {}, ) => { - trace('startAuction'); + trace('startAuctioneer'); const STORAGE_PATH = 'auction'; const poserInvitationP = E(electorateCreatorFacet).getPoserInvitation(); @@ -598,7 +598,7 @@ export const startAuction = async ( E(governorStartResult.creatorFacet).getPublicFacet(), ]); - auctionKit.resolve( + auctioneerKit.resolve( harden({ creatorFacet: governedCreatorFacet, governorCreatorFacet: governorStartResult.creatorFacet, From 7ee72109480b5fd33b270520132458c1273f75cb Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Fri, 3 Mar 2023 16:55:54 -0800 Subject: [PATCH 16/20] chore: drop auction from startPSM --- packages/inter-protocol/src/proposals/startPSM.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/inter-protocol/src/proposals/startPSM.js b/packages/inter-protocol/src/proposals/startPSM.js index 100c5226f98..6d2f8628409 100644 --- a/packages/inter-protocol/src/proposals/startPSM.js +++ b/packages/inter-protocol/src/proposals/startPSM.js @@ -282,7 +282,6 @@ export const installGovAndPSMContracts = async ({ contractGovernor, committee, binaryVoteCounter, - auction, psm, econCommitteeCharter, }, @@ -299,7 +298,6 @@ export const installGovAndPSMContracts = async ({ contractGovernor, committee, binaryVoteCounter, - auction, psm, econCommitteeCharter, }).map(async ([name, producer]) => { @@ -327,7 +325,6 @@ export const PSM_GOV_MANIFEST = { contractGovernor: 'zoe', committee: 'zoe', binaryVoteCounter: 'zoe', - auction: 'zoe', psm: 'zoe', econCommitteeCharter: 'zoe', }, @@ -423,7 +420,6 @@ export const getManifestForPsm = ( return { manifest: PSM_MANIFEST, installations: { - auction: restoreRef(installKeys.auctioneer), psm: restoreRef(installKeys.psm), mintHolder: restoreRef(installKeys.mintHolder), }, From 8d641af1c80e8871977304d8e420b21ca45034f4 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Sun, 5 Mar 2023 10:41:21 -0800 Subject: [PATCH 17/20] chore: clean-ups from review --- packages/SwingSet/tools/manual-timer.js | 3 +- .../inter-protocol/src/auction/auctionBook.js | 2 +- .../inter-protocol/src/auction/auctioneer.js | 5 +- .../inter-protocol/src/auction/offerBook.js | 2 +- .../inter-protocol/src/auction/scheduler.js | 13 +++- .../test/auction/test-scheduler.js | 64 ++++++++----------- 6 files changed, 45 insertions(+), 44 deletions(-) diff --git a/packages/SwingSet/tools/manual-timer.js b/packages/SwingSet/tools/manual-timer.js index ab78dfe005f..0676c38eee2 100644 --- a/packages/SwingSet/tools/manual-timer.js +++ b/packages/SwingSet/tools/manual-timer.js @@ -58,7 +58,7 @@ const setup = () => { * kernel. You can make time pass by calling `advanceTo(when)`. * * @param {{ startTime?: Timestamp }} [options] - * @returns {TimerService & { advanceTo: (when: Timestamp) => void; }} + * @returns {TimerService & { advanceTo: (when: Timestamp) => bigint; }} */ export const buildManualTimer = (options = {}) => { const { startTime = 0n, ...other } = options; @@ -79,6 +79,7 @@ export const buildManualTimer = (options = {}) => { assert(when > state.now, `advanceTo(${when}) < current ${state.now}`); state.now = when; wake(); + return when; }; return Far('ManualTimer', { ...bindAllMethods(timerService), advanceTo }); diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 9a9801c0393..eafbec3394a 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -2,7 +2,7 @@ import '@agoric/zoe/exported.js'; import '@agoric/zoe/src/contracts/exported.js'; import '@agoric/governance/exported.js'; -import { M, makeScalarBigMapStore, provide } from '@agoric/vat-data'; +import { M, provide } from '@agoric/vat-data'; import { AmountMath } from '@agoric/ertp'; import { Far } from '@endo/marshal'; import { mustMatch } from '@agoric/store'; diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index d783d3b4997..61daed35c90 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -89,7 +89,7 @@ export const distributeProportionalShares = ( transfers.push([collateralSeat, seat, { Collateral: collPortion }]); } - // TODO The leftovers should go to the reserve, and should be visible. + // TODO(#7117) The leftovers should go to the reserve, and should be visible. transfers.push([currencySeat, reserveSeat, { Currency: currencyLeft }]); // There will be multiple collaterals, so they can't all use the same keyword @@ -183,6 +183,7 @@ export const start = async (zcf, privateArgs, baggage) => { for (const { seat } of depositsForBrand) { seat.exit(); } + deposits.set(brand, []); } } }; @@ -306,6 +307,8 @@ export const start = async (zcf, privateArgs, baggage) => { collateralBrand, priceAuthority, ); + + // These three store.init() calls succeed or fail atomically deposits.init(collateralBrand, harden([])); books.init(collateralBrand, newBook); brandToKeyword.init(collateralBrand, kwd); diff --git a/packages/inter-protocol/src/auction/offerBook.js b/packages/inter-protocol/src/auction/offerBook.js index 7b896ddfb6f..09b25287900 100644 --- a/packages/inter-protocol/src/auction/offerBook.js +++ b/packages/inter-protocol/src/auction/offerBook.js @@ -91,7 +91,7 @@ export const makeScaledBidBook = ( * @param {Brand} collateralBrand */ export const makePriceBook = (baggage, ratioPattern, collateralBrand) => { - const store = provideDurableMapStore(baggage, 'scaledBidStore'); + const store = provideDurableMapStore(baggage, 'pricedBidStore'); return Far('priceBook ', { add(seat, price, wanted) { mustMatch(price, ratioPattern); diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index 124e3675be6..7e7e0f50210 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -19,9 +19,15 @@ const trace = makeTracer('SCHED', false); * round has finished, so we need to schedule the next round each time an * auction starts. This means if the scheduling parameters change, it'll be a * full cycle before we switch. Otherwise, the vaults wouldn't know when to - * start their lock period. + * start their lock period. If the lock period for the next auction hasn't + * started when each aucion ends, we recalculate it, in case the parameters have + * changed. + * + * If the clock skips forward (because of a chain halt, for instance), the + * scheduler will try to cleanly and quickly finish any round already in + * progress. It would take additional work on the manual timer to test this + * thoroughly. */ - const makeCancelToken = () => { let tokenCount = 1; return Far(`cancelToken${(tokenCount += 1)}`, {}); @@ -186,10 +192,11 @@ export const makeScheduler = async ( ); nextSchedule = computeRoundTiming(params, after); scheduleRound(time); - scheduleNextRound(TimeMath.toAbs(nextSchedule.startTime)); + scheduleNextRound(nextSchedule.startTime); }; const baseNow = await E(timer).getCurrentTimestamp(); + // XXX manualTimer returns a bigint, not a timeRecord. const now = TimeMath.toAbs(baseNow, timerBrand); nextSchedule = computeRoundTiming(params, now); scheduleNextRound(nextSchedule.startTime); diff --git a/packages/inter-protocol/test/auction/test-scheduler.js b/packages/inter-protocol/test/auction/test-scheduler.js index bd713e95846..12a2eaeeddd 100644 --- a/packages/inter-protocol/test/auction/test-scheduler.js +++ b/packages/inter-protocol/test/auction/test-scheduler.js @@ -22,7 +22,7 @@ import { test('schedule start to finish', async t => { const { zoe } = await setupZCFTest(); const installations = await setUpInstallations(zoe); - /** @type {TimerService & { advanceTo: (when: Timestamp) => void; }} */ + /** @type {TimerService & { advanceTo: (when: Timestamp) => bigint; }} */ const timer = buildManualTimer(); const timerBrand = await timer.getTimerBrand(); @@ -44,8 +44,8 @@ test('schedule start to finish', async t => { params2[key] = value; } - let now = 127n; - await timer.advanceTo(now); + /** @type {bigint} */ + let now = await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, @@ -76,13 +76,12 @@ test('schedule start to finish', async t => { t.is(fakeAuctioneer.getState().step, 0); t.false(fakeAuctioneer.getState().final); - await timer.advanceTo((now += 1n)); + now = await timer.advanceTo(now + 1n); t.is(fakeAuctioneer.getState().step, 0); t.false(fakeAuctioneer.getState().final); - now = 131n; - await timer.advanceTo(now); + now = await timer.advanceTo(131n); await eventLoopIteration(); const schedule2 = scheduler.getSchedule(); @@ -100,22 +99,22 @@ test('schedule start to finish', async t => { t.false(fakeAuctioneer.getState().final); // xxx I shouldn't have to tick twice. - await timer.advanceTo((now += 1n)); - await timer.advanceTo((now += 1n)); + now = await timer.advanceTo(now + 1n); + now = await timer.advanceTo(now + 1n); t.is(fakeAuctioneer.getState().step, 2); t.false(fakeAuctioneer.getState().final); // final step - await timer.advanceTo((now += 1n)); - await timer.advanceTo((now += 1n)); + now = await timer.advanceTo(now + 1n); + now = await timer.advanceTo(now + 1n); t.is(fakeAuctioneer.getState().step, 3); t.true(fakeAuctioneer.getState().final); // Auction finished, nothing else happens - await timer.advanceTo((now += 1n)); - await timer.advanceTo((now += 1n)); + now = await timer.advanceTo(now + 1n); + now = await timer.advanceTo(now + 1n); t.is(fakeAuctioneer.getState().step, 3); t.true(fakeAuctioneer.getState().final); @@ -134,13 +133,12 @@ test('schedule start to finish', async t => { }; t.deepEqual(finalSchedule.nextAuctionSchedule, secondSchedule); - now = 140n; - await timer.advanceTo(now); + now = await timer.advanceTo(140n); t.deepEqual(finalSchedule.liveAuctionSchedule, undefined); t.deepEqual(finalSchedule.nextAuctionSchedule, secondSchedule); - await timer.advanceTo((now += 1n)); + now = await timer.advanceTo(now + 1n); await eventLoopIteration(); const schedule3 = scheduler.getSchedule(); @@ -158,22 +156,22 @@ test('schedule start to finish', async t => { t.false(fakeAuctioneer.getState().final); // xxx I shouldn't have to tick twice. - await timer.advanceTo((now += 1n)); - await timer.advanceTo((now += 1n)); + now = await timer.advanceTo(now + 1n); + now = await timer.advanceTo(now + 1n); t.is(fakeAuctioneer.getState().step, 5); t.false(fakeAuctioneer.getState().final); // final step - await timer.advanceTo((now += 1n)); - await timer.advanceTo((now += 1n)); + now = await timer.advanceTo(now + 1n); + now = await timer.advanceTo(now + 1n); t.is(fakeAuctioneer.getState().step, 6); t.true(fakeAuctioneer.getState().final); // Auction finished, nothing else happens - await timer.advanceTo((now += 1n)); - await timer.advanceTo((now += 1n)); + now = await timer.advanceTo(now + 1n); + await timer.advanceTo(now + 1n); t.is(fakeAuctioneer.getState().step, 6); t.true(fakeAuctioneer.getState().final); @@ -205,8 +203,7 @@ test('lowest >= starting', async t => { params2[key] = value; } - const now = 127n; - await timer.advanceTo(now); + await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, @@ -248,8 +245,7 @@ test('zero time for auction', async t => { params2[key] = value; } - const now = 127n; - await timer.advanceTo(now); + await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, @@ -288,8 +284,7 @@ test('discountStep 0', async t => { params2[key] = value; } - const now = 127n; - await timer.advanceTo(now); + await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, @@ -329,8 +324,7 @@ test('discountStep larger than starting rate', async t => { params2[key] = value; } - const now = 127n; - await timer.advanceTo(now); + await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, @@ -369,8 +363,7 @@ test('start Freq 0', async t => { params2[key] = value; } - const now = 127n; - await timer.advanceTo(now); + await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, @@ -410,8 +403,7 @@ test('delay > freq', async t => { params2[key] = value; } - const now = 127n; - await timer.advanceTo(now); + await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, @@ -452,8 +444,7 @@ test('lockPeriod > freq', async t => { params2[key] = value; } - const now = 127n; - await timer.advanceTo(now); + await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, @@ -501,8 +492,7 @@ test('duration = freq', async t => { params2[key] = value; } - const now = 127n; - await timer.advanceTo(now); + await timer.advanceTo(127n); const paramManager = await makeAuctioneerParamManager( publisherKit, From 42dd965af10b1f0372df9532db5feee98ce25b0c Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Sun, 5 Mar 2023 10:43:30 -0800 Subject: [PATCH 18/20] refactor: computeRoundTiming should not allow duration === frequency --- .../inter-protocol/src/auction/scheduler.js | 12 +++-- .../test/auction/test-computeRoundTiming.js | 12 ++--- .../test/auction/test-scheduler.js | 45 +++++++++++++++---- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index 7e7e0f50210..1fd25fdd125 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -57,17 +57,23 @@ export const computeRoundTiming = (params, baseTime) => { Fail`startFrequency must exceed lock period, ${freq}, ${lockPeriod}`; startingRate > lowestRate || - Fail`startingRate ${startingRate} must be more than ${lowestRate}`; + Fail`startingRate ${startingRate} must be more than lowest: ${lowestRate}`; const rateChange = subtract(startingRate, lowestRate); const requestedSteps = floorDivide(rateChange, discountStep); requestedSteps > 0n || Fail`discountStep ${discountStep} too large for requested rates`; TimeMath.compareRel(freq, clockStep) >= 0 || - Fail`clockStep ${clockStep} must be shorter than startFrequency ${freq} to allow >1 steps `; + Fail`clockStep ${TimeMath.relValue( + clockStep, + )} must be shorter than startFrequency ${TimeMath.relValue( + freq, + )} to allow at least one step down`; const requestedDuration = TimeMath.multiplyRelNat(clockStep, requestedSteps); const targetDuration = - TimeMath.compareRel(requestedDuration, freq) < 0 ? requestedDuration : freq; + TimeMath.compareRel(requestedDuration, freq) < 0 + ? requestedDuration + : TimeMath.subtractRelRel(freq, TimeMath.toRel(1n)); const steps = TimeMath.divideRelRel(targetDuration, clockStep); const duration = TimeMath.multiplyRelNat(clockStep, steps); diff --git a/packages/inter-protocol/test/auction/test-computeRoundTiming.js b/packages/inter-protocol/test/auction/test-computeRoundTiming.js index a9b8c206cf5..74ad93cf43f 100644 --- a/packages/inter-protocol/test/auction/test-computeRoundTiming.js +++ b/packages/inter-protocol/test/auction/test-computeRoundTiming.js @@ -175,9 +175,9 @@ test( 100, { startTime: 3600 + 300, - endTime: 3600 + 6 * 10 * 60 + 300, - steps: 6n, - endRate: 10_500n - 6n * 10n, + endTime: 3600 + 5 * 10 * 60 + 300, + steps: 5n, + endRate: 10_500n - 5n * 10n, startDelay: 300, clockStep: 600, }, @@ -192,9 +192,9 @@ test( 100, { startTime: 3600 + 300, - endTime: 3600 + 6 * 10 * 60 + 300, - steps: 6n, - endRate: 10_500n - 6n * 350n, + endTime: 3600 + 5 * 10 * 60 + 300, + steps: 5n, + endRate: 10_500n - 5n * 350n, startDelay: 300, clockStep: 600, }, diff --git a/packages/inter-protocol/test/auction/test-scheduler.js b/packages/inter-protocol/test/auction/test-scheduler.js index 12a2eaeeddd..5fd850c85fa 100644 --- a/packages/inter-protocol/test/auction/test-scheduler.js +++ b/packages/inter-protocol/test/auction/test-scheduler.js @@ -215,7 +215,7 @@ test('lowest >= starting', async t => { await t.throwsAsync( () => makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), - { message: '-5 is negative' }, + { message: /startingRate "\[105n]" must be more than lowest: "\[110n]"/ }, ); }); @@ -257,7 +257,10 @@ test('zero time for auction', async t => { await t.throwsAsync( () => makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), - { message: /Frequency .* must exceed duration/ }, + { + message: + /clockStep "\[3n]" must be shorter than startFrequency "\[2n]" to allow at least one step down/, + }, ); }); @@ -475,6 +478,8 @@ test('duration = freq', async t => { const publisherKit = makePublisherFromFakes(); let defaultParams = makeDefaultParams(fakeInvitationPayment, timerBrand); + // start hourly, request 6 steps down every 10 minutes, so duration would be + // 1 hour. Instead cut the auction short. defaultParams = { ...defaultParams, priceLockPeriod: 20n, @@ -501,11 +506,35 @@ test('duration = freq', async t => { params2, ); - await t.throwsAsync( - () => - makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), - { - message: /Frequency .* must exceed duration .*/, - }, + const scheduler = await makeScheduler( + fakeAuctioneer, + timer, + paramManager, + timer.getTimerBrand(), ); + let schedule = scheduler.getSchedule(); + t.deepEqual(schedule.liveAuctionSchedule, undefined); + const firstSchedule = { + startTime: TimeMath.toAbs(365n, timerBrand), + endTime: TimeMath.toAbs(665n, timerBrand), + steps: 5n, + endRate: 50n, + startDelay: TimeMath.toRel(5n, timerBrand), + clockStep: TimeMath.toRel(60n, timerBrand), + }; + t.deepEqual(schedule.nextAuctionSchedule, firstSchedule); + + await timer.advanceTo(725n); + schedule = scheduler.getSchedule(); + + // start the second auction on time + const secondSchedule = { + startTime: TimeMath.toAbs(725n, timerBrand), + endTime: TimeMath.toAbs(1025n, timerBrand), + steps: 5n, + endRate: 50n, + startDelay: TimeMath.toRel(5n, timerBrand), + clockStep: TimeMath.toRel(60n, timerBrand), + }; + t.deepEqual(schedule.nextAuctionSchedule, secondSchedule); }); From feda8383545b5125ffcc4320d4dfbf4599e04169 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Sun, 5 Mar 2023 13:37:30 -0800 Subject: [PATCH 19/20] chore: clean up governance, add invitation patterns in auctioneer --- .../inter-protocol/src/auction/auctioneer.js | 152 ++++++++++-------- .../test/auction/test-auctionContract.js | 10 +- 2 files changed, 92 insertions(+), 70 deletions(-) diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index 61daed35c90..06c5d63b4ec 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -9,7 +9,7 @@ import { makeScalarBigMapStore, provideDurableMapStore, } from '@agoric/vat-data'; -import { AmountMath } from '@agoric/ertp'; +import { AmountMath, AmountShape } from '@agoric/ertp'; import { atomicRearrange, makeRatioFromAmounts, @@ -166,11 +166,11 @@ export const start = async (zcf, privateArgs, baggage) => { liqSeat.exit(); deposits.set(brand, []); } else if (depositsForBrand.length > 1) { - const collatRaise = collateralSeat.getCurrentAllocation().Collateral; - const currencyRaise = currencySeat.getCurrentAllocation().Currency; + const collProceeds = collateralSeat.getCurrentAllocation().Collateral; + const currProceeds = currencySeat.getCurrentAllocation().Currency; const transfers = distributeProportionalShares( - collatRaise, - currencyRaise, + collProceeds, + currProceeds, depositsForBrand, collateralSeat, currencySeat, @@ -188,7 +188,7 @@ export const start = async (zcf, privateArgs, baggage) => { } }; - const { publicMixin, creatorMixin, makeFarGovernorFacet, params } = + const { augmentPublicFacet, creatorMixin, makeFarGovernorFacet, params } = await handleParamGovernance( zcf, privateArgs.initialPoserInvitation, @@ -257,74 +257,88 @@ export const start = async (zcf, privateArgs, baggage) => { }; const getDepositInvitation = () => - zcf.makeInvitation(depositOfferHandler, 'deposit Collateral'); - - const publicFacet = Far('publicFacet', { - getBidInvitation(collateralBrand) { - const newBidHandler = (zcfSeat, bidSpec) => { - if (books.has(collateralBrand)) { - const auctionBook = books.get(collateralBrand); - auctionBook.addOffer(bidSpec, zcfSeat, isActive()); - return 'Your offer has been received'; - } else { - zcfSeat.exit(`No book for brand ${collateralBrand}`); - return 'Your offer was refused'; - } - }; + zcf.makeInvitation( + depositOfferHandler, + 'deposit Collateral', + undefined, + M.splitRecord({ give: { Collateral: AmountShape } }), + ); - return zcf.makeInvitation( - newBidHandler, - 'new bid', - {}, - FullProposalShape, - ); - }, - getSchedules() { - return E(scheduler).getSchedule(); - }, - getDepositInvitation, - ...publicMixin, - ...params, - }); + const publicFacet = augmentPublicFacet( + harden({ + getBidInvitation(collateralBrand) { + const newBidHandler = (zcfSeat, bidSpec) => { + if (books.has(collateralBrand)) { + const auctionBook = books.get(collateralBrand); + auctionBook.addOffer(bidSpec, zcfSeat, isActive()); + return 'Your offer has been received'; + } else { + zcfSeat.exit(`No book for brand ${collateralBrand}`); + return 'Your offer was refused'; + } + }; + const bidProposalShape = M.splitRecord( + { + give: { Currency: { brand: brands.Currency, value: M.nat() } }, + }, + { + want: M.or({ Collateral: AmountShape }, {}), + exit: FullProposalShape.exit, + }, + ); - const limitedCreatorFacet = Far('creatorFacet', { - /** - * @param {Issuer} issuer - * @param {Brand} collateralBrand - * @param {Keyword} kwd - */ - async addBrand(issuer, collateralBrand, kwd) { - zcf.assertUniqueKeyword(kwd); - !baggage.has(kwd) || - Fail`cannot add brand with keyword ${kwd}. it's in use`; - - zcf.saveIssuer(issuer, kwd); - baggage.init(kwd, makeScalarBigMapStore(kwd, { durable: true })); - const newBook = await makeAuctionBook( - baggage.get(kwd), - zcf, - brands.Currency, - collateralBrand, - priceAuthority, - ); + return zcf.makeInvitation( + newBidHandler, + 'new bid', + {}, + bidProposalShape, + ); + }, + getSchedules() { + return E(scheduler).getSchedule(); + }, + getDepositInvitation, + ...params, + }), + ); - // These three store.init() calls succeed or fail atomically - deposits.init(collateralBrand, harden([])); - books.init(collateralBrand, newBook); - brandToKeyword.init(collateralBrand, kwd); - }, - // XXX if it's in public, doesn't also need to be in creatorFacet. - getDepositInvitation, - /** @returns {Promise} */ - getSchedule() { - return E(scheduler).getSchedule(); - }, - ...creatorMixin, - }); + const creatorFacet = makeFarGovernorFacet( + Far('Auctioneer creatorFacet', { + /** + * @param {Issuer} issuer + * @param {Keyword} kwd + */ + async addBrand(issuer, kwd) { + zcf.assertUniqueKeyword(kwd); + !baggage.has(kwd) || + Fail`cannot add brand with keyword ${kwd}. it's in use`; + const { brand } = await zcf.saveIssuer(issuer, kwd); + + baggage.init(kwd, makeScalarBigMapStore(kwd, { durable: true })); + const newBook = await makeAuctionBook( + baggage.get(kwd), + zcf, + brands.Currency, + brand, + priceAuthority, + ); - const governorFacet = makeFarGovernorFacet(limitedCreatorFacet); + // These three store.init() calls succeed or fail atomically + deposits.init(brand, harden([])); + books.init(brand, newBook); + brandToKeyword.init(brand, kwd); + }, + // XXX if it's in public, doesn't also need to be in creatorFacet. + getDepositInvitation, + /** @returns {Promise} */ + getSchedule() { + return E(scheduler).getSchedule(); + }, + ...creatorMixin, + }), + ); - return { publicFacet, creatorFacet: governorFacet }; + return { publicFacet, creatorFacet }; }; /** @typedef {ContractOf} AuctioneerContract */ diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js index eb61a219476..fab8a4b946a 100644 --- a/packages/inter-protocol/test/auction/test-auctionContract.js +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -200,7 +200,6 @@ const makeAuctionDriver = async (t, customTerms, params = defaultParams) => { await E(creatorFacet).addBrand( issuerKit.issuer, - collateralBrand, collateralBrand.getAllegedName(), ); return depositCollateral(collateralAmount, issuerKit); @@ -899,3 +898,12 @@ test.serial('multiple bidders at one auction step', async t => { t.true(await E(liqSeat).hasExited()); await assertPayouts(t, liqSeat, currency, collateral, 347n, 0n); }); + +test('deposit unregistered collateral', async t => { + const asset = withAmountUtils(makeIssuerKit('Asset')); + const driver = await makeAuctionDriver(t); + + await t.throwsAsync(() => driver.depositCollateral(asset.make(500n), asset), { + message: /no ordinal/, + }); +}); From bfaa58747f6fc0669c06515e0bfe822a1981ab3a Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Sun, 5 Mar 2023 14:13:43 -0800 Subject: [PATCH 20/20] refactor: don't reschedule next if price is already locked --- .../inter-protocol/src/auction/scheduler.js | 24 ++++++++++++++++--- .../test/auction/test-computeRoundTiming.js | 7 ++++++ .../test/auction/test-scheduler.js | 6 +++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index 1fd25fdd125..3dca74d0384 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -93,8 +93,17 @@ export const computeRoundTiming = (params, baseTime) => { startDelay, ); const endTime = TimeMath.addAbsRel(startTime, actualDuration); + const lockTime = TimeMath.subtractAbsRel(startTime, lockPeriod); - const next = { startTime, endTime, steps, endRate, startDelay, clockStep }; + const next = { + startTime, + endTime, + steps, + endRate, + startDelay, + clockStep, + lockTime, + }; return harden(next); }; @@ -144,8 +153,17 @@ export const makeScheduler = async ( auctionState = AuctionState.WAITING; auctionDriver.finalize(); - const afterNow = TimeMath.addAbsRel(time, TimeMath.toRel(1n, timerBrand)); - nextSchedule = computeRoundTiming(params, afterNow); + + // only recalculate the next schedule at this point if the lock time has + // not been reached. + const nextLock = nextSchedule.lockTime; + if (TimeMath.compareAbs(time, nextLock) < 0) { + const afterNow = TimeMath.addAbsRel( + time, + TimeMath.toRel(1n, timerBrand), + ); + nextSchedule = computeRoundTiming(params, afterNow); + } liveSchedule = undefined; E(timer).cancel(stepCancelToken); diff --git a/packages/inter-protocol/test/auction/test-computeRoundTiming.js b/packages/inter-protocol/test/auction/test-computeRoundTiming.js index 74ad93cf43f..7bd9a0ba7ca 100644 --- a/packages/inter-protocol/test/auction/test-computeRoundTiming.js +++ b/packages/inter-protocol/test/auction/test-computeRoundTiming.js @@ -47,6 +47,7 @@ const checkSchedule = (t, params, baseTime, rawExpect) => { endRate: rawExpect.endRate, startDelay: TimeMath.toRel(rawExpect.startDelay, brand), clockStep: TimeMath.toRel(rawExpect.clockStep, brand), + lockTime: TimeMath.toAbs(rawExpect.lockTime, brand), }; t.deepEqual(schedule, expect); }; @@ -76,6 +77,7 @@ test('simple schedule', checkSchedule, makeDefaultParams(), 100, { endRate: 6_500n, startDelay: 300, clockStep: 600, + lockTime: 3000, }); test( @@ -90,6 +92,7 @@ test( endRate: 6_500n, startDelay: 300, clockStep: 600, + lockTime: 6600, }, ); @@ -107,6 +110,7 @@ test( endRate: 6_500n, startDelay: 300, clockStep: 300, + lockTime: 3000, }, ); @@ -155,6 +159,7 @@ test( endRate: 10_500n - 2_001n, startDelay: 300, clockStep: 600, + lockTime: 3000, }, ); @@ -180,6 +185,7 @@ test( endRate: 10_500n - 5n * 10n, startDelay: 300, clockStep: 600, + lockTime: 3000, }, ); @@ -197,5 +203,6 @@ test( endRate: 10_500n - 5n * 350n, startDelay: 300, clockStep: 600, + lockTime: 3000, }, ); diff --git a/packages/inter-protocol/test/auction/test-scheduler.js b/packages/inter-protocol/test/auction/test-scheduler.js index 5fd850c85fa..5c64a1d110b 100644 --- a/packages/inter-protocol/test/auction/test-scheduler.js +++ b/packages/inter-protocol/test/auction/test-scheduler.js @@ -69,6 +69,7 @@ test('schedule start to finish', async t => { endRate: 6500n, startDelay: TimeMath.toRel(1n, timerBrand), clockStep: TimeMath.toRel(2n, timerBrand), + lockTime: TimeMath.toAbs(126n, timerBrand), }; t.deepEqual(schedule.nextAuctionSchedule, firstSchedule); @@ -93,6 +94,7 @@ test('schedule start to finish', async t => { endRate: 6500n, startDelay: TimeMath.toRel(1n, timerBrand), clockStep: TimeMath.toRel(2n, timerBrand), + lockTime: TimeMath.toAbs(136, timerBrand), }); t.is(fakeAuctioneer.getState().step, 1); @@ -130,6 +132,7 @@ test('schedule start to finish', async t => { endRate: 6500n, startDelay: TimeMath.toRel(1n, timerBrand), clockStep: TimeMath.toRel(2n, timerBrand), + lockTime: TimeMath.toAbs(136n, timerBrand), }; t.deepEqual(finalSchedule.nextAuctionSchedule, secondSchedule); @@ -150,6 +153,7 @@ test('schedule start to finish', async t => { endRate: 6500n, startDelay: TimeMath.toRel(1n, timerBrand), clockStep: TimeMath.toRel(2n, timerBrand), + lockTime: TimeMath.toAbs(146n, timerBrand), }); t.is(fakeAuctioneer.getState().step, 4); @@ -521,6 +525,7 @@ test('duration = freq', async t => { endRate: 50n, startDelay: TimeMath.toRel(5n, timerBrand), clockStep: TimeMath.toRel(60n, timerBrand), + lockTime: TimeMath.toAbs(345n, timerBrand), }; t.deepEqual(schedule.nextAuctionSchedule, firstSchedule); @@ -535,6 +540,7 @@ test('duration = freq', async t => { endRate: 50n, startDelay: TimeMath.toRel(5n, timerBrand), clockStep: TimeMath.toRel(60n, timerBrand), + lockTime: TimeMath.toAbs(705n, timerBrand), }; t.deepEqual(schedule.nextAuctionSchedule, secondSchedule); });