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:
-
sqrtPriceX96is 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. - 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.
- Shared contracts. Balancer v2 Vault and Uniswap v4 PoolManager emit events for thousands of pools. We filter
eth_getLogsby the contract address only (no topic filter), route a pool-scoped event by the pool id intopic1, ignore other pool ids, and treat an unknowntopic0of 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
f64for 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_callto 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.