Steven Roose, the CEO of Second and an ex-Liquid team engineer at Blockstream, deep dives on what, exactly, happened that lead to the Liquid Network hack of almost 4,000 on Sept 6, 2026.
I worked for Blockstream as part of the Liquid team for about five years. I am quite certain that I speak for all my former colleagues that an inflation bug as we witnessed over the last few days was our greatest fear whenever we had to touch the complex section of code that implements Liquid’s Confidential Transactions.
This is a walk through what actually happened, at the level of the code. Which bugs existed, how they were exploited, and how an attacker managed to run away with 4000 Bitcoin.
What Liquid is, and how the peg works
Liquid is a federated sidechain to bitcoin. Instead of proof-of-work, eleven signatures are required to produce a valid block. These signatures are placed by the Liquid functionaries: around fifteen independent businesses running a specialized server, or HSM. The functionaries take turns to propose a block roughly every minute. As long as five functionaries remain honest, no invalid blocks can be produced.
The principal asset that circulates on Liquid is L-BTC, and it is meant to be backed one-to-one by bitcoin held in reserve. The reserve is held in a wallet managed by the same federation of 15 functionaries. Converting Bitcoin to L-BTC is a peg-in: you send Bitcoin to a federation-controlled address on the bitcoin mainchain, and, after enough confirmations, the same amount of L-BTC is credited to you in Liquid. Getting out is a peg-out: you destroy L-BTC on Liquid, and the federation releases the corresponding Bitcoin to you from reserve on the mainchain. Similarly, as long as five functionaries are honest, no one can take more money out of the system than they deserve. Or that was the idea.
Confidential transactions, confidential assets, and range proofs
On bitcoin, every output amount is public. On Liquid, amounts can be hidden. This feature is called Confidential Transactions. In a confidential transaction, the output amount field doesn’t hold a plain number. Instead, it carries a Pedersen commitment, or a curve point that binds the amount behind a blinding factor. The Pedersen commitment hides the amount while still supporting arithmetic. A verifier who cannot see any individual amount can still add up the input commitments, add up the output commitments, and check that the two sides balance. That is the balance proof, and it ensures transactions always have balanced inputs and outputs. In other words, the balance proof confirms that no new Bitcoin were created in the confidential transaction.
Confidential Assets extends the same idea to which asset an output holds, not just the amount. L-BTC, Tether USDt, and every other Liquid asset look alike in the blockchain; a per-output surjection proof ties each output’s asset back to the inputs.
Balance alone is not enough, and this is the crux of the whole incident. Pedersen commitments live in a finite group, so arithmetic wraps around in modulo. A commitment can encode a value so large it behaves like a negative number. If I am allowed to do that, I can build a transaction that balances on paper while minting money:
Imagine a transaction with an input of 1 L-BTC and two outputs: one of 100 L-BTC and one of “minus 99” L-BTC. The sums on both sides match up: 1 == 100-99. You can discard the -99 L-BTC output but keep the 100 L-BTC one.
The thing that forbids this is the range proof. Every confidential output must carry a zero-knowledge proof that proves the hidden amounts lie in a sane, positive range and not in the wrap-around zone. Range proofs are what make hidden inflation impossible.
The cache
Range proofs are big — a few kilobytes each — and expensive to verify. Liquid nodes see the same proof more than once: once when a transaction arrives in the mempool, again when it appears in a block. Re-running the elliptic-curve verification every time is wasteful, so Elements remembers successful verifications in a cache. The idea is simple and, in bitcoin, entirely standard: verify a proof once, remember that this exact proof passed, and skip the math if you see it again.
A cache needs a key. For a signature or a proof, the key is a hash of everything the verification depended on. Get everything into the key and the cache is safe: two verifications collide only when they truly are the same check. Leave something out and you have a problem, because now two different checks can share a key and the cache will happily answer “already valid” for a check it never ran.
That single sentence is the entire vulnerability that got Liquid hacked. The details is which fields went missing, and when.
The 2018 cache key change
The original cache implementation was using an existing function that Bitcoin Core uses to cache transaction signatures. Due to an upstream change to that function, in commit 9572165, a custom cache key function is introduced for range proofs specifically. Here’s the relevant changes:
+ void ComputeEntry(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment)
+ {
+ CSHA256().Write(nonce.begin(), nonce.size()).Write(proof.data(), proof.size()).Write(commitment.data(), commitment.size()).Finalize(entry.begin());
+ }- rangeProofCache.ComputeEntry(entry, uint256(), vchRangeProof, pubkey, vchAssetCommitment, scriptPubKey);
+ rangeProofCache.ComputeEntry(entry, vchRangeProof, vchValueCommitment);The new cache key is SHA256(nonce || rangeproof || value-commitment). The asset commitment and the scriptPubKey, which were previously there, are gone. The verification function still received them and still verified against them, but the cache no longer remembered that it had. Two range proof checks with the same proof and the same value commitment, but a different asset or a different output script, now would hash to the same cache entry.
This design violates the requirement that all relevant fields for validation must be part of the cache key, so it’s definitely a bug. Let’s look at how it could be exploited.
If the value commitment was a Pedersen commitment to the value alone, it would require breaking the elliptic curve logarithm problem to create an identical commitment to a different value. That means that it is as hard as forging a bitcoin signature. However, because of how Confidential Transactions and Confidential Assets work, the value commitment that is used here, is actually made up of two parts: it contains a commitment to the value, but it’s also mixing in a asset-specific factor so that you can’t blend different assets.
The commitment contains an additional generator H that is calculated from the asset. Now, if we could invent a different asset with generator -H, we can create an identical value commitment for the same amount, but negative.
Choosing our own asset generator would also require breaking our crypto, but we don’t really have to. The validity of the asset generators is checked in what is called the asset “surjection proof”. But, this check only happens after the range proofs are checked. This means that I can just claim to have an output for asset generator -H and a value of 1 BTC. The rangeproof will pass and be stored in the cache. Afterwards, the surjection proof will fail and the transaction will be invalid.
But any node that actually validated this transaction, will have made an entry in its rangeproof cache and will now accept a rangeproof for -1 BTC under asset generator H which allows the same transaction to add an extra output for 1 BTC without having sufficient input value.
What this means in practice is that an attacker could cause inflation if he could make enough functionary nodes first validate (and reject!) an invalid transaction and then propose a block that has an inflationary transaction in it.
The Liquid network has some protections against this sort of behavior: users cannot send transactions directly to the functionaries. All transactions pass through “bridge nodes” that then relay them to the functionaries. While this protects the functionaries somewhat against outside attackers, it still means that anyone with privileged access to the inner network could have exploited this bug and confirmed an inflationary transaction.
Also, if for example a malicious functionary would perform this attack, the entire rest of the network would reject this block because they hadn’t seen the invalid tx preceding it. So while an inflationary exploit was possible, it was pretty hard to execute, would be noticed way faster and could only be performed by Liquid’s inner circle.
Why variable sized fields should always have a prefix
On 1 September 2026, five days before the attack, commit c26d719 fixes the 2018 bug by putting the missing fields like the asset commitment back into the cache key:
-void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment) const {
+void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment, const std::vector<unsigned char>& asset_commitment, const CScript& scriptPubKey) const {
CSHA256 hasher = m_salted_hasher_range_proof;
- hasher.Write(proof.data(), proof.size()).Write(commitment.data(), commitment.size()).Finalize(entry.begin());
+ hasher.Write(proof.data(), proof.size()).Write(commitment.data(), commitment.size()).Write(asset_commitment.data(), asset_commitment.size()).Write(scriptPubKey.data(), scriptPubKey.size()).Finalize(entry.begin());
}Read that Write chain carefully. The key is now:
SHA256( nonce || proof || commitment || asset_commitment || scriptPubKey )
All four fields are present, which looks like exactly what you want. I wrote above that in the old key, only the range proof field was of variable length, and that’s why a collision could only happen for exactly the same range proof and value commitment. In this new cache key, two fields are of variable length and they are hashed without having a length prefix.
When you glue variable-length fields together without length prefixes, the boundaries between them stop being meaningful. Only the total byte string matters. If I can make the proof longer by the same number of bytes that I make the script shorter, and arrange the bytes in between them to line up, I can produce an identical concatenation for a completely different (proof, commitment, asset, script) tuple.
This implementation lets you create two identical cache keys for a different amount, asset, and script. If you can get a Liquid node to first parse a valid transaction and then an invalid one with a junk proof and a forged amount, but that is carefully crafted so that the cache key for this transaction matches exactly with the cache key of the first one, it won’t even validate the range proof because you convinced it that it has already done that before.
This is a commonly known pitfall in cryptography. Hashing structured data by raw concatenation is unsafe precisely because distinct structures can share a byte encoding. The 2016 signature cache in bitcoin gets away with concatenation because its fields are all fixed length. The moment two variable-length fields sit next to each other, concatenation is a trap.
I remember from my time at Blockstream that whenever we would introduce any new consensus-critical serialization, we would ask Russell O’Connor to review it. Russell has the reputation as the expert in spotting this kind of byte-shifting vulnerabilities.
Here is the field layout, from a diagram mononautical, an engineer at Mempool.space, made while going through the same rabbit hole. The cache key is proof | amount | asset | script. Feed it a genuine “primer” and a crafted “exploit” and the two produce the same 4,301 bytes with the field boundaries in different places:

How to print 4000 L-BTC
The exploit ran in two steps, exactly as the layout predicts. I pulled the raw transactions from the explorer and reconstructed the cache keys byte for byte.
Step one, the primer. In Liquid block 4,050,335 the attacker published two nearly identical transactions: 27117… and 71c93d. Note that just one transaction would have sufficed. The second is presumably insurance.
Each has an output that is an OP_RETURN carrying a genuine, valid range proof, 4,166 bytes, verifiable in its own right. The clever part is the script on that output. It is a 69-byte OP_RETURN whose pushed data is not random: it embeds, byte for byte, the value commitment, asset generator and script head that the second transaction will need. When a node verifies this proof, it computes the cache key over proof || commitment || asset || script and stores a success. The primer’s only job is to plant that entry.
Step two, the exploit. One block later, in 4,050,336, transaction f24a4b… creates an output whose “range proof” is 4,234 bytes that would never pass as a valid range proof. But those 4,234 bytes are not random either: they are the primer’s 4,166-byte proof, followed by the primer’s 33-byte commitment, followed by the 33-byte asset generator, followed by the two script-header bytes 6a 43. In other words, the exploit’s proof field has swallowed the primer’s proof, commitment, asset and script-head. The exploit’s own commitment, asset and a one-byte 6a (OP_RETURN) script then follow in their proper places, as they were encoded in the primer’s OP_RETURN data.
Line up the two cache-key preimages and they are the same string:
primer: <4166B proof> | <33B comm> | <33B asset> | < 2B head> | <33B comm> | <33B asset> | <1B script>
└───────────────── 69B script ────────────────────┘
exploit: <4166B proof> | <33B comm> | <33B asset> | < 2B head> | <33B comm> | <33B asset> | <1B script>
└───────────────── 4234B “proof” ───────────────────┘The exploit’s forged output collides with the primer’s cache entry, so the range proof checker short-circuits to “valid” and never looks at the invalid proof. That output hides a massive overflowing amount (the “negative” output from the balance-attack sketch earlier) and a sibling output credits the attacker with a large positive amount. The balance proof, which is real and which the attacker cannot fake, sums to zero. The range proof, which should have stopped it, was answered from cache.
The chain split
Interestingly, the attack did not fool most of the Liquid network. It only fooled nodes whose cache had been poisoned, and because the commit that introduced the buggy fix had not been included in an official Elements release yet, most of the network was not running this version of the Liquid node software. It looks like the Liquid federation decided to first deploy this bugfix release to the internal Liquid infrastructure. This is a common thing to do when deploying security fixes.
But it meant that most of the network actually rejected the attacker’s transaction. They didn’t have the cache key change, so no cache hit and they actually validated the range proof and rejected the transaction, as well as the block that the transaction was relayed in.
These nodes stalled at height 4,050,335: the last block before the exploit. The nodes running the vulnerable build, including the federation’s bridge nodes, accepted 4,050,336 and kept going.
You can still watch this split today. Blockstream’s explorer, blockstream.info, sits above height 4,051,000. mempool.space’s Liquid node, liquid.network, is frozen at exactly 4,050,335.
OrangeSurf published the reproduction recipe: run a build that includes the vulnerable commit, invalidateblock 4050335 to push the primer back into the mempool so the poisoned key gets re-inserted, then reconsiderblock and your node reorgs onto the federation’s inflated chain.
Out the front door: PAK and SideSwap
Inflating L-BTC is only half a heist. The attacker still had to convert unbacked L-BTC into real BTC, and Liquid does not let just anyone pull from the reserve. Peg-outs are gated by PAK, the Pegout Authorization Key system. Each peg-out output must carry a whitelist proof tying the destination to a registered key belonging to a trusted federation member. A peg-out to an unauthorized key is simply invalid as per Liquid’s consensus rules.
So the attacker needed a PAK holder to peg the coins out for them. Enter SideSwap, a Liquid member that runs a peg-out pass-through service: you send it L-BTC, it peg-outs under its own PAK key, and it forwards the resulting BTC to whatever mainchain address you give it. This is a useful product. It is also a good way to render the entire PAK system pointless.
In Liquid block 4,050,349, thirteen blocks after the exploit, the coins were pegged out. On the bitcoin side the federation released the reserve as designed: roughly 3,996 BTC landed at SideSwap’s whitelisted address bc1qgslsydz56d0ed6827hdemfmk5w2f6ldyc6wt7p, which forwarded it on to the attacker’s address bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte.
Because the balance and PAK checks all passed, SideSwap’s node and every functionary treated it as an ordinary authorized withdrawal. Liquid’s own incident report confirms the shape: the validation failure was at the transaction level, before the peg-out, so nothing downstream had reason to object.
The part that should give every system designer pause: neither the peg-out mechanism nor SideSwap had either a built-in time delay or an upper limit on amounts. A single transaction that moved on the order of 4,000 BTC, roughly 400M USD at the time, went straight through: no waiting, no manual review, no velocity check. Before the incident the reserve held about 4,205 BTC. Nothing was in place to prevent 95% of that from leaving the system in about half an hour.
The white hat and the negotiation
Then the story takes a very unexpected turn. Bitcoin heists like this usually are followed by total silence in which the funds stay put, or by transactions showing the attacker is trying to obfuscate his trails and white-wash the coins.
Instead, a few hours after the drain, with a transaction paying 1,000 sats to the federation wallet, the attacker left a note in an OP_RETURN:
> we are whitehats. contact us on chain
Blockstream replied from a known address: “Please contact security@blockstream.com”, then an encrypted message signed with their security key. The attacker agrees to send the funds back to the federation address but demands the bug be fixed first:
> Please fix the bug first. The chain is under risk at latest commit right now. Make sure every node is patched. Then we will transfer the money back safely after confirming the fix.
Blockstream clear-signed ”Yes, thank you,” and later ”Bridge nodes are patched, safe to return the funds.”
On 7 September at 16:09 UTC, the attacker returned exactly 3,400 BTC to the federation’s wallet and kept about 598.5 Bitcoin (roughly 15%) in his own wallet.
That 15% was not an accident, and the tone did not stay collegial. In a later message the attacker dropped his white-hat pose entirely:
Your dereliction of duty is obvious that you allocated only $1.5M (maybe even 0) to secure $5B assets. […] You SHALL pay 10% using your own money as bug bounty or you will cause all your holders a 15% loss for your irresponsibility and stinginess. […] Anyway we are going to publish the private key to decrypt our conversations afterwards.
Whatever you call someone who drains a chain, pegs it out through an unwitting third party, and then negotiates a bounty under threat, “white hat” is not a term I would use. As of writing, Liquid is still paused, the 3,400 BTC is back, and roughly 598 BTC is outstanding while the two sides argue over the difference.
The whole exchange is visible on the bitcoin mainchain, signed and verifiable; Sjors published a script and transcript, that checks the PGP signatures against Blockstream’s published key, and there is a readable viewer where you can follow the entire conversation live.
The real fix, and what it teaches
The actual repair got shipped, as part of Elements release v23.3.4. It stops hashing the cache-key fields by raw concatenation and serializes them through CHashWriter instead, which length-prefixes every field. Once each variable-length field is preceded by its length, the byte-shift trick is dead: a longer proof and a shorter script no longer produce the same encoding, because the lengths themselves are part of the hash. The release also adds a -norangeproofcache switch to turn the cache off entirely.
There are a few lessons here, and none of them are exotic.
A cache key is a security boundary. The instant a verification result is memoized, the key that identifies it inherits the full weight of the check it replaces. Leave a field out and you have silently widened what counts as “the same”.
Simple concatenation is not safe serialization. Gluing variable-length fields together without lengths or safe delimiters is a canonicalization bug waiting for someone with creativity to find a way to abuse it.
Fixes deserve the same fear as features, especially security fixes under time pressure. The recent change was trying to make the key correct and ended up breaking it instead. The dangerous edit was the one that looked like a patch.
And, finally, defense in depth is not optional at the money layer. Bugs happen to all of us, but the loss was 4,000 BTC because a single peg-out with no limit could carry the entire reserve out in half an hour. The consensus bug is fixed. The question of why the pegouts have no delay and no ceiling is the one Blockstream and SideSwap still have to answer.
bitcoin++ Insiders Edition will be in Berlin, this coming October 1 — 3 at the upcoming payments edition bitcoin++ conference. Join us for three days of hacking, talks, and hands-on workshops. Use code INSIDER for 20% off a ticket.

