We wanted one chart that shows BTC and ETH from Binance and from every large Ethereum pool as separate lines, with no averaging and no invented points. Binance is the easy half: its @trade stream gives price, quantity and time per trade. The pools are harder: eight protocol families, each with its own events, its own math and its own idea of what "the price" is. This post is what we learned building that observer in Rust, with numbers from its own data.

Two kinds of price

For every pool we keep two things apart:

  • Trade price — the ratio of what actually changed hands in one swap: quote_amount / base_amount, taken from the swap's two legs. It produces volume.
  • Spot price — the pool's marginal price right after an event (or at block end). It produces the line on the chart and never produces volume.

A swap whose legs do not move in opposite directions is not a trade and is skipped. All ratios are exact decimals truncated (not rounded) at 24 places; JSON carries them as strings. No f64 anywhere a trader reads a number.

Per family

Family Events Spot price
Uniswap v2 and forks (SushiSwap) Sync, Swap reserves after each Sync: reserve_quote / reserve_base, scaled by decimals
Uniswap v3 pool Swap (pool-signed amounts) sqrtPriceX96 of each swap
Uniswap v4 PoolManager Swap, topic1 = PoolId (swapper-signed amounts) sqrtPriceX96 of each swap; the PoolKey must hash to the PoolId; native ETH is 0x0
Balancer v2 (weighted) Vault Swap, PoolBalanceChanged, PoolBalanceManaged, topic1 = poolId getPoolTokens + getNormalizedWeights at N−1, replayed per event: (Bq/Wq)/(Bb/Wb)
Balancer v1 LOG_SWAP, LOG_JOIN, LOG_EXIT getBalance + getDenormalizedWeight at N−1, replayed
Curve TokenExchange variants, only the market's coin pair counts get_dy(base, quote, 10^(dec_base−3)) at block end, which includes the pool fee
Fluid DEX Swap(bool swap0to1, …) resolver getDexPricesAndExchangePrices, word 0 (token1 per token0 × 1e27), at block end
DODO v2 DODOSwap getMidPrice() / 1e18, scaled by decimals, at block end

Three details bit us:

  1. sqrtPriceX96 is token1 per token0, and token order is by address. price = (sqrtPriceX96 / 2^96)^2 × 10^(dec0 − dec1). In the main WETH/USDC pool USDC (0xa0b8…) sorts before WETH (0xc02a…), so the formula gives WETH per USDC and has to be inverted to get dollars per ETH. We keep the pool's native pair (WETH/USDC) on the label and mark the inversion explicitly instead of silently flipping it.
  2. v3 and v4 sign amounts differently. v3 amounts are signed from the pool's point of view, v4 from the swapper's. Same field names, opposite signs.
  3. Shared contracts. Balancer v2 Vault and Uniswap v4 PoolManager emit events for thousands of pools. We filter eth_getLogs by the contract address only (no topic filter), route a pool-scoped event by the pool id in topic1, ignore other pool ids, and treat an unknown topic0 of an observed contract as an error that stops the block. Silently skipping unknown events is how you end up with a price that drifted for a week without anyone noticing.

State reads pinned to a block hash

Some families cannot be priced from logs alone (Balancer weights, Curve, Fluid, DODO). We read their state with one Multicall3 aggregate3 per block, either at the parent (N−1, the pre-state to replay events on) or at N (block-end state). Every call is pinned by EIP-1898 {"blockHash": …, "requireCanonical": true}, never by number: if a reorg happens between reading the header and the call, the call fails instead of mixing two branches.

Curve and Fluid pools can move without their own events (oracle, amplification, weights), so every 5th block we also probe them and store a "probe" snapshot. A probe moves the line; it never creates a trade or volume.

Reorgs

Within 64 blocks of the head we fetch each block by number and check parentHash against the last committed block. On a mismatch we walk back to the fork point, and one database transaction retracts the orphaned trades, restores the affected candles and moves the checkpoint. The checkpoint never moves past uncommitted data, so a SIGTERM in the middle of a block leaves nothing half written.

Gaps are gaps

A pool with no swaps for an hour keeps its last price, and the chart says how old it is. A source that stopped delivering is shown as unavailable. We never interpolate or forward-fill a price as if it were fresh. In our data one Balancer v1 WBTC/USDC pool had no trades for four days; it is still listed, with its age.

What the data shows

From 5-minute closes, October 28 – November 4, 2026:

  • Binance ETH/USDT vs Uniswap v3 WETH/USDC: median gap 0.11%, 95th percentile 0.19%.
  • Binance BTC/USDT vs Uniswap v3 WBTC/USDC: median 0.21%, 95th percentile 0.35%. Two differences stack here: BTC vs WBTC and USDT vs USDC.

The exception is October 29, 2026, the day of the Binance hot-wallet hack. Prices moved together until about 20:30 UTC, then split: at 20:45 UTC Uniswap v3 showed about $2,744 per ETH and Binance about $2,262, a 21% gap. It stayed above 10% for about 45 minutes and was under 1% again around 21:35 UTC. With withdrawals suspended, inventory could not move between the exchange and the chain, so arbitrage could not close the gap at its usual speed. An averaged price feed would have shown a single number in the middle and hidden both markets.

Checklist if you build something similar

  • [ ] keep trade price and spot price separate; only trades make volume
  • [ ] use exact decimals, truncate, never f64 for prices
  • [ ] check token order before reading sqrtPriceX96, and label inversions
  • [ ] handle v3 vs v4 amount signs
  • [ ] fail on unknown events of an observed contract
  • [ ] pin eth_call to the block hash (EIP-1898)
  • [ ] retract trades and restore candles on reorgs in one transaction
  • [ ] show the age of the last price instead of filling gaps

Disclosure: we build StarGate, a wallet, swap and bridge service whose app has a Trade screen with these markets. This post was drafted by an AI assistant from our observer's code and documentation, and the numbers come from its production data.