← All posts
11 min read

The cut wire: autonomous GRANDPA equivocation slashing, end to end

Autonomous GRANDPA equivocation slashing, end to end: detection, verified reports, stake attribution, and deferred slashing, wired at the runtime seam. Two stub methods had silently dropped every detected attack. This is how we reconnected the wire.

A blockchain that finalizes is a blockchain that makes promises. GRANDPA, the finality gadget under QuantumAI Blockchain, lets a set of validators vote a block irreversible. Two thirds agree, the block is final, and the rest of the system can treat it as settled. The whole arrangement rests on one assumption: a validator casts at most one vote per round. Cast two conflicting votes in the same round and you are no longer a participant, you are an attacker. That act has a name, equivocation, and a chain that wants to be taken seriously has to make it expensive.

This post is about closing that loop on our chain. It turned out to be a smaller change than the gap looked, for a reason worth writing down: most of the machine was already built. What was missing was a wire between two halves of it.

What slashing actually requires

People say "slashing" as if it is one thing. It is really four, in sequence, and each one has to exist for the next to matter.

First, detection. Somebody has to notice the two conflicting votes. This happens off chain, inside each node, because that is where votes arrive over the gossip network.

Second, a report that can be trusted. A node that shouts "validator X equivocated" cannot be believed on its word, or every node could grief every other. The report has to carry a cryptographic proof: both signed votes, same round, same authority, different targets. Anyone can check the math.

Third, attribution. The proof names a consensus key. Slashing has to hit a stake. So the chain has to map that GRANDPA key back to the bonded validator who put up the stake.

Fourth, the slash itself, applied to that stake, with enough of a delay and enough of a governance brake that an honest validator caught by a bug is not wiped out before a human can look.

We had built three and a half of these. The half that was missing is the interesting part.

The state of the machine before this change

The on chain half was done and tested. Our offences pallet, qbc-offences, has an unsigned extrinsic, report_grandpa_equivocation_unsigned. Hand it an equivocation proof and it does the real work: it re-verifies the proof on chain with the same check_equivocation_proof routine the rest of the Substrate ecosystem uses, it resolves the offending GRANDPA key to a bonded stash through our staking pallet's key registry, and it schedules a slash. The slash is deferred by twenty seven eras, roughly a week, and governance can cancel it in that window with a single call. The pallet also has a pool gate, a ValidateUnsigned implementation, that runs the cryptographic check before the report is even allowed into the transaction pool. A junk report costs the spammer nothing on chain because it never lands. There are seven unit tests over this path, built on genuine signed equivocation proofs, and they cover the cases that matter: a valid proof schedules a deferred slash and never an immediate one, an invalid proof changes nothing, a proof for an unattributable key is rejected, the unsigned path behaves like the signed path, and the pool gate turns away non equivocations.

The node half was done too, though we did not write it. Substrate's GRANDPA voter already watches for equivocations as a side effect of processing votes. When it sees one it builds the proof itself, and it is configured on our nodes with everything it needs to report: a keystore, and an offchain transaction pool to submit through. We had wired both of those in months ago without thinking of them as slashing infrastructure, because they are needed for ordinary operation.

So detection existed. Verification existed. Attribution existed. The deferred slash existed. And yet not one equivocation could ever have been punished, because of how the two halves talk to each other.

The cut wire

The Substrate voter does not know about our custom pallet. It cannot. It is generic code. When it detects an equivocation it reaches into the runtime through a fixed interface, the GrandpaApi, and calls two methods. One asks the runtime to produce a key ownership proof. The other asks the runtime to submit the equivocation report as an extrinsic. The voter's logic is: get the key ownership proof, and if you get one, submit the report.

Both methods, in our runtime, were stubs. generate_key_ownership_proof returned nothing. submit_report_equivocation_unsigned_extrinsic returned nothing too, after logging a warning that an equivocation had been seen and a human should investigate.

That is the whole bug. The voter detected the equivocation, asked the runtime for a key ownership proof, got nothing back, and by its own logic stopped right there. The report was never submitted. The verified, tested, deferred slash path sat downstream of a method that always returned nothing, so it was never reached in production. The machine was complete except for the one wire that connects detection to punishment, and that wire was cut at the runtime boundary.

This is the kind of gap that does not show up in any single test, because every component passes its own. The pallet tests prove the slash path works when you call it. The node runs fine. Only the seam between them was dead, and seams are exactly what unit tests do not cover.

Reconnecting it

The fix is a runtime change, and it has two pieces.

The first piece gives the runtime the ability to submit an unsigned extrinsic from inside an offchain call. This is standard Substrate plumbing that we simply had never needed before: two small trait implementations, CreateTransactionBase and CreateBare, that together let the runtime construct a bare, unsigned extrinsic and hand it to the local transaction pool. We wrote them generically rather than narrowly, because the same plumbing is what a future sortition claim worker will use to submit its own unsigned messages. Build the general tool once.

The second piece fills in the two GrandpaApi methods. submit_report_equivocation_unsigned_extrinsic now takes the proof the voter handed it, wraps it in our pallet's report_grandpa_equivocation_unsigned call, builds the bare extrinsic, and submits it to the pool. From there the existing, tested path takes over: pool gate verifies, the extrinsic lands in a block, the pallet attributes and schedules the deferred slash.

generate_key_ownership_proof is the subtle one. The voter will not submit unless it gets a key ownership proof back, so returning nothing keeps the wire cut. But our pallet does not actually consume a key ownership proof. Standard Substrate slashing uses a historical session membership proof to attribute an offence to a key that was valid at some past set. Our pallet does its own attribution, on chain, against the current session's key registry, and ignores the membership proof entirely. So the honest thing is to return an empty proof: enough for the voter to proceed, never decoded by anything downstream. We verified that the pallet genuinely ignores it, not by reading the code and hoping, but because one of the existing pallet tests already drives the unsigned path with no membership proof at all and schedules a slash correctly. The design decision was validated by a test that predates the decision.

It is worth being clear about what this does not weaken. A forged report still cannot slash anyone. The pool gate re-runs the full cryptographic equivocation check before the report can enter the pool, and the pallet runs it again on chain. The empty key ownership proof is not a security shortcut, because the security does not live in that proof on our chain. It lives in the equivocation proof itself, which is checked twice.

Proving it

A consensus change earns trust by evidence, not by argument, so here is the evidence.

The production runtime compiles to its usual size, around 568 kilobytes, which matters because the new submission code has to be valid in the no_std WebAssembly environment the runtime actually runs in, not just in a normal build.

The offences pallet's seven unit tests still pass. They are the proof that the downstream path, the one this change finally connects to, does the right thing with a real signed proof.

We added a new test at the runtime level, which is the level this change actually lives at. It registers an offchain transaction pool, builds a genuine prevote equivocation signed with a real key, and calls the exact function the GrandpaApi method calls in production. It then asserts that exactly one extrinsic landed in the pool, that it is unsigned, and that it is our offences report call and nothing else. That test exercises the new plumbing end to end inside the full runtime. To avoid the trap of testing a copy of the code, the production method and the test both call the same function, so the tested code is the production code. The full runtime suite is twenty eight tests, all green.

Finally, try-runtime. This is the tool that takes a candidate runtime and replays it against the real, current chain state pulled live from a node. Our entire on chain state, just under seventy one megabytes, decodes cleanly under the new runtime, and every per pallet state check passes. The change carries no storage migration, so there was little for try-runtime to break, but running it is the standing bar for every runtime upgrade we ship and skipping it is not an option.

Being honest about the edges

Slashing invites a question: what else can be slashed? It is tempting to claim a broad answer. The honest answer is narrower, and the narrowness is deliberate.

GRANDPA equivocation is the offence that matters most, because it is a direct attack on finality safety, the one property a finalized chain absolutely cannot give up. That is now autonomous.

Invalid quantum mining proofs are not a slashing surface, by design. An invalid VQE proof is rejected before it can land in a block, by the same pool gate and execution checks that protect everything else. There is no accepted on chain offence to point a slash at, and a rejected proof costs its sender a wasted attempt and nothing more. The right defence there is rejection plus rate limiting, and that is already live. Building a slash for an offence that never lands would be theatre.

Block authoring equivocation, a validator authoring two blocks for one slot, is a real concept but it is not a finality safety violation, because GRANDPA finalizes only one branch regardless. The authoring layer also has no built in proof type for it the way GRANDPA does. We have left it deliberately unbuilt rather than ship something contrived, and it is tracked for the day block production moves off its current single author shape.

Where this sits

The change is staged, not yet live. It is a runtime upgrade, and we do not apply production runtime upgrades unattended. The rule on this chain is firm: a runtime swap is an owner attended operation, with the rollback runtime pinned before the new one goes on, because a bad upgrade can halt a live chain and there has to be a human present to catch it. The runbook is written, the rollback is pinned, and the deploy is a standard set_code identical to the last several we have done.

What changes the day it lands is quiet and important. From that block on, a validator that double votes on finality does not get a warning in a log file that someone might read. It gets a verified report submitted by every honest node that saw it, an on chain attribution to its stake, and a slash scheduled against that stake, with a week for governance to intervene if it was a bug rather than an attack. No operator has to be watching. That is the difference between a chain that can describe a punishment and a chain that delivers one.

The wire is reconnected. The machine was always there.

Further reading

ShareXLinkedIn

Written by

A
Ash Brown@blockartica
Founder, SusyLabs / QuantumAI Blockchain

Building the post-quantum AI-native L1 with permissionless on-chain training cycles. Writes about consensus, attestation, and the gap between what ships and what's claimed.

Related posts

10 min read

A post-quantum cold wallet, built in private

A long look at the QV desktop wallet: a post-quantum cold-storage wallet that holds Bitcoin, Lightning and QBC behind one recovery phrase. This is a private build, not a public release, and this post explains what is in it and how we harden it before anyone trusts it with value.