Skip to content

feat(swap): exact multi-tick taker swap simulation#63

Merged
lukemacauley merged 2 commits into
mainfrom
feat/exact-multitick-swap
Jul 10, 2026
Merged

feat(swap): exact multi-tick taker swap simulation#63
lukemacauley merged 2 commits into
mainfrom
feat/exact-multitick-swap

Conversation

@lukemacauley

@lukemacauley lukemacauley commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Why

simulateTakerSwap walks 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:

flat model truth
10-perp long, price impact 68.45% 25.42%
long capacity 24.61 perp 10.302 perp
short capacity quotes 60 perp happily 3.946 perp

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. PerpFactory creates a pool with fee: 0, hooks: NULL_HOOKS, and PerpLogic.swap settles it through POOL_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 of TickMath.getSqrtPriceAtTick
  • swapMath.tsSqrtPriceMath / SwapMath primitives, zero-fee token0 specialisation
  • swapExact.tssimulateTakerSwapExact, maxFillablePerpDelta, assertTickMapFresh
  • poolTicks.tsfetchPoolTickMap via batched PoolManager.extsload

Two choices worth calling out:

Ticks are read from PoolManager.extsload, not the StateView periphery. StateView sits at a different address on every chain and isn't deployed everywhere; extsload is on the PoolManager itself and batches an entire tick map into one eth_call. StateLibrary is only slot math, reproduced here.

No getTickAtSqrtPrice port. getSqrtPriceAtTick is monotonic, so the walker identifies the ticks below spot by comparing sqrt prices — the streamed sqrt_price_x96 alone drives it.

Staleness

The tick map only changes when a maker mints or burns, so callers cache it. assertTickMapFresh revalidates for free: the sum of liquidityNet at 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 maxFillablePerpDelta is exact — max fills, max + 1 reverts.

  • __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_call only, skipped without RPC_URL), so it keeps its teeth as liquidity moves

pnpm run ci green: build, 298 unit tests, tsc, biome.

Notes

  • Consumed next by perpcity-client fix/trade-calc-accuracy (#608), which is held until this publishes: its slippage copy is wrong-signed and its exceedsLiquidity gate lets reverting orders through.
  • PriceImpactTooHigh is deliberately not modelled. The deployed price-impact module has already diverged from the v0.1.0 source (no MIN_FACTOR/MAX_FACTOR getters; band is 0 → 2× EMA, not ±10%), and snap.emas decays to the execution block timestamp, so no off-chain replica can be exact. Revert prediction belongs in a pre-submit eth_call, not in a copy of a swappable module.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added exact multi-tick swap simulation for more accurate perp fill estimates.
    • Added tools to calculate maximum fillable amounts and detect liquidity limits.
    • Added live pool tick and liquidity data retrieval.
    • Added reusable tick, price, swap math, and fee-calculation utilities.
    • Expanded public utility exports for easier integration.
  • Improvements

    • Added validation to detect stale pool tick data.
    • Updated the package version to 0.16.0.

lukemacauley and others added 2 commits July 10, 2026 14:49
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>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds 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.

Changes

Exact Swap Simulation

Layer / File(s) Summary
Tick and swap math primitives
src/utils/tickMath.ts, src/utils/swapMath.ts
Adds bigint-precise tick conversion, bitmap helpers, token delta calculations, next-price computation, and token0 swap-step logic.
Pool tick and liquidity fetching
src/utils/poolTicks.ts
Reads PoolManager storage through extsload and builds initialized tick maps and active liquidity values.
Exact multi-tick simulation API
src/utils/swapExact.ts, src/utils/swap.ts, src/utils/index.ts
Adds tick-map validation, exact tick traversal, fill-capacity calculation, fee-adjusted results, and public utility exports.
Simulation validation and package release
src/__tests__/unit/swapExact.test.ts, src/__tests__/integration/swapQuoter.test.ts, package.json
Adds fixture-based and live-quoter validation for fills, boundaries, fees, liquidity, and tick math, and updates the package version.

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: exact multi-tick taker swap simulation.
Docstring Coverage ✅ Passed Docstring coverage is 81.25% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/exact-multitick-swap

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/__tests__/unit/swapExact.test.ts (1)

77-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider moving the staleness test out of the activeLiquidityAt describe block.

The second test in this block exercises simulateTakerSwapExact's rejection path, not activeLiquidityAt directly. Minor organizational nit; grouping it under simulateTakerSwapExact (or a dedicated assertTickMapFresh block) 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 win

Broad catch conflates "cannot settle" with other failures.

quote() swallows every error from simulateContract into null, treating RPC errors, ABI mismatches, and wrong-address failures identically to a genuine NotEnoughLiquidity-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 value

Doc comment references an unimplemented check.

endSqrtPriceX96 is documented as feeding the PriceImpactTooHigh bounds check, but the PR deliberately does not model PriceImpactTooHigh. 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

📥 Commits

Reviewing files that changed from the base of the PR and between ea4c4ff and 3455e69.

📒 Files selected for processing (9)
  • package.json
  • src/__tests__/integration/swapQuoter.test.ts
  • src/__tests__/unit/swapExact.test.ts
  • src/utils/index.ts
  • src/utils/poolTicks.ts
  • src/utils/swap.ts
  • src/utils/swapExact.ts
  • src/utils/swapMath.ts
  • src/utils/tickMath.ts

@lukemacauley
lukemacauley merged commit b1b05ee into main Jul 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant