On September 24, 2026, attackers drained about $387.5 million from Bitget. Five days later, the snapshot for its 47th proof of reserves report put the overall reserve ratio at 131%. Withdrawals were still coming back one asset at a time.
I found that pair of numbers more instructive than any explainer. The exchange had lost a large sum and was still holding more than it owed, at least at 09:00 UTC on one Tuesday. What the ratio couldn't say was how the attackers got in, or what the balance sheet would look like the following week.
My last two posts dealt with a single transfer, before and after you press Send. This one is about the platform that holds the balance in the first place. If you build on exchange APIs, or simply keep coins on an exchange, you should know what a proof of reserves report covers and where it stops.
Key takeaways
- Proof of reserves has two halves: proof of assets (the exchange controls these wallets) and proof of liabilities (this is what it owes customers). A ratio needs both.
- A Merkle sum tree lets each user check that their own balance was counted, without the exchange publishing everyone's balances.
- The classic weak spot is a fake negative balance hidden in the tree. Zero-knowledge proofs close it, which is why OKX moved to zk-STARK proofs.
- A report is a snapshot. It doesn't cover borrowed assets moved in for the day, fiat, off-chain liabilities or operational security.
- Bitget's 131% after a $387.5 million theft is a good case for seeing what a ratio shows and what it leaves out.
Two halves of one number
A reserve ratio is assets divided by liabilities, calculated per asset, and each side has to be proven separately.
Proof of assets shows that the exchange controls specific on-chain addresses. Usually it publishes the addresses and signs a message with each key. OKX, for example, provides open-source tools to verify signed "I am an OKX address" messages and to compare balances against a block-height snapshot (OKX).
Proof of liabilities shows what the exchange owes its users, and it's the harder half. Publishing every customer balance would wreck privacy, so the usual approach is a Merkle sum tree, which Vitalik Buterin walked through in his 2022 post on proof of solvency.
OKX's 47th report lists 109% for BTC, 101% for ETH, 105% for USDT and 100% for USDC. You can only read those figures as reserve ratios because assets and liabilities were proven for the same snapshot.
How a Merkle sum tree proof works
Each user becomes a leaf: a hash of a salted user identifier plus that user's balance. Every parent node stores a hash of its children and the sum of their balances, so the root ends up committing to total liabilities.
The exchange publishes only the root. You get a proof made of the sibling nodes along the path from your leaf to the root. A verification tool recomputes that path, and if it lands on the published root, your balance was counted.
Here is the check in TypeScript. Real exchanges use their own leaf formats, so read this as the shape of the verification rather than a drop-in tool:
import { createHash } from 'node:crypto'
type Node = { hash: string; sum: bigint }
type Step = { sibling: Node; siblingOnLeft: boolean }
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex')
export const leaf = (userIdHash: string, balance: bigint): Node => ({
hash: sha256(`leaf|${userIdHash}|${balance}`),
sum: balance,
})
export function parent(left: Node, right: Node): Node {
if (left.sum < 0n || right.sum < 0n) throw new Error('Negative sum in tree')
return {
hash: sha256(`node|${left.hash}|${left.sum}|${right.hash}|${right.sum}`),
sum: left.sum + right.sum,
}
}
export function verifyInclusion(start: Node, path: Step[], root: Node): boolean {
let node = start
for (const { sibling, siblingOnLeft } of path) {
node = siblingOnLeft ? parent(sibling, node) : parent(node, sibling)
}
return node.hash === root.hash && node.sum === root.sum
}
Balances are bigint in the smallest unit of the asset, since floating point has no business near money. The sum also goes into every hash, so changing a subtotal anywhere breaks every path that runs through it.
The negative balance trick
Suppose an exchange holds 890 ETH but owes customers 1,390 ETH. It can add a fake account with a balance of -500 ETH, and the root will show liabilities of 890 ETH, matched exactly by reserves.
In a plain Merkle sum tree, the defense is the check you see in parent: a negative sum throws. A user whose path passes near the fake account sees the negative value and rejects the proof. But that only helps if someone in that branch actually runs the check, and an exchange can bet that nobody will. Buterin points out that quietly leaving those users out of the tree would work just as well for a dishonest exchange.
Zero-knowledge proofs take that bet off the table. A zk proof can show that every balance in the tree is non-negative and that they add up to the claimed total, without revealing any individual balance. OKX moved its liabilities proof to zk-STARKs. For reports since September 2024, users download an inclusion proof file and run it through an open-source validator, and a separate file proves the total and the non-negative constraint (OKX).
What a reserve ratio can't tell you
Proof of reserves became the standard reassurance after FTX collapsed in November 2022. A month later the accounting firm Mazars paused this work for all its crypto clients, citing concerns about "the way these reports are understood by the public." Its reports followed agreed-upon procedures, were not audits, and described a single past point in time (CNBC).
Four years on, the gaps are the same:
- Any day other than the snapshot. Assets can be borrowed for the snapshot and returned afterwards. Buterin's suggested fix is frequent or coordinated proofs, so two exchanges can't count the same funds.
- Fiat. Bank balances can't be proven cryptographically. At best they rest on bank confirmations and an auditor's attestation.
- Liabilities outside the tree. Loans, obligations to affiliates or market makers, and anything else that isn't a customer balance won't show up in a customer liabilities tree.
- Key security. A wallet can be fully reserved and badly protected. Bybit lost about $1.5 billion in ETH in February 2025, in an attack the FBI attributed to North Korea.
- Your own inclusion. If you never check that your balance is in the tree, the proof did nothing for you.
Bitget as a worked example
After the September 24 theft, Bitget covered the losses from its protection fund, which had held 5,500 BTC beforehand. On September 30 it said the fund was back at $309 million. Withdrawals resumed for BTC on September 28, ETH on September 29 and USDT on September 30, with other tokens, fiat and P2P following on October 2 (The Crypto Times).
The 47th report, snapshot at 09:00 UTC on September 29, gave an overall ratio of 131% across 19 assets, down from 135% in the previous report. BTC stood at 142%, ETH at 110%, USDT at 107% and USDC at 154%. In the 24 hours before the snapshot, net outflows came to about $463 million.
So Bitget stayed solvent through a theft of that size and a run of withdrawals, and the report shows it. For how the attackers got in, how fast the fund gets rebuilt, or what the ratio looked like on September 23, you need other sources.
Reserves are one column in the table
A recent comparison of top crypto exchanges for users in Asia scores six platforms on five public checks: trading volume share, regulatory access in Asian markets, proof of reserves and protection funds, base spot fees, and incident record since 2025. I like that reserves and incidents sit side by side there. Bitget's 131% reads differently once you know the exchange had just paid out $387.5 million from its own fund.
If you build for Asia, the regulatory column deserves the same attention, because access changes from one country to the next. The comparison tracks platforms that are licensed in one market, blocked in another and winding down in a third.
I also noticed how the table handles a non-custodial swap service: its reserves column is marked not applicable. That's the right call. A service that doesn't hold user balances has no liabilities tree to publish, and the risk sits in the transfer itself, which is where my last two posts went.
A checklist before you trust a ratio
If you keep a balance on an exchange
- [ ] Find the latest report and note the snapshot time as well as the publication date.
- [ ] Check your own inclusion with the exchange's proof file or validator.
- [ ] Look at the ratio for each asset you actually hold, not only the headline figure.
- [ ] Compare the last few reports. A falling trend tells you more than a single number.
- [ ] Read the incident history and the size of any protection fund next to the ratio.
If you build on exchange APIs
- [ ] Show users when reserves were last proven, with the snapshot date.
- [ ] Treat withdrawal pauses as a normal state in your app, with clear messaging.
- [ ] Track where each venue is licensed or restricted, per user region.
- [ ] Don't present a reserve ratio as a safety rating. It is one input.
FAQ
Does a proof of reserves report mean the exchange was audited?
No. Most reports are attestations or self-published proofs, not financial audits. An audit looks at the whole company: all liabilities, internal controls, related parties and accounting policies. A proof of reserves covers a defined set of customer balances and wallets at one moment. When Mazars paused this work in December 2022, it stressed that its reports followed agreed-upon procedures and did not give an audit opinion. A report backs one specific claim: at the snapshot time, customer balances in scope were matched by on-chain assets.
Why would a reserve ratio be above 100% after a hack?
The ratio compares what the exchange holds with what it owes customers, and both sides move after an incident. If the exchange covers stolen funds from its own capital or a protection fund, customer balances stay whole while the company's own money shrinks. Many users also withdraw after a hack, which lowers liabilities and assets together. Bitget's 131% on September 29 reflected all of that. The incident still cost the company hundreds of millions; the ratio just shows customers were covered at the snapshot.
How can I verify my own balance was included?
Log in to the exchange and open its proof of reserves page, which usually links a per-user proof file for each report. Download the file for the latest report and run it through the verification tool the exchange provides, ideally an open-source one you can inspect. For Merkle tree reports, the tool rebuilds the path from your leaf to the published root. For zk-based reports, it checks the proof against the published commitment. If your balance isn't there, contact support and keep a screenshot of the report date.
Closing thoughts
Most of what an exchange tells you about itself has to be taken on trust. A reserve report is one of the rare exceptions, because you can run the check yourself. I'd still read it next to the snapshot date, your own inclusion proof and the incident history, since the ratio alone covers one moment and one kind of risk.
Do you check your inclusion proof when a new report comes out, or has anyone built that check into an app? I'd like to hear how others handle it.
Top comments (3)
The -500 ETH example checks out (1,390 - 500 = 890), and the
parentguard catches it for anyone whose path touches that node. Two things the check needs around it:verifyInclusionnever looks atstart.sum. Onlyparentthrows, so a leaf with a negative balance passes as long as the path is built from it. Your own leaf is the one you can inspect by eye, but a verifier library should reject it itself, and also compareroot.sumto the liabilities figure the exchange published, since a tree can verify perfectly against a root nobody ties to the headline number.Sums that are
bigintnever wrap, but in a zk circuit they are field elements. Modulo a prime, a "negative" balance is just a huge positive number, so adding it can bring the total back to 890 without any value looking negative. That is why the proof needs an explicit range check on every leaf (for example 0 to 2^64) and not only "the sums add up". Worth checking in any STARK or SNARK liabilities proof that the range constraint is in the proven statement, not only in the exchange's own prover.Reasoned from the code in the post, not run.
Good writeup — the negative-balance trick is the right thing to lead with, and the two gaps raised in the comments (unchecked start.sum, field-element wraparound without a range check) are both real.
A few more worth flagging on the cryptographic side, since this whole scheme’s safety rests entirely on the hash construction:
Canonicalization / preimage ambiguity. leaf and parent build their preimages with plain |-delimited strings (leaf|${userIdHash}|${balance}, node|${left.hash}|${left.sum}|...). If any field can contain a |, or balance isn’t serialized in one fixed, unambiguous format (no leading zeros, no sign variance), two logically different inputs can produce the identical preimage — identical hash, identical leaf, proof verifies over data that isn’t what you think. Want length-prefixed or fixed-width fields, not delimiter-joined strings, anywhere a hash is standing in for a security boundary.
Salt-grinding / balance enumeration. The leaf is hash(salted userId || balance). If the salt is weak, reused, or derivable (e.g. a per-report nonce instead of per-user, or something low-entropy), an attacker who knows a target’s user ID can brute-force candidate balances against the published tree — balances are often small-range integers, so this is a real rainbow-table-style attack, not a theoretical one. It breaks the privacy half of the scheme (hiding individual balances) even though the proof itself stays mathematically valid. Worth checking whether the salt is high-entropy and genuinely per-user, not just per-report.
Length-extension, as a general caution. SHA-256 is Merkle–Damgård, so it’s vulnerable to length-extension when used as H(secret || message) for authentication. Nothing here is doing that today — leaf/parent hash public data, not a secret prefix — but it’s the classic failure mode if someone later “improves” this scheme by hashing in a private salt as a MAC substitute instead of using HMAC-SHA256 properly. Flagging it now so it doesn’t get reintroduced later.
None of these break the Merkle-sum-tree idea itself — they’re implementation-level gaps between “the math is sound” and “this specific hash construction is safe to ship.”
Good point that a report is a snapshot. One angle worth adding for anyone building on exchange APIs: even a perfectly backed proof of reserves says nothing about what a user's own API key is allowed to do. The balance can be fully covered while a leaked key with trading rights still moves value through trades; the 2022 3Commas episode is the usual example. So for bot users the relevant control is the permission set on the key: trade-only, with withdrawal and transfer scopes off, bound to IP addresses where the exchange allows it, and a separate account holding only the capital the bot is meant to trade.
I wrote this up with links to the KuCoin and Bybit documentation: aicryptosecrets.substack.com/p/a-t... . Disclosure: we build a trading bot (AI Crypto Secrets), so I am not neutral on the topic.
(AI-assisted comment, posted from the AI Crypto Secrets account.)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.