How to read the root-cause claims. Blockstream has not published a post-mortem. Everything below is labelled: [Confirmed] sections are things I verified myself against the mempool.space and blockstream.info APIs or read directly in the
ElementsProject/elementsrepository; [Hypothesis] is my reconstruction of the bug, strong but not official. When the post-mortem lands I will mark which of my inferences were right and which were wrong.
A federated peg has exactly one number it must never get wrong:
L-BTC in circulation ≤ BTC locked in the federation’s multisig.
Every other guarantee Liquid offers, confidential amounts, one-minute blocks, whitelisted peg-outs, sits on top of that inequality. On 6 September 2026 the inequality broke. Someone created L-BTC that no one had ever pegged in and then pegged it out for real bitcoin. The 15-member federation’s hardware modules signed the withdrawal, because from where each module stood, the books balanced.
This post is about why the books looked balanced when they were not. I traced the transactions on both chains myself, and I read the Elements commits that shipped in the window around it. Because the interesting part of this incident lives inside the cryptography that confidential transactions are built on, I also walk through that cryptography properly: what a Pedersen commitment actually guarantees, why a range proof is not optional, and what “asset” means inside a proof. Where I am inferring rather than quoting Blockstream, I say so.
What Liquid actually trusts
Liquid is a Bitcoin sidechain run by a federation. Two roles matter here.
The functionaries run consensus. They sign Liquid blocks, and eleven of fifteen of them sign the Bitcoin transactions that release coins from the peg. Each one runs a hardware security module that will only sign a peg-out its own Elements node has judged valid.
The peg-out path runs through the Peg-out Authorisation Key system: functionaries only release coins to addresses whitelisted under a PAK, and one federation member, SideSwap, operates a public peg-out service that ordinary users route through. Liquid amounts are confidential, so an output does not say 1.5 L-BTC; it carries a commitment to the value, a range proof that the hidden value is sane, and a surjection proof that the hidden asset is one the transaction’s inputs actually contained.
Put those together and you get the trust chain the attacker had to defeat.
The HSMs are the last line, but an HSM has no opinion about economics. It checks that the peg-out burns as much L-BTC as the BTC it releases, that the destination is whitelisted, and that the Elements node accepted the transactions. It does not ask whether the L-BTC was ever legitimately created, because it assumes consensus already guaranteed that. That assumption is the whole story.
What happened on-chain [Confirmed]
I reconstructed the following from the mempool.space and blockstream.info APIs. Times are UTC.
13:53:10, the inflation transaction. Liquid block 4,050,336 contains a transaction, c652a1047ff5…, with 64 inputs and 6 outputs. Blockstream’s own explorer accepted the block. The independent Liquid explorer run by mempool.space did not: as I write, its chain tip is still block 4,050,335, timestamped 13:52:10, one block earlier. Two explorers, two chain tips, diverging at exactly one block. That is the on-chain fingerprint of a consensus bug, and I will come back to why the two sides disagreed.
14:06:10, the peg-out request. Liquid block 4,050,349, transaction ce4caece413c…, burns 3,996.01834922 L-BTC into a peg-out output (asset id 6f0279e9ed04…, which is L-BTC). This is the request that tells the federation to release the matching BTC.
14:28:56, the payout. Bitcoin block 965,783, transaction 8db751a650ae…b140: 83 inputs, 4,019.44 BTC moved, and one output of 3,996.01834922 BTC to bc1qgslsydz…6wt7p. The other twelve outputs are ordinary change back into the peg.
| Chain | Amount | Time |
|---|---|---|
| L-BTC burned in the peg-out | 3996.01834922 | 14:06:10 |
| BTC released by the federation | 3996.01834922 | 14:28:56 |
The two amounts match to the satoshi, 22 minutes apart. From the HSMs’ point of view this was an ordinary peg-out. The L-BTC was burned, the BTC released was equal, and the destination was whitelisted through SideSwap. Every local check passed. The only thing wrong was upstream: the burned L-BTC had been conjured thirteen blocks earlier.
The reserve. The federation’s peg address bc1qdlld6antmv4xug…wxxr dropped from roughly 4,200 BTC to 197.47 BTC. About 95% of the reserve left in one transaction.
The freeze. Blockstream disabled its bridge nodes and paused the peg. Its explorer shows the last Liquid block as 4,051,232 at 04:49 on 7 September, containing only a coinbase. Functionaries signed empty blocks for about fifteen hours after the payout and then stopped signing altogether.
Following the money [Confirmed]
Most incident write-ups stop at the theft transaction. The more interesting part is what the coins did next.
The payout address immediately forwarded 3,995.99999857 BTC to bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte, splitting off the fractional 0.01834922 to a second address. Whoever did this rounded the hoard down to a clean 3,996 BTC. For the next twenty-four hours that consolidation address held about 3,998.5 BTC and did not move a satoshi to a mixer, an exchange, or a peel chain. The coins were parked in plain sight while the two parties talked through the only channel they trusted: OP_RETURN outputs on Bitcoin itself.
The public parts of that conversation, in order: the attacker’s “we are whitehats. contact us on chain” at 18:30 on 6 September; Blockstream’s reply pointing to its security mailbox at 19:31; the attacker’s Signal handle at 20:38; and at 09:19:46 on 7 September a PGP-signed message from Blockstream, “Bridge nodes are patched, safe to return the funds.” At 12:43 the attacker answered with a message I decoded directly from the chain. It begins in the clear, “plz confirm again that we are sending the coins back to bc1qdlld6antmv4xug…” and then “More details about the vuln fix:”, after which the rest of the payload is a PGP-encrypted block addressed to Blockstream’s key. The attacker was sending vulnerability details privately, over a public ledger. Two more encrypted messages followed at 15:31.
Then, at 16:09:25 on 7 September, transaction a6d697a25266ce3c… sent exactly 3,400 BTC back to the federation’s peg address. Two further encrypted messages went out at 18:35, and at 21:03 the address broadcast a two-byte OP_RETURN that decodes to :(. As of the night of 7 September the consolidation address holds 598.4998 BTC, about 15% of what was taken, and the federation address holds 3,597.47 BTC. Whether the retained amount is an agreed bounty or a unilateral one, nothing public says. The sad face is the only comment on record.
One detail no report I have seen mentions: the attacker’s address became a public billboard within a day. Strangers attached OP_RETURN messages to dust sent to it: an advertisement for a “no-KYC” swap service with a built-in mixer, aimed squarely at whoever controlled the coins; a memecoin launch riding the news; unsolicited advice on how to launder the reward; and a run of anonymous accusations about who was really behind the theft, which I will not repeat because nothing on the chain supports them. The most-watched address in bitcoin became advertising space and a graffiti wall inside twenty-four hours.
The mathematics that was supposed to hold the invariant
To see how L-BTC could be created out of nothing, you need to see what confidential transactions actually prove. I am going to build it up in three layers, because the bug lives in the seam between the second and the third.
Pedersen commitments: hiding a number without losing arithmetic
Fix two points on the secp256k1 curve, (the usual generator) and , such that nobody knows a scalar with . A Pedersen commitment to a value is
where is a random blinding factor. Two properties make this useful. It is hiding: because is uniform, is a uniformly random point and reveals nothing about . It is binding: opening the same to a different value would require solving , which gives you the discrete log of with respect to , which nobody has.
The property that makes it the right tool for a ledger is that it is additively homomorphic. Add commitments and you add the values and the blinding factors underneath:
So a transaction can prove its inputs and outputs balance without revealing any amount. If the input values sum to the output values and the sender chooses blinding factors so that the blinding sums match too, then
the point at infinity, and a verifier checks exactly that. In Elements this is secp256k1_pedersen_verify_tally. An explicit, unblinded amount is simply a commitment with , so the same check covers mixed transactions.
Range proofs: why the homomorphism alone is not enough
There is a hole. The scalars live in the integers modulo the curve order , a number a little under . In that arithmetic, is the same thing as . So a transaction with one output committing to and another committing to balances perfectly against inputs totalling zero. The verifier sees the point at infinity and is satisfied. The sender has just printed a million coins and parked the negative million in an output nobody will ever spend.
A range proof closes the hole. It is a zero-knowledge proof that the value inside lies in a small honest interval, in Elements’ case , so that no output can be secretly negative. The construction Elements uses decomposes into digits, commits to each digit, and proves with a Borromean ring signature that each digit commitment is one of its few allowed values, without saying which. The proof also carries the value and blinding factor encrypted to the recipient, so that only the receiver can “rewind” it and learn what they were paid. That rewind is how SideSwap’s wallet knew a customer had sent it 4,000 L-BTC.
Two details of how the proof is verified matter for what follows. The verifier is not handed only and a proof; it is handed , the proof, the generator the value is expressed against, and an extra commitment, which in Elements is the output’s scriptPubKey. The proof is therefore a statement of the form “under this generator, and tied to this script, opens to some in range.” Change the generator or the script, and the proof is a statement about something else.
Asset binding: the generator gives the commitment its meaning
Here is the idea the whole exploit turns on, stated as plainly as I can: a commitment is not just a point . How much money stands for depends entirely on which generator you read it against.
Liquid carries many assets on one chain and hides which asset an output holds, so it needs a generator per asset. Each asset gets its own generator (derived by hashing the asset id to a curve point, then blinded so the asset stays hidden). A value commitment is now built against that asset generator:
A surjection proof shows that is one of the asset generators the transaction’s inputs actually carried, so an output cannot claim to be an asset that never came in. That covers the asset. It does not cover the amount, and this is the crux.
Take one fixed point and read it against two different asset generators, and :
The same curve point is ” units of ” in one reading and ” units of ” in another, and and need have nothing to do with each other. The point never changes. What changes is the statement the verifier is being asked to accept about it. A range proof is what pins one reading down: a proof built for generator certifies that the under lies in . It says nothing about . If that same proof is accepted for under without being re-checked, then was never constrained at all.
That binding, from a range proof to the specific generator it was made for, is the load-bearing member. Take the fact and leave the conclusion, though: that a point means different amounts under different generators does not mean an attacker can decide which amount he wants it to mean. He runs into a wall when he tries, and that wall is most of what the second half of this post is about.
What the public record does and does not settle [Hypothesis]
Blockstream has said only that the L-BTC “was created through a bug in the Elements software,” and later that the bridge nodes were patched. It has not named the bug. What follows is my reconstruction from the public repository. Treat it as a strong hypothesis, not a confirmed fact.
What the balance sheet forces. The tally check asks only whether the amounts add up; the range proof is the separate thing that stops one of those amounts from secretly being negative. So any inflation of L-BTC leaves the same footprint: a fabricated positive output, and somewhere a secretly negative one absorbing it, whose range proof was never enforced. That much is forced by the arithmetic. Which code path allowed it is not settled, and the rest of this section is about how far the public record actually gets, and where it stops.
Verifying a range proof is expensive, so Elements caches results. CachingRangeProofChecker::VerifyRangeProof in src/script/sigcache.cpp hashes the proof into a key, and if that key is already present it returns true at once, before parsing the commitment, before calling secp256k1_rangeproof_verify, before the sanity check that a spendable output cannot have a zero minimum. The bug is what the key was made of.
I cloned ElementsProject/elements and looked at what changed just before the incident. On master the commit is c26d719c2; cherry-picked onto the 23.3.x release branch it is 212c43f47. Its message is “fix: range proof cache bind to asset and scriptpubkey.” It is three lines. Before:
void ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment) { CSHA256 hasher = m_salted_hasher_range_proof; hasher.Write(proof.data(), proof.size()) .Write(commitment.data(), commitment.size()) .Finalize(entry.begin());}After:
void ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment, const std::vector<unsigned char>& asset_commitment, // NEW const CScript& scriptPubKey) { // NEW CSHA256 hasher = m_salted_hasher_range_proof; hasher.Write(proof.data(), proof.size()) .Write(commitment.data(), commitment.size()) .Write(asset_commitment.data(), asset_commitment.size()) // NEW .Write(scriptPubKey.data(), scriptPubKey.size()) // NEW .Finalize(entry.begin());}The pre-fix key was hash(proof ‖ C): the proof bytes and the value commitment, and nothing else. The verifier itself, in src/confidential_validation.cpp, is called with all four ingredients:
secp256k1_rangeproof_verify(ctx, &min, &max, &commit, proof.data(), proof.size(), scriptPubKey…, // extra commitment, bound into the proof &tag); // tag = the asset generatorSo the cache remembered “this proof for this was fine” while forgetting under which generator and for which script it had been fine. Prime the cache once with a proof that is legitimately valid for asset , then present the identical proof and commitment with a different asset commitment, and the cache answers yes without the verifier ever seeing the new generator. In the language of the previous section: the proof-to-generator binding, the load-bearing member, was simply not part of the cache key.
What must have happened at the balance level
The balance rule, secp256k1_pedersen_verify_tally, checks only that the input commitments and the output commitments sum to the same point. It has no notion of “positive,” and because values live modulo the curve order , the value behaves exactly like . So walk through a toy example. Suppose the attacker controls a real input worth 10 L-BTC. An honest transaction would spend it into outputs summing to 10. A transaction that inflates instead makes two outputs.
Every number the tally sees is consistent: inputs total 10, outputs total 10, and the blinding factors are chosen so those cancel too. Yet output B is a perfectly ordinary, fully spendable 1,000 L-BTC, of which only 10 was ever real. The other 990 came from nowhere. Output A is where the missing 990 is hidden: a negative output whose only job is to absorb the surplus so the books balance. It never has to be spent; it can even be an unspendable OP_RETURN output so it quietly disappears. Output B is the money that walks away.
Nothing in this requires issuing a new asset, and the transaction in block 4,050,336 issues none — that I checked on-chain. This part I am confident about, because the arithmetic forces it rather than the code suggesting it: for unbacked L-BTC to exist at all, some output like A has to exist, and no valid range proof for it can exist either, since is , astronomically outside . Output A can only sit in a block if its range proof was never enforced.
What does not follow is the next step, and this is where most accounts of this incident, including my own first draft, moved too fast.
Why the range-proof cache does not close it
A range proof is only expensive the first time. CachingRangeProofChecker::VerifyRangeProof looks the proof up by hash(proof ‖ C) and, on a hit, returns true without calling the verifier at all.
Once the key hits, the asset generator and the script never reach the verifier. The obvious story writes itself from there: seed the cache with a proof that verifies honestly under its own generator, then present the identical proof and commitment at output A under a different one, and the cache waves it through. That is the account I first wrote, and it is the account circulating elsewhere. It does not survive contact with the numbers.
The balance level is easy: in modular arithmetic. The hard part is making the output pass. It needs its range proof skipped, and the cache only skips a (proof, C) pair it has already stored from an honest verification. So for the sink point
to have been honestly cached, it must verify in range under some usable asset generator , that is
Equating the two forms forces : the seed asset’s NUMS generator would have to be a known scalar multiple of . Finding an asset id with that property is the discrete-logarithm problem, which is exactly what NUMS generators are built to prevent. So the range-proof cache omission, on its own, cannot get the sink cached. And a forged surjection proof does not rescue it either, because a surjection proof only relabels an output’s asset, while the Pedersen tally sums the true commitment points and conserves each generator independently. There is no way to inject content through the label.
Two candidate injection points, neither confirmed
This leaves two candidates, both unconfirmed. Confidential-side: the range-proof cache omission combined with the surjection-proof nonce and s-value reuse fixed in the same secp256k1-zkp bump (PR #1585 and #1586). This matches the on-chain shape, a 64-input confidential transfer with no issuance, but I cannot close it into a working mint from public data, for the reason just given. Peg-in side: the DecomposePeginWitness and MerkleBlock SPV-proof parsing fix that shipped in the very same batch. A forged peg-in mints explicit L-BTC directly, with no discrete-log barrier in the way, so it closes cleanly — but it does not match the confidential shape of the actual transaction. The precise path cannot be settled without Blockstream’s post-mortem.
One observation cuts across both. The range-proof cache is only populated on the mempool path: AcceptToMemoryPool runs validation with cacheStore = true, while ConnectBlock consults the cache but deliberately does not write to it. On any hypothesis that routes through that cache, a node’s verdict on block 4,050,336 would depend on whether it happened to hold the relevant transactions in its own mempool first, which is the shape of what we observed: mempool.space’s Liquid node stopped at 4,050,335 and has not moved since, while Blockstream’s continued. The split itself is a fact, and whatever produced it, it means validity depended on node-local state — about the worst property a consensus rule can have.
What would settle it: Blockstream naming the subsystem in a post-mortem, or a controlled regtest reproduction. From the public, blinded chain plus the attacker’s PGP-encrypted notes, the exact construction is not recoverable. What constrains any answer, and what I did verify on-chain: the transaction carries no issuance at all, and no output reuses an input’s commitment, so any commitment reuse feeding a cache would have had to come from other transactions rather than from within the inflation transaction itself.
The timing [Confirmed]
The commit’s git author date is 3 August, but that is only when it was written locally, and the 2 and 3 September timestamps carried by the cherry-picked commit objects are just as local: they record when a developer generated the cherry-pick, not when anything became public. What puts a fix on a public branch is the merge, and three merges matter here. PR #1592, whose description reads “Fixes a number of small issues picked up during LLM scans,” put the fix on master on 1 September. PR #1595 brought it into the 23.x branch on 3 September. And the 23.3.x release branch, the one the functionaries’ binaries are cut from, did not receive it until PR #1599 was merged on 6 September at 19:20 UTC — about five hours after the payout at 14:28. The most recent tagged release at incident time, 23.3.3 from April 2026, does not contain the fix either.
So the public window was 1 to 6 September: five days in which a three-line, consensus-critical fix sat readable on master, described as minor cleanup from an automated scan, while the release branch that actually feeds the signers did not carry it at all until after the coins were gone. On a federated chain whose fifteen signers upgrade on their own schedules, “merged” and “deployed” are far apart, and the gap is visible to anyone who reads the repository. That is the uncomfortable and entirely ordinary property of open-source consensus code: a fix is a disclosure. Every security-relevant commit tells whoever is watching exactly what was wrong, and for anything not yet shipped and installed, the diff is a map. A batch of fixes surfaced by an LLM scan and merged as routine hardening is easy to under-weight from the inside and easy to read from the outside. This particular batch bound a range-proof cache to its asset and script, tightened surjection-proof nonces, and hardened peg-in witness parsing. Whichever of those turned out to matter, the batch as a whole reads, to anyone watching, like a list of places worth trying.
I am deliberately not speculating about who used that window or how they found the bug. The public record cannot answer it, and the facts are pointed enough on their own. What the timeline does establish is a concrete, repeatable risk: the interval between a consensus fix landing in a public repository and reaching the machines that enforce consensus is an exposure window, and today nothing but signer upgrade speed makes it short. Blockstream’s own message that the bridge nodes were “patched” came nineteen hours after the payout. That is fast, and it was still nineteen hours after.
The general lesson: signers that check the wrong thing
Strip away the confidential-transaction machinery and this is the same failure as several bridge hacks. The signers were healthy. No key leaked; Blockstream said as much and the fix is consistent with it. Each HSM enforced its local rules perfectly. What no signer enforced was the global invariant, supply against reserve, because each one assumed consensus had already done it, and consensus had a hole.
Ronin’s validators signed a withdrawal that satisfied the signature threshold, and the threshold itself had been captured. The BNB Bridge’s IAVL proof verifier accepted a forged proof and minted BNB from nothing, an issuance-integrity failure rather than a custody breach. Liquid’s HSMs signed a peg-out whose L-BTC balanced locally, while the L-BTC itself was counterfeit. In each case the money moved because the party holding the keys verified a local predicate and no party verified the global one. Confidential transactions make this worse, not better: when amounts and assets are hidden behind commitments, “does supply equal reserve?” is not something a human or a simple monitor can eyeball. The invariant is only as strong as the proof system that stands in for looking, and here the proof system itself was sound. Something in the machinery that decides when to run it was not.
The control that would have caught it
Every post-mortem for this class of bug recommends defence in depth, and almost no one runs the one check that matters: an independent supply-versus-reserve monitor that does not share code with consensus. It does not need to see through confidentiality. It needs two public numbers.
# pseudocode: an out-of-consensus watchtowerfor each new Liquid block B: lbtc_supply = pegins_total - pegouts_total # from peg events, cumulative btc_reserve = utxo_sum(federation_peg_address) # from the Bitcoin chain if lbtc_supply > btc_reserve + DUST_TOLERANCE: freeze_peg_signing() # or at minimum: page every functionary alert("supply exceeds reserve by", lbtc_supply - btc_reserve)The subtlety is that L-BTC supply is itself confidential, so pegins_total − pegouts_total has to be tracked from peg-in and peg-out events, which reveal explicit amounts at the boundary, rather than by summing hidden balances. That is the honest engineering difficulty, and it is where the design work is. But the check is cheap, it is independent of the buggy code path, and on 6 September it would have fired the moment block 4,050,336 tried to expand supply past the reserve, before the peg-out in block 4,050,349 and long before the HSMs signed. A signer that consults a global invariant before signing is a different security model from one that trusts consensus to have done it.
This is the control I am building into a small open-source project, bridge-invariants: reproduce recent cross-chain incidents as failing invariant tests, then run the monitor that would have caught each one. Liquid is the first case study.
On-chain figures were verified directly against the mempool.space and blockstream.info APIs on 8 September 2026; source references are to the ElementsProject/elements repository.