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; +};