All writing

A Slashing Condition Must Be Objectively Provable

· 16 min read

  • architecture
  • security
  • proof-of-stake
  • restaking

A Slashing Condition Must Be Objectively Provable

Ethereum's consensus specification decides whether two attestations contradict each other inside a function whose two parameters are those two attestation data values, and whose parameter list carries no beacon state at all. the consensus specification, is_slashable_attestation_data Sixty nine of the eighty nine functions in that file take the state. This one is among the twenty that don't, and the absence is the posture: whether a fault happened is a question about two messages, and the answer doesn't depend on who is asking.

Now put a live restaking core beside it. The struct a live restaking core hands its slashing call carries an operator, an operator set id, a list of strategies, a list of proportions to burn, and a string its own comment calls, for legibility, the description of the slashing provided by the AVS. the restaking core, the slashing parameters struct An AVS here is a service renting security from stake somebody else bonded. Nothing on the burn path reads that string, so whatever a caller puts in it is not a condition of anything. The fault isn't checked. It's described.

One takes two messages, the other takes a string

Start with what the specification separates. The operation around that function reads the state three more times: to look up the public keys and the signing domain, to check the accused is in a slashable window, and to apply the penalty. So the path is stateful and the one step that decides whether a fault happened is not.

The second fault has the same shape. The proposer operation asks that two signed headers carry the same slot and the same proposer index, that they differ, and that both signatures verify against that proposer's key. And those two are the whole of it. Across both retained specification files the line validator.slashed = True occurs inside one function, and that function is reached from exactly two places, the proposer operation and the attester operation. the consensus specification, slash_validator I ran that search rather than describing it: looking for the write across both files returns two hits, one per fork file, each inside a definition of the same function, and looking for callers of that function returns two, both of them operations a block carries.

Role: the two declarations side by side, each written as its own file declares it.

  ETHEREUM, the step that decides          A RESTAKING CORE, the call that burns
  whether two attestations contradict
 
  def is_slashable_attestation_data(       function slashOperator(
      data_1: AttestationData,                 address avs,
      data_2: AttestationData                  SlashingParams calldata params
  ) -> bool                                ) external returns (uint256, uint256[])
 
      no state parameter                   struct SlashingParams {
      no caller parameter                      address operator;
      no oracle                                uint32 operatorSetId;
                                               IStrategy[] strategies;
  the step BESIDE it, which does take          uint256[] wadsToSlash;
  the state, and is what attributes            string description;   <- the only
  the fault to a person:                   }                            field about
                                                                        the fault
  def is_valid_indexed_attestation(
      state: BeaconState,                  what it checks before it burns:
      indexed_attestation                      msg.sender == getSlasher(set)
  ) -> bool
      state for the public keys            the slasher is an ADDRESS. The core
      state for the signing domain         publishes WHICH address. It says
                                           nothing about what that address does,
  contradiction and identity are two       so a proof verifier and a multisig
  steps here, and only the second one      are told apart by auditing code, not
  reads anything the chain holds           by reading this core

Neither operation takes a caller, and neither is free of one either. Slashing evidence rides in a block body, and a block is only valid from the validator the schedule names, so the submitter is always whoever was due to propose. the consensus specification, process_block and process_block_header What the specification does about that is name a whistleblower one function further in, default it to that same proposer, and pay a reward computed from the slashed validator's own effective balance. It can't make the evidence appear, so it buys it.

That shape is older than any client. The theorem the two conditions exist to serve says two conflicting checkpoints cannot both be finalized unless at least a third of the validators by weight violated one of them. Casper the Friendly Finality Gadget, accountable safety Read it backwards and the design falls out. The conditions weren't chosen because double voting is rude, but because a safety failure has to imply a violation somebody can prove.

The restaking core is doing something else, and its own code says so. Seven requirements stand on the restaking core's burn path and not one of them reads what the operator did: four in the entry point, over array lengths, the operator set existing, the operator being slashable, and the sender being the registered slasher, and three more inside the function it hands off to, over strategy ordering, proportion bounds, and each strategy belonging to the set. the restaking core, slashOperator There's a pause guard on the declaration too, and it isn't about the fault either.

The case for letting somebody decide

The serious alternative is to let a named authority reach the verdict and call the burn, and the case for it is that a service can then put stake behind a duty it has no way to write down as a predicate over signed messages. Did the operator serve the bytes it promised? Answer inside the latency it advertised? Compute correctly over an input only it could see? None arrives as two contradictory signatures, and a market that secured nothing else would secure almost nothing.

The core doesn't stop you from doing this well, either. A service that wants a proof can put a verifier behind that slasher address and get one. And the core does give a staker one real protection around that address: the public route an AVS uses to change a slasher stages it behind a delay, writing a pending slasher and an effect block a fixed number of blocks out, and the two paths that set one immediately are the ones that establish it rather than replace it, creating the operator set and a one time migration. My harness drops that delay: what it asserts is that the slashable set follows the key at all, not how fast.

So the objection to state first is the one I'd raise myself. This read like a purity argument, and the usual answer is a better committee: a veto window, an appeal period, reputation the slasher has staked. Each is a real improvement and none is what I'm arguing against.

What the contracts below show is narrower and harder to answer. The same code that takes a proven fraudster's stake takes the stake of somebody who signed nothing, on a sentence of English, and which of those two just happened is decided by who was holding a key. The core does publish the key. What the core publishes is which address may slash and nothing about what that address does, so telling a proof verifier from a multisig means fetching the code at it and auditing it yourself, once per operator set, and again whenever the address changes. the restaking core, getSlasher That's a different thing from a condition every verifier recomputes for free. The posture isn't wrong. It's expensive to price, and calling it slashing borrows the word.

Give the verdict to everybody, or admit you gave it to somebody

The repair is to make the verdict something every verifier computes rather than something one of them announces, by giving the condition no argument beyond the messages the accused signed. Two contracts, one bond ledger, one difference: what has to be true before the burn runs.

Role: reduced executable model, the two postures excerpted from the harness that ran.

/// A vote is the message a validator signs: where it came from, where it is going,
/// and which branch it is voting for.
struct Vote {
    uint64 sourceEpoch;
    uint64 targetEpoch;
    bytes32 targetRoot;
}
// ...
/// The stake ledger both postures slash out of. Identical in both, on purpose:
/// the only difference between the two contracts below is what has to be true
/// before `_burn` runs.
abstract contract Bond {
    mapping(address => uint256) public bonded;
    mapping(address => bool) public slashed;
 
    function bond(address operator, uint256 amount) external {
        bonded[operator] += amount;
    }
 
    function _burn(address operator) internal {
        slashed[operator] = true;
        bonded[operator] = 0;
    }
}
// ...
/// POSTURE ONE. The fault is a predicate over two signed messages.
/// `isFault` takes no storage, no caller and no oracle. Anyone may call `slash`.
contract EvidenceSlasher is Bond {
    error NotAFault();
    error NotTheSigner();
    // ...
    /// The Casper FFG shape, in Solidity: a double vote or a surround vote.
    /// `pure` is the load-bearing word. It is the compiler asserting that this
    /// verdict cannot depend on who is asking or on what the chain currently holds.
    function isFault(Vote memory a, Vote memory b) public pure returns (bool) {
        bool distinct = a.sourceEpoch != b.sourceEpoch
            || a.targetEpoch != b.targetEpoch
            || a.targetRoot != b.targetRoot;
        bool doubleVote = distinct && a.targetEpoch == b.targetEpoch;
        bool surroundVote = a.sourceEpoch < b.sourceEpoch && b.targetEpoch < a.targetEpoch;
        return doubleVote || surroundVote;
    }
    // ...
    function slash(
        address operator,
        Vote calldata a,
        bytes calldata sigA,
        Vote calldata b,
        bytes calldata sigB
    ) external {
        if (!isFault(a, b)) revert NotAFault();
        if (_signer(a, sigA) != operator) revert NotTheSigner();
        if (_signer(b, sigB) != operator) revert NotTheSigner();
        _burn(operator);
    }
// ...
}
 
/// POSTURE TWO. The fault is whatever the authorised caller says it is.
/// The core this is reduced from takes an operator, an operator set id, a list of
/// strategies, a list of proportions to burn, and a string. Everything but the
/// operator and the string is accounting, so this keeps those two.
///
/// `setSlasher` here is ungated and takes effect at once. The core it models gates
/// rotation behind a pending slasher and an effect block; the harness drops that,
/// because what it is asserting is that the slashable set follows the key at all,
/// not how fast it follows.
contract AuthoritySlasher is Bond {
    error InvalidCaller();
 
    address public slasher;
    // ...
    function setSlasher(address slasher_) external {
        slasher = slasher_;
    }
 
    function slash(address operator, string calldata description) external {
        if (msg.sender != slasher) revert InvalidCaller();
        description;
        _burn(operator);
    }
}

The word carrying the argument is pure, the compiler asserting the verdict can't read the chain or the caller. In the harness a caller who is nobody in particular hands in the two signed votes and the bond goes to zero. Two callers standing in different positions, one holding a bond inside the contract and one holding nothing, ask the same question about the same two messages and get the same answer, before and after the contract's storage has moved under them. That last one is the property made watchable, in the reduced harness, two contracts over one bond ledger, and a mutant that makes the verdict read storage fails there.

Why is a stranger's reading worth that machinery? Not because strangers are smarter. Outside reviewers sent back a finding that broke something I had already decided was safe, and they were right. A judgment you've finished making stops generating doubt the moment you make it, and from inside, a closed wrong one looks like a closed right one. A condition anybody can recompute is that reading made permanent and free.

Now the other posture. On the authority contract an operator who never signed anything is bonded and then burned, and the only thing the call carried about him was a string. Rotate the slasher address and the set of operators who can be burned changes with it, while the same rotation asked of the evidence contract moves no verdict, because there is no key in it to rotate. One difference, legible from the parameter lists before either is deployed.

The predicate rewards close reading, and it taught me something I had wrong. The surround arm is not symmetric in its arguments, and the asymmetry reaches the entry point rather than stopping at the predicate: the same two signed votes are refused in one order and accepted in the other. Take the specification's own function out of the retained bytes and run it over every ordered pair in a bounded domain of twenty attestation data values, and it calls a hundred and twenty of the four hundred pairs a fault, a hundred of them symmetric under swapping the arguments and twenty of them not, and every one of the twenty is a surround. The ordering work falls on whoever submits, which is how the predicate stays cheap. The predicate decides that a fault happened and the signatures decide whose it was, so the pair cannot be pointed at a different operator and cannot ride on one signature used twice.

Here is the case that looks like it kills the argument. A validator that stops attesting harms the chain, and there is no evidence object anywhere: a validator that simply stopped produces no second message, and in the harness every vote paired against itself returns false, across the whole enumerated domain. So the rule says don't write it, and the chain punishes it anyway. Look at what Ethereum did. The inactivity penalty in the same specification is selected by the absence of a matching target vote in the previous epoch and sized from two numbers the chain already holds, and it reaches the slashing function from nowhere. the consensus specification, get_inactivity_penalty_deltas The selector is wider than absence: the set it measures against filters out every slashed validator whatever it did, so a slashed one is penalised as inactive on top. It never sets the slashed flag, needs nobody to submit, and never says a fault occurred. The protocol prices a harm it can't prove through a mechanism that makes no accusation, and keeps the word slashing for what it can. That separation is the part to steal.

Eleven mutations went into the two contracts, one aimed at the location each assertion family depends on, and ten were killed, and the single survivor is the declared control, a bond ledger that overwrites where it should accumulate, which no assertion in the suite reaches. One worth naming: the surround arm made symmetric, the shape a careful implementer writes without reading the specification twice, and two assertions catch it. The first campaign wasn't this clean. It left three survivors, two of them real holes in my own assertions, closed with new assertions rather than an argument.

What objectivity costs, and who pays

Objectivity is bought with faults you may not punish, because the only thing a predicate over signed messages can be handed is what the operator signed, so a service either makes him sign a claim he can be held to, or gives the fault up. That bill comes due at design time.

The operator pays next, in what he has to put his name to. A duty nobody signed is a duty nobody can be slashed for, so the price of the protection from arbitrary burning is committing to more, more often, under his own key.

The staker pays in watching, and the harness shows the gap rather than arguing it. The fault is complete and the stake is untouched: in the harness the contradictory pair is already a fault and the bond is still whole until a transaction carries the evidence in. Casper's authors saw this and said so: the evidence of the violation goes in as a transaction, and the submitter is paid a finder's fee out of the burned deposit. A bounty is what a protocol offers when it needs somebody outside it to act.

I've paid that bill on my own design. The settlement venue I built put watchtowers on the timeout/claim paths, and a watchtower is a process somebody has to keep running, which is an operating dependency my own design chose and not a property of the mechanism. That narrows what I'm recommending. Objectivity doesn't remove the human; it moves him from deciding to submitting, and a submitter who isn't there is a fault that stands. None of this is priced in gas, money or engineer time: all three are per service, and a number from a harness with one operator would be a number about the harness.

Where the bound moves

The bound moves off the top level condition and onto the base case of a dispute game, the moment the disputed thing is a computation both sides can replay. You don't need a predicate that decides whether a whole execution was correct, only one for a single machine step, and bisection walks the disagreement down to it.

A rollup's challenge manager can end a dispute in a function that takes a one step proof and is public, so where it is the proof that closes the dispute, the last word is again something anyone computes from data everybody has. the challenge manager, confirmEdgeByOneStepProof And the honest half of that: the same file carries the path that runs when nobody challenges, also public, which closes an unchallenged edge on elapsed time and takes no one step proof at all, only a witness it checks against a hash the chain already stored. The proof is the floor the timer stands on rather than the thing that runs every day, which is the right relationship and not a weakness.

What the game can't rescue is the case it can't start on. You can bisect a computation because both halves exist. You can't bisect a silence. Write the enforcement condition as a predicate over what the accused signed, because a verdict that needs a privileged caller is a committee decision wearing a bond. Where you can't, price the harm and don't call it a fault.

What this piece does not establish

Written from six retained files and two harnesses built for it, the sources retrieved on 2026-09-20 and both harnesses executed the same day. Five of the six files this piece cites were fetched again from their pinned commits on the day of writing and compared byte for byte against the copies the argument was read from; the sixth is a paper, refetched and compared as a PDF and then read through a local rendering of it. No deployed contract was called, no chain was read, no validator was operated, and no real slashing event was examined. The Solidity harness has one bond ledger, one operator key, no committees, no aggregate signatures and no penalty arithmetic, so it models what is checked before a burn and nothing else.

The oracle divergence breaker I built fired twice in the whole testnet campaign, two triggers, both correct, and two is not enough to tell a well set threshold from a lucky one. The threshold came from watching the variance, not from the mechanism, which is the part of an automatic condition no type signature protects.

This piece does not establish what fraction of the faults a real service wants to punish can be driven down to a checkable base case, and nothing here was run against a deployed slashing contract.


Abdel KIARI

I’ve owned the EVM side of a DeFi protocol with $110M+ in funding, including backing from Coinbase Ventures. I redesigned its architecture from scratch and built the Solidity infrastructure end to end.

Always happy to talk about interesting opportunities

abdel.kiari@gmail.com