Skip to the content.

Parity, 2017 — and Liquid, 2026. Two fixes that opened what they closed.


I don’t have a discovery story for this one. Nobody taught it to me and I didn’t read it in a paper. It arrived the way most useful habits arrive: I kept hunting bugs, and after enough of them I started looking at patches with the same suspicion I look at everything else. By now it’s one of the first questions I ask, which is exactly why it’s worth writing down — the things that become automatic are the things you stop being able to explain.

So here is the explanation. A fix is code. Nobody reviews it like code.


First, three things that are not the same thing

Most explanations of what happened to Parity assume you already know how a contract holds money. This one doesn’t, because the whole failure lives in a distinction that’s easy to miss.

On Ethereum, moving money out of a contract requires three separate things, in three separate places:

A key. Your EOA — “externally owned account,” the address you control with a seed phrase. It signs transactions. That’s all it does. A key never holds the funds it controls. It is an identity, not a container. It also pays the gas for every transaction it signs, which is why nothing a contract does is ever paid for out of the contract’s own balance.

A rule, and the money. Both live at the contract’s address. The balance is a number recorded against that address. The rule is a row in the contract’s storage that says which keys are allowed to move it. Your name being on that list is a piece of stored data — it has no force by itself.

Code that executes the rule. Something has to read the list, check your signature against it, and perform the transfer. In an ordinary contract this code sits at the same address as the money. In Parity’s design it deliberately did not.

That third separation is the one that matters. Parity deployed the logic once, as a shared library contract, and gave each customer a thin wallet contract that forwarded every call to it. The wallets held the money and the owner lists. The library held the code and never held a single wei.

Splitting them was a cost decision, not a careless one — deploying a full wallet for every customer would have been enormously expensive, and this is still standard practice today. But it means that when you call your wallet, the function that runs is not in your wallet. It is at another address, and the call site gives you no indication of that.

Let’s see it in a definitive example.


Part one: Parity, 2017

Parity sold multisig wallets on Ethereum in 2017, built on exactly that split.

Because the wallet contract forwards calls rather than running its own constructor, setup had to be an ordinary function. It was called initWallet, and in July 2017 it looked like this:

function initWallet(address[] _owners, uint _required, uint _daylimit) {
  initDaylimit(_daylimit);
  initMultiowned(_owners, _required);
}

Public. No access control. Anyone could call it on someone else’s wallet, install themselves as owner, and take the money. On 19 July 2017, someone did, three times, for 153,037 ETH.

The fix, in four hours

The repository records what happened next with unusual precision.

   
b640df8fb — Fix initialisation bug. (#6102) 19 Jul, 22:29
02d462e26 — update wallet library modifiers (#6103) 20 Jul, 00:02
1f3b1652a / 8116ad995 — backports to stable and beta 20 Jul, 01:12

Incident to shipped release, overnight, two authors.

The first commit added a guard:

// throw unless the contract is not yet initialized.
modifier only_uninitialized { if (m_numOwners > 0) throw; _; }

and applied it to initWallet. The second commit, ninety-three minutes later, applied the same guard to the two sub-initializers, replacing the internal visibility the first commit had given them.

Then nothing. From 20 July to 6 November, no commit alters the source of these contracts. The file’s blob hash is 90a15c07 continuously for 110 days. There are moves, deletions and re-additions in that window — the file is even absent from the tree for five weeks — but the code itself never changes.

What the guard actually asks

Read the modifier as a question, in plain language:

Has this contract been initialized yet?

For a deployed wallet, the answer is yes. m_numOwners was set when the customer created it. The guard throws. All 587 wallets, correctly protected.

For the library, the answer is no — and it is permanently no. The library was deployed to hold code, not funds. Nobody ever initialized it, because there was nothing to initialize. In its own storage, m_numOwners is zero.

So the guard opens.

On 6 November 2017, an account called devops199 called initWallet directly on the library, passing a single owner — themselves — and a threshold of one. They became the library’s owner. Then they called kill.

The library’s code was deleted. Not the wallets, not the balances, not the owner lists — the library held none of those. It held the code, and now it held nothing.

Go back to the three things. Parity’s users still had the first: their keys worked, their signatures were valid. They still had the second: their wallets still held the money, and the stored list still named them as owners. They no longer had the third.

And here is the part worth sitting with: the 587 wallets were untouched. Right owners, right balances, right code. They simply forwarded their calls to an address that no longer contained anything. In the EVM of the time, a delegatecall to a codeless address returns success. So every withdrawal after that confirmed on-chain, cost gas, and did nothing at all.

513,774 ETH, permanently unreachable. The largest single victim was the Web3 Foundation, whose Polkadot ICO had closed three weeks earlier — roughly 306,000 ETH of the total. Parity Technologies had built both the wallet and Polkadot.

The gap, named

The guard asked has this been initialized?

The question it needed to ask was are you allowed to initialize this?

Those two questions give the same answer for every wallet, which is why the fix looked complete and why it passed review. They give opposite answers for exactly one contract: the one that is permanently uninitialized and permanently in use.

A check is a claim about the set of things it protects. The claim here was “uninitialized means not yet in service, therefore safe to claim.” For 587 contracts that was true. For the 588th it was precisely backwards.

Two details that are hard to read afterwards

Four days after the fix, a committer added release notes to the repository describing the update as a hotfix for the recent multisig vulnerability, and stated that “all future multi-sig wallets created by any version of Parity are secure”.

That sentence is true. The wallets were secure. It is also completely silent about the one contract that was not a wallet — because in the mental model that produced the fix, the library wasn’t the kind of thing the sentence was about.

And according to Parity’s own post-mortem and contemporaneous reporting, a GitHub user had pointed out the uninitialized library in early August and recommended initializing it. The company knew for roughly three months. The repository corroborates the inaction from its own side: 110 days, no change to the source.

What happened legally

Nothing.

devops199’s account was deleted. No charges were ever brought; the user was never publicly identified. Half an hour after the transaction they opened an issue describing what had happened, asked the project chat whether it was serious, and then asked whether they would be arrested. The answer turned out to be no.

Lawyers wrote about whether the Computer Fraud and Abuse Act might apply, to Parity’s claims or to users’. Nobody filed. One affected company said its own investigation suggested the actions had been deliberate rather than accidental, and floated going to law enforcement. It went nowhere.

Parity itself was covered by a GPL licence and terms disclaiming all liability, including for negligence. That was never tested in court. Several EIPs were proposed in 2017 and 2018 to restore access; none was accepted.

The money is still there. Nine years later, it has simply never moved.

It’s worth putting that next to Mango Markets, where the attacker used a live protocol exactly as written and ended up in federal court. Parity made three hundred million dollars unreachable and produced no legal consequence for anyone. The difference isn’t the damage. It’s that nobody gained.


Part two: Liquid, nine years later

On 6 September 2026, roughly four thousand bitcoin left Liquid, Blockstream’s federated sidechain, through a flaw in the cache that stores the results of range-proof verification. Blockstream published its incident assessment on 23 September. Most of what follows is from that report.

Liquid blinds transaction amounts. A range proof attached to each confidential output proves the hidden value is in a valid range without revealing it — and it is this proof that stops anyone creating coins out of nothing. Verifying a proof is expensive, so results are cached: verify once, and if the same proof turns up again, reuse the answer.

That cache is the whole story.

The original bug

In April 2018 a commit simplified the cache key. From then on the key was a hash of the nonce, the range proof and the value commitment — and no longer included the asset commitment or the output’s script, even though verification depended on both. A result cached in one context could be reused in a different one.

It sat there for eight years. On 2 August 2026, an external researcher — stutxo — reported it to Blockstream’s security mailbox.

So far this is not the Parity story. It’s the older, more familiar one: a defect survives because the code around it is stable, and review attention follows change.

Two fixes were offered. The narrower one was chosen.

stutxo sent two remedies the same day. The first added the missing fields to the cache key by concatenating their bytes. The second serialized each field with a length prefix.

Blockstream implemented the first. The report gives the reasoning plainly: the length-prefixed approach addressed a broader serialization pattern beyond the scope of the defect being fixed.

That is ordinary engineering discipline. Fix what was reported; don’t let a patch sprawl.

The fix shipped to bridge nodes the same day, and reached thirteen of fifteen functionaries by 11 August.

What the narrow fix created

Four fields now went into the hash, raw bytes one after another, with nothing marking where one ended and the next began.

Two of them are fixed at 33 bytes. The range proof and the script vary. The range proof carries its own length in its header — but that header is only read during verification, and verification is exactly what a cache hit skips. From the cache’s point of view, the proof was an undelimited run of bytes.

So an attacker could lengthen the proof and shorten the script by the same amount and produce the same byte string. Reuse the asset commitment and the final byte of the script from a transaction the node had already verified, and the hash matches an entry already in the cache. Cache hit. The fabricated proof never has to be valid. It only has to produce matching bytes.

That is what happened at 13:53 UTC on 6 September: roughly 4,000 LBTC backed by nothing, accepted as valid.

The report includes one comparison that is worth more than the rest of the document. Bitcoin Core caches signature validity the same way — concatenating a variable-length public key and a variable-length signature. It is not vulnerable, because the length prefixes inside those objects are checked before the cache is consulted. Same construction, opposite outcome, decided by the order of two steps.

Four reviews, all correct, all aimed elsewhere

Between the fix being written and being exploited, the code was examined by a manual engineering review, an AI-assisted review in the standard QA cycle, a second AI scan commissioned outside that cycle, and a functional test on testnet against a purpose-built attack transaction. The Bitcoin Red Team reported the original bug independently on 7 August and proposed the same narrow remedy.

Every one of them confirmed that the correct fields had been added. None asked how the fields were combined. The testnet test reproduced the bug that had been reported, and passed.

Nothing here was skipped. Nothing was rushed — this fix had a day, not four hours. The reviews were scoped to the reported defect, which is what scoping means, and the defect that mattered was one layer below the scope.

Two more layers that didn’t hold

The last line of defence was advisory. Liquid’s peg-out design puts a deliberate delay between a bad withdrawal and unrecoverable loss: proceeds go to an address derived from a member’s offline key, so even if consensus wrongly accepts invalid coins, someone has to physically retrieve the funds from cold storage. That pause is the window in which the federation catches the problem.

Blockstream’s own report describes the purpose in those terms. The Federation Charter instructs members to keep the receiving wallet offline. At 14:28:56 the federation released 3,996 BTC, and the receiving member forwarded them onward in the same Bitcoin block.

The window was zero seconds. The control existed, was correctly designed, and was not binding.

The alarm fired and was read as something else. At 14:10 — eighteen minutes before the peg-out confirmed — internal monitoring reported that Liquid block production had stalled. That stall was the attack: nodes with a cold cache ran the real check, failed it, and rejected the block.

But a stall was also the known symptom of the bug they had just fixed. The signal arrived in the shape of a problem that had already been closed.

The admission

Blockstream’s corrective actions include this: when more than one remediation exists for a consensus-critical issue, the approach with the strongest structural guarantees will now be the default, even if it requires a broader change — and critical consensus fixes will require review by an independent security auditor before release.

Read that against what happened. The remediation was chosen by the standards appropriate to the scope of the report. There was no standard for the reach of the code being touched. Blockstream has now written one.

A separate action goes further, and is the one I’d keep: caching a bare verification result for a context-dependent proof is called out as a fragile pattern in itself, independent of how the key is built. Correct keys close this incident. The pattern — trusting a stored boolean without re-deriving the context that made it true — is what they say they’re removing.

The remaining dispute is about disclosure history, not mechanism, and nobody has published the records.


Part three: the thing both cases are instances of

Here is the asymmetry, stated as plainly as I can manage.

The severity of the original bug determines how carefully the fix is reviewed. The blast radius of the fix has nothing to do with the severity of the original bug.

A critical finding gets a war room. A low-severity finding gets triaged, patched, and merged with the attention a low-severity finding deserves. But the patch touches the same code either way. It ships with the review budget of the report, not of the region it modifies.

Speed makes it worse but is not the cause. Parity’s fix took four hours end to end — the correct urgency for a live incident, and structurally the worst conditions for reviewing a change to an access-control path. Liquid’s had a full day, a manual review, two AI scans and a functional test. It failed anyway, because every one of those was pointed at the reported defect. Buying more time doesn’t help when the scope is wrong; it just produces a more thorough examination of the same square meter.

There’s a second thing, which is the one I actually find harder.

Both of these bugs are invisible at the point of change. Nothing is wrong with only_uninitialized as a piece of code. It is three tokens long and it does exactly what it says. You cannot find the bug by reading it, because the bug isn’t in it — the bug is in the relationship between that guard and one particular contract that happens to live in a state the guard’s author never pictured.

The same is true of Liquid’s cache key, and the Bitcoin Core comparison proves it: identical code, safe in one codebase and fatal in the other, because of what runs before it. There is no reading of the line itself that distinguishes the two.

That kind of failure doesn’t survive local review, because local review is where it hides. You find it by stepping back far enough to see what interacts with what, and asking which objects in the system fall outside the set the author had in mind. That’s a different activity from reading a diff, it takes longer, and in most organisations it is nobody’s specific job — or it is one person’s job, in a role that gets created after the first incident rather than before it.

I don’t say that as an accusation. It’s how essentially every codebase I’ve looked at is organised, and given how reviews actually get scheduled, it’s hard to see what else anyone would do.

Including mine

I went back through my own audit reports from May and found the same failure in my own severity ratings.

In one report I filed two separate criticals: a purchase function that didn’t validate the amount of ether sent, and a refund function that paid out real ether. Individually, each is a serious bug. Together they are a money printer — and I never wrote the sentence that connected them. In the same report I rated a missing access control on a price setter as High. It was a second, independent path to draining the contract. That’s a critical.


Closing

The thing that stops me, every time I come back to Parity, is how little there was to find.

There’s no clever code in that story. only_uninitialized is a single line. The people who wrote it were experienced, they were responding correctly to a real incident, they shipped fast because shipping fast was right, and the sentence they wrote about it four days later was true. Every local decision was defensible. The failure only exists at a distance — in the space between one contract’s assumption and another contract’s state — and at a distance is where nobody was standing.

What I keep coming back to instead is the thing underneath it: contracts almost never run alone, and the places they connect are where the assumptions stop matching.

A library reached through delegatecall is one version of that. Inheritance is another — a contract assembled from three parents, where a variable declared in one silently occupies the same storage slot as a variable in another, and neither author ever saw the combination. Proxy and upgrade patterns are a third: the implementation behind the address can change, and code that was audited is not necessarily code that is running. Cross-function state is a fourth and the most ordinary of all — two functions, each correct, sharing a variable in a way that only becomes wrong when they’re called in a particular order.

They look like different bug classes. They’re all the same shape: the call site does not show you what runs. In Parity, initWallet looks like a local function. It is a jump to another address, whose contents you did not verify and which turned out to be capable of vanishing. That’s true whether the code is open or closed. Parity’s was open — it was on GitHub, people read it, and in early August one of them wrote the warning down. Visibility was never the missing piece. Nobody had asked what depended on what.

So the question I actually try to hold on to is smaller and more mechanical than “read more carefully.” For every call: where does this code actually live, and who else can reach it?

Parity’s answer was: somewhere else, and anyone.

Liquid’s report answers the mechanism and leaves the history open. The Bitcoin Red Team’s own account hasn’t been published yet. I’m waiting for it — it may clarify something more, and I’d rather read it than guess at it.