From 58d36fb9dfaaad0c0a962ba2940b6fcba2ea8b25 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Thu, 30 Mar 2023 10:44:22 -0700 Subject: [PATCH] feat(auction): allow orders to automatically exit after partial fill (#7269) * feat(auction): allow orders to automatically exit after partial fill * chore: better arg structure, better types --- .../inter-protocol/src/auction/auctionBook.js | 71 +++++++++------ .../inter-protocol/src/auction/auctioneer.js | 4 +- .../inter-protocol/src/auction/offerBook.js | 15 +++- .../test/auction/test-auctionContract.js | 90 ++++++++++++++++++- 4 files changed, 144 insertions(+), 36 deletions(-) diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 722e6ed5be0..b2430bbf81f 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -54,10 +54,12 @@ const trace = makeTracer('AucBook', false); /** * @typedef {{ * want: Amount<'nat'> + * } & { + * exitAfterBuy?: boolean, * } & ({ - * offerPrice: Ratio, + * offerPrice: Ratio, * } | { - * offerBidScaling: Ratio, + * offerBidScaling: Ratio, * })} BidSpec */ /** @@ -71,6 +73,7 @@ export const makeBidSpecShape = (currencyBrand, collateralBrand) => { return M.splitRecord( { want: collateralAmountShape }, { + exitAfterBuy: M.boolean(), // xxx should have exactly one of these properties offerPrice: makeBrandedRatioPattern( currencyAmountShape, @@ -266,9 +269,16 @@ export const prepareAuctionBook = (baggage, zcf) => { * @param {ZCFSeat} seat * @param {Ratio} price * @param {Amount<'nat'>} want - * @param {boolean} trySettle + * @param {object} opts + * @param {boolean} opts.trySettle + * @param {boolean} [opts.exitAfterBuy] */ - acceptPriceOffer(seat, price, want, trySettle) { + acceptPriceOffer( + seat, + price, + want, + { trySettle, exitAfterBuy = false }, + ) { const { priceBook, curAuctionPrice } = this.state; const { helper } = this.facets; trace('acceptPrice'); @@ -281,13 +291,14 @@ export const prepareAuctionBook = (baggage, zcf) => { const stillWant = AmountMath.subtract(want, collateralSold); if ( + (exitAfterBuy && !AmountMath.isEmpty(collateralSold)) || AmountMath.isEmpty(stillWant) || AmountMath.isEmpty(seat.getCurrentAllocation().Currency) ) { seat.exit(); } else { trace('added Offer ', price, stillWant.value); - priceBook.add(seat, price, stillWant); + priceBook.add(seat, price, stillWant, exitAfterBuy); } }, @@ -299,9 +310,16 @@ export const prepareAuctionBook = (baggage, zcf) => { * @param {ZCFSeat} seat * @param {Ratio} bidScaling * @param {Amount<'nat'>} want - * @param {boolean} trySettle + * @param {object} opts + * @param {boolean} opts.trySettle + * @param {boolean} [opts.exitAfterBuy] */ - acceptScaledBidOffer(seat, bidScaling, want, trySettle) { + acceptScaledBidOffer( + seat, + bidScaling, + want, + { trySettle, exitAfterBuy = false }, + ) { trace('accept scaled bid offer'); const { curAuctionPrice, lockedPriceForRound, scaledBidBook } = this.state; @@ -318,12 +336,13 @@ export const prepareAuctionBook = (baggage, zcf) => { const stillWant = AmountMath.subtract(want, collateralSold); if ( + (exitAfterBuy && !AmountMath.isEmpty(collateralSold)) || AmountMath.isEmpty(stillWant) || AmountMath.isEmpty(seat.getCurrentAllocation().Currency) ) { seat.exit(); } else { - scaledBidBook.add(seat, bidScaling, stillWant); + scaledBidBook.add(seat, bidScaling, stillWant, exitAfterBuy); } }, }, @@ -418,7 +437,8 @@ export const prepareAuctionBook = (baggage, zcf) => { const { totalProceedsGoal } = this.state; const { helper } = this.facets; - for (const [key, { seat, price: p, wanted }] of prioritizedOffers) { + for (const [key, seatRecord] of prioritizedOffers) { + const { seat, price: p, wanted, exitAfterBuy } = seatRecord; if (totalProceedsGoal && AmountMath.isEmpty(totalProceedsGoal)) { break; } else if (seat.hasExited()) { @@ -428,6 +448,7 @@ export const prepareAuctionBook = (baggage, zcf) => { const alloc = seat.getCurrentAllocation(); if ( + (exitAfterBuy && !AmountMath.isEmpty(collateralSold)) || AmountMath.isEmpty(alloc.Currency) || ('Collateral' in alloc && AmountMath.isGTE(alloc.Collateral, wanted)) @@ -462,32 +483,17 @@ export const prepareAuctionBook = (baggage, zcf) => { ); }, /** - * * @param {BidSpec} bidSpec * @param {ZCFSeat} seat * @param {boolean} trySettle */ addOffer(bidSpec, seat, trySettle) { - const { collateralAmountShape, currencyAmountShape } = this.state; - const BidSpecShape = M.or( - { - want: collateralAmountShape, - offerPrice: makeBrandedRatioPattern( - currencyAmountShape, - collateralAmountShape, - ), - }, - { - want: collateralAmountShape, - offerBidScaling: makeBrandedRatioPattern( - currencyAmountShape, - currencyAmountShape, - ), - }, - ); + const { currencyBrand, collateralBrand } = this.state; + const BidSpecShape = makeBidSpecShape(currencyBrand, collateralBrand); mustMatch(bidSpec, BidSpecShape); const { give } = seat.getProposal(); + const { currencyAmountShape } = this.state; mustMatch( give.Currency, currencyAmountShape, @@ -495,19 +501,26 @@ export const prepareAuctionBook = (baggage, zcf) => { ); const { helper } = this.facets; + const { exitAfterBuy } = bidSpec; if ('offerPrice' in bidSpec) { return helper.acceptPriceOffer( seat, bidSpec.offerPrice, bidSpec.want, - trySettle, + { + trySettle, + exitAfterBuy, + }, ); } else if ('offerBidScaling' in bidSpec) { return helper.acceptScaledBidOffer( seat, bidSpec.offerBidScaling, bidSpec.want, - trySettle, + { + trySettle, + exitAfterBuy, + }, ); } else { throw Fail`Offer was neither a price nor a scaled bid`; diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index 9de66a097a9..054c211c060 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -420,7 +420,9 @@ export const start = async (zcf, privateArgs, baggage) => { deposits.set(brand, []); } else if (depositsForBrand.length > 1) { const collProceeds = collateralSeat.getCurrentAllocation().Collateral; - const currProceeds = currencySeat.getCurrentAllocation().Currency; + const currProceeds = + currencySeat.getCurrentAllocation().Currency || + AmountMath.makeEmpty(brands.Currency); const transfers = distributeProportionalSharesWithLimits( collProceeds, currProceeds, diff --git a/packages/inter-protocol/src/auction/offerBook.js b/packages/inter-protocol/src/auction/offerBook.js index 1fed8d5a828..efec916c9fb 100644 --- a/packages/inter-protocol/src/auction/offerBook.js +++ b/packages/inter-protocol/src/auction/offerBook.js @@ -29,7 +29,7 @@ const nextSequenceNumber = () => { * wanted: Amount<'nat'>, * seqNum: NatValue, * received: Amount<'nat'>, - * } & ({ bidScaling: Pattern, price: undefined } | { bidScaling: undefined, price: Ratio}) + * } & {exitAfterBuy: boolean} & ({ bidScaling: Pattern, price: undefined } | { bidScaling: undefined, price: Ratio}) * } BidderRecord */ @@ -60,8 +60,9 @@ export const prepareScaledBidBook = baggage => * @param {ZCFSeat} seat * @param {Ratio} bidScaling * @param {Amount<'nat'>} wanted + * @param {boolean} exitAfterBuy */ - add(seat, bidScaling, wanted) { + add(seat, bidScaling, wanted, exitAfterBuy) { const { bidScalingPattern, collateralBrand, records } = this.state; mustMatch(bidScaling, bidScalingPattern); @@ -76,6 +77,7 @@ export const prepareScaledBidBook = baggage => seat, seqNum, wanted, + exitAfterBuy, }; records.init(key, harden(bidderRecord)); return key; @@ -139,7 +141,13 @@ export const preparePriceBook = baggage => records: makeScalarBigMapStore('scaledBidRecords', { durable: true }), }), { - add(seat, price, wanted) { + /** + * @param {ZCFSeat} seat + * @param {Ratio} price + * @param {Amount<'nat'>} wanted + * @param {boolean} exitAfterBuy + */ + add(seat, price, wanted, exitAfterBuy) { const { priceRatioPattern, collateralBrand, records } = this.state; mustMatch(price, priceRatioPattern); @@ -154,6 +162,7 @@ export const preparePriceBook = baggage => seat, seqNum, wanted, + exitAfterBuy, }; records.init(key, harden(bidderRecord)); return key; diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js index a0f101cdb07..d34bae9edf1 100644 --- a/packages/inter-protocol/test/auction/test-auctionContract.js +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -138,7 +138,7 @@ const makeAuctionDriver = async (t, customTerms, params = defaultParams) => { * @param {Amount<'nat'>} giveCurrency * @param {Amount<'nat'>} wantCollateral * @param {Ratio} [discount] - * @param {ExitRule} [exitRule] + * @param {ExitRule | { onBuy: true }} [exitRule] */ const bidForCollateralSeat = async ( giveCurrency, @@ -155,7 +155,8 @@ const makeAuctionDriver = async (t, customTerms, params = defaultParams) => { // IF we had multiples, the buyer could express an offer-safe want. // want: { Collateral: wantCollateral }, }; - if (exitRule) { + + if (exitRule && !('onBuy' in exitRule)) { rawProposal.exit = exitRule; } const proposal = harden(rawProposal); @@ -172,6 +173,10 @@ const makeAuctionDriver = async (t, customTerms, params = defaultParams) => { discount || harden(makeRatioFromAmounts(giveCurrency, wantCollateral)), }; + if (exitRule && 'onBuy' in exitRule) { + offerArgs.exitAfterBuy = true; + } + return E(zoe).offer(bidInvitation, proposal, payment, harden(offerArgs)); }; @@ -376,6 +381,32 @@ test.serial('discount bid settled', async t => { await assertPayouts(t, seat, currency, collateral, 250n - 231n, 200n); }); +test.serial('discount bid exit onBuy', 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(2000n), + collateral.make(200n), + makeRatioFromAmounts(currency.make(120n), currency.make(100n)), + { onBuy: true }, + ); + t.is(await E(seat).getOfferResult(), 'Your bid has been accepted'); + await driver.advanceTo(180n); + + // 250 - 200 * (1.1 * 1.05) + await assertPayouts(t, seat, currency, collateral, 2000n - 231n, 200n); +}); + // serial because dynamicConfig is shared across tests test.serial('priced bid insufficient collateral added', async t => { const { collateral, currency } = t.context; @@ -627,7 +658,60 @@ test.serial('multiple Depositors, with goal', async t => { await E(seat).tryExit(); t.true(await E(seat).hasExited()); - // 1500 Collateral was put up for auction by two bidders (1000 and 500), so + // 1500 Collateral was put up for auction by two depositors (1000 and 500), so + // one seller gets 66% of the proceeds, and the other 33%. The price authority + // quote was 1.1, and the goods were sold in the first auction round at 105%. + // At those rates, 900 pays for 799 collateral. The sellers pro-rate 900 and + // the returned collateral. The auctioneer sets the remainder aside. + await assertPayouts(t, seat, currency, collateral, 600n, 779n); + await assertPayouts(t, liqSeatA, currency, collateral, 600n, 480n); + await assertPayouts(t, liqSeatB, currency, collateral, 300n, 239n); +}); + +// serial because dynamicConfig is shared across tests +test.serial('multiple Depositors, exit onBuy', 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, + { goal: currency.make(300n) }, + ); + 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(1500n), + collateral.make(1000n), + undefined, + { onBuy: true }, + ); + + t.is(await E(seat).getOfferResult(), 'Your bid has been accepted'); + 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 depositors (1000 and 500), so // one seller gets 66% of the proceeds, and the other 33%. The price authority // quote was 1.1, and the goods were sold in the first auction round at 105%. // At those rates, 900 pays for 799 collateral. The sellers pro-rate 900 and