Conversation
Add opening auction contracts and related infrastructure: - OpeningAuction.sol and OpeningAuctionInitializer.sol - Supporting interfaces and libraries (PoolTickBitmap, QuoterMath) - Integration and unit tests for opening auction - TickLibrary updates
CRITICAL-1 bug fix: _beforeInitialize() was unconditionally setting isToken0 = true, overwriting the value set by setIsToken0() called BEFORE pool.initialize(). This broke ~50% of auctions where asset address > numeraire address (asset is token1). Changes: - Add isToken0Set guard state variable - Make setIsToken0() one-time only with guards (prevents double-set) - Remove isToken0 = true overwrite in _beforeInitialize() - Add IsToken0NotSet validation in _beforeInitialize() - Add IsToken0AlreadySet and IsToken0NotSet errors to interface Tests added (9 tests in TokenOrdering.t.sol): - test_setIsToken0_CanOnlyBeCalledOnce - test_setIsToken0_RevertsAfterInitialization - test_beforeInitialize_RevertsIfIsToken0NotSet - test_isToken0True_PreservedThroughInitialization - test_isToken0False_PreservedThroughInitialization - test_fullAuctionFlow_AssetIsToken1 - test_bidValidation_UsesCorrectDirectionForToken1Asset - test_isToken0Set_InitiallyFalse - test_setIsToken0_OnlyInitializer
CRITICAL-2 bug fix: Partial removal didn't decrement pos.liquidity, allowing users to over-claim incentives by repeatedly partial-removing and re-claiming based on stale liquidity values. Fix: Require full position removal during Active phase. After settlement, partial removals are allowed since incentive accounting is finalized. Changes: - Add PartialRemovalNotAllowed error to interface - Add check in _beforeRemoveLiquidity: liquidityToRemove must equal pos.liquidity Tests added (9 tests in PartialRemoval.t.sol): - test_fullRemoval_OutOfRangePosition_Succeeds - test_partialRemoval_Reverts - test_partialRemoval_SlightlyLess_Reverts - test_partialRemoval_SlightlyMore_Reverts - test_inRangePosition_CannotBeRemoved_EvenFullRemoval - test_afterSettlement_FullRemoval_Succeeds - test_afterSettlement_PartialRemoval_Succeeds - test_fullRemoval_MinimumLiquidity_Succeeds - test_multiplePositionsSameTick_IndividualFullRemoval
- Fix position key collision: use owner instead of sender in position key hash to prevent collisions when multiple users go through same router - Add time cap enforcement: cap accumulator updates at auctionEndTime to prevent post-auction time accrual if settlement is delayed - Fix settlement race condition: block liquidity removals when phase is Closed to prevent race with cached denominator calculation These fixes address HIGH and MEDIUM severity issues identified during deep analysis of the incentive mechanism.
Add invariant tests for the OpeningAuction incentive mechanism: Invariants tested: 1. Conservation: claimed incentives never exceed incentiveTokensTotal 2. Monotonic accumulators: tick accumulators never decrease 3. Monotonic earned time: position earned time never decreases 4. Settlement finality: claimable amounts frozen after settlement 5. No double claim: cannot claim same position twice 6. Total bounded: pending + claimed <= incentiveTokensTotal 7. Active ticks validity: all active ticks have non-zero liquidity Includes Handler contract that performs randomized auction operations (addBid, removeBid, warpTime, settleAuction, claimIncentives) for thorough fuzz testing of the incentive mechanism.
Add comprehensive integration tests covering real-world token launch scenarios: - Small cap launches with low incentive shares (0.3% / 60 bps) - Many bidders with majority ending up out of range (50 bidders, 70%+ out of range) - Partial time in range scenarios (positions pushed out mid-auction) - Mixed bidder sizes (whales vs retail distribution) - Last-minute bidding/sniping behavior - Very short time in range precision tests - Complete incentive claiming flow These tests address gaps identified in test coverage analysis where previous tests used unrealistic parameters (10% incentives vs real-world 0.3%).
- Add re-initialization guard to prevent duplicate auctions for same asset - Validate isToken0 in dopplerData matches derived token ordering - Forward numeraire proceeds to airlock in completeAuction - Align clearing tick to Doppler's tick spacing before pool init - Add exitLiquidity target validation with reverse mapping - Add ReentrancyGuard to completeAuction - Update pragma to ^0.8.26 New errors: AssetAlreadyInitialized, IsToken0Mismatch, InvalidExitTarget New event: ProceedsForwarded
- Use minAcceptableTick as sqrtPriceLimitX96 in settlement swap to prevent price manipulation between validation and execution - Add SenderNotPoolManager error for receive() and unlockCallback - Update pragma to ^0.8.26
- test_completeAuction_forwardsProceedsToAirlock: verifies proceeds forwarding - test_initialize_revertsWhenAssetAlreadyInitialized: verifies re-init guard - test_initialize_revertsOnIsToken0Mismatch: verifies isToken0 validation
- Move OpeningAuction.sol to src/initializers/ for consistency - Remove dead afterSwap hook (~2100 gas savings per swap) - Add NatSpec documenting zero-bid settlement behavior - Add comprehensive test coverage (50 new tests): - IncentiveRecovery.t.sol: 12 tests for recovery edge cases - OpeningAuctionFuzz.t.sol: 21 fuzz tests for tick/amount edges - OpeningAuctionToken1.t.sol: 9 tests for isToken0=false flow - OpeningAuctionGas.t.sol: 8 gas benchmarks for settlement scaling
…pler timing - Fix migrate() to exclude incentiveTokensTotal from transfer, ensuring users can still claim their LP incentives after auction completion - Fix _modifyDopplerStartingTick() to auto-adjust timing when the original startingTime has passed, preventing Doppler deployment from reverting - Add DopplerTransition.t.sol with 6 tests covering both fixes
- Emit BidWithdrawn event in _afterRemoveLiquidity when position is removed - Remove obsolete afterSwap comment (was explaining removed hook) - Remove unused PositionRolled event from interface (feature not implemented)
- Add OpeningAuctionToken1Direction.t.sol with 9 tests for inverse direction - Full auction flow with token1 as asset (price moves UP from MIN_TICK) - Bid placement priority, partial fills, withdrawal, multi-bidder scenarios - Add OpeningAuctionSettlementFailure.t.sol with 4 tests for edge cases - SettlementPriceTooLow/TooHigh reverts for both directions - No bids scenario (empty activeTicks)
Use bitmap data structure for efficient tick tracking during auction settlement. Implements position calculation, tick flipping, and directional tick traversal using the same pattern as Uniswap V3 TickBitmap. - Add tickBitmap mapping for O(1) tick lookups - Add _position, _flipTick, _isTickActive helper functions - Add _nextInitializedTickWithinOneWord and _nextInitializedTick for traversal - Add _insertTick and _removeTick with min/max tracking - Add comprehensive bitmap unit tests (54 tests)
…ifecycle Add 10 new events for better off-chain indexing and monitoring: IOpeningAuction.sol: - AuctionStarted: full config details on auction initialization - PhaseChanged: track NotStarted→Active→Closed→Settled transitions - TickEnteredRange/TickExitedRange: tick range boundary changes - LiquidityAddedToTick/LiquidityRemovedFromTick: per-tick liquidity tracking - TimeHarvested: incentive time harvesting per position - PositionRolled: position tick changes OpeningAuctionInitializer.sol: - AuctionInitialized: deployment config and addresses - DopplerTransitionStarted: auction→doppler phase transition
New test files covering previously untested scenarios: TickSpacingEdgeCases.t.sol (15 tests): - Tick alignment at min/max boundaries - Common tick spacings (1, 10, 60, 200) - Fuzz tests for alignment invariants - Token0/Token1 rounding direction validation OpeningAuctionStress.t.sol (5 tests): - 50 unique bidders with settlement - Concentrated bids at single tick - Gas scaling with 75 active ticks - Many incentive claims scenario - Rapid bid add/remove churn OpeningAuctionAttacks.t.sol (8 tests): - Flash loan manipulation attempts - Settlement sandwich attack vectors - Price manipulation below minAcceptableTick - TOCTOU protection validation - Last-block manipulation scenarios OpeningAuctionConcurrent.t.sol (6 tests): - Multiple simultaneous auctions - Cross-auction interference checks - Shared bidder across auctions
…nditions - Add clearingTick == minAcceptableTick exact boundary test - Add estimatedClearingTick vs actual clearingTick accuracy verification - Add position increase creates new position (not modify existing) - Add settlement failure when clearing tick below minimum acceptable - Add same-block settlement timing test - Add auction end exact timestamp boundary test - Add cross-contract state consistency tests (Initializer <-> Hook) - Add phase transition verification tests - Add liquidity tracking consistency tests 17 new integration tests covering previously untested edge cases
- Add alignTick tests for isToken0=true direction (rounds down/negative) - Add alignTick tests for isToken0=false direction (rounds up/positive) - Add tests for various tick spacings (60, 200, 1) - Add already-aligned tick verification - Add negative/positive tick boundary tests - Add MIN_TICK/MAX_TICK boundary behavior tests - Document that alignTick can exceed tick bounds (caller must clamp) - Add fuzz tests for alignment properties 18 new unit tests verifying tick alignment math for auction->Doppler transition
- Add test for exitLiquidity revert when status is not DopplerActive - Add test for exitLiquidity revert when target is invalid address - Add state transition verification tests - Add status enum validation tests 4 new tests for exitLiquidity error handling and state transitions
Support both abi.encodePacked (20 bytes) and abi.encode (32+ bytes) address formats in hookData, enabling more gas-efficient encoding while maintaining backward compatibility.
Verify that abi.encodePacked addresses work correctly for adding and removing liquidity through the hook.
Add tests for: - Estimated clearing tick accuracy after out-of-range removals - Settlement with mixed in-range and out-of-range positions - Incentive claims for positions that go out of range - Zero incentives for positions that never enter range
|
ack/fix pushed - addressed all review comments:
|
- Remove native token handling from OpeningAuctionPositionManager (docs state ERC20-only support) - Change sweepAuctionIncentives and recoverAuctionIncentives from onlyAirlock to onlyAirlockOwner (Airlock can't call these functions) - Define onlyAirlockOwner modifier locally to avoid modifying shared ImmutableAirlock base contract
|
Addressed remaining review feedback in 7ca86e1: token1 min-acceptable tick check now uses tickUpper (ceiling) and token1 tests/docs updated to cover the boundary case; auction config fields (incl. totalAuctionTokens) are now immutable. Thanks for the catches! |
Replace _floorToSpacing with alignTick in _updateClearingTickAndTimeStates to ensure consistent "in range" behavior between isToken0=true and isToken0=false auctions. Previously, _floorToSpacing always rounded toward negative infinity, causing isToken0=true auctions to lock more positions (conservative) while isToken0=false auctions locked fewer positions (not conservative). The fix uses alignTick which rounds down for isToken0=true and rounds up for isToken0=false, making both auction types equally conservative. Adds test demonstrating the asymmetry and verifying the fix.
|
Addressed in 2c67f10: replaced |
| emit TickExitedRange(nextTick, liquidityAtTick[nextTick]); | ||
| } | ||
|
|
||
| iterTick = nextCompressed + 1; |
There was a problem hiding this comment.
I think this should instead be iterTick = nextCompressed. Since we're using lte: false, we increment the provided tick in _nextInitializedTickWithinOneWord to start from the word of the next tick. So seems like we skip a tick as a result of this
Note that we do the same thing in _finalizeAllTickTimes and _sumActiveTickTimes
There was a problem hiding this comment.
Just realized the reason this works is because we pass iterTick - 1 to _nextInitializedTick. Would be slightly more efficient to initialize iterTick as startCompressed - 1 and then set do iterTick = nextCompressed at the end of each loop, but this is logically the same as what we currently have
Btw, I noticed that _nextInitializedTickWithinOneWord/_nextInitializedTick differ between the actual implementations and those in BitmapUnit.t.sol. Would recommend using a mock contract to point a public function to these internal functions for tests instead of the current pattern so that it doesn't have to be manually updated
cc @z80dev
| bool enteringRange = isToken0 ? newClearingTick < oldClearingTick : newClearingTick > oldClearingTick; | ||
| if (enteringRange) { | ||
| // More ticks are now filled - walk ticks that entered range | ||
| _walkTicksEnteringRange(oldClearingTick, newClearingTick, tickSpacing); |
There was a problem hiding this comment.
I think we can remove the entering range case altogether. Since we can only add in range liquidity, we can only really decrease the range. The only exception is that when the first position is added we go from oldClearingTick = MAX_TICK to the new clearing tick. But here we're at most only actually marking one tick as in range, which is done later in _afterAddLiquidity regardless
| // For isToken0=false (!zeroForOne swap, price moves up from MIN_TICK): | ||
| // Position is utilized if clearing tick is at or above tickLower | ||
| // (price moved up into or through the range) | ||
| return estimatedClearingTick >= tick; |
There was a problem hiding this comment.
Previously we always rounded the estimatedClearingTick down, but now we round up if !isToken0. Before, the equality case made sense because of that, but now it will consider a tick to be in range if the actual clearing tick is any greater than tick - tickSpacing
Should instead be:
return estimatedClearingTick > tick| int24 finalTick = TickMath.getTickAtSqrtPrice(sqrtPriceX96); | ||
| if (_tickViolatesPriceLimit(finalTick)) revert SettlementPriceTooLow(); |
There was a problem hiding this comment.
Since we start with a minimum tick value, minAcceptableTickToken0/minAcceptableTickToken1, then convert that tick into a sqrt price, and then here convert the sqrt price back into a tick before this important validation, we are assuming that tick == getTickAtSqrtPrice(getSqrtPriceAtTick(tick))
I'm pretty confident this is correct, but ideally we should write a fuzz test to prove this, especially since triggering this revert would be a big problem
|
One minor concern I have is that if we reward incentives to all bids which are at least partially in range, it would be possible to continually place bids that are only slightly in range, e.g. via a script, receiving the same incentives as other bids which are fully in range, while only ever having to buy a small amount of tokens if any I'm not sure if it's better to only incentivize bids that are fully in range though. It might honestly be partly a good thing as if bots competed to do this it would push the clearing price up. I'm not sure, just something I've been thinking about |
|
I tried pretty hard to reproduce an actual skip here and couldn’t, so I added regression tests that exercise the real production walker. What I did / why I think it’s fine:
Coverage:
Also tested the suggested change: Commits on this branch:
If you have a specific edge case in mind that you think I’m missing, I’m happy to add it. |
| // Try to resolve asset from Doppler hook mapping first | ||
| address asset = dopplerHookToAsset[target]; | ||
| // If not found, try the Opening Auction hook mapping (Airlock passes this address) |
There was a problem hiding this comment.
This will never work as target is auctionHook, but this mapping returns the dopplerHook
- QuoterMath: add protocol fee support by reading protocolFee/lpFee from Slot0 and using ProtocolFeeLibrary.calculateSwapFee() - OpeningAuctionInitializer: pre-validate Doppler constructor params to prevent locked funds if deployment fails - OpeningAuction: remove unused param names, update outdated comment - BitmapUnit.t.sol: use inheritance-based harness to test production code - Add TickMathRoundTrip fuzz tests for tick->sqrtPrice->tick assumption Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| ) = _decodeDopplerInitData(initData.dopplerData); | ||
|
|
||
| // Check numPDSlugs: must be > 0 and <= 15 | ||
| if (dopplerNumPDSlugs == 0 || dopplerNumPDSlugs > 15) revert InvalidNumPDSlugs(); |
There was a problem hiding this comment.
Ideally we use the MAX_PRICE_DISCOVERY_SLUGS constant directly here instead of 15 in case it gets updated in the future
| if (dopplerGamma <= 0) revert InvalidGamma(); | ||
|
|
||
| // Check tick spacing: must be <= 30 | ||
| if (dopplerTickSpacing_ > 30) revert InvalidDopplerTickSpacing(); |
There was a problem hiding this comment.
Should use MAX_TICK_SPACING directly instead of 30 here
Summary (What this PR adds)
OpeningAuctionhook contract with full lifecycle management and bid lockingOpeningAuctionInitializerto deploy + initialize the auction pool, then transition to DopplerDocs / Spec
High-level Design
On-chain Components
Core contracts
src/initializers/OpeningAuction.sol(OpeningAuction)clearingTick.src/OpeningAuctionInitializer.sol(OpeningAuctionInitializer)Supporting libraries
src/libraries/QuoterMath.sol: simulates swap to computeestimatedClearingTick.src/libraries/PoolTickBitmap.sol: iterates pool tick bitmap for quoter.src/libraries/TickLibrary.sol: tick alignment for pool init + Doppler transition.Auction Lifecycle
Hook phase (
OpeningAuction)NotStarted → Active → Closed → SettledClosedis transient duringsettleAuction().Initializer status (
OpeningAuctionInitializer)Uninitialized → AuctionActive → DopplerActive → ExitedToken Custody & Flows
Creation
numTokensToSellto the hook.auctionTokens=numTokensToSell * shareToAuctionBps / 10_000.dopplerTokens) held temporarily by the hook.During Active
Settlement
Migration
Configuration & Initialization
Per‑auction config (
OpeningAuctionConfig)auctionDurationminAcceptableTickToken0,minAcceptableTickToken1(min price guard)incentiveShareBpstickSpacing,fee,minLiquidity,shareToAuctionBpsInitializer input (
OpeningAuctionInitData)auctionConfig,dopplerData,salt(CREATE2 for hook address permissions)Bidding Rules (Add Liquidity)
Validation in
_beforeAddLiquidity:tickUpper - tickLower == tickSpacing)minAcceptableTickAccounting in
_afterAddLiquidity:ownerfromhookDatapositionIdand recordsAuctionPositionliquidityAtTickestimatedClearingTick+ range stateBid Withdrawal (Remove Liquidity)
Active, removal is only allowed if the position is out‑of‑range (not expected to fill).Active.Settled, removals are unrestricted.Active, time‑in‑range is harvested and reward debt updated.Estimated Clearing Tick & Locking
estimatedClearingTickis computed by simulating settlement swap viaQuoterMath.Incentives (Time‑in‑Range Accounting)
incentiveTokensTotal = totalAuctionTokens * incentiveShareBps / 10_000auctionEndTimecachedTotalWeightedTimeX128becomes denominatorincentiveTokensTotal * positionEarnedTimeX128 / cachedTotalWeightedTimeX128Settlement
Entry:
settleAuction()(permissionless)phase == Activeandblock.timestamp >= auctionEndTimeamountToSell = totalAuctionTokens - incentiveTokensTotal - totalTokensSoldclearingTickand entersSettledMigration & Incentive Recovery
Migration
migrate(recipient)only by initializer, only inSettledEdge cases
recoverIncentivesif no earned time existssweepUnclaimedIncentivesafter claim windowInitializer Details
initialize(...)(only Airlock)shareToAuctionBpscompleteAuction(asset)(permissionless)clearingTickfor DopplerDopplerActiveexitLiquidity(...)(only Airlock)ExitedValidations, Constraints, and Invariants
Config
auctionDuration > 0incentiveShareBps <= 10_000tickSpacingvalidminLiquidity > 0Bids
Withdrawals
ActiveSettlement
auctionEndTimeKnown Limitations / Audit Notes
minLiquidity.TickEnteredRange.feemust be static; dynamic fee flags not supported byQuoterMath.Test Coverage Map
Unit (
test/unit/openingauction/)Integration (
test/integration/)Invariant (
test/invariant/OpeningAuctionInvariants.t.sol)Review Focus (Audit Checklist)