Summary
Lane A accusation-quorum slashing (SlashingManager.proposeSlash /
proposeSlashByDkgParty) verifies that a threshold_m quorum of committee
members signed off on a shared dataHash, but never verifies that the
accused operator actually authored the proof bytes being punished. Under a
dishonest threshold_m majority, this lets a colluding quorum fabricate an
invalid proof, attribute it to an innocent operator, and get them slashed —
with nothing on-chain, or checkable by an external observer from public
chain data, able to distinguish that from a genuine accusation.
Background
For a proof-type fault (C0–C6), the off-chain AccusationManager protocol
collects threshold_m signed votes and submits them on-chain as
attestation evidence:
proof = abi.encode(proofType, voters[], dataHashes[], evidence, issuedAt, deadline, signatures[])
SlashingEvidenceLib.verifyAttestationEvidence (packages/interfold-contracts/contracts/lib/SlashingEvidenceLib.sol:43-96)
checks:
keccak256(evidence) == dataHashes[0]
- each voter's ECDSA signature recovers to an address that is an active
committee member
voters.length >= threshold_m
It never checks a signature from the accused over evidence. evidence
itself is just abi.encode(proof.data, proof.public_signals) — the raw ZK
proof and public signals, with no binding to who produced them
(crates/slashing/src/accusation_voting/transitions/initiate_accusation.rs:44-53,
carried through unmodified by encode_attestation_evidence in
crates/evm/src/slashing/evidence.rs:26-59).
Because evidence is public calldata, anyone can independently re-run the
ZK verifier on it and confirm it's mathematically invalid — that half of
the claim is trustlessly checkable. But nothing ties those specific bytes
to the accused's own key. The only thing standing between an innocent
operator and a slash is threshold_m committee members choosing to sign
the same (possibly fabricated) dataHash.
agent/INVARIANTS.md:170-171 documents this as accepted design —
"Lane A is attestation-based (ECDSA per voter), not on-chain ZK
re-verification" — but doesn't call out that authorship attribution is
also unauthenticated, which is the actual gap.
The missing piece already exists in the codebase
Every proof a node broadcasts is wrapped as SignedProofPayload { payload, signature } (crates/events/src/interfold_event/signed_proof.rs:179-185),
where signature is the accused's own ECDSA signature over
(chainId, e3Id, proofType, keccak256(proof.data), keccak256(public_signals)).
The type's doc comment even states the design intent:
"If the proof later fails verification, the signed bundle is
self-authenticating evidence of fault: the signature proves authorship
and the proof bytes prove invalidity."
This signature is already used, twice, off-chain:
- Every accuser calls
SignedProofPayload::recover_address() locally
before raising an accusation — ProofVerificationFailed.accused_address
is set to the recovered signer
(crates/zk-prover/src/proof_verification/handlers.rs:151).
- For C3a/C3b, it's forwarded in the P2P accusation gossip
(ProofFailureAccusation.signed_payload) so peer committee members can
also recover_address() before voting
(crates/events/src/interfold_event/proof_failure_accusation.rs:41-43).
It is dropped before the evidence reaches the chain. encode_attestation_evidence
only ABI-encodes proof.data + public_signals
(crates/evm/src/slashing/evidence.rs:26-59); the accused's signature
field never makes it into the evidence bytes submitted to
SlashingManager.proposeSlash.
Proposed fix
- Include the accused's
SignedProofPayload.signature in the on-chain
evidence encoding (alongside proof.data / public_signals).
- In
SlashingEvidenceLib.verifyAttestationEvidence, ecrecover that
signature against the same digest the accused originally signed
(ProofPayload::digest()'s Solidity-side reconstruction:
keccak256(abi.encode(PROOF_PAYLOAD_TYPEHASH, chainId, e3Id, proofType, keccak256(zkProof), keccak256(publicSignals)))) and require(recovered == operator).
- With that check in place, the voter-quorum requirement can, in
principle, be relaxed to a single submitter for proof-based faults —
the evidence becomes self-authenticating (accused's signature proves
authorship, the ZK math proves invalidity), so threshold_m honest
votes are no longer load-bearing for correctness, only for whatever
liveness/spam-resistance property the quorum requirement is also meant
to provide. That's a separate design decision and out of scope for this
fix, but worth flagging since it changes the trust model materially.
Impact if unfixed
A colluding threshold_m majority can slash (ticket + license penalties,
optional ban, committee expulsion) any innocent operator by fabricating an
invalid proof and jointly attesting to its hash. No on-chain check, and no
external observer working from public chain data alone, can currently
distinguish this from a legitimate accusation.
Scope note
This affects Lane A (attestation-based) slashing for proof-type faults
(C0–C6). It does not affect: liveness/timeout failures (markE3Failed,
deadline-based, no accusation involved), or Lane B (evidence-based,
SLASHER_ROLE-gated, already delayed-with-appeal by design). Aggregation
proofs (C5/C7) don't currently have a peer-accusation path wired at all
(separate gap, not covered here).
Summary
Lane A accusation-quorum slashing (
SlashingManager.proposeSlash/proposeSlashByDkgParty) verifies that athreshold_mquorum of committeemembers signed off on a shared
dataHash, but never verifies that theaccused operator actually authored the proof bytes being punished. Under a
dishonest
threshold_mmajority, this lets a colluding quorum fabricate aninvalid proof, attribute it to an innocent operator, and get them slashed —
with nothing on-chain, or checkable by an external observer from public
chain data, able to distinguish that from a genuine accusation.
Background
For a proof-type fault (C0–C6), the off-chain
AccusationManagerprotocolcollects
threshold_msigned votes and submits them on-chain asattestation evidence:
proof = abi.encode(proofType, voters[], dataHashes[], evidence, issuedAt, deadline, signatures[])
SlashingEvidenceLib.verifyAttestationEvidence(packages/interfold-contracts/contracts/lib/SlashingEvidenceLib.sol:43-96)checks:
keccak256(evidence) == dataHashes[0]committee member
voters.length >= threshold_mIt never checks a signature from the accused over
evidence.evidenceitself is just
abi.encode(proof.data, proof.public_signals)— the raw ZKproof and public signals, with no binding to who produced them
(
crates/slashing/src/accusation_voting/transitions/initiate_accusation.rs:44-53,carried through unmodified by
encode_attestation_evidenceincrates/evm/src/slashing/evidence.rs:26-59).Because
evidenceis public calldata, anyone can independently re-run theZK verifier on it and confirm it's mathematically invalid — that half of
the claim is trustlessly checkable. But nothing ties those specific bytes
to the accused's own key. The only thing standing between an innocent
operator and a slash is
threshold_mcommittee members choosing to signthe same (possibly fabricated)
dataHash.agent/INVARIANTS.md:170-171documents this as accepted design —"Lane A is attestation-based (ECDSA per voter), not on-chain ZK
re-verification" — but doesn't call out that authorship attribution is
also unauthenticated, which is the actual gap.
The missing piece already exists in the codebase
Every proof a node broadcasts is wrapped as
SignedProofPayload { payload, signature }(crates/events/src/interfold_event/signed_proof.rs:179-185),where
signatureis the accused's own ECDSA signature over(chainId, e3Id, proofType, keccak256(proof.data), keccak256(public_signals)).The type's doc comment even states the design intent:
This signature is already used, twice, off-chain:
SignedProofPayload::recover_address()locallybefore raising an accusation —
ProofVerificationFailed.accused_addressis set to the recovered signer
(
crates/zk-prover/src/proof_verification/handlers.rs:151).(
ProofFailureAccusation.signed_payload) so peer committee members canalso
recover_address()before voting(
crates/events/src/interfold_event/proof_failure_accusation.rs:41-43).It is dropped before the evidence reaches the chain.
encode_attestation_evidenceonly ABI-encodes
proof.data+public_signals(
crates/evm/src/slashing/evidence.rs:26-59); the accused'ssignaturefield never makes it into the
evidencebytes submitted toSlashingManager.proposeSlash.Proposed fix
SignedProofPayload.signaturein the on-chainevidenceencoding (alongsideproof.data/public_signals).SlashingEvidenceLib.verifyAttestationEvidence,ecrecoverthatsignature against the same digest the accused originally signed
(
ProofPayload::digest()'s Solidity-side reconstruction:keccak256(abi.encode(PROOF_PAYLOAD_TYPEHASH, chainId, e3Id, proofType, keccak256(zkProof), keccak256(publicSignals)))) andrequire(recovered == operator).principle, be relaxed to a single submitter for proof-based faults —
the evidence becomes self-authenticating (accused's signature proves
authorship, the ZK math proves invalidity), so
threshold_mhonestvotes are no longer load-bearing for correctness, only for whatever
liveness/spam-resistance property the quorum requirement is also meant
to provide. That's a separate design decision and out of scope for this
fix, but worth flagging since it changes the trust model materially.
Impact if unfixed
A colluding
threshold_mmajority can slash (ticket + license penalties,optional ban, committee expulsion) any innocent operator by fabricating an
invalid proof and jointly attesting to its hash. No on-chain check, and no
external observer working from public chain data alone, can currently
distinguish this from a legitimate accusation.
Scope note
This affects Lane A (attestation-based) slashing for proof-type faults
(C0–C6). It does not affect: liveness/timeout failures (
markE3Failed,deadline-based, no accusation involved), or Lane B (evidence-based,
SLASHER_ROLE-gated, already delayed-with-appeal by design). Aggregationproofs (C5/C7) don't currently have a peer-accusation path wired at all
(separate gap, not covered here).