Skip to content

Compare a contract across chains ​

Sample wstETH on Ethereum and Base every hour for a week, on one clock.

cql
let ethWstETH  = ethereum:0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
let baseWstETH = base:0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452;

from [ethWstETH, baseWstETH] at time '2026-09-01'..'2026-09-08' every 1h
     with { abi: erc20 } as w
| project {
    t:      bin($sampleAt, 1h),
    chain:  $chain,
    supply: format(w.totalSupply(), 18)
  }
| order by t asc, chain asc

Open in workbench →

Two rows per hour — one ethereum, one base — with t the exact hour and supply the wstETH minted on that chain at the last block before it: on Ethereum the whole supply, on Base the part bridged there. 338 rows: the week's 168 hours and its closing midnight, on each chain.

How it reads ​

A list in from reads every contract in it, and the chains need not match. at time … every 1h resolves each hour boundary to a block on each chain — Ethereum's block a few seconds before the hour, Base's another — and $sampleAt holds the boundary itself, identical on both. bin($sampleAt, 1h) is the column to sort and join on. $timestamp can differ by seconds from one chain to the other, and $block is no key at all: two chains' block numbers mean nothing to each other, so a join on $block across them is refused before the run (CQL3007). The first hour is also where the range starts, and a range starts at the first block at or after its start, so that row is read a few seconds past midnight on each chain, not before it.

wstETH is the same token on both chains, wrapped on Ethereum and bridged to Base, and erc20 fits both, which is what a list needs: every contract in it must answer the calls the query makes.

The share on Base ​

Join the two chains on the hour, and divide. The first four hours of the same week:

cql
let ethWstETH  = ethereum:0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
let baseWstETH = base:0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452;

from ethWstETH at time '2026-09-01'..'2026-09-01T03:00:00Z' every 1h
     with { abi: erc20 } as e
| extend t = bin($sampleAt, 1h), eth = format(e.totalSupply(), 18)
| join (
    from baseWstETH at time '2026-09-01'..'2026-09-01T03:00:00Z' every 1h
         with { abi: erc20 } as b
    | extend t = bin($sampleAt, 1h), base = format(b.totalSupply(), 18)
  ) on t
| project { t, eth, base, sharePct: base / eth * 100 }
| order by t asc

Open in workbench →

tethbasesharePct
2026-09-01T00:00:00Z3751370.81035250370603246429236.8482564082391126930.77936439063087316479444063430684163206
2026-09-01T01:00:00Z3751281.98058887263444030829236.8482564082391126930.77938284585630288491197732689953316778
2026-09-01T02:00:00Z3751282.91646163212679501429236.8482564082391126930.77938265141531005770435294725727521195
2026-09-01T03:00:00Z3751274.43921908198633531529236.8482564082391126930.77938441268761430280918846326993838546

One row per hour, Ethereum's supply beside Base's. Base held the same 29 236.85 wstETH in all four hours, so the share, about 0.78 %, moves only with Ethereum's supply, which counts the bridged tokens too.

Each side samples its own chain and gives its rows a column t, the hour its sample stands for. The two sides' blocks are different numbers, and their timestamps need not agree — the first hour's are ten seconds apart — but the four hours are the same four, so an inner join … on t keeps every one of them: four on each side, four joined. t is one column in the output, and the right side's $-columns are renamed and left out by the project.

Each call is written inside its own side, before the join, and read there on its own chain at its own block for the hour. A joined row is read at the left row's block, which is on Ethereum, and a call on an address on another chain than the row's must name a block on the address's chain: b.totalSupply() written after the join is refused (CQL3006). $block is not that block. On a joined row it is Ethereum's number, which read on Base is a block more than a year older, and nothing refuses it. b.$block is: b.totalSupply() with { block: b.$block } after the join is read at the Base block the right side sampled, and answers as the extend on the right does. Where a row carries no block of the other chain, with { block: blockat(chain(b), $timestamp) } reads that chain's last block at or before the row's time.

A division of two decimals is carried to 38 significant digits, so sharePct has more digits than it means; Numbers says why.

End both ranges on the hour, as these do. An end between two hours is sampled as well, at its own block, and its t is the hour before it, so the join repeats that hour.

Variations ​

  • One row per hour. | summarize eth = max(iff(chain == 'ethereum', supply, null)), base = max(iff(chain == 'base', supply, null)) by t, after the first query's project, which has named the chain chain.
  • The share rate, not the supply. wstETH's stEthPerToken() is only on Ethereum, where the stETH it wraps lives; drop with { abi: erc20 } and the list, and read it from ethWstETH alone.

See also ​