diff --git a/contracts/AnchoredDutchAuction.sol b/contracts/AnchoredDutchAuction.sol index ba5ba8d..0ebabf5 100644 --- a/contracts/AnchoredDutchAuction.sol +++ b/contracts/AnchoredDutchAuction.sol @@ -7,14 +7,11 @@ import { IOrderRegistrator } from "./interfaces/IOrderRegistrator.sol"; /** * @title Announcement-anchored Dutch auction - * @notice Dutch auction whose curve and resolver exclusivity may be measured from an order's - * on-chain announcement, for orders whose submission time is unknown at build time. + * @notice Dutch auction whose curve and resolver exclusivity may run from the order's on-chain + * announcement instead of build-time timestamps. * @dev Opt-in per order via the top bit of the uint32 timestamps the legacy encodings already - * carry, so a legacy blob parses unchanged: the top bit of `auctionStartTime` and of the whitelist - * `allowedTime` anchor the start and the exclusivity to the announcement (each taking the later of - * announcement and built time), and the top bit of the anchored delay adds a fill deadline. Free - * until 19 Jan 2038 (2^31 s) — replace before then, when real timestamps set it and anchored reads - * revert. + * carry (`auctionStartTime`, whitelist `allowedTime`); anchoring takes the later of announcement + * and built time. The bit is free until 19 Jan 2038. */ abstract contract AnchoredDutchAuction is DutchAuctionBase { uint256 private constant _ANCHORED_FLAG = 1 << 31; // top bit of a uint32 timestamp @@ -33,17 +30,14 @@ abstract contract AnchoredDutchAuction is DutchAuctionBase { _ORDER_REGISTRATOR = orderRegistrator; } - /// @dev Rate bump for the AuctionDetails of {DutchAuctionBase-_getRateBump}; when the top bit of - /// `auctionStartTime` is set, the start becomes max(announcement, built start). - function _auctionRateBump(bytes32 orderHash, bytes calldata auctionDetails) internal view returns (uint256, bytes calldata) { + /// @dev {DutchAuctionBase-_getNetBump} with an anchored start raised to the announcement time. + function _auctionNetBump(bytes32 orderHash, bytes calldata auctionDetails) internal view returns (int256 netBump, bytes calldata tail) { unchecked { uint256 auctionStartTime = uint32(bytes4(auctionDetails[7:11])); if (auctionStartTime & _ANCHORED_FLAG == 0) { - return _getRateBump(auctionDetails); + return _getNetBump(auctionDetails); } - uint256 gasBumpEstimate = uint24(bytes3(auctionDetails[0:3])); - uint256 gasPriceEstimate = uint32(bytes4(auctionDetails[3:7])); uint256 auctionDuration = uint24(bytes3(auctionDetails[11:14])); uint256 initialRateBump = uint24(bytes3(auctionDetails[14:17])); @@ -51,19 +45,18 @@ abstract contract AnchoredDutchAuction is DutchAuctionBase { uint256 anchoredStartTime = _announcedAt(orderHash); if (anchoredStartTime > auctionStartTime) auctionStartTime = anchoredStartTime; - uint256 gasBump = gasBumpEstimate == 0 || gasPriceEstimate == 0 ? 0 : gasBumpEstimate * block.basefee / gasPriceEstimate / _GAS_PRICE_BASE; - (uint256 auctionBump, bytes calldata tail) = _getAuctionBump(auctionStartTime, auctionStartTime + auctionDuration, initialRateBump, auctionDetails[17:]); - return (auctionBump > gasBump ? auctionBump - gasBump : 0, tail); + uint256 auctionBump; + (auctionBump, tail) = _getAuctionBump(auctionStartTime, auctionStartTime + auctionDuration, initialRateBump, auctionDetails[_POINTS_COUNT_OFFSET:]); + netBump = int256(auctionBump) - int256(_getGasBump(auctionDetails)); } } /** - * @dev Anchored pre-checks for a fill; a no-op for a legacy blob. Walks the exclusivity ladder - * from the announcement and enforces the optional deadline; the caller walks the same ladder - * from the absolute allowed time, so a taker must clear both. `whitelistData` layout: + * @dev Announcement-based deadline and exclusivity checks; a no-op for a legacy blob. + * `whitelistData` layout: * ``` - * 4 bytes - allowed time; top bit: measure exclusivity from the announcement too - * 3 bytes - anchored allowed-time delay (present when set); its top bit adds the deadline + * 4 bytes - allowed time; top bit: anchored + * 3 bytes - anchored allowed-time delay (present when anchored); its top bit adds the deadline * 3 bytes - announcement deadline delay (present when the deadline bit is set) * 1 byte - size of the whitelist * (bytes12)[N] — taker whitelist @@ -85,8 +78,7 @@ abstract contract AnchoredDutchAuction is DutchAuctionBase { } } - /// @dev Absolute allowed time and the offset of the whitelist size byte, skipping the anchored - /// fields (enforced by `_validateAnchoredFill`) so a whitelist walk works anchored or not. + /// @dev Absolute allowed time and the offset of the whitelist size byte, anchored fields skipped. function _skipAnchoredFields(bytes calldata whitelistData) internal pure returns (uint256 allowedTime, uint256 offset) { unchecked { allowedTime = uint32(bytes4(whitelistData)); @@ -98,8 +90,7 @@ abstract contract AnchoredDutchAuction is DutchAuctionBase { } } - /// @dev Walks the resolver ladder from the anchored allowed time, mirroring the settlement's - /// walk from the absolute one. + /// @dev The settlement's ladder walk, starting from the anchored allowed time. function _checkAnchoredExclusivity(bytes calldata whitelistData, address taker, uint256 allowedTime) private view { unchecked { uint80 maskedTakerAddress = uint80(uint160(taker)); diff --git a/contracts/DutchAuctionBase.sol b/contracts/DutchAuctionBase.sol index 550cc8c..0502c85 100644 --- a/contracts/DutchAuctionBase.sol +++ b/contracts/DutchAuctionBase.sol @@ -10,6 +10,14 @@ abstract contract DutchAuctionBase { uint256 internal constant _BASE_POINTS = 10_000_000; // 100% uint256 internal constant _GAS_PRICE_BASE = 1_000_000; // 1000 means 1 Gwei + /// @dev Offset of the auction points count byte, i.e. the fixed AuctionDetails header: + /// 3-byte gas bump estimate, 4-byte gas price estimate, 4-byte start time, 3-byte duration + /// and 3-byte initial rate bump. + uint256 internal constant _POINTS_COUNT_OFFSET = 17; + + /// @dev The top bit of the points count is reserved for extensions to flag their own fields. + uint256 private constant _POINTS_COUNT_MASK = 0x7f; + /** * @dev Parses auction rate bump data from the `auctionDetails` field. * `gasBumpEstimate` and `gasPriceEstimate` are used to estimate the transaction costs @@ -29,18 +37,36 @@ abstract contract DutchAuctionBase { * @return Remaining calldata after parsing auction data. */ function _getRateBump(bytes calldata auctionDetails) internal view virtual returns (uint256, bytes calldata) { + (int256 netBump, bytes calldata tail) = _getNetBump(auctionDetails); + return (_clampBump(netBump), tail); + } + + /// @dev Auction bump net of the gas bump; negative when the gas bump exceeds it. + function _getNetBump(bytes calldata auctionDetails) internal view virtual returns (int256 netBump, bytes calldata tail) { unchecked { - uint256 gasBumpEstimate = uint24(bytes3(auctionDetails[0:3])); - uint256 gasPriceEstimate = uint32(bytes4(auctionDetails[3:7])); - uint256 gasBump = gasBumpEstimate == 0 || gasPriceEstimate == 0 ? 0 : gasBumpEstimate * block.basefee / gasPriceEstimate / _GAS_PRICE_BASE; uint256 auctionStartTime = uint32(bytes4(auctionDetails[7:11])); uint256 auctionFinishTime = auctionStartTime + uint24(bytes3(auctionDetails[11:14])); uint256 initialRateBump = uint24(bytes3(auctionDetails[14:17])); - (uint256 auctionBump, bytes calldata tail) = _getAuctionBump(auctionStartTime, auctionFinishTime, initialRateBump, auctionDetails[17:]); - return (auctionBump > gasBump ? auctionBump - gasBump : 0, tail); + uint256 auctionBump; + (auctionBump, tail) = _getAuctionBump(auctionStartTime, auctionFinishTime, initialRateBump, auctionDetails[_POINTS_COUNT_OFFSET:]); + netBump = int256(auctionBump) - int256(_getGasBump(auctionDetails)); + } + } + + /// @dev The rate bump estimating the taker's transaction costs at the current base fee. + function _getGasBump(bytes calldata auctionDetails) internal view returns (uint256) { + unchecked { + uint256 gasBumpEstimate = uint24(bytes3(auctionDetails[0:3])); + uint256 gasPriceEstimate = uint32(bytes4(auctionDetails[3:7])); + return gasBumpEstimate == 0 || gasPriceEstimate == 0 ? 0 : gasBumpEstimate * block.basefee / gasPriceEstimate / _GAS_PRICE_BASE; } } + /// @dev Clamps a net bump to a non-negative rate bump. + function _clampBump(int256 netBump) internal pure returns (uint256) { + return netBump > 0 ? uint256(netBump) : 0; + } + /** * @dev Calculates auction price bump. Auction is represented as a piecewise linear function with `N` points. * Each point is represented as a pair of `(rateBump, timeDelta)`, where `rateBump` is the @@ -60,7 +86,7 @@ abstract contract DutchAuctionBase { unchecked { uint256 currentPointTime = auctionStartTime; uint256 currentRateBump = initialRateBump; - uint256 pointsCount = uint8(pointsAndTimeDeltas[0]); + uint256 pointsCount = uint8(pointsAndTimeDeltas[0]) & _POINTS_COUNT_MASK; pointsAndTimeDeltas = pointsAndTimeDeltas[1:]; bytes calldata tail = pointsAndTimeDeltas[5 * pointsCount:]; diff --git a/contracts/PartialFillPremiumAuction.sol b/contracts/PartialFillPremiumAuction.sol new file mode 100644 index 0000000..fa25067 --- /dev/null +++ b/contracts/PartialFillPremiumAuction.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.23; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { AnchoredDutchAuction } from "./AnchoredDutchAuction.sol"; +import { IOrderRegistrator } from "./interfaces/IOrderRegistrator.sol"; + +/** + * @title Partial-fill premium auction + * @notice Dutch auction where a fill that leaves part of the order behind pays a premium, so + * sweeping the remainder is the cheapest way to fill. + * @dev Opt-in per order via the top bit of the auction points count, which a legacy blob never + * sets. The curve follows the time points, read over the fill's share of the remainder in `_SHARE_BASE`: + * ``` + * 3 bytes - initial premium, paid by a vanishing fill + * 1 byte - points count + * (bytes3,bytes2)[M] — premiums and share deltas + * ``` + */ +abstract contract PartialFillPremiumAuction is AnchoredDutchAuction { + uint256 private constant _FILL_CURVE_FLAG = 0x80; // top bit of the uint8 auction points count + uint256 private constant _SHARE_BASE = 10_000; + + /// @dev The fill curve header is a 3-byte initial premium followed by the 1-byte points count. + uint256 private constant _CURVE_POINTS_COUNT_OFFSET = 3; + uint256 private constant _CURVE_HEADER_SIZE = 4; + + /// @dev Each fill curve point is a 3-byte premium followed by a 2-byte share delta. + uint256 private constant _CURVE_POINT_SIZE = 5; + + error NonMonotonicFillCurve(); + + constructor(IOrderRegistrator orderRegistrator) AnchoredDutchAuction(orderRegistrator) {} + + /// @dev The auction's net bump and its fill curve, which is empty for an order without one. + function _parseAuctionDetails(bytes32 orderHash, bytes calldata auctionDetails) + internal view returns (int256 netBump, bytes calldata fillCurve, bytes calldata tail) + { + unchecked { + (netBump, tail) = _auctionNetBump(orderHash, auctionDetails); + if (uint8(auctionDetails[_POINTS_COUNT_OFFSET]) & _FILL_CURVE_FLAG == 0) { + fillCurve = tail[:0]; + } else { + uint256 length = _CURVE_HEADER_SIZE + _CURVE_POINT_SIZE * uint256(uint8(tail[_CURVE_POINTS_COUNT_OFFSET])); + fillCurve = tail[:length]; + tail = tail[length:]; + } + } + } + + /// @dev Rate bump for a fill of known size; a completing fill never reads the curve. + function _fillRateBump(int256 netBump, bytes calldata fillCurve, uint256 makingAmount, uint256 remainingMakingAmount) + internal pure returns (uint256) + { + unchecked { + if (fillCurve.length != 0 && makingAmount < remainingMakingAmount) { + netBump += int256(_fillPremium(makingAmount, remainingMakingAmount, fillCurve)); + } + return _clampBump(netBump); + } + } + + /// @dev Making-amount path: the fill size is estimated at the worst (initial) premium and + /// repriced, which can only overstate the bump. + function _estimatedFillRateBump(int256 netBump, bytes calldata fillCurve, uint256 unbumpedMakingAmount, uint256 remainingMakingAmount) + internal pure returns (uint256) + { + unchecked { + if (fillCurve.length == 0) return _clampBump(netBump); + uint256 worstRateBump = _clampBump(netBump + int256(uint256(uint24(bytes3(fillCurve[0:3]))))); + uint256 estimatedMakingAmount = Math.mulDiv(unbumpedMakingAmount, _BASE_POINTS, _BASE_POINTS + worstRateBump); + return _fillRateBump(netBump, fillCurve, estimatedMakingAmount, remainingMakingAmount); + } + } + + /// @dev Premium interpolated over the fill's share of the remainder, ending at zero for a full + /// sweep. A rising curve would reward splitting a fill, so the walk rejects it lazily. + function _fillPremium(uint256 makingAmount, uint256 remainingMakingAmount, bytes calldata fillCurve) private pure returns (uint256) { + unchecked { + uint256 currentPremium = uint24(bytes3(fillCurve[0:3])); + uint256 share = Math.mulDiv(makingAmount, _SHARE_BASE, remainingMakingAmount); + if (share == 0) return currentPremium; + + uint256 currentShare = 0; + uint256 pointsCount = uint8(fillCurve[_CURVE_POINTS_COUNT_OFFSET]); + bytes calldata points = fillCurve[_CURVE_HEADER_SIZE:]; + for (uint256 i = 0; i < pointsCount; i++) { + uint256 nextPremium = uint24(bytes3(points[:3])); + if (nextPremium > currentPremium) revert NonMonotonicFillCurve(); + uint256 nextShare = currentShare + uint16(bytes2(points[3:5])); + if (share <= nextShare) { + return ((share - currentShare) * nextPremium + (nextShare - share) * currentPremium) / (nextShare - currentShare); + } + currentPremium = nextPremium; + currentShare = nextShare; + points = points[_CURVE_POINT_SIZE:]; + } + return (_SHARE_BASE - share) * currentPremium / (_SHARE_BASE - currentShare); + } + } +} diff --git a/contracts/SimpleSettlement.sol b/contracts/SimpleSettlement.sol index 6a03c25..1e96674 100644 --- a/contracts/SimpleSettlement.sol +++ b/contracts/SimpleSettlement.sol @@ -7,16 +7,16 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { IOrderMixin } from "@1inch/limit-order-protocol-contract/contracts/interfaces/IOrderMixin.sol"; import { FeeTaker } from "@1inch/limit-order-protocol-contract/contracts/extensions/FeeTaker.sol"; -import { AnchoredDutchAuction } from "./AnchoredDutchAuction.sol"; +import { PartialFillPremiumAuction } from "./PartialFillPremiumAuction.sol"; import { IOrderRegistrator } from "./interfaces/IOrderRegistrator.sol"; /** * @title Simple Settlement contract * @notice Contract to execute limit orders settlement, created by Fusion mode. - * @dev The Dutch auction and the resolver exclusivity may be anchored to the moment an order was - * announced on-chain; see {AnchoredDutchAuction} for the opt-in encoding and its 2038 horizon. + * @dev The auction and exclusivity may be anchored to the order's on-chain announcement + * ({AnchoredDutchAuction}) and a partial fill may pay a premium ({PartialFillPremiumAuction}). */ -contract SimpleSettlement is FeeTaker, AnchoredDutchAuction { +contract SimpleSettlement is FeeTaker, PartialFillPremiumAuction { using Math for uint256; /// @dev FeeTaker's custom-receiver bit in the first byte of its post-interaction data. @@ -31,12 +31,11 @@ contract SimpleSettlement is FeeTaker, AnchoredDutchAuction { * @param accessToken Contract address whose tokens allow filling limit orders with a fee for resolvers that are outside the whitelist. * @param weth The WETH address. * @param owner The owner of the contract. - * @param orderRegistrator The registrator whose announcements anchored orders are measured from, - * or the zero address when announcements are unavailable on this chain. + * @param orderRegistrator Announcement registry for anchored orders, or zero when unavailable. */ constructor(address limitOrderProtocol, IERC20 accessToken, address weth, address owner, IOrderRegistrator orderRegistrator) FeeTaker(limitOrderProtocol, accessToken, weth, owner) - AnchoredDutchAuction(orderRegistrator) + PartialFillPremiumAuction(orderRegistrator) {} /** @@ -78,18 +77,7 @@ contract SimpleSettlement is FeeTaker, AnchoredDutchAuction { /** * @notice See {FeeTaker-_postInteraction}. - * @dev Runs the announcement-anchored checks, which need the order hash, before handing the fill - * to the fee logic. The whitelist blob is read in place inside FeeTaker's `extraData`, whose - * layout pins the offsets used below: - * ``` - * 1 byte - FeeTaker flags (0x01 signals a custom receiver) - * 20 bytes — integrator fee recipient - * 20 bytes - protocol fee recipient - * 20 bytes — receiver of taking tokens (present when the custom-receiver flag is set) - * 5 bytes - integrator fee, integrator rev share and resolver fee - * 1 byte - whitelist discount numerator - * bytes - whitelist blob determined by `_isWhitelistedPostInteractionImpl` - * ``` + * @dev Runs the anchored checks, which need the order hash, before FeeTaker's fee logic. */ function _postInteraction( IOrderMixin.Order calldata order, @@ -102,7 +90,7 @@ contract SimpleSettlement is FeeTaker, AnchoredDutchAuction { bytes calldata extraData ) internal virtual override { unchecked { - // 1 flags + 20 + 20 recipients (+ 20 custom receiver) + 6 fee bytes, per the layout above. + // FeeTaker's extraData: 1 flags byte, two or three 20-byte recipients, 6 fee bytes, whitelist. uint256 whitelistOffset = extraData[0] & _CUSTOM_RECEIVER_FLAG != 0 ? 67 : 47; _validateAnchoredFill(extraData[whitelistOffset:], orderHash, taker); } @@ -121,12 +109,10 @@ contract SimpleSettlement is FeeTaker, AnchoredDutchAuction { uint256 remainingMakingAmount, bytes calldata extraData ) internal view override returns (uint256) { - (uint256 rateBump, bytes calldata tail) = _auctionRateBump(orderHash, extraData); - return Math.mulDiv( - super._getMakingAmount(order, extension, orderHash, taker, takingAmount, remainingMakingAmount, tail), - _BASE_POINTS, - _BASE_POINTS + rateBump - ); + (int256 netBump, bytes calldata fillCurve, bytes calldata tail) = _parseAuctionDetails(orderHash, extraData); + uint256 unbumpedMakingAmount = super._getMakingAmount(order, extension, orderHash, taker, takingAmount, remainingMakingAmount, tail); + uint256 rateBump = _estimatedFillRateBump(netBump, fillCurve, unbumpedMakingAmount, remainingMakingAmount); + return Math.mulDiv(unbumpedMakingAmount, _BASE_POINTS, _BASE_POINTS + rateBump); } /** @@ -141,7 +127,8 @@ contract SimpleSettlement is FeeTaker, AnchoredDutchAuction { uint256 remainingMakingAmount, bytes calldata extraData ) internal view override returns (uint256) { - (uint256 rateBump, bytes calldata tail) = _auctionRateBump(orderHash, extraData); + (int256 netBump, bytes calldata fillCurve, bytes calldata tail) = _parseAuctionDetails(orderHash, extraData); + uint256 rateBump = _fillRateBump(netBump, fillCurve, makingAmount, remainingMakingAmount); return Math.mulDiv( super._getTakingAmount(order, extension, orderHash, taker, makingAmount, remainingMakingAmount, tail), _BASE_POINTS + rateBump, @@ -159,9 +146,8 @@ contract SimpleSettlement is FeeTaker, AnchoredDutchAuction { * (bytes12)[N] — taker whitelist * ``` * Only 10 lowest bytes of the address are used for comparison. - * When the allowed time carries the anchored bit, the anchored fields of - * {AnchoredDutchAuction-_validateAnchoredFill} sit between it and the whitelist size; they are - * enforced by `_postInteraction`, so this walk skips them. + * Anchored fields, when present, sit between the allowed time and the size; they are enforced + * by `_postInteraction` and only skipped here. * @param taker The taker address to check. * @return isWhitelisted Whether the taker is whitelisted. * @return tail Remaining calldata. diff --git a/contracts/interfaces/IOrderRegistrator.sol b/contracts/interfaces/IOrderRegistrator.sol index fce15d1..bf3d963 100644 --- a/contracts/interfaces/IOrderRegistrator.sol +++ b/contracts/interfaces/IOrderRegistrator.sol @@ -7,10 +7,6 @@ pragma solidity ^0.8.0; * @notice The announcement surface of the order registrator that anchored auctions read. */ interface IOrderRegistrator { - /** - * @notice Returns the time an order was first registered, or zero when it never was. - * @param orderHash The hash of the order. - * @return timestamp The block timestamp of the first registration. - */ + /// @notice The time an order was first registered, or zero when it never was. function announcedAt(bytes32 orderHash) external view returns (uint256 timestamp); } diff --git a/deploy/deploy-settlement.js b/deploy/deploy-settlement.js index 3ea0287..edc426d 100644 --- a/deploy/deploy-settlement.js +++ b/deploy/deploy-settlement.js @@ -23,8 +23,7 @@ module.exports = async ({ getNamedAccounts, deployments, config }) => { DEPLOYMENT_METHOD = 'create'; } - // The zero address is a deliberate choice: it deploys with anchoring disabled (anchored orders - // fail closed) on chains where the registrator does not exist yet. Set it explicitly in config. + // The zero address deploys with anchoring disabled (anchored orders fail closed). const orderRegistrator = constants.ORDER_REGISTRATOR_ADDRESS[chainId]; if (orderRegistrator === undefined) { throw new Error(`orderRegistratorAddress is not configured for chain ${chainId}`); diff --git a/test/AnchoredAuction.js b/test/AnchoredAuction.js index 4cdff06..d80d7e4 100644 --- a/test/AnchoredAuction.js +++ b/test/AnchoredAuction.js @@ -118,7 +118,6 @@ describe('AnchoredAuction', function () { const order = await buildAnchoredOrder({ dai, weth, settlement: bareSettlement, auctionDetails: buildAnchoredAuctionDetails(anchoredParams) }); const sig = await signature(order, chainId, lopv4); - // Even an announced order cannot anchor to a registrator the settlement does not have. await announce(registrator, order); await expect(fill(lopv4, order, sig, MAKING_AMOUNT)).to.be.revertedWithCustomError(bareSettlement, 'OrderNotAnnounced'); }); @@ -129,7 +128,6 @@ describe('AnchoredAuction', function () { const order = await buildAnchoredOrder({ dai, weth, settlement, auctionDetails: buildAnchoredAuctionDetails(anchoredParams) }); const sig = await signature(order, chainId, lopv4); - // A build-time start of 0 is long past; the announcement is what the auction runs from. const announcedAt = await announce(registrator, order); const resolved = { ...anchoredParams, startTime: announcedAt }; @@ -169,8 +167,7 @@ describe('AnchoredAuction', function () { const order = await buildAnchoredOrder({ dai, weth, settlement, auctionDetails: buildAnchoredAuctionDetails(anchoredParams) }); const sig = await signature(order, chainId, lopv4); - // The maker announces and a resolver fills in the same block; the fill sees the announcement - // written earlier in the block and prices at the very start of the curve. + // The fill sees an announcement written earlier in the same block and prices at the curve's start. await hre.network.provider.send('evm_setAutomine', [false]); let announceTx, fillTx; try { @@ -208,8 +205,7 @@ describe('AnchoredAuction', function () { await time.setNextBlockTimestamp(fillTime); const fillTx = fill(lopv4, order, sig, MAKING_AMOUNT); - // One second into the anchored curve — essentially the top, rather than the floor price an - // unanchored order would have decayed to hours ago. + // The top of the curve, not the floor an unanchored order would have decayed to hours ago. const expected = takingAmountFor(order, { ...anchoredParams, startTime: announcedAt }, fillTime, MAKING_AMOUNT, MAKING_AMOUNT); expect(expected).to.be.greaterThan(ceilDiv(TAKING_AMOUNT * (BASE_POINTS + HALF_PERCENT * 9n / 10n), BASE_POINTS)); await expect(fillTx).to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); @@ -313,7 +309,6 @@ describe('AnchoredAuction', function () { const sig = await signature(order, chainId, lopv4); const announcedAt = await announce(registrator, order); - // The second resolver is whitelisted but its window opens a delta after the first one's. await time.setNextBlockTimestamp(announcedAt + 15); await expect(fill(lopv4, order, sig, MAKING_AMOUNT / 2n, { from: otherResolver })) .to.be.revertedWithCustomError(settlement, 'AllowedTimeViolation'); @@ -397,8 +392,7 @@ describe('AnchoredAuction', function () { it('enforces the anchored window behind a custom receiver', async function () { const { dai, weth, lopv4, chainId, registrator, settlement } = await loadFixture(deployContractsAndInit); - // A custom receiver shifts FeeTaker's post-interaction layout by 20 bytes; the anchored - // walk must still find the whitelist blob behind it. + // A custom receiver shifts FeeTaker's layout by 20 bytes; the whitelist must still be found. const resolverFee = 1000n; // 1% in 1e5 const order = await buildAnchoredOrder({ dai, @@ -502,8 +496,7 @@ describe('AnchoredAuction', function () { return receipt.gasUsed; } - // Warm-up fill: pays the zero-to-nonzero balance writes so the measured fills all - // write already-touched balance slots and differ only by settlement logic. + // Warm-up fill, so the measured fills write already-touched balance slots. await measure({ auctionDetails: buildAnchoredAuctionDetails({ ...params, startTime: await time.latest() }), exclusivity: buildAnchoredExclusivity({}), @@ -554,8 +547,7 @@ describe('AnchoredAuction', function () { const legacyExtraData = ethers.solidityPacked(['bytes', 'bytes'], [buildAnchoredAuctionDetails(params), NO_FEE_DATA]); const anchoredExtraData = ethers.solidityPacked(['bytes', 'bytes'], [buildAnchoredAuctionDetails({ ...params, anchored: true }), NO_FEE_DATA]); - // Announced before the built start, the anchored auction keeps the built start — its curve - // must be the legacy one at every point of the timeline. + // Announced before the built start, so the curve must match the legacy one everywhere. await registrator.registerOrder(order); expect(await time.latest()).to.be.lessThan(params.startTime); diff --git a/test/PartialFillPremium.js b/test/PartialFillPremium.js new file mode 100644 index 0000000..0e6e9c6 --- /dev/null +++ b/test/PartialFillPremium.js @@ -0,0 +1,408 @@ +const hre = require('hardhat'); +const { ethers } = hre; +const { expect, ether, deployContract } = require('@1inch/solidity-utils'); +const { loadFixture, time } = require('@nomicfoundation/hardhat-network-helpers'); +const { buildOrder, buildTakerTraits, signOrder } = require('@1inch/limit-order-protocol-contract/test/helpers/orderUtils'); +const { deploySwapTokens, getChainId } = require('./helpers/fixtures'); +const { buildSettlementExtensions } = require('./helpers/fusionUtils'); +const { + BASE_POINTS, + NO_FEE_DATA, + ceilDiv, + buildAnchoredAuctionDetails, + buildAnchoredExclusivity, + takingAmountFor, + makingAmountFor, +} = require('./helpers/anchoredAuction'); + +const HALF_PERCENT = 50_000n; // 0.5% in 1e7 + +describe('PartialFillPremium', function () { + let maker, taker; + + before(async function () { + [maker, taker] = await ethers.getSigners(); + }); + + async function deployContractsAndInit() { + const { dai, weth, accessToken, lopv4 } = await deploySwapTokens(); + const chainId = await getChainId(); + + await dai.approve(lopv4, ether('1000')); + await weth.connect(taker).deposit({ value: ether('1') }); + await weth.connect(taker).approve(lopv4, ether('1')); + await accessToken.mint(taker, 1); + + const registrator = await deployContract('OrderRegistratorMock', [lopv4]); + const settlement = await deployContract('SimpleSettlement', [lopv4, accessToken, weth, maker, registrator]); + + return { dai, weth, lopv4, chainId, registrator, settlement }; + } + + const MAKING_AMOUNT = ether('100'); + const TAKING_AMOUNT = ether('0.1'); + + async function buildPremiumOrder({ dai, weth, settlement, auctionDetails, exclusivity = buildAnchoredExclusivity({}) }) { + return buildOrder( + { + maker: maker.address, + makerAsset: await dai.getAddress(), + takerAsset: await weth.getAddress(), + makingAmount: MAKING_AMOUNT, + takingAmount: TAKING_AMOUNT, + }, + buildSettlementExtensions({ + feeTaker: await settlement.getAddress(), + estimatedTakingAmount: TAKING_AMOUNT, + getterExtraPrefix: auctionDetails, + protocolFeeRecipient: maker.address, + resolverFee: 0, + whitelistDiscount: 50, + whitelist: '0x00', + whitelistPostInteraction: exclusivity, + }), + ); + } + + async function signature(order, chainId, lopv4) { + return ethers.Signature.from(await signOrder(order, chainId, await lopv4.getAddress(), maker)); + } + + function fill(lopv4, order, sig, amount, { byMakingAmount = true, overrides = {} } = {}) { + const takerTraits = buildTakerTraits({ makingAmount: byMakingAmount, extension: order.extension }); + return lopv4.connect(taker).fillOrderArgs(order, sig.r, sig.yParityAndS, amount, takerTraits.traits, takerTraits.args, overrides); + } + + /** The blob a settlement getter is called with directly: the auction followed by empty fee data. */ + function getterExtraData(params) { + return ethers.solidityPacked(['bytes', 'bytes'], [buildAnchoredAuctionDetails(params), NO_FEE_DATA]); + } + + // Premium each decile of the remainder pays, 1/10 … 10/10; deliberately convex, as a depth-based quote is. + const MATRIX = [400_000, 250_000, 160_000, 105_000, 70_000, 45_000, 26_000, 12_000, 4_000, 0]; + const FILL_PREMIUMS = { + initial: 500_000, // 5% for a vanishing fill + points: MATRIX.slice(0, 9).map((premium) => ({ premium, shareDelta: 1000 })), + }; + + after(async function () { + await hre.network.provider.send('hardhat_setNextBlockBaseFeePerGas', ['0x1']); + }); + + async function deployMatrixOrder(extra = {}) { + const contracts = await loadFixture(deployContractsAndInit); + const { dai, weth, lopv4, chainId, settlement } = contracts; + const startTime = await time.latest() + 10; + const params = { startTime, duration: 100, initialRateBump: Number(HALF_PERCENT), fillPremiums: FILL_PREMIUMS, ...extra }; + const order = await buildPremiumOrder({ dai, weth, settlement, auctionDetails: buildAnchoredAuctionDetails(params) }); + const sig = await signature(order, chainId, lopv4); + return { ...contracts, order, sig, params, afterAuction: startTime + 200 }; + } + + describe('pricing by fill share', function () { + it('prices every decile exactly at its matrix row', async function () { + const { lopv4, settlement, order, params, afterAuction } = await deployMatrixOrder(); + + await time.setNextBlockTimestamp(afterAuction); + await hre.network.provider.send('evm_mine'); + + const orderHash = await lopv4.hashOrder(order); + const extraData = getterExtraData(params); + for (let decile = 1; decile <= 10; decile++) { + const makingAmount = MAKING_AMOUNT * BigInt(decile) / 10n; + const taking = await settlement.getTakingAmount(order, order.extension, orderHash, taker.address, makingAmount, MAKING_AMOUNT, extraData); + const expectedBump = BigInt(MATRIX[decile - 1]); + expect(taking).to.equal(ceilDiv(ceilDiv(TAKING_AMOUNT * BigInt(decile), 10n) * (BASE_POINTS + expectedBump), BASE_POINTS)); + } + }); + + it('prices successive fills by their share of what remains', async function () { + const { weth, lopv4, order, sig, params, afterAuction } = await deployMatrixOrder(); + + // Each fill is priced by its share of what remains, not by where it lands on the original amount. + let fillTime = afterAuction; + let remaining = MAKING_AMOUNT; + for (let i = 0; i < 3; i++) { + await time.setNextBlockTimestamp(fillTime); + const expected = takingAmountFor(order, params, fillTime, MAKING_AMOUNT / 10n, remaining); + const share = (MAKING_AMOUNT / 10n) * 10000n / remaining; + const interpolated = (share - 1000n) * BigInt(MATRIX[1]) + (2000n - share) * BigInt(MATRIX[0]); + const premium = i === 0 ? BigInt(MATRIX[0]) : interpolated / 1000n; + expect(expected).to.equal(ceilDiv((TAKING_AMOUNT / 10n) * (BASE_POINTS + premium), BASE_POINTS)); + await expect(fill(lopv4, order, sig, MAKING_AMOUNT / 10n)) + .to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); + remaining -= MAKING_AMOUNT / 10n; + fillTime++; + } + }); + + it('charges a late small fill by its share of the remainder, not its place on the original', async function () { + const { weth, lopv4, order, sig, params, afterAuction } = await deployMatrixOrder(); + + // With 80% filled, 10% of the original is half of the remainder: the 5/10 row, not the 9/10 one. + await time.setNextBlockTimestamp(afterAuction); + await fill(lopv4, order, sig, MAKING_AMOUNT * 8n / 10n); + + const fillTime = afterAuction + 1; + await time.setNextBlockTimestamp(fillTime); + const remaining = MAKING_AMOUNT * 2n / 10n; + const expected = takingAmountFor(order, params, fillTime, MAKING_AMOUNT / 10n, remaining); + expect(expected).to.equal(ceilDiv((TAKING_AMOUNT / 10n) * (BASE_POINTS + BigInt(MATRIX[4])), BASE_POINTS)); + await expect(fill(lopv4, order, sig, MAKING_AMOUNT / 10n)) + .to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); + }); + + it('interpolates between matrix rows instead of stepping', async function () { + const { weth, lopv4, order, sig, params, afterAuction } = await deployMatrixOrder(); + + await time.setNextBlockTimestamp(afterAuction); + const makingAmount = MAKING_AMOUNT * 15n / 100n; // halfway between the 1/10 and 2/10 rows + const fillTx = fill(lopv4, order, sig, makingAmount); + + const expected = takingAmountFor(order, params, afterAuction, makingAmount, MAKING_AMOUNT); + const midRowBump = BigInt(MATRIX[0] + MATRIX[1]) / 2n; + expect(expected).to.equal(ceilDiv(ceilDiv(TAKING_AMOUNT * 15n, 100n) * (BASE_POINTS + midRowBump), BASE_POINTS)); + await expect(fillTx).to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); + }); + + it('interpolates past the last row toward zero at completion', async function () { + const { lopv4, settlement, order, params, afterAuction } = await deployMatrixOrder(); + + await time.setNextBlockTimestamp(afterAuction); + await hre.network.provider.send('evm_mine'); + + // 95% lands on the implied final segment, halfway between the 9/10 row and zero. + const makingAmount = MAKING_AMOUNT * 95n / 100n; + const taking = await settlement.getTakingAmount( + order, order.extension, await lopv4.hashOrder(order), taker.address, makingAmount, MAKING_AMOUNT, getterExtraData(params), + ); + const halfLastRow = BigInt(MATRIX[8]) / 2n; + expect(taking).to.equal(ceilDiv(ceilDiv(TAKING_AMOUNT * 95n, 100n) * (BASE_POINTS + halfLastRow), BASE_POINTS)); + }); + + it('adds the matrix premium on top of the running time curve', async function () { + const { weth, lopv4, order, sig, params } = await deployMatrixOrder(); + + // The 3/10 row rides on top of the half-decayed time curve. + const fillTime = params.startTime + 50; + await time.setNextBlockTimestamp(fillTime); + const makingAmount = MAKING_AMOUNT * 3n / 10n; + const fillTx = fill(lopv4, order, sig, makingAmount); + + const expected = takingAmountFor(order, params, fillTime, makingAmount, MAKING_AMOUNT); + expect(expected).to.equal(ceilDiv(ceilDiv(TAKING_AMOUNT * 3n, 10n) * (BASE_POINTS + HALF_PERCENT / 2n + BigInt(MATRIX[2])), BASE_POINTS)); + await expect(fillTx).to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); + }); + + it('sweeps the remainder at the plain curve price', async function () { + const { weth, lopv4, order, sig, params, afterAuction } = await deployMatrixOrder(); + + await time.setNextBlockTimestamp(afterAuction); + await fill(lopv4, order, sig, MAKING_AMOUNT * 3n / 5n); + + // Whatever its absolute size, taking everything that is left costs no premium at all. + const fillTime = afterAuction + 10; + await time.setNextBlockTimestamp(fillTime); + const remainder = MAKING_AMOUNT * 2n / 5n; + const expected = takingAmountFor(order, params, fillTime, remainder, remainder); + expect(expected).to.equal(ceilDiv(TAKING_AMOUNT * 2n, 5n)); + await expect(fill(lopv4, order, sig, remainder)).to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); + }); + + it('prices a fill by taking amount through the conservative estimate', async function () { + const { dai, lopv4, order, sig, params, afterAuction } = await deployMatrixOrder(); + + const fillTime = afterAuction; + await time.setNextBlockTimestamp(fillTime); + const takingAmount = TAKING_AMOUNT / 10n; + const expected = makingAmountFor(order, params, fillTime, takingAmount, MAKING_AMOUNT); + await expect(fill(lopv4, order, sig, takingAmount, { byMakingAmount: false })) + .to.changeTokenBalances(dai, [taker, maker], [expected, -expected]); + }); + + it('offsets the gas bump from the matrix premium', async function () { + const { dai, weth, lopv4, chainId, settlement } = await loadFixture(deployContractsAndInit); + + const startTime = await time.latest() + 10; + const baseFee = 1000000000n; // 1 gwei, exactly the estimate below + const params = { + startTime, + duration: 100, + initialRateBump: Number(HALF_PERCENT), + fillPremiums: FILL_PREMIUMS, + gasBumpEstimate: Number(HALF_PERCENT * 4n), // 2% + gasPriceEstimate: 1000, + }; + const order = await buildPremiumOrder({ dai, weth, settlement, auctionDetails: buildAnchoredAuctionDetails(params) }); + const sig = await signature(order, chainId, lopv4); + + // After the auction the first decile carries the 4% row, and the 2% gas bump comes off it. + await hre.network.provider.send('hardhat_setNextBlockBaseFeePerGas', ['0x' + baseFee.toString(16)]); + await time.setNextBlockTimestamp(startTime + 200); + const fillTx = fill(lopv4, order, sig, MAKING_AMOUNT / 10n, { overrides: { gasPrice: baseFee * 2n } }); + + const expected = ceilDiv((TAKING_AMOUNT / 10n) * (BASE_POINTS + BigInt(MATRIX[0]) - HALF_PERCENT * 4n), BASE_POINTS); + await expect(fillTx).to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); + }); + + it('never lets any split of the order undercut a single sweep', async function () { + const { lopv4, settlement, order, params, afterAuction } = await deployMatrixOrder(); + const singleRow = await deployMatrixOrder({ fillPremiums: { initial: Number(HALF_PERCENT), points: [] } }); + + await time.setNextBlockTimestamp(afterAuction + 1000); + await hre.network.provider.send('evm_mine'); + + // For both curve shapes, no random partition may cost less in total than one full sweep. + let seed = 0xdead4351n; + const nextRand = (bound) => { + seed = (seed * 6364136223846793005n + 1442695040888963407n) & ((1n << 64n) - 1n); + return seed % bound; + }; + + for (const { o, p } of [{ o: order, p: params }, { o: singleRow.order, p: singleRow.params }]) { + const orderHash = await lopv4.hashOrder(o); + const extraData = getterExtraData(p); + const sweep = await settlement.getTakingAmount(o, o.extension, orderHash, taker.address, MAKING_AMOUNT, MAKING_AMOUNT, extraData); + + for (let trial = 0; trial < 8; trial++) { + const chunks = []; + let remaining = MAKING_AMOUNT; + const parts = 2n + nextRand(4n); + for (let i = 1n; i < parts; i++) { + const chunk = 1n + nextRand(remaining - (parts - i)); + chunks.push(chunk); + remaining -= chunk; + } + chunks.push(remaining); + + let total = 0n; + let left = MAKING_AMOUNT; + for (const chunk of chunks) { + total += await settlement.getTakingAmount(o, o.extension, orderHash, taker.address, chunk, left, extraData); + left -= chunk; + } + expect(total, `partition ${chunks.join('+')}`).to.be.greaterThanOrEqual(sweep); + } + } + }); + }); + + describe('curve validation', function () { + it('rejects a matrix whose premium rises along the ladder', async function () { + // A rising stretch would make splitting a fill cheaper than its sum, so pricing off it reverts. + const humpPremiums = { + initial: 100_000, + points: [ + { premium: 400_000, shareDelta: 3000 }, + { premium: 50_000, shareDelta: 4000 }, + ], + }; + const { lopv4, settlement, order, sig, afterAuction } = await deployMatrixOrder({ fillPremiums: humpPremiums }); + + await time.setNextBlockTimestamp(afterAuction); + await expect(fill(lopv4, order, sig, MAKING_AMOUNT * 3n / 10n)) + .to.be.revertedWithCustomError(settlement, 'NonMonotonicFillCurve'); + + // The making-amount direction walks the same curve and refuses it the same way. + await expect(fill(lopv4, order, sig, TAKING_AMOUNT / 10n, { byMakingAmount: false })) + .to.be.revertedWithCustomError(settlement, 'NonMonotonicFillCurve'); + }); + + it('rejects a rising first row before any interior point is read', async function () { + // A first row above the initial premium is caught on the walk's very first comparison. + const risingPremiums = { initial: 50_000, points: [{ premium: 100_000, shareDelta: 5000 }] }; + const { lopv4, settlement, order, sig, afterAuction } = await deployMatrixOrder({ fillPremiums: risingPremiums }); + + await time.setNextBlockTimestamp(afterAuction); + await expect(fill(lopv4, order, sig, MAKING_AMOUNT / 10n)) + .to.be.revertedWithCustomError(settlement, 'NonMonotonicFillCurve'); + }); + + it('validates only the prefix a fill is actually priced on', async function () { + // Enforcement is lazy: a fill priced on the legal prefix passes despite a broken tail. + const brokenTail = { + initial: 300_000, + points: [ + { premium: 200_000, shareDelta: 3000 }, + { premium: 400_000, shareDelta: 4000 }, // illegal, but only for fills that reach it + ], + }; + const { weth, lopv4, settlement, order, sig, params, afterAuction } = await deployMatrixOrder({ fillPremiums: brokenTail }); + + await time.setNextBlockTimestamp(afterAuction); + const firstAmount = MAKING_AMOUNT * 2n / 10n; + const first = takingAmountFor(order, params, afterAuction, firstAmount, MAKING_AMOUNT); + await expect(fill(lopv4, order, sig, firstAmount)).to.changeTokenBalances(weth, [taker, maker], [-first, first]); + + // Reaching past the legal prefix hits the rising row and reverts. + await time.setNextBlockTimestamp(afterAuction + 10); + await expect(fill(lopv4, order, sig, MAKING_AMOUNT * 3n / 10n)) + .to.be.revertedWithCustomError(settlement, 'NonMonotonicFillCurve'); + + // Completing the order short-circuits to the plain auction price without walking the curve. + const fillTime = afterAuction + 20; + await time.setNextBlockTimestamp(fillTime); + const rest = MAKING_AMOUNT - firstAmount; + const completing = takingAmountFor(order, params, fillTime, rest, rest); + await expect(fill(lopv4, order, sig, rest)).to.changeTokenBalances(weth, [taker, maker], [-completing, completing]); + }); + }); + + describe('composition with the rest of the encoding', function () { + it('keeps the time curve points readable alongside a fill curve', async function () { + const { dai, weth, lopv4, chainId, settlement } = await loadFixture(deployContractsAndInit); + + // With both curves present, the fill curve sits directly behind the time points. + const startTime = await time.latest() + 10; + const params = { + startTime, + duration: 100, + initialRateBump: Number(HALF_PERCENT * 2n), + points: [{ coefficient: Number(HALF_PERCENT), delay: 50 }], + fillPremiums: FILL_PREMIUMS, + }; + const order = await buildPremiumOrder({ dai, weth, settlement, auctionDetails: buildAnchoredAuctionDetails(params) }); + const sig = await signature(order, chainId, lopv4); + + const fillTime = startTime + 50; // exactly the single point, where the curve is at 0.5% + await time.setNextBlockTimestamp(fillTime); + const makingAmount = MAKING_AMOUNT / 10n; + const expected = takingAmountFor(order, params, fillTime, makingAmount, MAKING_AMOUNT); + expect(expected).to.equal(ceilDiv((TAKING_AMOUNT / 10n) * (BASE_POINTS + HALF_PERCENT + BigInt(MATRIX[0])), BASE_POINTS)); + await expect(fill(lopv4, order, sig, makingAmount)).to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); + }); + + it('prices an anchored auction by fill share too', async function () { + const { dai, weth, lopv4, chainId, registrator, settlement } = await loadFixture(deployContractsAndInit); + + const params = { startTime: 0, duration: 100, initialRateBump: Number(HALF_PERCENT), anchored: true, fillPremiums: FILL_PREMIUMS }; + const order = await buildPremiumOrder({ dai, weth, settlement, auctionDetails: buildAnchoredAuctionDetails(params) }); + const sig = await signature(order, chainId, lopv4); + await registrator.registerOrder(order); + const announcedAt = await time.latest(); + + // Halfway through the auction the announcement carries the start, and the fill share the price. + const fillTime = announcedAt + 50; + await time.setNextBlockTimestamp(fillTime); + const makingAmount = MAKING_AMOUNT / 10n; + const expected = takingAmountFor(order, { ...params, startTime: announcedAt }, fillTime, makingAmount, MAKING_AMOUNT); + expect(expected).to.equal(ceilDiv((TAKING_AMOUNT / 10n) * (BASE_POINTS + HALF_PERCENT / 2n + BigInt(MATRIX[0])), BASE_POINTS)); + await expect(fill(lopv4, order, sig, makingAmount)).to.changeTokenBalances(weth, [taker, maker], [-expected, expected]); + }); + + it('leaves an order without a fill curve on the legacy bytes', async function () { + const { dai, weth, lopv4, settlement } = await loadFixture(deployContractsAndInit); + + const params = { startTime: await time.latest() + 10, duration: 100, initialRateBump: Number(HALF_PERCENT) }; + expect(buildAnchoredAuctionDetails(params)).to.equal(buildAnchoredAuctionDetails({ ...params, fillPremiums: undefined })); + + // And the price it quotes ignores the fill size entirely. + const order = await buildPremiumOrder({ dai, weth, settlement, auctionDetails: buildAnchoredAuctionDetails(params) }); + const orderHash = await lopv4.hashOrder(order); + const extraData = getterExtraData(params); + const args = [order, order.extension, orderHash, taker.address]; + expect(await settlement.getTakingAmount(...args, MAKING_AMOUNT / 10n, MAKING_AMOUNT, extraData)) + .to.equal(await settlement.getTakingAmount(...args, MAKING_AMOUNT / 10n, MAKING_AMOUNT / 10n, extraData)); + }); + }); +}); diff --git a/test/helpers/anchoredAuction.js b/test/helpers/anchoredAuction.js index 9da35c6..794a6b9 100644 --- a/test/helpers/anchoredAuction.js +++ b/test/helpers/anchoredAuction.js @@ -6,16 +6,17 @@ const BASE_POINTS = 10_000_000n; // 100% const ANCHORED_FLAG = 1n << 31n; // Top bit of the uint24 anchored allowed-time delay. const ANNOUNCEMENT_DEADLINE_FLAG = 1n << 23n; +// Top bit of the uint8 auction points count. +const FILL_CURVE_FLAG = 1n << 7n; +// Fill shares are measured in 1e4. +const SHARE_BASE = 10_000n; const ceilDiv = (a, b) => (a + b - 1n) / b; /** Zero fees and an empty whitelist, so a settlement getter passes its input straight through. */ const NO_FEE_DATA = ethers.solidityPacked(['uint16', 'uint8', 'uint16', 'uint8', 'uint8'], [0, 0, 0, 0, 0]); -/** - * Packs the AuctionDetails blob read by the settlement getters. With `anchored` unset the bytes are - * exactly the legacy encoding; anchoring only sets the top bit of the packed start time. - */ +/** Packs the AuctionDetails blob; with no options set the bytes are exactly the legacy encoding. */ function buildAnchoredAuctionDetails({ gasBumpEstimate = 0, gasPriceEstimate = 0, @@ -23,23 +24,29 @@ function buildAnchoredAuctionDetails({ duration = 0, initialRateBump = 0, anchored = false, + fillPremiums = undefined, points = [], } = {}) { const packedStartTime = BigInt(startTime) | (anchored ? ANCHORED_FLAG : 0n); + const packedPointsCount = BigInt(points.length) | (fillPremiums !== undefined ? FILL_CURVE_FLAG : 0n); const types = ['uint24', 'uint32', 'uint32', 'uint24', 'uint24', 'uint8']; - const values = [gasBumpEstimate, gasPriceEstimate, packedStartTime, duration, initialRateBump, points.length]; + const values = [gasBumpEstimate, gasPriceEstimate, packedStartTime, duration, initialRateBump, packedPointsCount]; for (const { coefficient, delay } of points) { types.push('uint24', 'uint16'); values.push(coefficient, delay); } + if (fillPremiums !== undefined) { + types.push('uint24', 'uint8'); + values.push(fillPremiums.initial, fillPremiums.points.length); + for (const { premium, shareDelta } of fillPremiums.points) { + types.push('uint24', 'uint16'); + values.push(premium, shareDelta); + } + } return ethers.solidityPacked(types, values); } -/** - * Packs the resolver exclusivity read by the settlement post-interaction: the absolute allowed time - * and whitelist exactly as the legacy encoding, with the anchored fields carried on the top bits. - * `whitelist` entries are `{ address, delta }`, where `delta` is the wait until the next resolver may fill. - */ +/** Packs the whitelist blob; anchored fields ride on the top bits, legacy bytes otherwise. */ function buildAnchoredExclusivity({ allowedTime = 0, allowedTimeDelay = undefined, @@ -47,7 +54,6 @@ function buildAnchoredExclusivity({ whitelist = [], } = {}) { if (announcementDeadlineDelay !== undefined && allowedTimeDelay === undefined) { - // The deadline flag lives inside the anchored delay field, so the combination cannot be encoded. throw new Error('announcementDeadlineDelay requires allowedTimeDelay'); } @@ -100,13 +106,52 @@ function applyGasBump(rateBump, gasBump) { return rateBump > gasBump ? rateBump - gasBump : 0n; } +/** Mirrors PartialFillPremiumAuction._fillPremium. */ +function fillPremiumAt(makingAmount, remainingMakingAmount, { initial, points = [] }) { + let currentPremium = BigInt(initial); + const share = makingAmount * SHARE_BASE / remainingMakingAmount; + if (share === 0n) return currentPremium; + + let currentShare = 0n; + for (const { premium, shareDelta } of points) { + const nextPremium = BigInt(premium); + const nextShare = currentShare + BigInt(shareDelta); + if (share <= nextShare) { + return ((share - currentShare) * nextPremium + (nextShare - share) * currentPremium) / (nextShare - currentShare); + } + currentPremium = nextPremium; + currentShare = nextShare; + } + return (SHARE_BASE - share) * currentPremium / (SHARE_BASE - currentShare); +} + +/** Mirrors PartialFillPremiumAuction._fillRateBump. */ +function rateBumpForFill(auctionBump, auction, makingAmount, remainingMakingAmount) { + if (!auction.fillPremiums || makingAmount >= remainingMakingAmount) return auctionBump; + return auctionBump + fillPremiumAt(makingAmount, remainingMakingAmount, auction.fillPremiums); +} + /** Taking amount a fill by making amount is priced at. */ function takingAmountFor(order, auction, timestamp, makingAmount, remainingMakingAmount, gasBump = 0n) { - const rateBump = applyGasBump(auctionBumpAt(timestamp, auction), gasBump); + const rateBump = applyGasBump(rateBumpForFill(auctionBumpAt(timestamp, auction), auction, makingAmount, remainingMakingAmount), gasBump); const unbumped = ceilDiv(order.takingAmount * makingAmount, order.makingAmount); return ceilDiv(unbumped * (BASE_POINTS + rateBump), BASE_POINTS); } +/** Making amount a fill by taking amount is priced at, with the conservative fill-share estimate. */ +function makingAmountFor(order, auction, timestamp, takingAmount, remainingMakingAmount, gasBump = 0n) { + const auctionBump = auctionBumpAt(timestamp, auction); + const unbumped = order.makingAmount * takingAmount / order.takingAmount; + let rateBump = auctionBump; + if (auction.fillPremiums) { + // Premium curves are enforced non-increasing, so the initial premium is the worst one. + const worstRateBump = applyGasBump(auctionBump + BigInt(auction.fillPremiums.initial), gasBump); + const estimate = unbumped * BASE_POINTS / (BASE_POINTS + worstRateBump); + rateBump = rateBumpForFill(auctionBump, auction, estimate, remainingMakingAmount); + } + return unbumped * BASE_POINTS / (BASE_POINTS + applyGasBump(rateBump, gasBump)); +} + module.exports = { BASE_POINTS, NO_FEE_DATA, @@ -114,5 +159,7 @@ module.exports = { auctionBumpAt, buildAnchoredAuctionDetails, buildAnchoredExclusivity, + fillPremiumAt, takingAmountFor, + makingAmountFor, };