diff --git a/packages/inter-protocol/README.md b/packages/inter-protocol/README.md index 6edecdfaafb..4d9a9daf54c 100644 --- a/packages/inter-protocol/README.md +++ b/packages/inter-protocol/README.md @@ -52,9 +52,10 @@ The canonical keys (under `published`) are as follows. Non-terminal nodes could - `vaults` - `vault0` - `auction` - [snapshot of details](./test/auction/snapshots/test-auctionContract.js.md) - - `schedule` + - `schedule` - for global schedule info - `governance` - - `book0` + - `books.book0` - stats about the bid book for one collateral + - `books.book0.bids.bid0` - data about one bid - `reserve` - [snapshot of details](./test/reserve/snapshots/test-reserve.js.md) - `governance` - `metrics` diff --git a/packages/inter-protocol/package.json b/packages/inter-protocol/package.json index 419a2cde0c3..646e60d5ce3 100644 --- a/packages/inter-protocol/package.json +++ b/packages/inter-protocol/package.json @@ -46,6 +46,7 @@ "@endo/far": "^0.2.19", "@endo/marshal": "^0.8.6", "@endo/nat": "^4.1.28", + "@endo/promise-kit": "^0.2.57", "jessie.js": "^0.3.2" }, "devDependencies": { diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 548be5f0943..3a617d75128 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -2,9 +2,13 @@ import '@agoric/governance/exported.js'; import '@agoric/zoe/exported.js'; import '@agoric/zoe/src/contracts/exported.js'; -import { AmountMath } from '@agoric/ertp'; +import { AmountMath, AmountShape, RatioShape } from '@agoric/ertp'; import { mustMatch } from '@agoric/store'; -import { M, prepareExoClassKit } from '@agoric/vat-data'; +import { + M, + prepareExoClassKit, + provideDurableMapStore, +} from '@agoric/vat-data'; import { assertAllDefined, makeTracer } from '@agoric/internal'; import { @@ -20,7 +24,11 @@ import { E } from '@endo/captp'; import { observeNotifier } from '@agoric/notifier'; import { makeNatAmountShape } from '../contractSupport.js'; -import { preparePriceBook, prepareScaledBidBook } from './offerBook.js'; +import { + BidsDataNotificationShape, + preparePriceBook, + prepareScaledBidBook, +} from './offerBook.js'; import { isScaledBidPriceHigher, makeBrandedRatioPattern, @@ -72,8 +80,8 @@ const trace = makeTracer('AucBook', true); * @param {Brand<'nat'>} collateralBrand */ export const makeOfferSpecShape = (bidBrand, collateralBrand) => { - const bidAmountShape = makeNatAmountShape(bidBrand); - const collateralAmountShape = makeNatAmountShape(collateralBrand); + const bidAmountShape = makeNatAmountShape(bidBrand, 0n); + const collateralAmountShape = makeNatAmountShape(collateralBrand, 0n); return M.splitRecord( { maxBuy: collateralAmountShape }, { @@ -106,6 +114,18 @@ export const makeOfferSpecShape = (bidBrand, collateralBrand) => { * @property {Amount<'nat'> | null} collateralAvailable The amount of collateral * remaining */ +export const BookDataNotificationShape = M.splitRecord( + { + startPrice: M.or(RatioShape, null), + currentPriceLevel: M.or(RatioShape, null), + startCollateral: AmountShape, + collateralAvailable: M.or(AmountShape, null), + }, + { + proceedsRaised: AmountShape, + }, +); +harden(BookDataNotificationShape); /** * @param {Baggage} baggage @@ -113,8 +133,9 @@ export const makeOfferSpecShape = (bidBrand, collateralBrand) => { * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit */ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { - const makeScaledBidBook = prepareScaledBidBook(baggage); - const makePriceBook = preparePriceBook(baggage); + const bidDataKits = provideDurableMapStore(baggage, 'bidDataKits'); + const makeScaledBidBook = prepareScaledBidBook(baggage, makeRecorderKit); + const makePriceBook = preparePriceBook(baggage, makeRecorderKit); const AuctionBookStateShape = harden({ collateralBrand: M.any(), @@ -126,6 +147,8 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { priceAuthority: M.any(), updatingOracleQuote: M.any(), bookDataKit: M.any(), + bidDataKits: M.any(), + bidsDataKit: M.any(), priceBook: M.any(), scaledBidBook: M.any(), startCollateral: M.any(), @@ -143,9 +166,9 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { * @param {Brand<'nat'>} bidBrand * @param {Brand<'nat'>} collateralBrand * @param {PriceAuthority} pAuthority - * @param {StorageNode} node + * @param {[bookNode: StorageNode, bidsNode: StorageNode]} nodes */ - (bidBrand, collateralBrand, pAuthority, node) => { + (bidBrand, collateralBrand, pAuthority, nodes) => { assertAllDefined({ bidBrand, collateralBrand, pAuthority }); const zeroBid = makeEmpty(bidBrand); const zeroRatio = makeRatioFromAmounts( @@ -159,23 +182,36 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { // returned to the funders. const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); const { zcfSeat: bidHoldingSeat } = zcf.makeEmptySeatKit(); + const [bookNode, bidsNode] = nodes; + + const bidAmountShape = makeNatAmountShape(bidBrand, 0n); + const collateralAmountShape = makeNatAmountShape(collateralBrand, 0n); - const bidAmountShape = makeNatAmountShape(bidBrand); - const collateralAmountShape = makeNatAmountShape(collateralBrand); const scaledBidBook = makeScaledBidBook( makeBrandedRatioPattern(bidAmountShape, bidAmountShape), collateralBrand, + bidsNode, ); const priceBook = makePriceBook( makeBrandedRatioPattern(bidAmountShape, collateralAmountShape), collateralBrand, + bidsNode, ); const bookDataKit = makeRecorderKit( - node, + bookNode, /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( - M.any() + BookDataNotificationShape + ), + ); + + /** @typedef {import('./offerBook.js').BidDataNotification} BidDataNotification */ + + const bidsDataKit = makeRecorderKit( + bidsNode, + /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( + BidsDataNotificationShape ), ); @@ -191,6 +227,8 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { updatingOracleQuote: zeroRatio, bookDataKit, + bidDataKits, + bidsDataKit, priceBook, scaledBidBook, @@ -346,7 +384,6 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { return collateralTarget; }, - /** * 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 @@ -355,14 +392,17 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { * @param {ZCFSeat} seat * @param {Ratio} price * @param {Amount<'nat'>} maxBuy + * @param {Timestamp} timestamp * @param {object} opts * @param {boolean} opts.trySettle * @param {boolean} [opts.exitAfterBuy] */ + acceptPriceOffer( seat, price, maxBuy, + timestamp, { trySettle, exitAfterBuy = false }, ) { const { priceBook, curAuctionPrice } = this.state; @@ -389,7 +429,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat.exit(); } else { trace('added Offer ', price, stillWant.value); - priceBook.add(seat, price, stillWant, exitAfterBuy); + priceBook.add(seat, price, stillWant, exitAfterBuy, timestamp); } void helper.publishBookData(); @@ -403,6 +443,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { * @param {ZCFSeat} seat * @param {Ratio} bidScaling * @param {Amount<'nat'>} maxBuy + * @param {Timestamp} timestamp * @param {object} opts * @param {boolean} opts.trySettle * @param {boolean} [opts.exitAfterBuy] @@ -411,6 +452,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat, bidScaling, maxBuy, + timestamp, { trySettle, exitAfterBuy = false }, ) { trace(this.state.collateralBrand, 'accept scaledBid offer'); @@ -443,7 +485,13 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { ) { seat.exit(); } else { - scaledBidBook.add(seat, bidScaling, stillWant, exitAfterBuy); + scaledBidBook.add( + seat, + bidScaling, + stillWant, + exitAfterBuy, + timestamp, + ); } void helper.publishBookData(); @@ -466,6 +514,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { collateralAvailable, currentPriceLevel: state.curAuctionPrice, }); + return state.bookDataKit.recorder.write(bookData); }, }, @@ -588,7 +637,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { const { remainingProceedsGoal } = state; const { helper } = facets; for (const [key, seatRecord] of prioritizedOffers) { - const { seat, price: p, wanted, exitAfterBuy } = seatRecord; + const { seat, price: p } = seatRecord; if ( remainingProceedsGoal && AmountMath.isEmpty(remainingProceedsGoal) @@ -597,18 +646,19 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { } else if (seat.hasExited()) { helper.removeFromItsBook(key, p); } else { - const collateralSold = helper.settle(seat, wanted); + const { remainingWant, originalWant, exitAfterBuy } = seatRecord; + const collateralSold = helper.settle(seat, remainingWant); const alloc = seat.getCurrentAllocation(); if ( (exitAfterBuy && !AmountMath.isEmpty(collateralSold)) || AmountMath.isEmpty(alloc.Bid) || ('Collateral' in alloc && - AmountMath.isGTE(alloc.Collateral, wanted)) + AmountMath.isGTE(alloc.Collateral, originalWant)) ) { seat.exit(); helper.removeFromItsBook(key, p); - } else if (!AmountMath.isGTE(collateralSold, wanted)) { + } else if (!AmountMath.isGTE(collateralSold, remainingWant)) { helper.updateItsBook(key, collateralSold, p); } } @@ -652,8 +702,9 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { * @param {OfferSpec} offerSpec * @param {ZCFSeat} seat * @param {boolean} trySettle + * @param {Timestamp} timestamp */ - addOffer(offerSpec, seat, trySettle) { + addOffer(offerSpec, seat, trySettle, timestamp) { const { bidBrand, collateralBrand } = this.state; const offerSpecShape = makeOfferSpecShape(bidBrand, collateralBrand); @@ -669,6 +720,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat, offerSpec.offerPrice, offerSpec.maxBuy, + timestamp, { trySettle, exitAfterBuy, @@ -679,6 +731,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat, offerSpec.offerBidScaling, offerSpec.maxBuy, + timestamp, { trySettle, exitAfterBuy, @@ -718,6 +771,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { 'Auction schedule', this.state.bookDataKit, ), + bids: makeRecorderTopic('Auction Bids', this.state.bidsDataKit), }; }, }, diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index 9eb4d101eb4..ee20d9b82cb 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -21,9 +21,9 @@ import { makeRatioFromAmounts, makeRecorderTopic, natSafeMath, + offerTo, prepareRecorder, provideEmptySeat, - offerTo, } from '@agoric/zoe/src/contractSupport/index.js'; import { FullProposalShape } from '@agoric/zoe/src/typeGuards.js'; import { E } from '@endo/eventual-send'; @@ -32,7 +32,7 @@ import { Far } from '@endo/marshal'; import { makeNatAmountShape } from '../contractSupport.js'; import { makeOfferSpecShape, prepareAuctionBook } from './auctionBook.js'; import { auctioneerParamTypes } from './params.js'; -import { makeScheduler } from './scheduler.js'; +import { makeScheduler, ScheduleNotificationShape } from './scheduler.js'; import { AuctionState } from './util.js'; /** @typedef {import('@agoric/vat-data').Baggage} Baggage */ @@ -441,7 +441,7 @@ export const start = async (zcf, privateArgs, baggage) => { * @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher< * import('./scheduler.js').ScheduleNotification * >} - */ (M.any()), + */ (ScheduleNotificationShape), ); /** @@ -636,11 +636,12 @@ export const start = async (zcf, privateArgs, baggage) => { * @param {ZCFSeat} zcfSeat * @param {import('./auctionBook.js').OfferSpec} offerSpec */ - const newBidHandler = (zcfSeat, offerSpec) => { + const newBidHandler = async (zcfSeat, offerSpec) => { // xxx consider having Zoe guard the offerArgs with a provided shape mustMatch(offerSpec, offerSpecShape); const auctionBook = books.get(collateralBrand); - auctionBook.addOffer(offerSpec, zcfSeat, isActive()); + const timestamp = await E(timer).getCurrentTimestamp(); + auctionBook.addOffer(offerSpec, zcfSeat, isActive(), timestamp); return 'Your bid has been accepted'; }; @@ -687,6 +688,7 @@ export const start = async (zcf, privateArgs, baggage) => { ), ); + const booksNode = await E(privateArgs.storageNode).makeChildNode('books'); const creatorFacet = makeFarGovernorFacet( Far('Auctioneer creatorFacet', { /** @@ -701,13 +703,18 @@ export const start = async (zcf, privateArgs, baggage) => { const bookId = `book${bookCounter}`; bookCounter += 1; - const bNode = await E(privateArgs.storageNode).makeChildNode(bookId); + + const bookNodeP = E(booksNode).makeChildNode(bookId); + const [bookNode, bidsNode] = await Promise.all([ + bookNodeP, + E(bookNodeP).makeChildNode('bids'), + ]); const newBook = await makeAuctionBook( brands.Bid, brand, priceAuthority, - bNode, + [bookNode, bidsNode], ); // These three store.init() calls succeed or fail atomically diff --git a/packages/inter-protocol/src/auction/offerBook.js b/packages/inter-protocol/src/auction/offerBook.js index 77a8eb46937..3a318e4f044 100644 --- a/packages/inter-protocol/src/auction/offerBook.js +++ b/packages/inter-protocol/src/auction/offerBook.js @@ -1,9 +1,18 @@ // book of offers to buy liquidating vaults with prices in terms of // discount/markup from the current oracle price. -import { AmountMath } from '@agoric/ertp'; +import { E } from '@endo/captp'; +import { AmountMath, BrandShape, AmountShape, RatioShape } from '@agoric/ertp'; +import { StorageNodeShape } from '@agoric/internal'; import { M, mustMatch } from '@agoric/store'; -import { makeScalarBigMapStore, prepareExoClass } from '@agoric/vat-data'; +import { + makeScalarBigMapStore, + makeScalarMapStore, + prepareExoClass, + provide, +} from '@agoric/vat-data'; +import { makePromiseKit } from '@endo/promise-kit'; +import { TimestampShape } from '@agoric/time'; import { toBidScalingComparator, @@ -14,21 +23,22 @@ import { /** @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 = () => { +/** @type {(baggage: Baggage) => bigint} */ +const nextSequenceNumber = baggage => { + let latestSequenceNumber = provide(baggage, 'sequenceNumber', () => 1000n); latestSequenceNumber += 1n; + baggage.set('sequenceNumber', latestSequenceNumber); return latestSequenceNumber; }; /** * @typedef {{ * seat: ZCFSeat; - * wanted: Amount<'nat'>; + * originalWant: Amount<'nat'>; + * remainingWant: Amount<'nat'>; * seqNum: NatValue; * received: Amount<'nat'>; + * timestamp: Timestamp; * } & { exitAfterBuy: boolean } & ( * | { bidScaling: Pattern; price: undefined } * | { bidScaling: undefined; price: Ratio } @@ -37,31 +47,124 @@ const nextSequenceNumber = () => { const ScaledBidBookStateShape = harden({ bidScalingPattern: M.any(), - collateralBrand: M.any(), + collateralBrand: BrandShape, records: M.any(), + bidsNode: StorageNodeShape, }); +const makeBidNode = (bidsNode, bidId) => + E(bidsNode).makeChildNode(`bid${bidId}`); + +const makeGetBidDataRecorder = (bidDataKits, bidDataKitPromises) => { + const getBidDataRecorder = key => { + if (bidDataKitPromises.has(key)) { + return E.get(bidDataKitPromises.get(key)).recorder; + } + return bidDataKits.get(key).recorder; + }; + + const deleteNodeIfPresent = key => { + if (bidDataKitPromises.has(key) || bidDataKits.has(key)) { + // TODO(8063) delete node rather than erasing the data + return E(getBidDataRecorder(key)).writeFinal(''); + } + }; + + return { getBidDataRecorder, deleteNodeIfPresent }; +}; + +/** + * @typedef {ReturnType< + * import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit + * >} RecorderKit + */ + +/** + * @typedef {object} ScaledBidData + * @property {Ratio} bidScaling + * @property {Amount<'nat'>} wanted + * @property {boolean} exitAfterBuy + */ + +/** + * @typedef {object} PricedBidData + * @property {Ratio} price + * @property {Amount<'nat'>} wanted + * @property {boolean} exitAfterBuy + */ + +/** + * @typedef {object} BidDataNotification + * @property {ScaledBidData[]} scaledBids + * @property {PricedBidData[]} pricedBids + */ + +export const BidDataNotificationShape = M.or( + { + price: RatioShape, + originalWant: AmountShape, + remainingWant: AmountShape, + exitAfterBuy: M.boolean(), + timestamp: TimestampShape, + balance: AmountShape, + sequence: M.bigint(), + }, + { + bidScaling: RatioShape, + originalWant: AmountShape, + remainingWant: AmountShape, + exitAfterBuy: M.boolean(), + timestamp: TimestampShape, + balance: AmountShape, + sequence: M.bigint(), + }, + // XXX for deletion. Should go away when we can actually delete + M.string(), +); +harden(BidDataNotificationShape); + +export const BidsDataNotificationShape = { + scaledBids: M.arrayOf(BidDataNotificationShape), + pricedBids: M.arrayOf(BidDataNotificationShape), +}; +harden(BidsDataNotificationShape); + /** * 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 {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit */ -export const prepareScaledBidBook = baggage => - prepareExoClass( +export const prepareScaledBidBook = (baggage, makeRecorderKit) => { + // multiple offers might be provided at the same timestamp (since timestamp + // granularity is limited to blocks), so we increment a sequenceNumber with + // each offer for uniqueness. + + const bidDataKits = baggage.get('bidDataKits'); + /** @type {MapStore>} */ + const bidDataKitPromises = makeScalarMapStore('bidDataKit Promises'); + const { getBidDataRecorder, deleteNodeIfPresent } = makeGetBidDataRecorder( + bidDataKits, + bidDataKitPromises, + ); + + return prepareExoClass( baggage, 'scaledBidBook', undefined, /** * @param {Pattern} bidScalingPattern * @param {Brand} collateralBrand + * @param {StorageNode} bidsNode */ - (bidScalingPattern, collateralBrand) => ({ + (bidScalingPattern, collateralBrand, bidsNode) => ({ bidScalingPattern, collateralBrand, /** @type {MapStore} */ records: makeScalarBigMapStore('scaledBidRecords', { durable: true }), + bidsNode, }), { /** @@ -69,25 +172,44 @@ export const prepareScaledBidBook = baggage => * @param {Ratio} bidScaling * @param {Amount<'nat'>} wanted * @param {boolean} exitAfterBuy + * @param {Timestamp} timestamp */ - add(seat, bidScaling, wanted, exitAfterBuy) { - const { bidScalingPattern, collateralBrand, records } = this.state; + add(seat, bidScaling, wanted, exitAfterBuy, timestamp) { + const { bidScalingPattern, collateralBrand, records, bidsNode } = + this.state; mustMatch(bidScaling, bidScalingPattern); - - const seqNum = nextSequenceNumber(); + const seqNum = nextSequenceNumber(baggage); const key = toScaledRateOfferKey(bidScaling, seqNum); - const empty = AmountMath.makeEmpty(collateralBrand); + + /** @type {PromiseKit} */ + const bidDataKitP = makePromiseKit(); + bidDataKitPromises.init(key, bidDataKitP.promise); + E.when(makeBidNode(bidsNode, seqNum), childBidNode => { + const recorderKit = makeRecorderKit( + childBidNode, + /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ + (BidDataNotificationShape), + ); + bidDataKits.init(key, recorderKit); + bidDataKitP.resolve(recorderKit); + bidDataKitPromises.delete(key); + return recorderKit; + }); + /** @type {BidderRecord} */ const bidderRecord = { bidScaling, price: undefined, - received: empty, + received: AmountMath.makeEmpty(collateralBrand), seat, seqNum, - wanted, + originalWant: wanted, + remainingWant: wanted, exitAfterBuy, + timestamp, }; records.init(key, harden(bidderRecord)); + this.self.publishOffer(bidderRecord); return key; }, /** @param {Ratio} bidScaling */ @@ -95,31 +217,56 @@ export const prepareScaledBidBook = baggage => const { records } = this.state; return [...records.entries(M.gte(toBidScalingComparator(bidScaling)))]; }, + publishOffer(record) { + const key = toScaledRateOfferKey(record.bidScaling, record.seqNum); + + // users can exit seats + if (record.seat.hasExited()) { + this.self.delete(key); + return; + } + + return E(getBidDataRecorder(key)).write( + harden({ + bidScaling: record.bidScaling, + originalWant: record.originalWant, + remainingWant: record.remainingWant, + exitAfterBuy: record.exitAfterBuy, + timestamp: record.timestamp, + balance: record.seat.getCurrentAllocation().Bid, + sequence: record.seqNum, + }), + ); + }, hasOrders() { const { records } = this.state; return records.getSize() > 0; }, delete(key) { const { records } = this.state; + void deleteNodeIfPresent(key); + if (bidDataKits.has(key)) { + bidDataKits.delete(key); + } records.delete(key); }, updateReceived(key, sold) { const { records } = this.state; const oldRec = records.get(key); - records.set( - key, - harden({ - ...oldRec, - received: AmountMath.add(oldRec.received, sold), - }), - ); + const newRecord = harden({ + ...oldRec, + received: AmountMath.add(oldRec.received, sold), + remainingWant: AmountMath.subtract(oldRec.remainingWant, sold), + }); + records.set(key, newRecord); + this.self.publishOffer(newRecord); }, exitAllSeats() { const { records } = this.state; for (const [key, { seat }] of records.entries()) { if (!seat.hasExited()) { seat.exit(); - records.delete(key); + this.self.delete(key); } } }, @@ -128,11 +275,13 @@ export const prepareScaledBidBook = baggage => stateShape: ScaledBidBookStateShape, }, ); +}; const PriceBookStateShape = harden({ priceRatioPattern: M.any(), - collateralBrand: M.any(), + collateralBrand: BrandShape, records: M.any(), + bidsNode: StorageNodeShape, }); /** @@ -140,21 +289,32 @@ const PriceBookStateShape = harden({ * collateral amount. * * @param {Baggage} baggage + * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit */ -export const preparePriceBook = baggage => - prepareExoClass( +export const preparePriceBook = (baggage, makeRecorderKit) => { + const bidDataKits = baggage.get('bidDataKits'); + /** @type {MapStore>} */ + const bidDataKitPromises = makeScalarMapStore('bidDataKit Promises'); + const { getBidDataRecorder, deleteNodeIfPresent } = makeGetBidDataRecorder( + bidDataKits, + bidDataKitPromises, + ); + + return prepareExoClass( baggage, 'priceBook', undefined, /** * @param {Pattern} priceRatioPattern * @param {Brand} collateralBrand + * @param {StorageNode} bidsNode */ - (priceRatioPattern, collateralBrand) => ({ + (priceRatioPattern, collateralBrand, bidsNode) => ({ priceRatioPattern, collateralBrand, /** @type {MapStore} */ records: makeScalarBigMapStore('scaledBidRecords', { durable: true }), + bidsNode, }), { /** @@ -162,56 +322,101 @@ export const preparePriceBook = baggage => * @param {Ratio} price * @param {Amount<'nat'>} wanted * @param {boolean} exitAfterBuy + * @param {Timestamp} timestamp */ - add(seat, price, wanted, exitAfterBuy) { - const { priceRatioPattern, collateralBrand, records } = this.state; + add(seat, price, wanted, exitAfterBuy, timestamp) { + const { priceRatioPattern, collateralBrand, records, bidsNode } = + this.state; mustMatch(price, priceRatioPattern); - const seqNum = nextSequenceNumber(); + const seqNum = nextSequenceNumber(baggage); const key = toPriceOfferKey(price, seqNum); - const empty = AmountMath.makeEmpty(collateralBrand); + + /** @type {PromiseKit} */ + const bidDataKitP = makePromiseKit(); + bidDataKitPromises.init(key, bidDataKitP.promise); + E.when(makeBidNode(bidsNode, seqNum), childBidNode => { + const recorderKit = makeRecorderKit( + childBidNode, + /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ + (BidDataNotificationShape), + ); + bidDataKits.init(key, recorderKit); + bidDataKitP.resolve(recorderKit); + bidDataKitPromises.delete(key); + return recorderKit; + }); + /** @type {BidderRecord} */ - const bidderRecord = { + const bidderRecord = harden({ bidScaling: undefined, price, - received: empty, + received: AmountMath.makeEmpty(collateralBrand), seat, seqNum, - wanted, + originalWant: wanted, + remainingWant: wanted, exitAfterBuy, - }; - records.init(key, harden(bidderRecord)); + timestamp, + }); + records.init(key, bidderRecord); + this.self.publishOffer(bidderRecord); return key; }, offersAbove(price) { const { records } = this.state; return [...records.entries(M.gte(toPartialOfferKey(price)))]; }, + publishOffer(record) { + const key = toPriceOfferKey(record.price, record.seqNum); + + // users can exit seats + if (record.seat.hasExited()) { + this.self.delete(key); + return; + } + + return E(getBidDataRecorder(key)).write( + harden({ + price: record.price, + originalWant: record.originalWant, + remainingWant: record.remainingWant, + exitAfterBuy: record.exitAfterBuy, + timestamp: record.timestamp, + balance: record.seat.getCurrentAllocation().Bid, + sequence: record.seqNum, + }), + ); + }, hasOrders() { const { records } = this.state; return records.getSize() > 0; }, delete(key) { const { records } = this.state; + void deleteNodeIfPresent(key); + if (bidDataKits.has(key)) { + bidDataKits.delete(key); + } records.delete(key); }, updateReceived(key, sold) { const { records } = this.state; const oldRec = records.get(key); - records.set( - key, - harden({ - ...oldRec, - received: AmountMath.add(oldRec.received, sold), - }), - ); + const newRecord = harden({ + ...oldRec, + received: AmountMath.add(oldRec.received, sold), + remainingWant: AmountMath.subtract(oldRec.remainingWant, sold), + }); + records.set(key, newRecord); + this.self.publishOffer(newRecord); }, exitAllSeats() { const { records } = this.state; for (const [key, { seat }] of records.entries()) { if (!seat.hasExited()) { seat.exit(); - records.delete(key); + this.self.delete(key); } } }, @@ -220,3 +425,4 @@ export const preparePriceBook = baggage => stateShape: PriceBookStateShape, }, ); +}; diff --git a/packages/inter-protocol/src/auction/scheduler.js b/packages/inter-protocol/src/auction/scheduler.js index c1d05b18b5f..f49b74cef42 100644 --- a/packages/inter-protocol/src/auction/scheduler.js +++ b/packages/inter-protocol/src/auction/scheduler.js @@ -1,9 +1,10 @@ import { E } from '@endo/eventual-send'; -import { TimeMath } from '@agoric/time'; +import { TimeMath, TimestampShape } from '@agoric/time'; import { Far } from '@endo/marshal'; import { makeTracer } from '@agoric/internal'; import { observeIteration, subscribeEach } from '@agoric/notifier'; +import { M } from '@agoric/store'; import { AuctionState, makeCancelTokenMaker } from './util.js'; import { computeRoundTiming, @@ -56,6 +57,12 @@ const makeCancelToken = makeCancelTokenMaker('scheduler'); * @property {Timestamp | null} nextDescendingStepTime when the next descending * step will take place */ +export const ScheduleNotificationShape = { + activeStartTime: M.or(null, TimestampShape), + nextStartTime: M.or(null, TimestampShape), + nextDescendingStepTime: M.or(null, TimestampShape), +}; +harden(ScheduleNotificationShape); const safelyComputeRoundTiming = (params, baseTime) => { try { diff --git a/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.md b/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.md index f2aab85afd4..eec92fa14b1 100644 --- a/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.md +++ b/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.md @@ -13,7 +13,7 @@ Generated by [AVA](https://avajs.dev). [ [ - 'published.auction.book0', + 'published.auction.books.book0', { collateralAvailable: { brand: Object @Alleged: Collateral brand {}, @@ -48,6 +48,138 @@ Generated by [AVA](https://avajs.dev). startProceedsGoal: null, }, ], + [ + 'published.auction.books.book0.bids.bid1001', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 134n, + }, + exitAfterBuy: false, + originalWant: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + price: { + denominator: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 250n, + }, + }, + remainingWant: { + brand: Object @Alleged: Collateral brand {}, + value: 100n, + }, + sequence: 1001n, + timestamp: { + absValue: 167n, + timerBrand: Object @Alleged: timerBrand {}, + }, + }, + ], + [ + 'published.auction.books.book0.bids.bid1002', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 200n, + }, + exitAfterBuy: false, + originalWant: { + brand: Object @Alleged: Collateral brand {}, + value: 250n, + }, + price: { + denominator: { + brand: Object @Alleged: Collateral brand {}, + value: 250n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 200n, + }, + }, + remainingWant: { + brand: Object @Alleged: Collateral brand {}, + value: 250n, + }, + sequence: 1002n, + timestamp: { + absValue: 167n, + timerBrand: Object @Alleged: timerBrand {}, + }, + }, + ], + [ + 'published.auction.books.book0.bids.bid1003', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 20n, + }, + bidScaling: { + denominator: { + brand: Object @Alleged: Bid brand {}, + value: 100n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 50n, + }, + }, + exitAfterBuy: false, + originalWant: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + remainingWant: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + sequence: 1003n, + timestamp: { + absValue: 170n, + timerBrand: Object @Alleged: timerBrand {}, + }, + }, + ], + [ + 'published.auction.books.book0.bids.bid1004', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 40n, + }, + bidScaling: { + denominator: { + brand: Object @Alleged: Bid brand {}, + value: 100n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 80n, + }, + }, + exitAfterBuy: false, + originalWant: { + brand: Object @Alleged: Collateral brand {}, + value: 2000n, + }, + remainingWant: { + brand: Object @Alleged: Collateral brand {}, + value: 2000n, + }, + sequence: 1004n, + timestamp: { + absValue: 170n, + timerBrand: Object @Alleged: timerBrand {}, + }, + }, + ], [ 'published.auction.governance', { @@ -127,3 +259,83 @@ Generated by [AVA](https://avajs.dev). }, ], ] + +## multiple bidders at one auction step + +> Under "published", the "auction.books.book0.bids" node is delegated to the auctioneer contract. +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.auction.books.book0.bids.bid1001', + '', + ], + [ + 'published.auction.books.book0.bids.bid1002', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 116n, + }, + exitAfterBuy: false, + originalWant: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + price: { + denominator: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 232n, + }, + }, + remainingWant: { + brand: Object @Alleged: Collateral brand {}, + value: 100n, + }, + sequence: 1002n, + timestamp: { + absValue: 167n, + timerBrand: Object @Alleged: timerBrand {}, + }, + }, + ], + [ + 'published.auction.books.book0.bids.bid1003', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 210n, + }, + exitAfterBuy: false, + originalWant: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + price: { + denominator: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 210n, + }, + }, + remainingWant: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + sequence: 1003n, + timestamp: { + absValue: 167n, + timerBrand: Object @Alleged: timerBrand {}, + }, + }, + ], + ] diff --git a/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.snap b/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.snap index d8afa212683..5bca422de2a 100644 Binary files a/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.snap and b/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.snap differ diff --git a/packages/inter-protocol/test/auction/test-auctionBook.js b/packages/inter-protocol/test/auction/test-auctionBook.js index b288460dc81..afc7d9777fc 100644 --- a/packages/inter-protocol/test/auction/test-auctionBook.js +++ b/packages/inter-protocol/test/auction/test-auctionBook.js @@ -14,8 +14,8 @@ import { makeOffer } from '@agoric/zoe/test/unitTests/makeOffer.js'; import { setup } from '@agoric/zoe/test/unitTests/setupBasicMints.js'; import { setupZCFTest } from '@agoric/zoe/test/unitTests/zcf/setupZcfTest.js'; import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; -import { makeMockChainStorageRoot } from '../supports.js'; +import { makeMockChainStorageRoot } from '../supports.js'; import { prepareAuctionBook } from '../../src/auction/auctionBook.js'; const buildManualPriceAuthority = initialPrice => @@ -69,12 +69,10 @@ const assembleAuctionBook = async basics => { const makeAuctionBook = prepareAuctionBook(baggage, zcf, makeRecorderKit); const mockChainStorage = makeMockChainStorageRoot(); - const book = await makeAuctionBook( - moolaKit.brand, - simoleanKit.brand, - pa, - mockChainStorage.makeChildNode('thisBook'), - ); + const book = await makeAuctionBook(moolaKit.brand, simoleanKit.brand, pa, [ + mockChainStorage.makeChildNode('schedule'), + mockChainStorage.makeChildNode('bids'), + ]); return { pa, book }; }; @@ -132,13 +130,15 @@ test('simple addOffer', async t => { book.captureOraclePriceForRound(); book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + const tenFor100 = makeRatioFromAmounts(moola(10n), simoleans(100n)); book.addOffer( harden({ - offerPrice: makeRatioFromAmounts(moola(10n), simoleans(100n)), + offerPrice: tenFor100, maxBuy: simoleans(50n), }), zcfSeat, true, + 0n, ); t.true(book.hasOrders()); @@ -174,13 +174,15 @@ test('getOffers to a price limit', async t => { book.captureOraclePriceForRound(); book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + const tenPercent = makeRatioFromAmounts(moola(10n), moola(100n)); book.addOffer( harden({ - offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), + offerBidScaling: tenPercent, maxBuy: simoleans(50n), }), zcfSeat, true, + 0n, ); t.true(book.hasOrders()); @@ -226,6 +228,7 @@ test('Bad keyword', async t => { }), zcfSeat, true, + 0n, ), { message: /give must include "Bid".*/ }, ); @@ -259,13 +262,15 @@ test('getOffers w/discount', async t => { moolaKit, ); + const tenPercent = makeRatioFromAmounts(moola(10n), moola(100n)); book.addOffer( harden({ - offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), + offerBidScaling: tenPercent, maxBuy: simoleans(50n), }), zcfSeat, true, + 0n, ); t.true(book.hasOrders()); diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js index 16960075028..3110f116eb2 100644 --- a/packages/inter-protocol/test/auction/test-auctionContract.js +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -325,7 +325,11 @@ const makeAuctionDriver = async (t, params = defaultParams) => { subscriptionTracker(t, subscribeEach(subscription)), ); }, - getBookDataTracker, + getBookDataTracker(brand) { + return E.when(E(publicFacet).getBookDataUpdates(brand), subscription => + subscriptionTracker(t, subscribeEach(subscription)), + ); + }, getReserveBalance(keyword) { const reserveCF = E.get(reserveKit).creatorFacet; return E.get(E(reserveCF).getAllocations())[keyword]; @@ -929,10 +933,14 @@ test.serial('onDeadline exit, with chainStorage RPC snapshot', async t => { /** @type {BookDataTracker} */ const bookTracker = await driver.getBookDataTracker(collateral.brand); - - await bookTracker.assertChange({ - collateralAvailable: { value: 100n }, - startCollateral: { value: 100n }, + await bookTracker.assertInitial({ + collateralAvailable: collateral.make(100n), + currentPriceLevel: null, + proceedsRaised: undefined, + remainingProceedsGoal: null, + startCollateral: collateral.make(100n), + startPrice: null, + startProceedsGoal: null, }); await driver.updatePriceAuthority( @@ -965,6 +973,22 @@ test.serial('onDeadline exit, with chainStorage RPC snapshot', async t => { t.is(await E(exitingSeat).getOfferResult(), 'Your bid has been accepted'); t.false(await E(exitingSeat).hasExited()); + await driver.bidForCollateralSeat( + bid.make(200n), + collateral.make(250n), + undefined, + ); + driver.bidForCollateralSeat( + bid.make(20n), + collateral.make(200n), + makeRatioFromAmounts(bid.make(50n), bid.make(100n)), + ); + driver.bidForCollateralSeat( + bid.make(40n), + collateral.make(2000n), + makeRatioFromAmounts(bid.make(80n), bid.make(100n)), + ); + await bookTracker.assertChange({ startPrice: makeRatioFromAmounts( bid.make(1_100_000_000n), @@ -974,6 +998,7 @@ test.serial('onDeadline exit, with chainStorage RPC snapshot', async t => { await driver.advanceTo(170n, 'wait'); await bookTracker.assertChange({}); + await bookTracker.assertChange({}); await bookTracker.assertChange({ collateralAvailable: { value: 0n }, @@ -990,6 +1015,8 @@ test.serial('onDeadline exit, with chainStorage RPC snapshot', async t => { await scheduleTracker.assertChange({ nextDescendingStepTime: { absValue: 180n }, }); + await bookTracker.assertChange({}); + await bookTracker.assertChange({}); await bookTracker.assertChange({ currentPriceLevel: { numerator: { value: 9_350_000_000_000n } }, }); @@ -1068,9 +1095,14 @@ test.serial('add assets to open auction', async t => { ); const bookTracker = await driver.getBookDataTracker(collateral.brand); - await bookTracker.assertChange({ - collateralAvailable: { value: 1000n }, - startCollateral: { value: 1000n }, + await bookTracker.assertInitial({ + collateralAvailable: collateral.make(1000n), + currentPriceLevel: null, + proceedsRaised: undefined, + remainingProceedsGoal: null, + startCollateral: collateral.make(1000n), + startPrice: null, + startProceedsGoal: null, }); const scheduleTracker = await driver.getScheduleTracker(); await scheduleTracker.assertInitial({ @@ -1199,34 +1231,39 @@ test.serial('multiple collaterals', async t => { ); // offers 290 for up to 300 at 1.1 * .875, so will trigger at the first discount + const price = makeRatioFromAmounts(bid.make(950n), collateral.make(1000n)); const bidderSeat1C = await driver.bidForCollateralSeat( bid.make(265n), collateral.make(300n), - makeRatioFromAmounts(bid.make(950n), collateral.make(1000n)), + price, ); t.is(await E(bidderSeat1C).getOfferResult(), 'Your bid has been accepted'); + driver.getTimerService().getCurrentTimestamp(); // offers up to 500 for 2000 at 1.1 * 75%, so will trigger at second discount step + const scale2C = makeRatioFromAmounts(bid.make(75n), bid.make(100n)); const bidderSeat2C = await driver.bidForCollateralSeat( bid.make(500n), collateral.make(2000n), - makeRatioFromAmounts(bid.make(75n), bid.make(100n)), + scale2C, ); t.is(await E(bidderSeat2C).getOfferResult(), 'Your bid has been accepted'); // offers 50 for 200 at .25 * 50% discount, so triggered at third step + const scale1A = makeRatioFromAmounts(bid.make(50n), bid.make(100n)); const bidderSeat1A = await driver.bidForCollateralSeat( bid.make(23n), asset.make(200n), - makeRatioFromAmounts(bid.make(50n), bid.make(100n)), + scale1A, ); t.is(await E(bidderSeat1A).getOfferResult(), 'Your bid has been accepted'); // offers 100 for 300 at .25 * 33%, so triggered at fourth step + const price2A = makeRatioFromAmounts(bid.make(100n), asset.make(1000n)); const bidderSeat2A = await driver.bidForCollateralSeat( bid.make(19n), asset.make(300n), - makeRatioFromAmounts(bid.make(100n), asset.make(1000n)), + price2A, ); t.is(await E(bidderSeat2A).getOfferResult(), 'Your bid has been accepted'); @@ -1313,6 +1350,15 @@ test.serial('multiple bidders at one auction step', async t => { await driver.advanceTo(now, 'wait'); t.true(await E(seat1).hasExited()); + + const doc = { + node: 'auction.books.book0.bids', + owner: 'the auctioneer contract', + pattern: 'mockChainStorageRoot.auction', + replacement: 'published.auction', + }; + await documentStorageSchema(t, driver.mockChainStorage, doc); + t.false(await E(seat2).hasExited()); await E(seat2).tryExit(); diff --git a/packages/vats/test/bootstrapTests/test-liquidation-1.js b/packages/vats/test/bootstrapTests/test-liquidation-1.js index 23df47aaeed..4b3c562b428 100644 --- a/packages/vats/test/bootstrapTests/test-liquidation-1.js +++ b/packages/vats/test/bootstrapTests/test-liquidation-1.js @@ -276,7 +276,7 @@ const checkFlow1 = async ( console.log(collateralBrandKey, 'step 2 of 10'); await advanceTimeBy(3, 'minutes'); - t.like(readLatest(`published.auction.book${managerIndex}`), { + t.like(readLatest(`published.auction.books.book${managerIndex}`), { collateralAvailable: { value: scale6(setup.auction.start.collateral) }, startCollateral: { value: scale6(setup.auction.start.collateral) }, startProceedsGoal: { value: scale6(setup.auction.start.debt) }, @@ -311,7 +311,7 @@ const checkFlow1 = async ( console.log(collateralBrandKey, 'step 6 of 10'); await advanceTimeBy(3, 'minutes'); - t.like(readLatest(`published.auction.book${managerIndex}`), { + t.like(readLatest(`published.auction.books.book${managerIndex}`), { collateralAvailable: { value: 9659301n }, }); diff --git a/packages/vats/test/bootstrapTests/test-liquidation-2b.js b/packages/vats/test/bootstrapTests/test-liquidation-2b.js index 84452a123fb..f4be74198d1 100644 --- a/packages/vats/test/bootstrapTests/test-liquidation-2b.js +++ b/packages/vats/test/bootstrapTests/test-liquidation-2b.js @@ -227,7 +227,7 @@ test.serial('scenario: Flow 2b', async t => { console.log('step 1 of 10'); await advanceTimeBy(3, 'minutes'); - t.like(readLatest('published.auction.book0'), { + t.like(readLatest('published.auction.books.book0'), { collateralAvailable: { value: scale6(setup.auction.start.collateral) }, startCollateral: { value: scale6(setup.auction.start.collateral) }, startProceedsGoal: { value: scale6(setup.auction.start.debt) }, @@ -244,7 +244,7 @@ test.serial('scenario: Flow 2b', async t => { console.log('step 5 of 10'); await advanceTimeBy(3, 'minutes'); - t.like(readLatest('published.auction.book0'), { + t.like(readLatest('published.auction.books.book0'), { collateralAvailable: { value: scale6(45) }, });