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.