Skip to content

A quote is not a price unless the pool can absorb it - #820

Open
nategeier wants to merge 1 commit into
mainfrom
fix/arc-thin-pool-price
Open

nategeier wants to merge 1 commit into
mainfrom
fix/arc-thin-pool-price

Conversation

@nategeier

Copy link
Copy Markdown
Member

Nate — the Arc pin that went red today was not a code regression, and it was not really about Arc either. It caught a hole that had been open on every chain since the probe was written.

What happened

Arc's WETH/USDC 1% pool was empty when Arc shipped, which is why #793 pinned WETH there as honestly unpriceable. It has since woken up holding about $18 of USDC in total. One whole WETH in drains it, so the quoter answered:

WETH on Arc   1 whole token  → $18.25 /token   (2 ticks crossed)
              1/1000 token   → $2,476 /token
              1/1e6 token    → $2,485 /token

usdPerToken quotes exactly one whole token, takes the best amount out across fee tiers, and returns it. So it handed $18.25 per WETH to everything downstream. That number values holdings, sizes "$50 of X" asks, and is the figure a spend cap and a policy gate are checked against — wrong by two orders of magnitude, in the direction that makes a real position look like pocket change.

The fix: a depth fence, not an Arc-WETH denylist

I went with the general option in the brief. Every v3 fee tier — and the v4 fallback — is now quoted at the probe size and half of it, and trusted only when out(full) is within MAX_PROBE_DECAY_BPS of 2 × out(half).

Two properties make this the right measure:

  • The fee rate cancels in the ratio. Both quotes pay the same fee, so what is left is pure slippage: how much of the book the probe ate.
  • It maps onto the error we actually care about. With x = probe ÷ reserve, constant-product gives decay = 1 − (1 + x/2)/(1 + x), and the average price understates the marginal price by 1/(1 + x). So 5% decay ⟺ a price within ~10% of the pool's marginal price — the same 10% band lib/stock-tape holds 4663 stock fills to.

A tier that fails is dropped as if it had no pool. When no tier survives, the probe returns null and callers ask for a token amount. Failing closed, as the brief asked.

Why 5%

Measured live across every app chain, 2026-09-18 (decay at one whole token, per tier; bold = the tier that wins the max):

chain token 0.01% 0.05% 0.3% 1%
Base ETH 0.03% 0.00% 0.00% 0.04%
Base cbBTC 50.00% 0.06% 0.25% 50.00%
Base AERO 46.72% 0.00% 0.00% 0.08%
Base USDT 0.00% 0.00% 0.00% 48.18%
Base DEGEN 0.00% 0.00% 0.00%
Ethereum ETH 0.01% 0.00% 0.00% 0.17%
Ethereum WBTC 50.00% 2.52% 0.09% 50.00%
Ethereum UNI 0.00% 23.76%
Arbitrum LINK 50.00% 50.00% 0.01%
Optimism ETH 48.15% 0.24% 0.00% 3.88%
Optimism OP 45.32% 47.54% 0.00% 50.00%
Arc BTC 0.15% 50.00%
Arc EURC 0.00% 0.00%
Arc WETH 50.00%

Every pool that is really traded decays under 0.4%. The thinnest legitimate tier anywhere was 3.88% (and it is not a winner). Every drained pool decays 23.76% or more — 50% in the constant-product limit, which is where Arc's WETH pool sits. 5% is the middle of that gap with room on both sides, and the harness pins that the constant stays inside it.

Judged per tier, not per token

This matters more than the Arc case. Today the probe takes max(amountOut), so a thin tier is usually harmless — a drained pool cannot out-quote a deep one. But it only takes a thin pool that is mispriced upward for max() to pick it, and drained sibling tiers are everywhere: Base cbBTC 0.01% and 1%, Ethereum WBTC 0.01% and 1%, Arbitrum LINK 0.01% and 0.05%, Optimism OP 0.01% and 0.05%. Each tier now carries its own verdict, and a dropped one logs why:

[usd-probe] WETH/USDC on Arc: dropping the 1% tier — one whole token decays
50.0% (fence 5%), so its $18.25 is the pool being drained, not a price.

No price on any chain changed. Every winner above was already the winner; the fence only removes tiers max() was ignoring anyway — except on Arc WETH, where the only tier was the bad one.

The precision guard

SHIB's half-token quote is 2 raw units of USDC, and 1 − 5/(2×2) reads as −25% "decay" on a perfectly healthy pool. So below MIN_DEPTH_QUOTE_UNITS (100 units = $0.0001 of a 6-decimal stable) the measure is rounding noise and the check steps aside rather than accusing a healthy pool. That covers every token worth more than ~$0.0002 a whole token — DEGEN at $0.001 clears it by 5× — and steps aside only where a whole token is worth less than the quoter can resolve, which is also where a thin pool cannot hide a real position. Negative decay clamps to zero: only a price that collapses with size is evidence of thinness.

Item 3 — what else reads Arc WETH, and does a refusal read as $0?

Grepped every usdPerToken caller. Nothing coerces a null probe to $0, and every path names the refusal:

caller on a null probe
chat, $N of X swap sizing "I couldn't price WETH on Arc to size a $50 swap — say a token amount instead"
lib/transfer-exec (spend cap) valueUsd = nullpolicyCheck blocks: "Spend policy is ON but this action has no priceable leg — refusing rather than bypassing your caps"
lib/funding-plan traces "couldn't price X to size the plan — falling through", returns null
lib/spot-guard-exec "Couldn't price X on Arc to anchor the trigger — nothing armed"
lib/dca-auto-exec "Couldn't price X to set the buy's floor — refused. Nothing pulled."
lib/lifi-bridge gas leg Arc is gasLeg: false, so it never runs; the branch degrades to a warn anyway
lib/wallet-view prices Arc holdings via Alchemy, never this probe — unaffected
/api/markets/routes Arc is not in the markets chain set

So the fence makes every one of these more honest, not silently zero.

Re-pinning: the rule, not the market

The old pin said "WETH is unpriceable", which is a fact about a pool on one day. Two pins replace it.

1. Pure — the fence's arithmetic. A drained pool (~50%) is refused; an exactly-at-the-fence pool passes and one bp over does not; a sub-resolution quote steps aside; negative decay clamps; a dead pool is refused; and MAX_PROBE_DECAY_BPS must sit inside the measured gap (400 < it < 2300). Mutation-tested: removing the threshold, removing the clamp, or removing the precision floor each turns it red.

2. Live — the invariant, derived without the fence. For each Arc token it takes fresh 1/1000-size quotes per tier and asserts: every price the probe returns is within 15% of the pool's own marginal price, and every refusal is earned — no tier was healthy enough to price. It deliberately does not call judgePoolDepth; a pin that re-used the fence's own arithmetic would move with it under a mutation (I checked — it did, which is why I rewrote it this way). Neutering the fence turns it red:

unmutated    [{"sym":"WETH","got":null,"bestMarginal":2476.14,"ok":true}, …]   GREEN
fence off    [{"sym":"WETH","got":18.25,"bestMarginal":2476.14,"ok":false}, …] RED

Which tiers are thin today is market state and is not asserted anywhere. A puddle that fills up flips the answer without flipping the pin.

Calls for you

  1. 5% is the number. The gap between "thinnest real pool" (3.88%) and "thinnest drained pool" (23.76%) is wide, so anything in 5–20% would work. 5% buys the tightest guarantee (~10% of marginal). If you'd rather never refuse a borderline-but-real pool, 10% is the other sensible pick — one constant.
  2. The refusal is total, not a downgrade. I do not fall back to the marginal price from the small quote when a pool fails. Arc's marginal WETH price is $2,485 while ETH trades ~$2,510 everywhere else — a pool nobody arbitrages is stale as well as thin, and the brief said fail closed. One line to change if you'd rather show the marginal price.
  3. A half-quote that fails on transport is trusted, not refused. A smaller input cannot revert where a larger one succeeded, so a half-failure is always a transport blip — and it already counts toward the existing retry. Refusing there would cost real prices on RPC hiccups.
  4. The SWAP BUILD is not fenced — only the probe. buildUniswapSwap still takes max(amountOut) across tiers with no depth check; on 4663 stocks checkFillAgainstTape (Stock swaps on Robinhood Chain are checked against the tape #796) covers it, and everywhere else max-out naturally routes away from thin pools at real trade sizes (a drained pool cannot out-quote a deep one for $50). The probe was exposed precisely because it always quotes a whole token. I've filed a task chip rather than widen this PR.

Gates

  • npx tsc --noEmit clean
  • npm run build clean
  • npm run test:api — see the comment below (run alone, against next start on this branch)
  • Live read-only probe of every app chain before and after: Arc WETH $18.25 → null; Base/Ethereum/Arbitrum/Optimism/Arc/4663 prices unchanged to 3 decimal places.

No DDL, no env, no new dependency.

🤖 Generated with Claude Code

Arc's WETH/USDC 1% pool — empty at launch, so the chain shipped with WETH
honestly unpriceable (#793) — woke up holding about $18 of USDC. One whole
WETH in drains it, so `usdPerToken` answered $18.25 per WETH against the same
pool's ~$2,485 marginal price. That number values holdings, sizes "$50 of X"
asks, and is the figure a spend cap and a policy gate are checked against, and
it is wrong in the direction that makes a real position look like pocket
change.

The fence is general, not an Arc-WETH denylist: every v3 fee tier (and the v4
fallback) is quoted at the probe size AND half of it, and trusted only when
`out(full)` is within MAX_PROBE_DECAY_BPS of `2 × out(half)`. The fee rate
cancels in that ratio, so what is left is pure slippage — how much of the book
the probe ate. Constant-product arithmetic maps 5% decay to a price within
~10% of the pool's marginal price, the same band lib/stock-tape holds 4663
stock fills to. A tier that fails is dropped as if it had no pool; when none
survives the probe returns null and callers ask for a token amount instead
(they already fail closed — an unpriced leg refuses under a spend policy
rather than bypassing it, and never reads as $0).

Judged per TIER, not per token: a thin tier that quoted HIGH would otherwise
win the max() below. Base's cbBTC 0.01%, Ethereum's WBTC 0.01%/1%, Arbitrum's
LINK 0.01%/0.05% and Optimism's OP 0.01%/0.05% tiers are all drained today
next to healthy siblings.

Measured live across every app chain 2026-09-18: every traded pool decays
under 0.4% at one whole token, the thinnest legitimate tier seen was 3.88%,
and every drained pool decays 23.8% or more. No price on any chain changed.

Harness: the old pin asserted today's market state ("WETH is unpriceable") and
so went red on a market move. It is replaced by two pins that assert the RULE
— one pure (a drained pool is refused, an at-the-fence pool passes and one bp
over does not, a sub-resolution quote steps aside rather than accusing a
healthy pool, negative decay clamps), and one live that derives the invariant
from fresh 1/1000-size quotes WITHOUT the fence's own arithmetic: every price
returned is within 15% of the pool's marginal price, and every refusal is
earned. A puddle that fills up flips the answer without flipping the pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
website Ready Ready Preview Sep 18, 2026 10:24am UTC

Request Review

@nategeier

Copy link
Copy Markdown
Member Author

Gates — 8e28fb73

npx tsc --noEmit clean · npm run build clean.

npm run test:api → 3107 passed / 1 known-red (router select: prefers Yeetful · Claude), against next start on :3969, run alone.

Both new pins green:

✅ depth fence (rule): … a drained pool (~50%) is refused, an exactly-at-the-fence
   pool passes and one bp over does not, a quote under 100 stable units is rounding
   noise and steps aside rather than accusing a healthy pool, and negative decay
   clamps to zero
   {"deep":{"decayBps":0,"trusted":true},"edgeOk":{"decayBps":500,"trusted":true},
    "edgeBad":{"decayBps":550,"trusted":false},"drained":{"decayBps":4999,"trusted":false},
    "dust":{"decayBps":null,"trusted":true},"negative":{"decayBps":0,"trusted":true}, …}

✅ depth fence (live, Arc): every price usdPerToken returns is within 15% of the
   pool's own marginal price, and every refusal is earned
   WETH  got null       bestMarginal $2,476.14   (1% tier: full $18.25 vs marginal $2,476)
   BTC   got $77,896.52 bestMarginal $78,126.74  (0.3% tier refused: full $376 vs marginal $77,419)
   EURC  got $1.146816  bestMarginal $1.146

Run 1 was 3106/2 — the extra red was another session, not this diff

receipt money: …/activity money-moved is byte-stable read {"before":1.31,"mid":1.35}. The check spoofs three $4,999 signs; the number moved $0.04, so the spoof defence held — and the studio half of the same check was byte-identical. A harness in website-jobstep-beacon was running against the shared TEST DB at the time (pgrep confirmed it, and it started another run while I was waiting to re-run). Run 2, alone, is green. This diff touches no telemetry path.

Mutation tests

Three source mutations, each turning the pure pin red:

mutation pin
trusted: decayBps <= MAX_PROBE_DECAY_BPStrusted: true RED
drop the Math.max(0, decay) clamp RED
drop the half < MIN_DEPTH_QUOTE_UNITS precision floor RED

And the live pin, which deliberately does not call judgePoolDepth, catches a fence that stops fencing:

unmutated   [{"sym":"WETH","got":null,    "bestMarginal":2476.14,"ok":true}, …]   GREEN
fence off   [{"sym":"WETH","got":18.2516, "bestMarginal":2476.14,"ok":false}, …]  RED

(My first draft of that pin re-derived expected through judgePoolDepth and stayed GREEN under the same mutation — both sides moved together. That's why it now reads the marginal price from fresh 1/1000-size quotes instead.)

Live before/after, every app chain

Read-only, on a prod build. One price changed:

Arc      WETH   $18.2516  →  null (unpriceable)      ← the bug
Arc      BTC    $77,868   →  $77,868   (0.3% tier dropped, 0.01% tier already won)
Arc      EURC   $1.1467   →  $1.1467
Base     ETH    $2,509.71 →  $2,509.65    Base  cbBTC $77,998 → $77,987
Base     DEGEN  $0.001012 →  $0.001012    Base  USDT  $0.9991 → $0.9991
Ethereum ETH    $2,511.61 →  $2,510.26    Ethereum WBTC $77,781 → $77,781
Ethereum SHIB   $0.000005 →  $0.000005  ← under the precision floor, steps aside
Arbitrum LINK   $11.8146  →  $11.8146     Optimism OP   $0.1104 → $0.1102
4663     AAPL   $336.00   →  $336.00   (tape, never a pool — unchanged path)

Deltas are tick drift between reads, not the fence. Every dropped tier logs why:

[usd-probe] CBBTC/USDC on Base: dropping the 0.01% tier — one whole token decays
50.0% (fence 5%), so its $104.92 is the pool being drained, not a price.

Cost: the quoter scan goes from 4 calls to 8 per chain, in parallel — 263ms → 331ms on the slowest path measured.

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