feat(swap): exact multi-tick taker swap simulation#63
Conversation
simulateTakerSwap assumes the pool's current active liquidity holds across the whole trade. That is wrong in both directions once an order crosses an initialized tick, and not merely optimistic: on the live mainnet market a band of liquidity above spot makes a 10-perp long cost 25.4% impact where the flat model predicts 68.5%, while the band's upper edge means the pool runs dry at 10.302 perp where the flat model claims capacity of 24.6. Overstated capacity is the costly half -- the UI quotes orders that revert on-chain with InsufficientLiquidityToFill. A perp's fill is settled by a real Uniswap v4 PoolManager.swap against a hookless, fee-0 pool, so the exact fill can be reproduced by walking the same ticks the chain walks: - tickMath.ts exact bigint port of TickMath.getSqrtPriceAtTick - swapMath.ts SqrtPriceMath/SwapMath primitives, zero-fee token0 paths - swapExact.ts simulateTakerSwapExact, maxFillablePerpDelta - poolTicks.ts fetchPoolTickMap via batched PoolManager.extsload Ticks are read straight from the PoolManager rather than the StateView periphery, which is deployed at a different address per chain and not at all on some; extsload also batches a whole tick map into one eth_call. getSqrtPriceAtTick is monotonic, so the walker locates the current tick by comparing sqrt prices and needs no getTickAtSqrtPrice port. The map only changes when a maker mints or burns, so callers cache it and revalidate with assertTickMapFresh: the sum of liquidityNet at or below the current tick must equal the pool's streamed active liquidity. That check is free and fails loudly rather than quoting from a stale map. Validated against Uniswap's own V4Quoter, which quotes by running the same PoolManager swap: the walker matches it to the wei on both sides across sizes, and maxFillablePerpDelta is exact -- max fills, max + 1 reverts. swapQuoter.test.ts re-derives this against the live chain (eth_call only, skipped without RPC_URL) so it keeps its teeth as liquidity moves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds exact Uniswap v4-style perp swap math, on-chain tick-map and liquidity fetching, multi-tick simulation utilities, public exports, validation tests, and a package version bump. ChangesExact Swap Simulation
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant TestSuite
participant PublicClient
participant PoolManager
participant V4Quoter
participant ExactSimulator
TestSuite->>PublicClient: Read pool state
PublicClient->>PoolManager: Read tick bitmap and liquidity slots
PoolManager-->>PublicClient: Return pool storage values
TestSuite->>ExactSimulator: Simulate exact perp swap
TestSuite->>V4Quoter: Simulate exact-input or exact-output quote
V4Quoter-->>TestSuite: Return quote or revert
ExactSimulator-->>TestSuite: Return fill and liquidity status
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/__tests__/unit/swapExact.test.ts (1)
77-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider moving the staleness test out of the
activeLiquidityAtdescribe block.The second test in this block exercises
simulateTakerSwapExact's rejection path, notactiveLiquidityAtdirectly. Minor organizational nit; grouping it undersimulateTakerSwapExact(or a dedicatedassertTickMapFreshblock) would better reflect what's under test.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/__tests__/unit/swapExact.test.ts` around lines 77 - 91, Move the “rejects a tick map that no longer describes the pool” test out of the activeLiquidityAt describe block and place it under the simulateTakerSwapExact describe block or a dedicated assertTickMapFresh block, keeping its existing setup and assertions unchanged.src/__tests__/integration/swapQuoter.test.ts (1)
120-134: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winBroad catch conflates "cannot settle" with other failures.
quote()swallows every error fromsimulateContractintonull, treating RPC errors, ABI mismatches, and wrong-address failures identically to a genuineNotEnoughLiquidity-style revert. A misconfigured quoter address (see comment on Lines 32-35) or a transient RPC hiccup would silently look like "the pool couldn't settle this size," which is exactly the condition the capacity-boundary test (Lines 194-205) relies on. Consider inspecting the revert reason/error name to distinguish real liquidity-exhaustion reverts from other failures, at least in the boundary test.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/__tests__/integration/swapQuoter.test.ts` around lines 120 - 134, Update quote() to distinguish genuine liquidity-exhaustion reverts from unrelated simulateContract failures: inspect the thrown error’s revert reason or error name and return null only for the expected NotEnoughLiquidity-style failure. Re-throw RPC errors, ABI/address mismatches, and other unexpected failures so the boundary test cannot silently pass due to misconfiguration; preserve the existing quoteExactInputSingle/quoteExactOutputSingle behavior.src/utils/swapExact.ts (1)
38-39: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDoc comment references an unimplemented check.
endSqrtPriceX96is documented as feeding thePriceImpactTooHighbounds check, but the PR deliberately does not modelPriceImpactTooHigh. Consider rewording to avoid implying a check that doesn't exist.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/swapExact.ts` around lines 38 - 39, Reword the doc comment for endSqrtPriceX96 to describe its actual purpose without referencing the unimplemented PriceImpactTooHigh bounds check; retain the field’s role as the pool’s post-swap square-root price.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/__tests__/integration/swapQuoter.test.ts`:
- Around line 120-134: Update quote() to distinguish genuine
liquidity-exhaustion reverts from unrelated simulateContract failures: inspect
the thrown error’s revert reason or error name and return null only for the
expected NotEnoughLiquidity-style failure. Re-throw RPC errors, ABI/address
mismatches, and other unexpected failures so the boundary test cannot silently
pass due to misconfiguration; preserve the existing
quoteExactInputSingle/quoteExactOutputSingle behavior.
In `@src/__tests__/unit/swapExact.test.ts`:
- Around line 77-91: Move the “rejects a tick map that no longer describes the
pool” test out of the activeLiquidityAt describe block and place it under the
simulateTakerSwapExact describe block or a dedicated assertTickMapFresh block,
keeping its existing setup and assertions unchanged.
In `@src/utils/swapExact.ts`:
- Around line 38-39: Reword the doc comment for endSqrtPriceX96 to describe its
actual purpose without referencing the unimplemented PriceImpactTooHigh bounds
check; retain the field’s role as the pool’s post-swap square-root price.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1affdf76-d147-4a63-9245-4cdd04a8b038
📒 Files selected for processing (9)
package.jsonsrc/__tests__/integration/swapQuoter.test.tssrc/__tests__/unit/swapExact.test.tssrc/utils/index.tssrc/utils/poolTicks.tssrc/utils/swap.tssrc/utils/swapExact.tssrc/utils/swapMath.tssrc/utils/tickMath.ts
Why
simulateTakerSwapwalks the curve as if the pool's current active liquidity held for the whole trade. Once an order crosses an initialized tick that is wrong — and wrong in both directions, not merely optimistic.Measured against the live mainnet market
0x8ac0179073a9eb5aaee58e5ebe9882066b9e7b6c:Liquidity concentrated above spot makes real fills cheaper than the flat model predicts; the band's upper edge means the pool runs dry far sooner. The overstated capacity is the costly half: the UI quotes orders that revert on-chain with
InsufficientLiquidityToFill.How
A perp's fill isn't custom AMM code.
PerpFactorycreates a pool withfee: 0, hooks: NULL_HOOKS, andPerpLogic.swapsettles it throughPOOL_MANAGER.unlock(SWAP)with no price limit — a real Uniswap v4 swap. So the exact fill is reproducible by walking the same ticks the chain walks.tickMath.ts— exact bigint port ofTickMath.getSqrtPriceAtTickswapMath.ts—SqrtPriceMath/SwapMathprimitives, zero-fee token0 specialisationswapExact.ts—simulateTakerSwapExact,maxFillablePerpDelta,assertTickMapFreshpoolTicks.ts—fetchPoolTickMapvia batchedPoolManager.extsloadTwo choices worth calling out:
Ticks are read from
PoolManager.extsload, not theStateViewperiphery. StateView sits at a different address on every chain and isn't deployed everywhere;extsloadis on the PoolManager itself and batches an entire tick map into oneeth_call.StateLibraryis only slot math, reproduced here.No
getTickAtSqrtPriceport.getSqrtPriceAtTickis monotonic, so the walker identifies the ticks below spot by comparing sqrt prices — the streamedsqrt_price_x96alone drives it.Staleness
The tick map only changes when a maker mints or burns, so callers cache it.
assertTickMapFreshrevalidates for free: the sum ofliquidityNetat or below the current tick must equal the pool's streamed active liquidity. A stale map fails loudly instead of silently quoting.Validation
Uniswap's own V4Quoter quotes by running that same PoolManager swap, so it is ground truth for a perp fill. The walker matches it to the wei on both sides across sizes, and
maxFillablePerpDeltais exact —maxfills,max + 1reverts.__tests__/unit/swapExact.test.ts— 19 tests pinning a live-pool fixture whose expectations were produced by V4Quoter__tests__/integration/swapQuoter.test.ts— differential harness that re-derives expectations from the live chain (eth_callonly, skipped withoutRPC_URL), so it keeps its teeth as liquidity movespnpm run cigreen: build, 298 unit tests, tsc, biome.Notes
fix/trade-calc-accuracy(#608), which is held until this publishes: its≥slippage copy is wrong-signed and itsexceedsLiquiditygate lets reverting orders through.PriceImpactTooHighis deliberately not modelled. The deployed price-impact module has already diverged from thev0.1.0source (noMIN_FACTOR/MAX_FACTORgetters; band is0 → 2× EMA, not ±10%), andsnap.emasdecays to the execution block timestamp, so no off-chain replica can be exact. Revert prediction belongs in a pre-submiteth_call, not in a copy of a swappable module.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements