Skip to content

WBTC and cbBTC Proof of Reserves Check in TypeScript

Compare Chainlink's WBTC and cbBTC Proof of Reserve feeds with on-chain totalSupply in a TypeScript script that exits non-zero when the ratio or feed age slips.

evmquery team8 min read
Try it: Chainlink Price Feed ReaderLatest answer, decimals and pair on Ethereum, Base, BNB Smart Chain, Polygon. Free, no signup.

The first version of this script told me cbBTC was backed 2.15 to 1. That number was wrong in an interesting way, and the rest of this post is how I got to the version that prints this instead:

WBTC: reserves/supply 1.0001 (min 0.999), feed updated 2.5h ago (max 25h) -> ok
cbBTC: reserves/supply 1.0333 (min 1), feed updated 15.0h ago (max 25h) -> ok

TL;DR

Divide the Chainlink Proof of Reserve answer by the token’s totalSupply(), but for cbBTC sum the supply across every chain the reserve covers, or the ratio is meaningless. The script below reads everything with parallel evmquery requests and exits non-zero when the ratio or the feed’s age is out of bounds.

Prerequisites

  • Node.js 22.18 or newer (24 works too), which runs .ts files directly.
  • An evmquery API key from the free tier, saved in a .env file next to the script as EVMQUERY_API_KEY=your-key.

Set up the project:

npm init -y
npm pkg set type=module
npm install @evmquery/sdk

Run the script with node --env-file=.env por-check.ts.

What the feed number actually says

A Proof of Reserve feed is an ordinary Chainlink aggregator. latestRoundData() returns an answer, here the reserve in BTC scaled by 8 decimals, plus the updatedAt timestamp of the last round. What is unusual is where the answer comes from. In the feed metadata that Chainlink publishes (the feeds-mainnet.json file in its reference data directory), both feeds are tagged as cross-chain Proof of Reserve with a wallet-based source. The issuer is BitGo for WBTC and Coinbase for cbBTC.

Chainlink’s SmartData docs are blunt about what that means. For feeds in this category the reserve data is self-reported: the project registers the wallet addresses it owns, and Chainlink Labs states it is not responsible for the accuracy of self-reported reserves data. So the feed attests that the addresses the issuer declared hold this much BTC. It does not attest that the list is complete, and it says nothing about liquidity.

Two more fields in that metadata matter for the script. Both feeds have a 24 hour heartbeat. The deviation threshold is 0.5 (percent, as I read the field) for the cbBTC feed and a vanishingly small 1e-7 for WBTC, so cbBTC reserves can move about half a percent without a new round.

Reading reserves and supply in one request

For a single chain, one expression reads the feed and the token together. The struct returned by latestRoundData() takes dot access, and cel.bind keeps the call from running once per field:

const { data } = await evmquery.query({
throwOnError: true,
body: {
chain: "evm_ethereum",
schema: {
contracts: {
feed: { address: "0xcBe87Dc0Cf9d807848a3E703B01A90B28eCFb2a7" },
token: { address: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" },
},
},
expression:
"cel.bind(r, feed.latestRoundData(), [r.answer, r.updatedAt, feed.decimals(), token.totalSupply(), token.decimals()])",
},
});

When I ran this, data.result.value was a list of five integers as strings:

["9806410423871", "1794184859", "8", "4560965062881", "8"]

Reserves of 98,064 BTC against a supply of 45,609 BTC. Divide and you get 2.15. A check with a minimum ratio of 1 would never fire on that, which is the problem.

Horizontal bars in BTC comparing reserves and supply. WBTC reserves 116,144.65 against supply 116,132.18, ratio 1.0001. cbBTC reserves 98,064.10 against Ethereum supply 45,609.65, ratio 2.15, and against Ethereum plus Base supply 94,897.95, ratio 1.033. The remaining gap of 3,166.15 BTC is supply on other chains or extra reserves.
Reserves against counted supply: WBTC matches almost exactly, while cbBTC only lines up once Base is added.

The cbBTC supply trap

The cbBTC reserve feed covers Coinbase’s wallets for the token on every chain it is issued on, not just Ethereum. Ethereum’s totalSupply() is only one slice. Adding the Base token, which has the same address as the Ethereum one, brought the supply to 94,898 BTC (4,560,965,062,881 on Ethereum plus 4,928,829,964,540 on Base, both in 8-decimal units) and the ratio to 1.0333.

Two details are worth knowing. The cbBTC reserve feed deployed on Base (0x0F8E057D1D7b282EF968D26E9cB432617dF52519) returned exactly the same answer as the Ethereum one, so one feed is enough and you do not need to read both. And Base held slightly more supply than Ethereum.

The remaining gap is where I want to be careful. Reserves exceed the supply I counted by about 3,166 BTC. That headroom could be supply on chains the script does not read, or it could be extra BTC Coinbase holds. I cannot tell which. cbBTC is also issued on other chains, such as Arbitrum, BNB Chain and Polygon, and the script counts only Ethereum and Base. Supply on any chain outside that list is not in the total, so add those chains to the tokens list if you want them counted.

So treat the ratio as a floor check. If it drops below 1 with Ethereum and Base alone, supply on the chains you counted already exceeds the reserves, and that is a real alarm. If it stays above 1, the 3,166 BTC of headroom is the budget for everything you cannot see, which is not the same as “fully backed”. The script’s cbBTC threshold of exactly 1.0 sits on that reading. For WBTC I used 0.999, because the feed and the supply are never read at the same instant and 1.0001 leaves almost no room.

The check script

Here is the whole file. It runs both checks at once, and inside each check the feed read and the per-chain supply reads go out in parallel. Against raw RPC you would batch the reads on each chain with Multicall3 and wire up a client per chain, which is more plumbing than a handful of reads deserves.

import { createEvmqueryClient } from "@evmquery/sdk";
const evmquery = createEvmqueryClient({
apiKey: process.env.EVMQUERY_API_KEY,
});
interface Check {
name: string;
feed: { chain: string; address: string };
tokens: { chain: string; address: string }[];
minRatio: number;
maxAgeSeconds: number;
}
const HEARTBEAT = 86_400; // both feeds publish a 24 hour heartbeat
const GRACE = 3_600;
const CHECKS: Check[] = [
{
name: "WBTC",
feed: { chain: "evm_ethereum", address: "0xa81FE04086865e63E12dD3776978E49DEEa2ea4e" },
tokens: [{ chain: "evm_ethereum", address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }],
minRatio: 0.999,
maxAgeSeconds: HEARTBEAT + GRACE,
},
{
name: "cbBTC",
feed: { chain: "evm_ethereum", address: "0xcBe87Dc0Cf9d807848a3E703B01A90B28eCFb2a7" },
tokens: [
{ chain: "evm_ethereum", address: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" },
{ chain: "evm_base", address: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" },
],
minRatio: 1.0,
maxAgeSeconds: HEARTBEAT + GRACE,
},
];
async function read(chain: string, contracts: Record<string, { address: string }>, expression: string): Promise<bigint[]> {
const { data } = await evmquery.query({ throwOnError: true, body: { chain, schema: { contracts }, expression } });
return (data.result.value as string[]).map(BigInt);
}
async function runCheck(check: Check): Promise<boolean> {
const [feedRead, ...supplies] = await Promise.all([
read(
check.feed.chain,
{ feed: { address: check.feed.address } },
"cel.bind(r, feed.latestRoundData(), [r.answer, r.updatedAt, feed.decimals()])",
),
...check.tokens.map((t) =>
read(t.chain, { token: { address: t.address } }, "[token.totalSupply(), token.decimals()]"),
),
]);
const [reserves, updatedAt, feedDecimals] = feedRead;
const totalSupply = supplies.reduce((sum, [supply, decimals]) => {
if (decimals !== feedDecimals) throw new Error(`${check.name}: decimals differ`);
return sum + supply;
}, 0n);
const ratio = Number((reserves * 10_000n) / totalSupply) / 10_000;
const ageSeconds = Math.floor(Date.now() / 1000) - Number(updatedAt);
const ratioOk = ratio >= check.minRatio;
const fresh = ageSeconds <= check.maxAgeSeconds;
console.log(
`${check.name}: reserves/supply ${ratio.toFixed(4)} (min ${check.minRatio}), ` +
`feed updated ${(ageSeconds / 3600).toFixed(1)}h ago (max ${(check.maxAgeSeconds / 3600).toFixed(0)}h) ` +
`-> ${ratioOk && fresh ? "ok" : "FAIL"}`,
);
return ratioOk && fresh;
}
try {
const results = await Promise.all(CHECKS.map(runCheck));
process.exitCode = results.every(Boolean) ? 0 : 1;
} catch (error) {
console.error(error);
process.exitCode = 2;
}

Some of the lines are not obvious. read converts every value to bigint, since a totalSupply() for an 18-decimal token loses precision above 2^53 as a JavaScript number. The decimals comparison throws if the feed and a token disagree, which would otherwise produce a ratio off by a power of ten without any error. The ratio is computed in integer math and divided by 10,000 only at the end. maxAgeSeconds is the heartbeat plus an hour of grace, so a feed that is 15 hours old (the cbBTC one was, when I ran this) passes and one that has missed a full heartbeat does not.

The exit codes carry the meaning. 0 means every check passed, 1 means a ratio or an age check failed, and 2 means the script could not read something at all, so a network or key problem does not look like a reserves problem.

Staleness and the exit paths

A bad API key exits with 2. The SDK throws an EvmqueryError on any non-2xx response, so a wrong key lands in the catch block and exits with 2. I checked that by running the script with a wrong key.

The cbBTC feed is much older than the WBTC one and that is normal. WBTC updated 2.5 hours before my run, cbBTC 15. A staleness limit copied from a price feed (often an hour or less) would have failed on a healthy feed. Read the heartbeat for the feed you are checking. The Chainlink price feeds guide covers heartbeats and L2 sequencer checks in more depth.

Raising the threshold really does fail the run. To confirm the exit path, I set the cbBTC minimum to 1.05 and got cbBTC: reserves/supply 1.0333 (min 1.05) ... -> FAIL with exit code 1, while WBTC still printed ok.

Running it on a schedule

The feeds only update on a deviation or the 24 hour heartbeat, so polling faster than hourly gains nothing. Cron has no shell profile and a minimal PATH, so the entry uses the absolute path to node (which node shows it), and the cd makes the .env file resolve. A crontab entry that mails or logs on failure is enough:

0 * * * * cd /opt/por-check && /usr/local/bin/node --env-file=.env por-check.ts || echo "PoR check failed" | mail -s "PoR check" you@example.com

In a CI job, the non-zero exit already fails the run, so the notification is whatever your pipeline sends for a red build. If you add a Telegram or Slack message on top, send it only when the state changes, or the same failure pings you every hour.

Next steps

Share

Run the reserves check against your own tokens

The free tier covers a reserves-versus-supply check on a schedule. Get an API key, swap in the feed and token addresses you care about, and wire the exit code into cron or CI.