From 398b70f7e028f957afc1582f0ee31eb2574c94d0 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Sun, 5 Mar 2023 17:24:28 -0800 Subject: [PATCH] feat(auction): add an auctioneer to manage vault liquidation (#7000) * 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 * refactor: cleanups from working on vault liquidation * chore: cleanups suggested in review * refactor: simplify keys in sortOffers * chore: cosmetic cleanups from review * chore: auction was introduced to vaultfactory too early. It will be introduced with #7047 when vaultfactory makes use of the auction for liquidation * 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. * test: repair dependencies for startAuction * chore: responses to reviews * test: refactor proportional distribution test to use macros * refactor: correct sorting of orders by time Add a test for this case. make all auctionContract tests serial * chore: cleanups suggested in review for loops rather than foreach better type extraneous line * chore: clean up scheduler; add test for computeRoundTiming * chore: minor cleanups from review * chore: rename auctionKit to auctioneerKit * chore: drop auction from startPSM * chore: clean-ups from review * refactor: computeRoundTiming should not allow duration === frequency * chore: clean up governance, add invitation patterns in auctioneer * refactor: don't reschedule next if price is already locked --- packages/SwingSet/tools/manual-timer.js | 3 +- packages/governance/src/constants.js | 2 + .../src/contractGovernance/assertions.js | 14 + .../src/contractGovernance/paramManager.js | 20 + .../contractGovernance/typedParamManager.js | 2 + packages/governance/src/contractHelper.js | 5 + packages/governance/src/types-ambient.js | 7 +- .../scripts/add-collateral-core.js | 12 + .../scripts/deploy-contracts.js | 1 + packages/inter-protocol/scripts/init-core.js | 4 + .../inter-protocol/src/auction/auctionBook.js | 382 ++++++++ .../inter-protocol/src/auction/auctioneer.js | 348 +++++++ .../inter-protocol/src/auction/offerBook.js | 130 +++ packages/inter-protocol/src/auction/params.js | 212 ++++ .../inter-protocol/src/auction/scheduler.js | 252 +++++ .../src/auction/sortedOffers.js | 126 +++ packages/inter-protocol/src/auction/util.js | 46 + .../src/proposals/core-proposal.js | 24 + .../src/proposals/econ-behaviors.js | 131 +++ .../src/vaultFactory/storeUtils.js | 4 +- .../test/auction/test-auctionBook.js | 281 ++++++ .../test/auction/test-auctionContract.js | 909 ++++++++++++++++++ .../test/auction/test-computeRoundTiming.js | 208 ++++ .../test/auction/test-proportionalDist.js | 164 ++++ .../test/auction/test-scheduler.js | 546 +++++++++++ .../test/auction/test-sortedOffers.js | 115 +++ packages/inter-protocol/test/auction/tools.js | 97 ++ .../test/swingsetTests/setup.js | 8 +- packages/internal/src/utils.js | 2 + 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 | 5 + packages/zoe/src/contractSupport/ratio.js | 12 + 35 files changed, 4073 insertions(+), 11 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/offerBook.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-computeRoundTiming.js create mode 100644 packages/inter-protocol/test/auction/test-proportionalDist.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/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/governance/src/constants.js b/packages/governance/src/constants.js index 7fa2b609003..53ec759c703 100644 --- a/packages/governance/src/constants.js +++ b/packages/governance/src/constants.js @@ -15,6 +15,8 @@ export const ParamTypes = /** @type {const} */ ({ RATIO: 'ratio', STRING: 'string', PASSABLE_RECORD: 'record', + 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..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; @@ -41,9 +43,21 @@ const makeAssertBrandedRatio = (name, modelRatio) => { }; harden(makeAssertBrandedRatio); +const assertRelativeTime = value => { + mustMatch(value, RelativeTimeRecordShape); +}; +harden(assertRelativeTime); + +const assertTimestamp = value => { + mustMatch(value, TimestampRecordShape, 'timestamp'); +}; +harden(assertTimestamp); + export { makeLooksLikeBrand, makeAssertInstallation, makeAssertInstance, makeAssertBrandedRatio, + assertRelativeTime, + assertTimestamp, }; diff --git a/packages/governance/src/contractGovernance/paramManager.js b/packages/governance/src/contractGovernance/paramManager.js index 426abfcc0e9..8a38e0d4fc2 100644 --- a/packages/governance/src/contractGovernance/paramManager.js +++ b/packages/governance/src/contractGovernance/paramManager.js @@ -8,6 +8,8 @@ import { assertAllDefined } from '@agoric/internal'; import { ParamTypes } from '../constants.js'; import { + assertTimestamp, + assertRelativeTime, makeAssertBrandedRatio, makeAssertInstallation, makeAssertInstance, @@ -44,6 +46,8 @@ 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').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 +188,18 @@ const makeParamManagerBuilder = (publisherKit, zoe) => { 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 +372,8 @@ const makeParamManagerBuilder = (publisherKit, zoe) => { getRatio: name => getTypedParam(ParamTypes.RATIO, name), getRecord: name => getTypedParam(ParamTypes.PASSABLE_RECORD, name), getString: name => getTypedParam(ParamTypes.STRING, name), + getTimestamp: name => getTypedParam(ParamTypes.TIMESTAMP, name), + getRelativeTime: name => getTypedParam(ParamTypes.RELATIVE_TIME, name), getUnknown: name => getTypedParam(ParamTypes.UNKNOWN, name), getVisibleValue, getInternalParamValue, @@ -379,6 +397,8 @@ 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), + 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..fe19d7b6c79 100644 --- a/packages/governance/src/contractHelper.js +++ b/packages/governance/src/contractHelper.js @@ -4,6 +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 { RelativeTimeRecordShape, TimestampRecordShape } from '@agoric/time'; import { assertElectorateMatches } from './contractGovernance/paramManager.js'; import { makeParamManagerFromTerms } from './contractGovernance/typedParamManager.js'; @@ -23,6 +24,8 @@ const publicMixinAPI = harden({ getNat: M.call().returns(M.bigint()), getRatio: M.call().returns(M.record()), getString: M.call().returns(M.string()), + getTimestamp: M.call().returns(TimestampRecordShape), + getRelativeTime: M.call().returns(RelativeTimeRecordShape), getUnknown: M.call().returns(M.any()), }); @@ -51,6 +54,8 @@ const facetHelpers = (zcf, paramManager) => { getNat: paramManager.getNat, getRatio: paramManager.getRatio, getString: paramManager.getString, + 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..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,6 +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').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 bc12fd21c33..2b8d130cb91 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-vaultFactory.js', + ), + ), + auction: publishRef( + install( + '../src/auction/auctioneer.js', + '../bundles/bundle-auctioneer.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..3f363bafcf5 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-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 a937a039acc..0574e6327e8 100644 --- a/packages/inter-protocol/scripts/init-core.js +++ b/packages/inter-protocol/scripts/init-core.js @@ -36,6 +36,10 @@ const installKeyGroups = { ], }, main: { + 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 new file mode 100644 index 00000000000..eafbec3394a --- /dev/null +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -0,0 +1,382 @@ +import '@agoric/zoe/exported.js'; +import '@agoric/zoe/src/contracts/exported.js'; +import '@agoric/governance/exported.js'; + +import { M, provide } from '@agoric/vat-data'; +import { AmountMath } 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 { E } from '@endo/captp'; +import { makeTracer } from '@agoric/internal'; + +import { makeScaledBidBook, makePriceBook } from './offerBook.js'; +import { + isScaledBidPriceHigher, + makeBrandedRatioPattern, + priceFrom, +} from './util.js'; + +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 + * the allocation of assets for sale. + * + * The book contains orders for the collateral. It holds two kinds of + * orders: + * - Prices express the bid in terms of a Currency amount + * - 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. 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); + +/** @typedef {import('@agoric/vat-data').Baggage} Baggage */ + +export const makeAuctionBook = async ( + baggage, + zcf, + currencyBrand, + collateralBrand, + priceAuthority, +) => { + const zeroRatio = makeRatioFromAmounts( + AmountMath.makeEmpty(currencyBrand), + AmountMath.make(collateralBrand, 1n), + ); + const [currencyAmountShape, collateralAmountShape] = await Promise.all([ + E(currencyBrand).getAmountShape(), + E(collateralBrand).getAmountShape(), + ]); + const BidSpecShape = M.or( + { + want: collateralAmountShape, + offerPrice: makeBrandedRatioPattern( + currencyAmountShape, + collateralAmountShape, + ), + }, + { + 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 = 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}`); + }, + }); + }, + ); + + let curAuctionPrice = zeroRatio; + + const scaledBidBook = provide(baggage, 'scaledBidBook', () => { + const ratioPattern = makeBrandedRatioPattern( + currencyAmountShape, + currencyAmountShape, + ); + return makeScaledBidBook(baggage, ratioPattern, collateralBrand); + }); + + const priceBook = provide(baggage, 'sortedOffers', () => { + const ratioPattern = makeBrandedRatioPattern( + currencyAmountShape, + collateralAmountShape, + ); + + return makePriceBook(baggage, ratioPattern, collateralBrand); + }); + + /** + * 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(); + 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); + } + + 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, { Collateral: collateralAmount }], + [seat, currencySeat, { Currency: currencyAmount }], + ]), + ); + return collateralAmount; + }; + + /** + * 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 {boolean} trySettle + */ + const acceptPriceOffer = (seat, price, want, trySettle) => { + trace('acceptPrice'); + // Offer has ZcfSeat, offerArgs (w/price) and timeStamp + + const collateralSold = + trySettle && ratioGTE(price, curAuctionPrice) + ? settle(seat, want) + : AmountMath.makeEmptyFromAmount(want); + + const stillWant = AmountMath.subtract(want, collateralSold); + if ( + AmountMath.isEmpty(stillWant) || + AmountMath.isEmpty(seat.getCurrentAllocation().Currency) + ) { + seat.exit(); + } else { + trace('added Offer ', price, stillWant.value); + priceBook.add(seat, price, stillWant); + } + }; + + /** + * 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} bidScaling + * @param {Amount} want + * @param {boolean} trySettle + */ + const acceptScaledBidOffer = (seat, bidScaling, want, trySettle) => { + trace('accept scaled bid offer'); + const collateralSold = + trySettle && + isScaledBidPriceHigher(bidScaling, curAuctionPrice, lockedPriceForRound) + ? settle(seat, want) + : AmountMath.makeEmptyFromAmount(want); + + const stillWant = AmountMath.subtract(want, collateralSold); + if ( + AmountMath.isEmpty(stillWant) || + AmountMath.isEmpty(seat.getCurrentAllocation().Currency) + ) { + seat.exit(); + } else { + scaledBidBook.add(seat, bidScaling, stillWant); + } + }; + + 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, lockedPriceForRound); + + 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 { + 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( + (a, b) => compareValues(a[1].seqNum, b[1].seqNum), + ); + for (const [key, { seat, price: p, wanted }] of prioritizedOffers) { + if (seat.hasExited()) { + removeFromItsBook(key, p); + } else { + const collateralSold = settle(seat, wanted); + + if ( + AmountMath.isEmpty(seat.getCurrentAllocation().Currency) || + AmountMath.isGTE(seat.getCurrentAllocation().Collateral, wanted) + ) { + seat.exit(); + removeFromItsBook(key, p); + } else if (!AmountMath.isGTE(collateralSold, wanted)) { + updateItsBook(key, collateralSold, p); + } + } + } + }, + getCurrentPrice() { + return curAuctionPrice; + }, + hasOrders() { + return scaledBidBook.hasOrders() || priceBook.hasOrders(); + }, + lockOraclePriceForRound() { + trace(`locking `, updatingOracleQuote); + lockedPriceForRound = updatingOracleQuote; + }, + + setStartingRate(rate) { + trace('set startPrice', lockedPriceForRound); + curAuctionPrice = multiplyRatios(lockedPriceForRound, rate); + }, + addOffer(bidSpec, seat, trySettle) { + mustMatch(bidSpec, BidSpecShape); + const { give } = seat.getProposal(); + mustMatch( + give.Currency, + currencyAmountShape, + 'give must include "Currency"', + ); + + if (bidSpec.offerPrice) { + return acceptPriceOffer( + seat, + bidSpec.offerPrice, + bidSpec.want, + trySettle, + ); + } else if (bidSpec.offerBidScaling) { + return acceptScaledBidOffer( + seat, + bidSpec.offerBidScaling, + bidSpec.want, + trySettle, + ); + } else { + throw Fail`Offer was neither a price nor a scaled bid`; + } + }, + getSeats() { + return { collateralSeat, currencySeat }; + }, + exitAllSeats() { + priceBook.exitAllSeats(); + scaledBidBook.exitAllSeats(); + }, + }); +}; + +/** @typedef {Awaited>} AuctionBook */ diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js new file mode 100644 index 00000000000..06c5d63b4ec --- /dev/null +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -0,0 +1,348 @@ +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, + provideDurableMapStore, +} from '@agoric/vat-data'; +import { AmountMath, AmountShape } from '@agoric/ertp'; +import { + atomicRearrange, + makeRatioFromAmounts, + makeRatio, + natSafeMath, + floorMultiplyBy, + provideEmptySeat, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { handleParamGovernance } from '@agoric/governance'; +import { makeTracer, BASIS_POINTS } from '@agoric/internal'; +import { FullProposalShape } from '@agoric/zoe/src/typeGuards.js'; + +import { makeAuctionBook } from './auctionBook.js'; +import { AuctionState } 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', false); + +const makeBPRatio = (rate, currencyBrand, collateralBrand = currencyBrand) => + makeRatioFromAmounts( + AmountMath.make(currencyBrand, rate), + 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. + * + * 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 + * @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(#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 + transfers.push([ + collateralSeat, + reserveSeat, + { Collateral: collateralLeft }, + { [collateralKeyword]: collateralLeft }, + ]); + return transfers; +}; + +/** + * @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(); + + /** @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'); + + const addDeposit = (seat, amount) => { + const depositListForBrand = deposits.get(amount.brand); + deposits.set( + amount.brand, + harden([...depositListForBrand, { seat, amount }]), + ); + }; + + // Called "discount" rate even though it can be above or below 100%. + /** @type {NatValue} */ + let currentDiscountRateBP; + + const distributeProceeds = () => { + 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()], + [currencySeat, liqSeat, currencySeat.getCurrentAllocation()], + ]), + ); + liqSeat.exit(); + deposits.set(brand, []); + } else if (depositsForBrand.length > 1) { + const collProceeds = collateralSeat.getCurrentAllocation().Collateral; + const currProceeds = currencySeat.getCurrentAllocation().Currency; + const transfers = distributeProportionalShares( + collProceeds, + currProceeds, + depositsForBrand, + collateralSeat, + currencySeat, + brandToKeyword.get(brand), + reserveFunds, + brand, + ); + atomicRearrange(zcf, harden(transfers)); + + for (const { seat } of depositsForBrand) { + seat.exit(); + } + deposits.set(brand, []); + } + } + }; + + const { augmentPublicFacet, creatorMixin, makeFarGovernorFacet, params } = + await handleParamGovernance( + zcf, + privateArgs.initialPoserInvitation, + // @ts-expect-error XXX How to type this? + auctioneerParamTypes, + privateArgs.storageNode, + privateArgs.marshaller, + ); + + const tradeEveryBook = () => { + const bidScalingRatio = makeRatio( + currentDiscountRateBP, + brands.Currency, + BASIS_POINTS, + ); + + for (const book of books.values()) { + book.settleAtNewRate(bidScalingRatio); + } + }; + + const driver = Far('Auctioneer', { + reducePriceAndTrade: () => { + trace('reducePriceAndTrade'); + + natSafeMath.isGTE(currentDiscountRateBP, params.getDiscountStep()) || + Fail`rates must fall ${currentDiscountRateBP}`; + + currentDiscountRateBP = natSafeMath.subtract( + currentDiscountRateBP, + params.getDiscountStep(), + ); + + tradeEveryBook(); + }, + finalize: () => { + trace('finalize'); + distributeProceeds(); + }, + startRound() { + trace('startRound'); + + currentDiscountRateBP = params.getStartingRate(); + for (const book of books.values()) { + book.lockOraclePriceForRound(); + book.setStartingRate( + makeBPRatio(currentDiscountRateBP, brands.Currency), + ); + } + + tradeEveryBook(); + }, + }); + + // @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); + trace(`deposited ${q(collateralAmount)}`); + book.addAssets(collateralAmount, zcfSeat); + addDeposit(zcfSeat, collateralAmount); + return 'deposited'; + }; + + const getDepositInvitation = () => + zcf.makeInvitation( + depositOfferHandler, + 'deposit Collateral', + undefined, + M.splitRecord({ give: { Collateral: AmountShape } }), + ); + + 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, + }, + ); + + return zcf.makeInvitation( + newBidHandler, + 'new bid', + {}, + bidProposalShape, + ); + }, + getSchedules() { + return E(scheduler).getSchedule(); + }, + getDepositInvitation, + ...params, + }), + ); + + 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, + ); + + // 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 }; +}; + +/** @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/offerBook.js b/packages/inter-protocol/src/auction/offerBook.js new file mode 100644 index 00000000000..09b25287900 --- /dev/null +++ b/packages/inter-protocol/src/auction/offerBook.js @@ -0,0 +1,130 @@ +// 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 { provideDurableMapStore } from '@agoric/vat-data'; + +import { + toBidScalingComparator, + toScaledRateOfferKey, + toPartialOfferKey, + toPriceOfferKey, +} from './sortedOffers.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 +// offer for uniqueness. +let latestSequenceNumber = 0n; +const nextSequenceNumber = () => { + latestSequenceNumber += 1n; + 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. + * + * @param {Baggage} baggage + * @param {Pattern} bidScalingPattern + * @param {Brand} collateralBrand + */ +export const makeScaledBidBook = ( + baggage, + bidScalingPattern, + collateralBrand, +) => { + const store = provideDurableMapStore(baggage, 'scaledBidStore'); + + return Far('scaledBidBook ', { + add(seat, bidScaling, wanted) { + mustMatch(bidScaling, bidScalingPattern); + + const seqNum = nextSequenceNumber(); + const key = toScaledRateOfferKey(bidScaling, seqNum); + const empty = AmountMath.makeEmpty(collateralBrand); + const bidderRecord = { + seat, + bidScaling, + wanted, + seqNum, + received: empty, + }; + store.init(key, harden(bidderRecord)); + return key; + }, + offersAbove(bidScaling) { + return [...store.entries(M.gte(toBidScalingComparator(bidScaling)))]; + }, + 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(); + } + } + }, + }); +}; + +/** + * Prices in this book are actual prices expressed in terms of currency amount + * and collateral amount. + * + * @param {Baggage} baggage + * @param {Pattern} ratioPattern + * @param {Brand} collateralBrand + */ +export const makePriceBook = (baggage, ratioPattern, collateralBrand) => { + const store = provideDurableMapStore(baggage, 'pricedBidStore'); + return Far('priceBook ', { + add(seat, price, wanted) { + mustMatch(price, ratioPattern); + + const seqNum = nextSequenceNumber(); + const key = toPriceOfferKey(price, seqNum); + const empty = AmountMath.makeEmpty(collateralBrand); + const bidderRecord = { seat, price, wanted, seqNum, received: empty }; + store.init(key, harden(bidderRecord)); + return key; + }, + offersAbove(price) { + return [...store.entries(M.gte(toPartialOfferKey(price)))]; + }, + 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.values()) { + 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..a5fccfd2ecb --- /dev/null +++ b/packages/inter-protocol/src/auction/params.js @@ -0,0 +1,212 @@ +import { + CONTRACT_ELECTORATE, + makeParamManager, + ParamTypes, +} from '@agoric/governance'; +import { TimeMath, RelativeTimeRecordShape } 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'); + +/** + * 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_BP = 'StartingRate'; +/** A limit below which the price will not be discounted. */ +export const LOWEST_RATE_BP = 'LowestRate'; +/** amount to reduce prices each time step in bp, as % of the start price */ +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. + */ +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_BP 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'; + +export const auctioneerParamPattern = M.splitRecord({ + [CONTRACT_ELECTORATE]: InvitationShape, + [START_FREQUENCY]: RelativeTimeRecordShape, + [CLOCK_STEP]: RelativeTimeRecordShape, + [STARTING_RATE_BP]: M.nat(), + [LOWEST_RATE_BP]: M.nat(), + [DISCOUNT_STEP_BP]: M.nat(), + [AUCTION_START_DELAY]: RelativeTimeRecordShape, + [PRICE_LOCK_PERIOD]: RelativeTimeRecordShape, +}); + +export const auctioneerParamTypes = harden({ + [CONTRACT_ELECTORATE]: ParamTypes.INVITATION, + [START_FREQUENCY]: ParamTypes.RELATIVE_TIME, + [CLOCK_STEP]: ParamTypes.RELATIVE_TIME, + [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, +}); + +/** + * @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_BP]: { type: ParamTypes.NAT, value: startingRate }, + [LOWEST_RATE_BP]: { type: ParamTypes.NAT, value: lowestRate }, + [DISCOUNT_STEP_BP]: { type: ParamTypes.NAT, value: discountStep }, + }); +}; +harden(makeAuctioneerParams); + +/** + * @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], + ], + [START_FREQUENCY]: [ParamTypes.RELATIVE_TIME, initial[START_FREQUENCY]], + [CLOCK_STEP]: [ParamTypes.RELATIVE_TIME, initial[CLOCK_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], + ], + [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, + }, +) => { + // XXX 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..3dca74d0384 --- /dev/null +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -0,0 +1,252 @@ +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 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. 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)}`, {}); +}; + +// exported for testability. +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 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 ${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 + : TimeMath.subtractRelRel(freq, TimeMath.toRel(1n)); + 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 lockTime = TimeMath.subtractAbsRel(startTime, lockPeriod); + + const next = { + startTime, + endTime, + steps, + endRate, + startDelay, + clockStep, + lockTime, + }; + return harden(next); +}; + +/** + * @typedef {object} AuctionDriver + * @property {() => void} reducePriceAndTrade + * @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(); + + /** @type {typeof AuctionState[keyof typeof AuctionState]} */ + let auctionState = AuctionState.WAITING; + + 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.reducePriceAndTrade(); + } + } + + if (TimeMath.compareAbs(time, schedule.endTime) >= 0) { + trace('LastStep', time); + auctionState = AuctionState.WAITING; + + auctionDriver.finalize(); + + // 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); + } + }; + + const scheduleRound = time => { + trace('nextRound', time); + + const { startTime } = liveSchedule; + trace('START ', startTime); + + const startDelay = + TimeMath.compareAbs(startTime, time) > 0 + ? TimeMath.subtractAbsAbs(startTime, time) + : TimeMath.subtractAbsAbs(startTime, startTime); + + E(timer).repeatAfter( + startDelay, + liveSchedule.clockStep, + Far('SchedulerWaker', { + wake(t) { + clockTick(t, liveSchedule); + }, + }), + stepCancelToken, + ); + }; + + const scheduleNextRound = start => { + trace(`SCHED nextRound`, start); + 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(params, after); + scheduleRound(time); + 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); + + return Far('scheduler', { + getSchedule: () => + harden({ + liveAuctionSchedule: liveSchedule, + nextAuctionSchedule: nextSchedule, + }), + 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/src/auction/sortedOffers.js b/packages/inter-protocol/src/auction/sortedOffers.js new file mode 100644 index 00000000000..7ae3dc61e58 --- /dev/null +++ b/packages/inter-protocol/src/auction/sortedOffers.js @@ -0,0 +1,126 @@ +import { + makeRatio, + ratioToNumber, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { M, mustMatch } from '@agoric/store'; +import { RatioShape } from '@agoric/ertp'; + +import { decodeData, encodeData } from '../vaultFactory/storeUtils.js'; + +const { Fail } = assert; + +/** + * @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 + * of the complete sort key, which is sufficient to find offers below a cutoff. + * + * @param {Ratio} offerPrice + */ +export const toPartialOfferKey = offerPrice => { + assert(offerPrice); + const mostSignificantPart = ratioToNumber(offerPrice); + return encodeData(harden([mostSignificantPart, 0n])); +}; + +/** + * Return a sort key that distinguishes by Price and sequence number + * + * @param {Ratio} offerPrice IST/collateral + * @param {bigint} sequenceNumber + * @returns {string} lexically sortable string in which highest price is first, + * ties will be broken by sequenceNumber of offer + */ +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()); + + const mostSignificantPart = ratioToNumber(offerPrice); + return encodeData(harden([mostSignificantPart, sequenceNumber])); +}; + +const priceRatioFromFloat = (floatPrice, numBrand, denomBrand, useDecimals) => { + const denominatorValue = 10 ** useDecimals; + return makeRatio( + BigInt(Math.round(floatPrice * denominatorValue)), + numBrand, + BigInt(denominatorValue), + denomBrand, + ); +}; + +const bidScalingRatioFromKey = (bidScaleFloat, numBrand, useDecimals) => { + const denominatorValue = 10 ** useDecimals; + return makeRatio( + BigInt(Math.round(bidScaleFloat * denominatorValue)), + numBrand, + BigInt(denominatorValue), + ); +}; + +/** + * fromPriceOfferKey is only used for diagnostics. + * + * @param {string} key + * @param {Brand} numBrand + * @param {Brand} denomBrand + * @param {number} useDecimals + * @returns {[normalizedPrice: Ratio, sequenceNumber: bigint]} + */ +export const fromPriceOfferKey = (key, numBrand, denomBrand, useDecimals) => { + const [pricePart, sequenceNumberPart] = decodeData(key); + return [ + priceRatioFromFloat(pricePart, numBrand, denomBrand, useDecimals), + sequenceNumberPart, + ]; +}; + +export const toBidScalingComparator = rate => { + assert(rate); + const mostSignificantPart = ratioToNumber(rate); + return encodeData(harden([mostSignificantPart, 0n])); +}; + +/** + * Sorts offers expressed as percentage of the current oracle price. + * + * @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 sequenceNumber of offer + */ +export const toScaledRateOfferKey = (rate, sequenceNumber) => { + mustMatch(rate, RatioShape); + rate.numerator.brand === rate.denominator.brand || + Fail`bid scaling rate must have the same numerator and denominator`; + mustMatch(sequenceNumber, M.nat()); + + const mostSignificantPart = ratioToNumber(rate); + return encodeData(harden([mostSignificantPart, sequenceNumber])); +}; + +/** + * fromScaledRateOfferKey is only used for diagnostics. + * + * @param {string} key + * @param {Brand} brand + * @param {number} useDecimals + * @returns {[normalizedPrice: Ratio, sequenceNumber: bigint]} + */ +export const fromScaledRateOfferKey = (key, brand, useDecimals) => { + const [bidScalingPart, sequenceNumberPart] = decodeData(key); + return [ + bidScalingRatioFromKey(bidScalingPart, brand, useDecimals), + sequenceNumberPart, + ]; +}; diff --git a/packages/inter-protocol/src/auction/util.js b/packages/inter-protocol/src/auction/util.js new file mode 100644 index 00000000000..3428884e599 --- /dev/null +++ b/packages/inter-protocol/src/auction/util.js @@ -0,0 +1,46 @@ +import { + makeRatioFromAmounts, + multiplyRatios, + ratioGTE, +} from '@agoric/zoe/src/contractSupport/index.js'; + +/** + * Constants for Auction State. + * + * @type {{ ACTIVE: 'active', WAITING: 'waiting' }} + */ +export const AuctionState = { + ACTIVE: 'active', + WAITING: 'waiting', +}; + +/** + * @param {{ brand: Brand, value: Pattern }} numeratorAmountShape + * @param {{ brand: Brand, value: Pattern }} denominatorAmountShape + */ +export const makeBrandedRatioPattern = ( + numeratorAmountShape, + denominatorAmountShape, +) => { + return harden({ + numerator: numeratorAmountShape, + denominator: denominatorAmountShape, + }); +}; + +/** + * @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); + +/** @type {(PriceQuote) => Ratio} */ +export const priceFrom = quote => + makeRatioFromAmounts( + quote.quoteAmount.value[0].amountOut, + quote.quoteAmount.value[0].amountIn, + ); diff --git a/packages/inter-protocol/src/proposals/core-proposal.js b/packages/inter-protocol/src/proposals/core-proposal.js index 212f1e93804..842387ebd04 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,27 @@ const SHARED_MAIN_MANIFEST = harden({ }, }, }, + + [econBehaviors.startAuctioneer.name]: { + consume: { + zoe: 'zoe', + board: 'board', + chainTimerService: 'timer', + priceAuthority: 'priceAuthority', + chainStorage: true, + economicCommitteeCreatorFacet: 'economicCommittee', + }, + produce: { auctioneerKit: 'auction' }, + instance: { + produce: { auction: 'auction' }, + }, + installation: { + consume: { contractGovernor: 'zoe', auction: 'zoe' }, + }, + issuer: { + consume: { [Stable.symbol]: 'zoe' }, + }, + }, }); const REWARD_MANIFEST = harden({ @@ -165,6 +188,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..ff173614e04 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, * }, + * auctioneerKit: { + * publicFacet: AuctioneerPublicFacet, + * creatorFacet: AuctioneerCreatorFacet, + * governorCreatorFacet: GovernedContractFacetAccess<{},{}>, + * adminFacet: AdminFacet, + * } * minInitialDebt: NatValue, * }>} EconomyBootstrapSpace */ @@ -479,6 +488,128 @@ export const startLienBridge = async ({ lienBridge.resolve(reporter); }; +/** + * @param {EconomyBootstrapPowers} powers + * @param {object} config + * @param {any} [config.auctionParams] + */ +export const startAuctioneer = async ( + { + consume: { + zoe, + board, + chainTimerService, + priceAuthority, + chainStorage, + economicCommitteeCreatorFacet: electorateCreatorFacet, + }, + produce: { auctioneerKit }, + 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('startAuctioneer'); + 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(), + ]); + + auctioneerKit.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/vaultFactory/storeUtils.js b/packages/inter-protocol/src/vaultFactory/storeUtils.js index 22e4cd43b3a..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,7 +38,7 @@ const encodeData = makeEncodePassable(); * @param {string} encoded * @returns {PureData} */ -const decodeData = makeDecodePassable(); +export const decodeData = makeDecodePassable(); /** * @param {number} n 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..1c4e30df5d7 --- /dev/null +++ b/packages/inter-protocol/test/auction/test-auctionBook.js @@ -0,0 +1,281 @@ +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'; + +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('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 zcfSeat = await makeSeatWithAssets( + zoe, + zcf, + moola(100n), + 'Currency', + moolaKit, + ); + + 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, + true, + ); + + 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), + 'Currency', + moolaKit, + ); + + book.lockOraclePriceForRound(); + book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + + book.addOffer( + harden({ + offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), + want: simoleans(50n), + }), + zcfSeat, + true, + ); + + t.true(book.hasOrders()); +}); + +test('Bad keyword', 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, + ); + + 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, + true, + ); + + 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..fab8a4b946a --- /dev/null +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -0,0 +1,909 @@ +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] + * @param {ExitRule} [exitRule] + */ + const bidForCollateralSeat = async ( + giveCurrency, + wantCollateral, + discount = undefined, + exitRule = undefined, + ) => { + const bidInvitation = E(publicFacet).getBidInvitation(wantCollateral.brand); + const rawProposal = { + give: { Currency: giveCurrency }, + // 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), + }); + const offerArgs = + discount && discount.numerator.brand === discount.denominator.brand + ? { want: wantCollateral, offerBidScaling: 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.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, exit) { + return bidForCollateralSeat(giveCurrency, wantCollateral, discount, exit); + }, + setupCollateralAuction, + 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; + }, + }; +}; + +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.serial('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.serial('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.serial('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.serial('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(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) + 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.serial('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(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, 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.serial('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), + ); + await driver.updatePriceAuthority( + makeRatioFromAmounts(currency.make(11n), collateral.make(10n)), + ); + + 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 * 1500 + currency.make(1733n), + collateral.make(1500n), + ); + t.is(await E(bidderSeat1).getOfferResult(), 'Your offer has been received'); + + // price lock period before auction start + await driver.advanceTo(167n); + + // another seller deposits 2000 + const liqSeat2 = await driver.depositCollateral( + collateral.make(2000n), + collateral, + ); + const resultL2 = await E(liqSeat2).getOfferResult(); + t.is(resultL2, 'deposited'); + + await driver.advanceTo(180n); + + // bidder gets collateral + await assertPayouts(t, bidderSeat1, currency, collateral, 0n, 1500n); + + await driver.advanceTo(190n); + // 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. +// 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); +}); + +// 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); +}); + +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/, + }); +}); 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..7bd9a0ba7ca --- /dev/null +++ b/packages/inter-protocol/test/auction/test-computeRoundTiming.js @@ -0,0 +1,208 @@ +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), + lockTime: TimeMath.toAbs(rawExpect.lockTime, 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, + lockTime: 3000, +}); + +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, + lockTime: 6600, + }, +); + +// 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, + lockTime: 3000, + }, +); + +// 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, + lockTime: 3000, + }, +); + +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 + 5 * 10 * 60 + 300, + steps: 5n, + endRate: 10_500n - 5n * 10n, + startDelay: 300, + clockStep: 600, + lockTime: 3000, + }, +); + +// 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 + 5 * 10 * 60 + 300, + steps: 5n, + endRate: 10_500n - 5n * 350n, + startDelay: 300, + clockStep: 600, + lockTime: 3000, + }, +); 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..a7ad2310f16 --- /dev/null +++ b/packages/inter-protocol/test/auction/test-proportionalDist.js @@ -0,0 +1,164 @@ +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 { 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 currency = withAmountUtils(makeIssuerKit('Currency')); + const collateral = withAmountUtils(makeIssuerKit('Collateral')); + + trace('makeContext'); + return { + 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({}); + + 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); +}; + +// 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], + ], +); + +// 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], + ], +); + +// 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], + ], +); + +// 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], + ], +); 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..5c64a1d110b --- /dev/null +++ b/packages/inter-protocol/test/auction/test-scheduler.js @@ -0,0 +1,546 @@ +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) => bigint; }} */ + 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; + } + + /** @type {bigint} */ + let now = await timer.advanceTo(127n); + + 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), + lockTime: TimeMath.toAbs(126n, timerBrand), + }; + t.deepEqual(schedule.nextAuctionSchedule, firstSchedule); + + t.false(fakeAuctioneer.getState().final); + t.is(fakeAuctioneer.getState().step, 0); + t.false(fakeAuctioneer.getState().final); + + now = await timer.advanceTo(now + 1n); + + t.is(fakeAuctioneer.getState().step, 0); + t.false(fakeAuctioneer.getState().final); + + now = await timer.advanceTo(131n); + 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), + lockTime: TimeMath.toAbs(136, timerBrand), + }); + + t.is(fakeAuctioneer.getState().step, 1); + t.false(fakeAuctioneer.getState().final); + + // xxx I shouldn't have to tick twice. + 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 + 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 + now = await timer.advanceTo(now + 1n); + now = 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), + lockTime: TimeMath.toAbs(136n, timerBrand), + }; + t.deepEqual(finalSchedule.nextAuctionSchedule, secondSchedule); + + now = await timer.advanceTo(140n); + + t.deepEqual(finalSchedule.liveAuctionSchedule, undefined); + t.deepEqual(finalSchedule.nextAuctionSchedule, secondSchedule); + + now = 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), + lockTime: TimeMath.toAbs(146n, timerBrand), + }); + + t.is(fakeAuctioneer.getState().step, 4); + t.false(fakeAuctioneer.getState().final); + + // xxx I shouldn't have to tick twice. + 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 + 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 + now = 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; + } + + await timer.advanceTo(127n); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { message: /startingRate "\[105n]" must be more than lowest: "\[110n]"/ }, + ); +}); + +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; + } + + await timer.advanceTo(127n); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + await t.throwsAsync( + () => + makeScheduler(fakeAuctioneer, timer, paramManager, timer.getTimerBrand()), + { + message: + /clockStep "\[3n]" must be shorter than startFrequency "\[2n]" to allow at least one step down/, + }, + ); +}); + +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; + } + + await timer.advanceTo(127n); + + 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; + } + + await timer.advanceTo(127n); + + 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; + } + + await timer.advanceTo(127n); + + 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; + } + + await timer.advanceTo(127n); + + 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; + } + + await timer.advanceTo(127n); + + 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); + // start hourly, request 6 steps down every 10 minutes, so duration would be + // 1 hour. Instead cut the auction short. + 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; + } + + await timer.advanceTo(127n); + + const paramManager = await makeAuctioneerParamManager( + publisherKit, + zoe, + // @ts-expect-error 3rd parameter of makeAuctioneerParamManager + params2, + ); + + 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), + lockTime: TimeMath.toAbs(345n, 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), + lockTime: TimeMath.toAbs(705n, timerBrand), + }; + t.deepEqual(schedule.nextAuctionSchedule, secondSchedule); +}); 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..75a5fc6857b --- /dev/null +++ b/packages/inter-protocol/test/auction/test-sortedOffers.js @@ -0,0 +1,115 @@ +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, + toScaledRateOfferKey, + fromScaledRateOfferKey, +} from '../../src/auction/sortedOffers.js'; + +// these used to be timestamps, but now they're bigInts +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(keyA26 > keyA25); + t.true(keyB25 > keyC25); + t.true(keyB26 > keyB25); + t.true(keyC25 > keyD25); + t.true(keyC26 > keyC25); + t.true(keyD26 > keyD25); +}); + +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 = 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); + t.true(keyB26 > keyB25); + t.true(keyD25 > keyC25); + t.true(keyC26 > keyC25); + t.true(keyD26 > keyD25); +}); + +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 = toScaledRateOfferKey(discountA, DEC25); + const keyB25 = toScaledRateOfferKey(discountB, DEC25); + + 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), + 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..c6a182bfde7 --- /dev/null +++ b/packages/inter-protocol/test/auction/tools.js @@ -0,0 +1,97 @@ +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, 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'; +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'; + +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 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', { + reducePriceAndTrade: () => { + 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/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 */ /** 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 3caf0014fb9..d0e134ad055 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..3e29ff53196 100644 --- a/packages/zoe/src/contractSupport/index.js +++ b/packages/zoe/src/contractSupport/index.js @@ -52,4 +52,9 @@ export { oneMinus, addRatios, multiplyRatios, + ratiosSame, + quantize, + ratioGTE, + subtractRatios, + ratioToNumber, } from './ratio.js'; diff --git a/packages/zoe/src/contractSupport/ratio.js b/packages/zoe/src/contractSupport/ratio.js index f6a94810b30..17d252455f2 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 ratioToNumber = ratio => { + const n = Number(ratio.numerator.value); + const d = Number(ratio.denominator.value); + return n / d; +};