DEV Community

Cover image for Majority vote is free to fake: three attempts at deciding which DEX pool to believe
BlazePhoenix
BlazePhoenix

Posted on

Majority vote is free to fake: three attempts at deciding which DEX pool to believe

Two dead pools agreed that a trade was worth 910. One pool holding $401,000 said 1,633. A median filter that counts votes threw the honest pool out as an outlier.

That is not a thought experiment. It is the measured case behind the design of BlazePhoenix's Solver, and the way it got fixed is more instructive than the fix: the first fix was also wrong, and a researcher showed it.

Three rules for whose quote counts, three attacks

The problem: quoting is free, holding tokens is not

A pool contract can answer a quote request with any number it likes, because a quote is arithmetic over whatever the contract says its reserves are. So an aggregator that asks eight pools for a price and "trusts the majority" has a voting system where votes cost nothing. The learn article on phantom liquidity has the measured case; the whitepaper (v2.3.0, DOI 10.5281/zenodo.23084091, §10.3) gives the rule that replaced it.

Below are three rules and three attacks. The numbers for pools and reserves are mine (toys); the rules are the ones the whitepaper describes, and the 910 / 1,633 pair is the learn article's measured case.

The code

"""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)])
Enter fullscreen mode Exit fullscreen mode

Run it with plain python3 phantom.py:


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']
Enter fullscreen mode Exit fullscreen mode

Attempt 1: one pool, one vote

Case 1 is the measured one: the median by count centres on 910 and admits only the two dead pools. Case 3 is the cheaper attack: six pools holding a hundred units each can quote the same forged number, and by count they outvote two honest pools holding hundreds of thousands. Votes that cost nothing are not evidence.

Attempt 2: anchor on the biggest real balance

The next idea sounds airtight: balanceOf(pool) cannot be faked without holding the tokens, so anchor the band on the pool with the largest real balance. The learn article (updated 2026-07-17) describes that design and sums it up as "balanceOf is sworn testimony."

It survives Case 1 and Case 3. It dies in Case 2. A plain transfer to a pool raises its balanceOf without moving its reserve or its price, and the donor can get it back later (the Solver's source names V2 skim and a V3 LP claim), so it is not capital at risk. The whitepaper (§10.3) records that an external researcher demonstrated the earlier balance anchor being captured by a donation, and that the anchor was replaced. In the toy: a pool quoting 2,000 with 1,000 in its reserves, plus a 5,000,000 donation, becomes the centre of the band and the honest pools fall outside it.

Attempt 3: a median weighted by depth that a donation cannot move

The rule that stands is the depth-weighted median, equation E25 in Appendix A: sort candidates by rate, carry each one's depth along, and return the first rate at which the cumulative depth reaches half of the total. Two properties matter:

  • The depth is not balanceOf. For reserve pools it is the reserves, for concentrated pools the active liquidity, for V4 pools measured liquidity: quantities a plain donation does not move.
  • The breakdown point is half the depth mass. To move the centre you must out-weigh half of the real liquidity of the pair; six hundred-unit pools get no say.

Then candidates whose marginal rate departs from that centre by more than MEDIAN_FILTER_BPS = 500 (±5 %) are dropped before anything is sent. The constant is in src/BlazePhoenixSolver.sol, and the comment on it states the cost out loud: the band is symmetric, so a pool 5 % worse is admitted too, and because weights follow depth, a deep pool that is 5 % worse can take a large share.

The same source file carries a second admission. Its comment records that for a long time the documentation said "median" while the code used the rate of the single deepest pool, an estimator with a breakdown point of zero, and that this was corrected on 2026-08-21, moving the breakdown point from 0 to 50 % of the mass. The prose was more reassuring than the code. A comment that says so is worth more than a clean history.

Where balanceOf is still the right tool

If a donation can inflate balanceOf, why does the Solver still read it? Because a different question needs a different instrument. The band asks a relative question (is this rate believable next to the others?), which a donation can skew. The capacity clamp asks a physical one: can this pool actually pay this much? A donation really does raise what the pool can pay. So concentrated legs are clamped to MAX_CONC_DRAIN_BPS = 3,000, 30 % of the pool's measured output-token balance (§10.4), and when the quote exceeds the pool's entire holdings the leg's committed input is cut in the same ratio.

Two measured numbers from the same section are worth carrying around. A single-tick concentrated-liquidity formula on a thin pool once promised 117× more than the pool ever held. And in a WETH/USDC case, splitting 50/50 between a 790-WETH pool and a 0.34-WETH dust pool returned 1,302 USDC, concentrating in the deep pool returned 1,638, and the depth-weighted allocation returned 1,638.

Capacity clamp, the measured WETH/USDC case

Check it yourself

  1. python3 phantom.py (above). Change the reserves and see which rule breaks first.
  2. On any Uniswap-V2-style pair, compare balanceOf(pair) with getReserves() for the same token. The gap is value the pair's accounting does not count yet. That gap is what Case 2 weaponises.
  3. Read E25 in the whitepaper (Appendix A), then _depthWeightedMedian in the source.

balanceOf and reserve react differently to a donation

Status

BlazePhoenix is pre-launch. The code in this article is my own toy. The Solver rules it imitates are described by the whitepaper for V2, which is not deployed (§19), and an independent external audit is scheduled before launch. Nothing here is a claim about volume or users; it is a claim about a rule you can run.

Sources: learn/phantom-liquidity, learn/capacity-clamp, BlazePhoenix Technical Whitepaper v2.3.0 §10.3–10.4 and E25 (DOI). Text of the learn articles and whitepaper is CC BY 4.0, credit BlazePhoenix, https://blazephoenix.xyz.

Top comments (0)