I shipped a bug into a paid API and it took me a while to see it, because nothing
errored. Every response was a clean HTTP 200 with a confident number in it.
The number was wrong by 25x.
Here is the finding, the arithmetic, and how to check your own code in about
thirty seconds.
The measurement
Uniswap v3, WETH/USDC on Base. Left column is what my API reported as TVL. Right
column is what the pool contract actually holds — a plain balanceOf on each
token, at the pool address.
| pool | reported | actually held | overstated |
|---|---|---|---|
| uniswapV3 0.01% | $2,070,000 | $215,646 | 9.6x |
| uniswapV3 0.05% | $73,600,000 | $10,069,584 | 7.3x |
| uniswapV3 0.30% | $2,840,000,000 | $111,513,855 | 25.5x |
| uniswapV3 1.00% | $14,800,000 | $846,661 | 17.4x |
$2.84 billion in one pool on Base. Base's entire ecosystem TVL is a few billion
dollars. That is what finally made me look — not a failing test, just a number
too large to be true.
Why it happens
A v2 pool holds two piles of tokens and the price is the ratio between them.
getReserves() returns the actual piles. Easy.
A v3 pool concentrates liquidity into price ranges. It does not have "reserves"
in the v2 sense. What it has is a liquidity value L at the current price
P, and the standard way to make v3 math reusable is to compute the virtual
reserves — the amounts a v2-style pool would need to behave identically
right here:
x_virtual = L / √P
y_virtual = L × √P
These are enormously useful. Feed them into the ordinary constant-product
formula and you get correct swap outputs and correct price impact, which is why
essentially every v3 integration computes them.
They are also not tokens anyone owns. They describe the shape of the curve at
the current price, not custody. Concentration is exactly the point of v3: a
position spanning a narrow band behaves like a much larger v2 pool while holding
far less capital. The 25x above is that leverage, showing up as a number I then
mislabelled.
My code did this:
tvlUsd = 2 * reserveA * priceA // fine for v2, nonsense for v3
That line is correct for a v2 pool and silently wrong for a v3 one. Same
variable names, same shape, completely different meaning.
Which number is right depends on the question
This is the part I got wrong twice, so it is worth being explicit. There are two
legitimate numbers and they answer different questions.
"How much money is in this pool?" → real token balances. This is TVL as
everyone else means it, the number DefiLlama shows, the number a user compares
against. Use balanceOf on the pool address.
"How much can I trade here before I move the price?" → virtual reserves.
Price impact, slippage, optimal trade sizing. A trader asking whether a $50k
order will get filled cares about depth at the current price, and virtual
reserves are the correct input.
So the fix is not "stop using virtual reserves." It is: use them for the math
they are for, and never label them TVL.
In my case:
// TVL = custody. Real balances, for anything a human compares.
const tvlUsd = amountA * priceA + amountB * priceB;
// Tradeable depth = virtual reserves. Feeds slippage and sizing.
const tradeableDepthUsd = 2 * reserveB * priceB;
Both are reported now, under names that say which is which. The swap simulator
still uses the virtual ones — changing that would have broken price impact while
every test kept passing, which is its own species of disaster.
Check yours
Pick any v3 pool your code reports TVL for, and compare against the token
balances at the pool address:
// balanceOf(pool) on each token — the tokens the pool actually holds
const data = '0x70a08231' + poolAddress.slice(2).padStart(64, '0');
const raw = await provider.call({ to: tokenAddress, data });
const held = Number(BigInt(raw)) / 10 ** decimals;
If your reported TVL is several times that sum, you have this bug. The ratio
scales with concentration, so the tightest, most efficient pools are the most
wrong — and those are the ones with the most volume.
The part that actually cost me
The bug was live in a paid product for a while, and none of my tests caught it,
because every test asserted on status codes and shapes. 200 OK. totalTvlUsd
is a number. venues is an array. All true. All passing. All useless.
The class of bug that survives that kind of testing is the one where the response
is structurally perfect and semantically false — and it is far more common in
data APIs than crashes are. A crash tells you. A confident wrong number does not,
and downstream it becomes someone's trade.
Since then I assert on magnitudes and cross-sources: does this pool's TVL
agree with its token balances, does WETH price the same on six chains, does a
market with $100M of depth avoid being labelled VERY_THIN. Those catch things
expect(res.status).toBe(200) never will.
Two others the same discipline caught later, both also 200s:
- A token quoting a clean price off a pool holding $0 — a real price, zero liquidity behind it. Anything that returns a price should return the depth backing it, and refuse when there isn't any.
- A routing endpoint recommending "sell into sushiswap" for a pool with $0 in it, against a 4787bps spread. Broken pools quote outlier prices, which makes the most broken venue look like the best one.
If it is useful, the corrected implementation is live — multi-chain DEX prices,
depth, routing and slippage, with the custody/tradeable distinction reported
explicitly on every response:
- MCP server for Claude/Cursor:
npx -y dex-data-mcp - Free tier, no API key:
curl https://x402.donnyautomation.com/price?symbol=CAKE
But mostly: go run balanceOf against a v3 pool you already trust, and see what
comes back.
Top comments (0)