An application decrypts an incoming message with an authenticated cipher. EVP_DecryptFinal_ex() returns 1. The documentation is unambiguous: a successful return means the authentication tag was verified. The application trusts the message.

It should not have. The tag was never computed.

A bridge contract on Ethereum receives a signed transfer from another chain. The notary signatures check out. The hash matches the payload. The contract pays out $11.58 million in ETH, tBTC and USDC. On the other chain, someone had deposited about one cent.

An AI agent finishes a task and replies:

Assigned to Team Birch.

In the previous article I asked what would make that last sentence trustworthy. I thought I was writing about language models.

The first two moments are real. Both happened in 2026, in mature, audited, heavily used code. Neither has anything to do with language models, and all three fail in the same way: a success signal was produced without the evidence it claimed to stand for.

So this article asks a wider question than the last one: when a system says “success”, what has actually been checked?

We will start with the smallest of the three failures—a bug rated Low, in a library you are almost certainly running—and let the rule fall out of it.

The Phantom Check

Did the check actually run?

On 9 June 2026, OpenSSL announced CVE-2026-45446, Incorrect Tag Processing for Empty Messages in AES-GCM-SIV and AES-SIV modes. The advisory credits Alex Gaynor of Anthropic with reporting it on 16 April 2026, and Dmitry Belyavskiy of Red Hat with developing the fix on the reporter’s recommendations.

AES-SIV and AES-GCM-SIV are authenticated encryption modes. You give them a key, a nonce, some associated data (AAD) and a plaintext; they produce a ciphertext plus a 16-byte tag. On decryption, EVP_DecryptFinal_ex() is documented to return success only if the tag verifies. That return value is the whole point of the mode.

The implementation computed the expected tag inside the code that processes ciphertext. If a caller supplied AAD and then called the final step without ever passing any ciphertext—which happens naturally when a received message is empty—the expected tag was never computed. It kept its initial value: all zeros.

Then the comparison ran, honestly, against a number that was never a result.

The advisory spells out the consequence for AES-GCM-SIV. An attacker who sends arbitrary AAD, an empty ciphertext, and an all-zeros tag passes authentication under a key they do not know, in a single attempt. Not a brute-force campaign. Not a timing side channel. One message, chosen freely, accepted as authentic.

AES-SIV has shipped since OpenSSL 3.0 and AES-GCM-SIV since 3.2; fixes landed in 3.0.21, 3.4.6, 3.5.7 and 3.6.3.

Two and a half months later, on 25 August 2026, CVE-2026-75803 described the same shape on a different path: when ChaCha20-Poly1305 or AES-OCB decryption was finalized through the one-shot EVP_Cipher() call, an empty ciphertext skipped tag verification and still reported success. Different mode, different API, different reporter — Billy Brumley of the Rochester Institute of Technology, who also wrote the fix — and the same defect.

I am not presenting these as catastrophes. OpenSSL rated both Low: no OpenSSL-native protocol—TLS, CMS, PKCS7, HPKE, QUIC—uses these cipher paths, so exploitation needs an application driving the low-level EVP interface in a particular way. I am presenting them because they are clean, small, recent, and because AES-SIV and AES-GCM-SIV are specifically designed to be misuse-resistant. The robust mode was undone by the empty case. Twice.

An aside: “Low” is also a success signal

CVE-2026-75803 carries a CVSS v3.1 base score of 9.1—Critical, on the same record that notes OpenSSL rated it Low. That gap is worth a paragraph, because it looks like a contradiction and is not one.

Both ratings come from checks that ran correctly. OpenSSL scores the vulnerability as it affects OpenSSL’s own protocol usage, where these paths are unreachable. A generic scorer rates the primitive: an authentication bypass in an AEAD mode, scored without assuming anything about the caller. Two honest answers to two different questions.

This is not the failure this section is about—nobody’s check skipped here. It is the same lesson one level up: a severity number is a signal produced by a check with a threat model inside it, and inheriting the number without the model is how a Low lands in a system where it is a Critical. If you consume OpenSSL through the EVP interface with your own protocol, OpenSSL’s rating was never about you.

The shape of the bug

Here is the pattern reduced to a toy. The tag function is a stand-in for illustration only, not a real authenticator.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/// Toy keyed tag. Illustration only: NOT a secure MAC.
fn toy_tag(key: u64, aad: &[u8], ciphertext: &[u8]) -> u64 {
    let mut h = key ^ 0xcbf2_9ce4_8422_2325;
    for &b in aad.iter().chain(&[0xffu8]).chain(ciphertext.iter()) {
        h ^= u64::from(b);
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    h
}

#[derive(Debug)]
pub struct AuthError;

pub struct BuggyDecryptor {
    key: u64,
    aad: Vec<u8>,
    expected_tag: u64, // starts at zero; only update() ever sets it
}

impl BuggyDecryptor {
    pub fn new(key: u64) -> Self {
        Self { key, aad: Vec::new(), expected_tag: 0 }
    }

    pub fn add_aad(&mut self, aad: &[u8]) {
        self.aad.extend_from_slice(aad);
    }

    pub fn update(&mut self, ciphertext: &[u8]) {
        self.expected_tag = toy_tag(self.key, &self.aad, ciphertext);
    }

    pub fn finalize(&self, received_tag: u64) -> Result<(), AuthError> {
        if received_tag == self.expected_tag { Ok(()) } else { Err(AuthError) }
    }
}

Nothing here looks wrong in isolation. Every method does what its name says. Now add an ordinary caller that skips work when there is nothing to decrypt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn receive(key: u64, aad: &[u8], ciphertext: &[u8], tag: u64) -> Result<(), AuthError> {
    let mut d = BuggyDecryptor::new(key);
    d.add_aad(aad);
    if !ciphertext.is_empty() {
        d.update(ciphertext); // "nothing to decrypt"
    }
    d.finalize(tag)
}

#[test]
fn forged_empty_message_is_accepted() {
    let secret_key = 0x5eed_u64; // the attacker never sees this
    assert!(receive(secret_key, b"transfer:all", b"", 0).is_ok());
}

The test passes. The attacker chose the AAD, sent nothing else, guessed a tag of zero, and received success.

That if !ciphertext.is_empty() is not a mistake a careless programmer makes. It is an optimisation a careful programmer makes. The bug is not in the branch; it is in a design where the verdict lives outside the only function that must produce it.

THE PHANTOM CHECK

The check ran. The tag was never computed.

One forged empty message through the buggy decryptor.

 

 

  

aad"transfer:all"

ciphertext(empty)

expected_tag0x0000…0000

update()SKIPPED

verdictOk(())

Authenticated under a key the attacker does not know.

The comparison executed honestly against a value that was never a result. The Phantom Check: if the verdict can be reached without the check computing it, the verdict is a default.

The shape of OpenSSL CVE-2026-45446, reduced to the toy decryptor above. expected_tag is initialised to zero and only update() ever writes it; an empty ciphertext means update() never runs, so finalize() compares the attacker's zero tag against the untouched default. Timing is illustrative.

You can download and run the whole example: it needs only rustc, has no dependencies, and contains both the buggy and the fixed decryptor plus the tests that separate them.

1
2
3
4
5
6
7
8
9
10
11
12
$ rustc --test verified.rs -o verified && ./verified --test-threads=1

running 7 tests
test buggy::tests::forged_empty_message_is_accepted ... ok
test buggy::tests::genuine_message_round_trips ... ok
test buggy::tests::wrong_tag_on_nonempty_message_is_rejected ... ok
test evidence::tests::only_verified_messages_reach_apply ... ok
test fixed::tests::forged_empty_message_is_rejected ... ok
test fixed::tests::genuine_empty_message_is_accepted ... ok
test fixed::tests::genuine_message_round_trips ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Read that output carefully. The buggy module’s tests all pass too—including the one asserting that a forgery is accepted. A green test suite is a success signal, and it establishes only what the tests actually assert. The buggy decryptor has correct round-trip behaviour and correctly rejects a wrong tag on a non-empty message. Every property anyone thought to test holds. The forgery lives in the case nobody wrote a test for.

What the fix changes

The fix is not “remember to call update”. It moves the check to the one place every path must go through:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pub struct Decryptor {
    key: u64,
    aad: Vec<u8>,
    ciphertext: Vec<u8>,
}

impl Decryptor {
    pub fn new(key: u64) -> Self {
        Self { key, aad: Vec::new(), ciphertext: Vec::new() }
    }

    pub fn add_aad(&mut self, aad: &[u8]) {
        self.aad.extend_from_slice(aad);
    }

    pub fn update(&mut self, ciphertext: &[u8]) {
        self.ciphertext.extend_from_slice(ciphertext);
    }

    // Consumes self: a finalized context cannot be reused by accident.
    pub fn finalize(self, received_tag: u64) -> Result<(), AuthError> {
        let expected = toy_tag(self.key, &self.aad, &self.ciphertext);
        if received_tag == expected { Ok(()) } else { Err(AuthError) }
    }
}

Three things changed:

  • The expected tag is computed in finalize. No path can reach the verdict without the computation, because the verdict and the computation are now the same function.
  • There is no stored default that could be mistaken for a computed value. The field that held a zero is gone.
  • finalize takes self by value. This matters because the AES-SIV variant of the bug required a decryption context to be reused without resetting the key. In Rust, the type system can make that reuse impossible rather than merely discouraged.

A real implementation would also compare tags in constant time; the toy uses == to keep the point visible.

The Phantom Check, restated: if the verdict can be reached without the check executing, the verdict is a default, not a result.

The rule that bug implies

That bug is small. The rule it implies is not.

Generalise what went wrong. It was not that the tag comparison was weak—it was perfectly good. It was that the thing being compared had never been produced by the check it claimed to come from. State that as a property you could hold any system up to:

A success signal may be produced only by the check that establishes it, over all the data it claims to cover, for this specific operation.

A success signal is anything a caller is allowed to act on: a return value of 1, an Ok(()), a payout, a status badge, a green check, the word “done”. It is not only the happy-path return value. A severity rating, a receipt, an audit stamp and a progress bar are all success signals when something downstream treats them as settled.

That rule can break in three ways, and the useful thing about them is that each breaks on its own. They need names, because “clause 2” is not a thing anyone says in a code review:

  1. The Phantom Check — did the check run? The signal comes from a check that actually executed on this path, not from a default value, an untouched variable, or a branch that was skipped.
  2. The Wrong Question — what did the check prove? It established the property the caller cares about, not a neighbouring one that happens to be easier to check.
  3. The Borrowed Alibi — is this evidence ours? The proof, receipt or tag was produced for this request, these inputs, this context—and not for some other perfectly valid one.

You have just watched the first one. The next two sections are a real public failure for each of the others, and then the same three gates in a system I work on every day.

None of these involved broken mathematics. The signatures were sound, the ciphers were sound, the hashes were sound. What failed was the wiring between a check and the signal that claimed to report it.

A note on what this property does not promise. It does not say the operation will succeed, or that the system is secure, or that the check is a good check. It says something much narrower: our reported state never gets ahead of our evidence. That narrowness is exactly what makes it enforceable, and what makes its violations findable.

THREE WAYS A SUCCESS SIGNAL GOES HOLLOW

Three gates, three ways through them.

Each gate can fail while the other two hold. Watch one real failure stop at each.

 

 

  

gate 1 · did the check run?PASS

gate 2 · what did it prove?PASS

gate 3 · is the evidence ours?PASS

caseall three hold

outcomea success signal is permitted

A success signal may be produced only by the check that establishes it. Over all the data it claims to cover, for this specific operation.

The three gates are independent: a system can pass two and fail the third. That is why “we verify the proof” is not an answer to the question of what your success means.

The three gates are independent. A system can pass two and fail the third, which is why “we verify the proof” is not an answer to the question of what your success means.

The Wrong Question

What did the check prove?

In May 2026, the Verus–Ethereum bridge lost an estimated $11.58 million. According to Halborn’s analysis, no cryptography failed and no code misbehaved. Every check that existed passed honestly.

On the Verus side, validation focused on the structure and authenticity of a transfer blob. The blob had a real hash, a valid Merkle proof, and well-formed contents. Its inputs were worth about one cent. Because every component was valid, the Verus notaries approved it.

On the Ethereum side, the attacker submitted the signed blob to submitImports(). That function checked the notaries’ signatures, checked that the signed hash matched the blob, and extracted the payout instructions. The instructions released about $11.58 million in ETH, tBTC and USDC.

The missing property was conservation of value: the amount paid out must not exceed the amount put in. Halborn places that check in a function called checkCCEValues on the Ethereum side, where it was not performed. Their summary is the sentence worth keeping: both sides validated, but neither was required to validate the one field that mattered.

Each check answered a different question

It helps to write down what each check actually established:

CheckProperty it establishedDid it pass?
Merkle proof on VerusThis blob is part of committed Verus stateYes
Notary approvalThe notaries agree this blob is genuineYes
Signature check on EthereumThe notaries signed exactly these bytesYes
Hash check on EthereumThe bytes match the signed hashYes
Value checkPayout is backed by deposited valueNever ran

Every row answers a version of “is this message authentic?” Not one answers “is this message acceptable?”

A signature proves who said something. It says nothing about whether what they said is allowed. An honestly signed lie is still a lie, and a quorum of notaries signing it only establishes that the quorum signed it.

THE WRONG QUESTION · MAY 2026

Every check passed. None of them asked.

A transfer blob crosses the bridge. Watch what each check establishes.

 

 

  

merkle proofPASS

notary quorumPASS

signed hashPASS

value conservationNEVER RAN

deposited$0.01

released$11.58M

Released $11.58M. Backing on the source chain: $0.01. A signature proves who said something. It says nothing about whether what they said is acceptable.

The Verus–Ethereum bridge, May 2026. Each check passes honestly and establishes something true. The dashed row is the one that would have compared the two pans, and it is not a check that failed—it is a check that was never there.

I raised the same distinction in the previous article in a much smaller setting: a JSON parser can accept {"team":"Team Cedar"}, a schema can confirm team is a string, a directory can confirm Team Cedar exists, and the owner can still be wrong. Structural checks and semantic checks are different jobs, and the cost of confusing them scales with what you are protecting.

The shape of the bug

An illustrative sketch of the pattern, not Verus’s actual code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct Transfer {
    pub deposited: u128, // value proven locked on the source chain
    pub payout: u128,    // value to release here, same unit after conversion
    pub recipient: [u8; 20],
}

pub enum Reject { BadSignatures, Malformed, Unbalanced }

pub fn submit_import(blob: &[u8], sigs: &Signatures, notaries: &NotarySet)
    -> Result<Payout, Reject>
{
    notaries.verify_quorum(blob, sigs).map_err(|_| Reject::BadSignatures)?; // who signed
    let t = Transfer::decode(blob).map_err(|_| Reject::Malformed)?;         // does it parse

    // The missing line. Without it, every check above passes for a
    // well-formed, honestly signed, wildly unbalanced transfer.
    if t.payout > t.deposited {
        return Err(Reject::Unbalanced);
    }

    Ok(Payout { to: t.recipient, amount: t.payout })
}

The fix is one comparison. Finding it requires asking a question no individual check was designed to ask: what property does the caller of this function actually rely on? The caller relies on “the bridge never releases more than it holds.” Nothing in the function guaranteed that, and nothing in the function looked wrong.

The part that should bother you

On 23 July 2026—roughly two months later—the same bridge was drained again, for about $7.54 million.

Reporting at the time, including from Blockaid, described the second attack as exploiting the same class of flaw as May’s: Ethereum payouts released without confirming that matching value had been committed on Verus, again with valid signatures and valid Merkle proofs. I want to be careful here—public reporting establishes the flaw class and the repetition, not that the identical missing comparison was still absent in the identical function. The precise diff is not something I can verify from outside.

Even stated that carefully, it is the most important fact in this article.

THE WRONG QUESTION · 23 JULY 2026

Every check passed. None of them asked.

A transfer blob crosses the bridge. Watch what each check establishes.

 

 

  

merkle proofPASS

notary quorumPASS

signed hashPASS

value conservationNEVER RAN

depositednever confirmed

released$7.54M

Released $7.54M. Backing on the source chain: never confirmed. A signature proves who said something. It says nothing about whether what they said is acceptable.

The same scene, two months later. This is literally the same component with two values changed, which is the point: public reporting describes the second attack as the same class of flaw, with valid signatures and valid Merkle proofs again releasing value that nothing had confirmed.

The May incident got a public post-mortem, a named missing check, and a clear explanation of the property that went unvalidated. Most of the May funds came back: the attacker returned 4,052 ETH, about $8.5 million, and kept 1,350 ETH as a bounty under a negotiated deal that also spared them prosecution—the kind of outcome that reads as a story resolved. Two months later the same question, “is this message acceptable?”, went unanswered again.

That is the practical difficulty with the Wrong Question. Phantom Checks are findable by machinery: fuzz the degenerate inputs, assert that no verdict is reachable without its computation. Wrong Questions are not in your code. They are in the gap between the checks you wrote and the properties you depend on, and nothing in a diff, a test run or a signature verification points at an absence. The only reliable instrument is the list—every check, the property it establishes, every reliance, and which column has no partner. Writing that list is tedious, which is why it does not get written, which is why the same bridge paid twice.

The same failure inside a crypto library

Cryptographic code is not immune to the Wrong Question either. CVE-2026-34182, announced by OpenSSL on 9 June 2026 and rated Moderate under the heading CMS AuthEnvelopedData Processing May Accept Forged Messages, concerned CMS AuthEnvelopedData: containers whose content is meant to be protected by an authenticated cipher.

The advisory describes insufficient validation of the cipher and tag-length fields. An on-path attacker takes one legitimate AES-GCM message and rewrites the inner cipher identifier to AES-256-OFB—an unauthenticated keystream mode—while leaving the key-wrapping part intact. The victim unwraps the genuine key, decrypts with OFB, never consults a MAC, and CMS_decrypt() returns success. A second variant shrinks the tag length to a single byte, making forgery a matter of brute force over 256 possibilities.

The decryption check ran. It just ran under a configuration where it could not establish integrity, and the success signal did not record that difference. The caller asked “is this content authentic?” and received an answer to “did decryption complete?”

Note who chose the configuration: the attacker. A check whose guarantee depends on a parameter your adversary supplies is not a check with a guarantee.

The Wrong Question, restated: a check proves exactly the property it was built to prove. If you cannot name that property, you do not know what your success means.

The Borrowed Alibi

Is this evidence ours?

On 13 April 2026, an attacker minted one billion bridged DOT tokens on Ethereum through Hyperbridge, a bridge whose selling point was replacing multisig trust with cryptographic proofs.

The design was sound in outline. A user sends a request on Polkadot. A relayer carries a proof of that request to Ethereum. The HandlerV1 contract verifies the proof against a trusted Merkle Mountain Range (MMR) root, then executes the request.

The flaw was in the relationship between the proof and the request. As Verichains’ analysis puts it, MMR verification proves only that a leaf hash belongs to the trusted root. The contract then executed the request payload supplied by the relayer, rather than the request the proof actually committed to.

Hyperbridge’s own postmortem, citing BlockSec Phalcon’s investigation, names the specific hole: missing input validation in VerifyProof(), which failed to enforce leaf_index < leafCount. With a particular combination of leaf count and index, the root calculation never incorporated the request commitment at all—so a proof could pass for arbitrary request content. The attacker reused data from a previously legitimate transaction to build it.

The forged request was a ChangeAssetAdmin call on the bridged DOT token. It passed verification, handed the attacker minting authority, and the mint followed.

Hypernative notes that either fix alone would have stopped it: a correct bounds check would have rejected the forged proof, and binding the proof to the request would have caught the mismatch even with the bounds bug. Two independent defences, both absent, one attack.

THE BORROWED ALIBI

Accepted by the verifier. Bound to nothing.

A relayer submits a proof and a request. Only one of them is verified.

 

 

  

proof leaf#A1 (recycled)

against rootACCEPTED

bindingNEVER CHECKED

executed request#B7

callChangeAssetAdmin

Admin transferred. One billion bridged DOT minted. Never execute what was presented. Execute only what was proven.

Hyperbridge, 13 April 2026. The postmortem describes an invalid proof being incorrectly accepted, after a missing bounds check let the root calculation omit the request commitment. Whatever the verifier's success established, nothing tied it to the request being run.

An aside: three numbers for one loss

How much was lost? There are three defensible answers, and the spread is instructive.

FigureWhat it measures
~$1.2 billionNominal value of the minted tokens, at the pre-attack price
~$237,000Initially reported realized loss—the bridged DOT pool was thin, so dumping a billion tokens raised very little
~$2.5 millionRevised realized loss after reconciling activity across Ethereum, Base, BNB Chain and Arbitrum

None is wrong. They answer “what did the attacker create?”, “what came out of one pool on day one?” and “what did this cost in total, once we looked everywhere?” The first number is the one that travels; the third is the one that took work. The reconciliation moved the figure by an order of magnitude, which is a fair warning about any incident figure published in the first 24 hours—including the ones in this article.

Accepted, but never bound to the request

This is the subtlest of the three failures, because the verifier did real work and returned a real answer. Be careful about what that answer was, though. Hyperbridge’s postmortem is precise: the invalid proof was incorrectly accepted. It was not a sound proof of some other statement — it was a forgery that a buggy verifier let through, because with the index unchecked the root calculation could omit the request commitment altogether.

The lesson survives the correction, and is arguably sharper for it. The verifier returned success. Nothing that success established was ever tied to the request the contract then executed.

There are two separate questions here:

  1. Is this proof valid against the trusted root?
  2. Does this proof attest to this exact request?

Answering the first and acting as if you answered the second is how an accepted proof becomes an authorisation it never granted. The safe pattern is to never execute what was presented. Execute only what was proven: derive the request from the committed data, or check that the hash of the presented request is exactly the leaf the proof verified.

The verb matters. “Verify the proof, then execute the request” contains the bug. “Verify the proof, then execute the proven request” does not.

Back to Team Birch

This is the same check the agent article placed at the confirmation gate:

1
2
3
4
5
6
7
8
9
10
11
12
pub struct Expected { operation_id: OpId, issue_id: IssueId, team_id: TeamId }
pub struct Receipt { state: State, operation_id: OpId, issue_id: IssueId, team_id: TeamId }

pub fn may_report_confirmed(expected: &Expected, receipt: Option<&Receipt>) -> bool {
    match receipt {
        Some(r) => r.state == State::Committed
            && r.operation_id == expected.operation_id // this operation, not another
            && r.issue_id == expected.issue_id
            && r.team_id == expected.team_id,
        None => false,                                 // no evidence, no confirmation
    }
}

A committed receipt for someone else’s operation is a perfectly real receipt. It is not evidence for ours.

That operation_id comparison is the agent-world equivalent of proof-to-request binding. Leave it out and you have rebuilt the Hyperbridge bug in a support ticket tool: a genuine receipt, correctly verified, attesting to a statement you did not make. The stakes differ by seven orders of magnitude. The defect is the same defect.

The Borrowed Alibi, restated: evidence is only evidence for the statement it was produced for. Bind it to the operation, or it proves nothing about the operation.

Closer to home: what an Ethereum client calls “done”

I build Ethereum JSON-RPC clients, and this invariant shows up there constantly—in a form that needs no attacker at all. A transaction moves through several states, and each one is evidence for a different statement.

Signal you receiveWhat it establishesWhat it does not establish
eth_sendRawTransaction returns a hashA node accepted the transaction into its poolThat it will ever be included; it can be dropped or replaced
A receipt exists for that hashThe transaction was included in some blockThat execution succeeded
Receipt status == 1Execution did not revertThat the intended effect happened
The expected event log, from the expected contractThe effect you wanted occurredThat the block will stay canonical
Block at or below the finalized tag, hash still canonicalThe result is final under normal consensusAnything about the next transaction

Each row is a check that ran and passed, and each answers a narrower question than “is it done?” Treating any row as the last row is a Wrong Question failure. Three of these rows have public incidents behind them.

CLOSER TO HOME

Five signals, and none of them says “done”.

One transaction, five things a client can observe. Each answers a narrower question than the last.

 

 

  

establishesThe result is final under normal consensus

does not establishAnything about the next transaction

Only now may a client say confirmed. The invariant does not forbid intermediate states. It forbids labelling one with the word for the final one.

Each rung is a check that ran and passed. Treating any rung as the last one is a Wrong Question failure — and two of these have real incidents behind them.

“Included” is not “canonical”

On 5 January 2019, Coinbase detected a deep chain reorganization on Ethereum Classic. By the end of their investigation they had counted twelve reorganizations containing double spends, totalling 219,500 ETC—about $1.1 million. Coinbase halted ETC sends and receives and reported that it was not the target and lost no funds.

Look at what that means mechanically. For each double spend, somebody held a transaction receipt. The receipt was real. The block was real. The transaction had been included, had not reverted, and had emitted exactly the right logs. Then the chain containing it stopped being the chain, and the coins were spent elsewhere.

Every check passed. The property established was “this transaction was included in a block on the chain I was looking at.” The property relied on was “this payment is settled.” Coinbase’s own defence was not a better signature check; it was detection plus a confirmation policy—treating “included” as an intermediate state, which is precisely what the Wrong Question asks you to do.

Ethereum’s post-Merge finality narrows this window a great deal, but it does not remove the distinction. On 25 May 2022 the beacon chain experienced a seven-block reorganization, blocks 3,887,075 through 3,887,081—attributed to the proposer-boost fork-choice change not being fully rolled out across clients, combined with a very late block. Finality held. Seven blocks of latest did not.

This is why the JSON-RPC block tags exist as three separate words. latest can be reorganized. safe is unlikely to be. finalized is settled under Casper FFG. A client that reads latest and reports “done” has substituted one tag’s guarantee for another’s.

Replacement breaks binding

A pending transaction can be replaced by another with the same sender and nonce, typically carrying a higher fee. If your client confirms “my transfer happened” by checking that the account nonce advanced, it can be fooled: the nonce was consumed, but by a different transaction—possibly one you yourself sent to speed things up.

The receipt you trust must be for your transaction hash. That is the Borrowed Alibi, in a wallet, with no adversary required.

status == 1 is not the effect

This one is written into the standard. EIP-20 says transfer “SHOULD throw if the message caller’s account balance does not have enough tokens to spend”—a should, not a must—and then warns callers directly:

Callers MUST handle false from returns (bool success). Callers MUST NOT assume that false is never returned!

Some tokens take the other branch: they signal failure by returning false rather than reverting. A call to such a token produces a receipt with status == 1 while no tokens moved. This is why wrappers like OpenZeppelin’s SafeERC20 exist—they turn a quiet false into a revert, so that the transaction-level success signal and the token-level outcome stop disagreeing.

The receipt answered “did execution complete?” The caller needed “did the transfer happen?” The spec told everyone, in capital letters, that these were different questions.

Pointing at every row

Put together, a client that reports “confirmed” should be able to point at every row:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn is_final_success(
    expected: &ExpectedTx,      // the hash we sent + the effect we expect
    receipt: Option<&Receipt>,
    canonical_hash_at: impl Fn(u64) -> Option<H256>,
    finalized_block: u64,
) -> bool {
    let Some(r) = receipt else { return false };   // no receipt: pending or dropped, not done
    r.transaction_hash == expected.hash            // our transaction, not a replacement
        && r.status == 1                           // execution did not revert
        && r.logs.iter().any(|log| expected.matches_effect(log)) // the effect, right contract
        && r.block_number <= finalized_block       // past finality
        && canonical_hash_at(r.block_number) == Some(r.block_hash) // still canonical
}

Not every application needs finality before showing progress. A UI can honestly say “included, not yet final”. The invariant does not forbid intermediate states. It forbids labelling an intermediate state with the word for the final one.

There is one more assumption hiding in that function: every value in it came from an RPC node. If the node is wrong, every check passes against false data. Whose evidence you trust is a question for another article.

Rules for checks that cannot lie

None of these failures needed new cryptography to fix. They needed the success signal to be wired to the right check. These are the rules I now apply, each tied to the failure that taught it.

  1. Make the unverified state the default. A verdict variable should start as “not verified” and become “verified” only by computation. Zero, None, false and empty must never coincide with success. OpenSSL’s SIV bug lived in a tag that started at zeros and was compared as if computed.
  2. Put the check on the only path to the verdict. If two APIs can reach success, both must pass through the check. Compute the verdict at the point of reporting, not earlier on a path that may be skipped. Both empty-ciphertext CVEs came from a path that bypassed the computation.
  3. Write down the property each check establishes. Not “validated” but “the notaries signed these bytes” or “the payload parses”. Then list what the caller relies on, and compare the two lists. Any reliance with no matching check is your bug. That one table would have caught Verus—and it is the only rule here that found the bug twice, because nothing else detects an absence.
  4. Bind evidence to the operation. Compare the identifier in the proof, receipt or tag with the identifier of the request you are about to act on, and act on the proven data, not the presented data. That was Hyperbridge, and it is the operation_id line at the confirmation gate.
  5. Refuse configurations where the check cannot hold. If an attacker can choose a cipher, a tag length or a proof shape, the verifier must reject the choices under which its guarantee fails. CMS accepted an unauthenticated cipher inside a container that promised authentication.
  6. Test the degenerate inputs on purpose. Empty messages, single-leaf trees, zero amounts, the first and last index. Ordinary tests exercise the happy path; these bugs lived at the edges. A property test asserting “no input without a valid tag ever verifies” would have found the toy bug in this article in seconds.
  7. Use types to make misuse unrepresentable. A finalize(self) that consumes the context cannot be called twice. A Verified<T> that only the verifier can construct cannot be forged by a careless caller. Rust gives you these tools; they turn rule 2 from discipline into structure.

The last rule is the one I find most useful in practice. Here is the shape of it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Lives in its own module so `inner` stays private outside it.
/// Can only be constructed by `verify`. Holding one is the evidence.
pub struct Verified<T> { inner: T }

impl<T> Verified<T> {
    pub fn get(&self) -> &T { &self.inner }
}

pub fn verify(msg: Message, key: &Key) -> Result<Verified<Message>, AuthError> {
    let expected = compute_tag(key, &msg.aad, &msg.ciphertext);
    if constant_time_eq(&expected, &msg.tag) {
        Ok(Verified { inner: msg })
    } else {
        Err(AuthError)
    }
}

// Downstream code asks for proof in its signature, not in a comment.
pub fn apply(update: &Verified<Message>) { /* ... */ }

If apply takes a Verified<Message>, then no code path can reach it with an unverified message. The success signal and the check become the same object—and the compiler, not a reviewer, rules out a Phantom Check.

Note the limit: Verified<Message> says the tag checked out. It does not say the message is acceptable (the Wrong Question) or that it was produced for the operation you are about to perform (the Borrowed Alibi). Those want their own types—Balanced<Transfer>, BoundTo<Request>—or they want the comparison written at the gate. A single Verified wrapper is not a licence to stop naming properties.

The three successes, revisited

We started with three moments and met several more along the way. Each now has a name.

MomentWhat the signal claimedWhat had actually been checkedWhich gate failed
EVP_DecryptFinal_ex() returns 1 on an empty messageThe tag is validNothing; the expected tag was never computedThe Phantom Check
The bridge pays out $11.58 millionThis transfer is legitimateWho signed it and whether it parsedThe Wrong Question
CMS_decrypt() returns success on a rewritten cipherThis content is authenticThat decryption completedThe Wrong Question
A forged request passes Hyperbridge’s verifierThis request was sent on the source chainThat the verifier returned successThe Borrowed Alibi
A receipt on a reorganized blockThis payment is settledThat it was included on the chain we were watchingThe Wrong Question
status == 1 from a token that returns falseThe transfer happenedThat the transaction did not revertThe Wrong Question
An agent says “Assigned to Team Birch”The assignment committedDepends entirely on the gate behind itAny of the three

The agent row is the reason I wrote this. When I first wrote about agent confirmation, it felt like a problem specific to language models: a fluent sentence with nothing behind it. It isn’t. Mature, audited, heavily used systems produce the same failure whenever a success signal can be emitted without its check. Language models make it easier to notice, because they can produce the word “done” with no machinery at all—but a C function returning 1 from an uninitialized variable is doing something much harder to see and exactly as hollow.

The invariant is the same everywhere: a success signal may be produced only by the check that establishes it, over all the data it claims to cover, for this specific operation. Sound primitives do not guarantee it. Only the wiring does.

Try the boundaries yourself

Your verifier checks a signature over a request, and the signature is valid. The request asks to withdraw more than the account holds. Which of the three, if any, is at risk?

The Wrong Question. The signature check establishes authorship, not acceptability—exactly the Verus shape. Add the balance comparison, and name the property it establishes so the next reader can see it was a deliberate second check rather than a redundant one.

A service returns a signed receipt. You verify the signature, and the receipt's state is committed. Is that enough?

No. The Borrowed Alibi is unaddressed: a validly signed, genuinely committed receipt may belong to a different operation. Compare its operation identifier with the request you are about to report on. This is the Hyperbridge pattern at a smaller scale.

You add an assertion that the verdict variable was written before it is read. Does that rule out a Phantom Check?

It addresses the symptom on the paths your tests reach. The Phantom Check is ruled out structurally, not dynamically: if the verdict is computed by the function that returns it, there is no path on which the write can be missing, and no assertion is needed. Prefer the restructure; keep the assertion only where you cannot restructure.

Your client reports confirmed whenever a receipt exists with status 1. Name every signal it is missing, and which gate each one belongs to.

The log check (the Wrong Question—status == 1 is not the effect), the transaction-hash comparison (the Borrowed Alibi—replacement), and the finality plus canonical-hash checks (the Wrong Question again—”included” is not “settled”). Four missing signals across two gates, in two lines of apparently reasonable code.

What I still find hard

Some of this is unresolved for me, and I would rather say so than pretend otherwise.

  • When the checker is also a model. It is common to have one model verify another’s work. A second model’s “looks correct” is a success signal too. What property does it establish, and how would you bind it to the operation?
  • Evidence from a party you have to trust. Every Ethereum check above assumes the RPC node tells the truth. Running your own node, querying several, or verifying light-client proofs all move the trust somewhere else rather than removing it. Where should it sit?
  • Honest intermediate states in a UI. Users want one word, and “included, not yet final” is two. How much nuance can an interface show before people ignore it?
  • Why rule 3 does not get followed. The check/reliance table is cheap, requires no tooling, and would have prevented the largest loss in this article—twice. I do not have a good account of why it stays unwritten, which makes me doubt that restating it is enough.

The next INVARIANT article will take up the second question: what a client should believe when the source of its evidence can lie.

Sources

Incident figures are as reported at the time of writing and have already been revised once in the Hyperbridge case; the Verus repeat attribution follows public reporting of the flaw class, not a diff I have inspected. The Ethereum section reflects standard JSON-RPC and consensus behaviour; check current client documentation for finality tags and timing. All Rust code in this article is illustrative and is not taken from the affected projects.