Which pool's quote to believe when quotes are free to forge: three rules, three attacks, one depth-weighted median

Disclosure: I work on BlazePhoenix, an on-chain DEX aggregator (pre-launch, independent external audit scheduled, nothing deployed for V2). This is a research note about a design question we got wrong once, posted here for criticism rather than promotion.

Question: when an aggregator/solver asks N pools for a price, what should decide whose quote is believed, given that a quote costs nothing to forge?

A pool contract can answer a quote request with any number, because a quote is arithmetic over whatever the contract says its reserves are. “Trust the majority” is then a vote where votes are free. I tried three rules against three attacks (toy numbers for reserves; the 910 / 1,633 pair is a measured case from our phantom liquidity write-up).

Rules

  1. Median by count (one pool, one vote).
  2. Anchor the band on the pool with the largest balanceOf.
  3. Depth-weighted median: sort by rate, carry depth (reserves / active liquidity, not balanceOf), return the first rate where cumulative depth >= ceil(total/2); then drop candidates more than ±500 bps from that centre. This is equation E25 in our whitepaper (v2.3.0, §10.3, DOI 10.5281/zenodo.23084091).

Attacks

  • Case 1 (measured): two dead pools agree on ~910, one pool with ~$401k says 1,633. Rule 1 centres on 910 and admits only the dead pools.
  • Case 2: a pool quotes 2,000 with 1,000 in reserves, then receives a 5,000,000 plain transfer (recoverable via skim / LP claim, so not capital at risk). Rule 2 picks it as the anchor. Our whitepaper records that an external researcher showed an earlier balance-anchored design being captured this way; it was replaced.
  • Case 3: six dust pools (100 units each) quote the same forged 2,000. Rule 1 follows them.
Toy script (python3 phantom.py)
"""phantom.py — three ways to decide whose price to believe. Toy numbers, real rules.
Rules copied from: Whitepaper v2.3.0 E25 / section 10.3 (depth-weighted median, band +-500 bps)."""
from dataclasses import dataclass

@dataclass
class Pool:
    name: str
    rate: float      # what the pool QUOTES (free to forge)
    reserve: float   # depth in the pool's own accounting (a plain transfer does not move it)
    balance: float   # balanceOf(pool): a plain transfer (donation) DOES move it

def band(pools, centre, bps):
    lo, hi = centre * (1 - bps / 1e4), centre * (1 + bps / 1e4)
    return [p.name for p in pools if lo <= p.rate <= hi]

def median_by_count(pools):                      # one pool, one vote
    r = sorted(p.rate for p in pools)
    return r[(len(r) - 1) // 2]

def anchor_largest_balance(pools):               # "balanceOf cannot be faked without capital"
    return max(pools, key=lambda p: p.balance).rate

def depth_weighted_median(pools):                # E25: first rate where cumulative depth >= ceil(total/2)
    ps = sorted(pools, key=lambda p: p.rate)
    total, acc = sum(p.reserve for p in ps), 0
    for p in ps:
        acc += p.reserve
        if acc >= -(-total // 2):
            return p.rate

def report(title, pools, bps_anchor=200):
    print(f"\n{title}")
    for label, centre, bps in [
        ("median by count            ", median_by_count(pools), 500),
        ("anchor = largest balanceOf ", anchor_largest_balance(pools), bps_anchor),
        ("depth-weighted median (E25)", depth_weighted_median(pools), 500),
    ]:
        print(f"  {label} centre {centre:>7.0f}  admits {band(pools, centre, bps)}")

# Case 1 -- the measured case from the learn article: two dead pools agree on ~910, one deep pool says 1633.
# (910 and 1633 are the article's numbers; the reserves/balances are mine, only their ORDER of size matters.)
report("Case 1: two dead pools agree on 910, the deep pool quotes 1633", [
    Pool("dead-1", 910, 2_000, 2_000),
    Pool("dead-2", 910, 3_000, 3_000),
    Pool("deep",  1633, 401_000, 401_000),
])

# Case 2 -- a donation: the attacker's pool quotes 2000, holds almost nothing in its reserves,
# and is sent 5,000,000 by a plain transfer, recoverable later, so it costs nothing at risk.
report("Case 2: a forged 2000 quote plus a donated balance", [
    Pool("honest-a", 1633, 401_000, 401_000),
    Pool("honest-b", 1630, 120_000, 120_000),
    Pool("attacker", 2000, 1_000, 5_000_000),
])

# Case 3 -- count is free to fake: six dust pools quote the same forged 2000.
report("Case 3: six dust pools agree on a forged 2000", [
    Pool("honest-a", 1633, 401_000, 401_000),
    Pool("honest-b", 1630, 120_000, 120_000),
] + [Pool(f"dust-{i}", 2000, 100, 100) for i in range(6)])

Output:

Case 1: two dead pools agree on 910, the deep pool quotes 1633
  median by count             centre     910  admits ['dead-1', 'dead-2']
  anchor = largest balanceOf  centre    1633  admits ['deep']
  depth-weighted median (E25) centre    1633  admits ['deep']

Case 2: a forged 2000 quote plus a donated balance
  median by count             centre    1633  admits ['honest-a', 'honest-b']
  anchor = largest balanceOf  centre    2000  admits ['attacker']
  depth-weighted median (E25) centre    1633  admits ['honest-a', 'honest-b']

Case 3: six dust pools agree on a forged 2000
  median by count             centre    2000  admits ['dust-0', 'dust-1', 'dust-2', 'dust-3', 'dust-4', 'dust-5']
  anchor = largest balanceOf  centre    1633  admits ['honest-a', 'honest-b']
  depth-weighted median (E25) centre    1633  admits ['honest-a', 'honest-b']

Only rule 3 survives all three: its breakdown point is half the depth mass, so to move the centre you must out-weigh half of the real liquidity of the pair. Caveats I’d like your view on:

  • The ±5% band is symmetric, so a pool 5% worse is admitted too, and since weights follow depth, a deep pool that is 5% worse can take a large share.
  • balanceOf is still used elsewhere, for a physical question (can this pool pay this much?): concentrated legs are clamped to 30% of the pool’s output-token balance (MAX_CONC_DRAIN_BPS = 3000, §10.4), because there a donation really does raise what the pool can pay. A single-tick CL formula on a thin pool once promised 117x more than the pool ever held.
  • For a long time our docs said “median” while the code used the single deepest pool’s rate (breakdown point zero); fixed on 2026-08-21.

Questions for the searchers/builders here: (a) is depth measured from reserves/active liquidity still gameable by just-in-time liquidity added within the same block? (b) would you weight by depth, or by simulated output at the actual trade size? (c) any known donation or skim variants that move reserve-based depth?

Sources: BlazePhoenix whitepaper v2.3.0 (https://blazephoenix.xyz/docs/BlazePhoenix_Whitepaper.pdf, text CC BY 4.0) and The two-tier capacity clamp: refusing trades a pool cannot absorb