There is no USDT/USDC market in our dataset, yet on October 29, 2026 we can say USDT traded at 0.9054 USDC on Ethereum at 21:09 UTC. This post shows how: read the cross rate through a third asset, keep the candles honest, and know what the number does and does not mean. All numbers come from our own observer, which records Binance trades and every swap of 23 BTC and ETH markets in Ethereum pools (Uniswap v2/v3/v4, SushiSwap, Curve, Balancer, Fluid, DODO) into 1m…1d candles.
The trick: a cross rate through ETH
Two pools in the same protocol, same base asset, different stablecoins:
P_usdc = close(Uniswap v3 WETH/USDC) # USDC per ETH
P_usdt = close(Uniswap v3 WETH/USDT) # USDT per ETH
implied USDT in USDC = P_usdc / P_usdt
If both pools are arbitraged against each other, the ratio is the market's price of USDT in USDC. Same idea for wrapped bitcoin: close(WBTC/USDT pool) / close(Binance BTC/USDT) is WBTC in BTC, because both legs are quoted in USDT.
What makes the number trustworthy
-
Same minute, both with trades. We join candles on
open_timeand keep only minutes where both markets had at least one trade (trade_count > 0). A pool without swaps keeps its last price; mixing a stale close with a fresh one manufactures a fake gap. -
Exact decimals. Prices are stored as
NUMERIC, computed from raw token amounts truncated at 24 decimal places. No floats until the chart. -
Native orientation kept. In the WETH/USDC pool, USDC sorts before WETH by address, so
sqrtPriceX96gives WETH per USDC. We invert explicitly and label the pair as the pool has it, instead of silently flipping. - Know the noise floor. Each pool has its own fee tier and its own last-trade side, so two healthy pools disagree by a few basis points. Over 20 days the median implied USDT was 0.9989 USDC and 90% of hours sat between 0.9985 and 0.9993. Anything inside that band is noise, not a depeg.
WITH m AS (
SELECT k.open_time,
MAX(k.close) FILTER (WHERE mk.symbol = 'WETHUSDC') AS c,
MAX(k.close) FILTER (WHERE mk.symbol = 'WETHUSDT') AS t
FROM market_kline k
JOIN trade_market mk ON mk.id = k.market_id
WHERE k.retention_group = '1m' AND k.trade_count > 0
AND mk.venue_id = :uniswap_v3
GROUP BY 1)
SELECT COUNT(*) FILTER (WHERE c / t < 0.99) AS minutes_below_099,
MIN(c / t) AS worst
FROM m;
What 20 days look like
October 15 – November 3, 2026:
| Measure | Median | 90% of hours | Beyond threshold |
|---|---|---|---|
| USDT in USDC | 0.9989 | 0.9985 – 0.9993 | below 0.99: 42 of 17,152 minutes |
| WBTC in BTC | −0.16% | −0.22% – −0.04% | beyond ±1%: 45 of 9,366 minutes |
Every one of those 42 and 45 minutes falls on the evening of October 29.
The evening of October 29
Attackers drained Binance hot wallets from about 20:05 UTC and Binance suspended withdrawals. One-minute closes:
| UTC | Binance ETH/USDT | Uni v3 WETH/USDC | Uni v3 WETH/USDT | implied USDT |
|---|---|---|---|---|
| 20:30 | 2,318 | 2,315 | 2,318 | 0.999 |
| 20:34 | 2,298 | 2,336 | 2,342 | 0.997 |
| 20:36 | 2,302 | 2,520 | 2,536 | 0.994 |
| 20:45 | 2,264 | 2,662 | 2,834 | 0.939 |
| 21:09 | 2,266 | 2,753 | 3,040 | 0.906 |
| 21:20 | 2,282 | 2,566 | 2,610 | 0.983 |
| 21:35 | 2,291 | 2,313 | 2,327 | 0.994 |
Two separate effects stack in the pools: ETH itself was bid on-chain (USDC pools about 21% above Binance), and USDT was discounted against USDC (USDT pools 34–40% above Binance). Twelve ETH pools crossed a 2% gap between 20:34 and 20:39 and almost all peaked at 21:09. Bitcoin shows the same split more mildly: WBTC/USDC stayed within about 1–2% of Binance, WBTC/USDT reached 11% above it.
Why it lasted an hour: with withdrawals closed, nobody could move inventory from Binance to Ethereum, so the cross-venue arbitrage that normally closes a 1% gap in minutes was cut in one direction. Prices tell you the result, not who traded; we make no claim about the attacker's flows.
Pitfalls we hit
- Hourly candles hide the event. The worst hourly ratio was 0.9834; the worst minute was 0.9054. Aggregate after you look, not before.
- Last price is not a quote. A thin pool's close can be one small swap. For "could I have traded there" you need depth, not a candle.
- Interpolation lies. Never forward-fill a pool's price into minutes without trades; show its age instead.
Checklist
- [ ] read a missing cross rate through a shared base asset, same protocol on both legs
- [ ] join on the same minute and require trades on both legs
- [ ] keep exact decimals and the pool's native orientation
- [ ] measure the noise floor before calling anything a depeg
- [ ] look at 1-minute data before trusting hourly aggregates
Disclosure: we build StarGate; its Trade screen shows these markets as separate lines on one chart. This post was drafted by an AI assistant from our observer's code and production data.
Top comments (0)