Commitment Consistency
This page describes the commitment consistency checks in crates/slashing and the accusation flow
that follows a mismatch. It is for auditors and core contributors. It lists every registered link
with its scope and its public-signal fields. At the end, you can predict which party the checker
blames for a mismatch and what the chain requires before it slashes that party.
Why the check exists
Each ZK proof shows that one circuit ran correctly on its own inputs. A dishonest node can still use one value in C2 and a different value in C4. Each proof then verifies alone, but the link between the proofs breaks. The recursive circuits reject such a result when they fold the proofs. They do not tell the committee which party caused the failure.
The commitment consistency checker compares public signals across proofs. When a commitment from one circuit does not match the value that another circuit consumes, the checker blames one party and starts an accusation.
Where links are enforced
The protocol checks commitment links at several layers. Only the off-chain checker assigns blame.
| Layer | What it checks | Result of a mismatch |
|---|---|---|
| Inner circuits | Each circuit recomputes the commitments that it consumes. | The proof does not verify. SignedProofFailed. |
node_fold | Same-party links: C1 to C2, C2 to C3, and equal C3a and C3b keys. | The node cannot make its fold proof. |
dkg_aggregator | Cross-party links: C0 to C3, C2 to C4, and C1 to C5. | The DKG proof fails. publishCommittee cannot succeed. |
decryption_aggregator, BfvDecryptionVerifier | C6 to C7, and C6 commitments against the DKG anchors. | The decryption proof is rejected. |
Off-chain checker (crates/slashing) | The 12 registered links in this page. | CommitmentConsistencyViolation and an accusation. |
For the circuit-level links, see PVDKG and ZK proofs.
Checker architecture
Each E3 has one CommitmentConsistencyChecker actor
(crates/slashing/src/commitment_consistency/actor.rs). The actor is a thin shell. The logic is in
the synchronous CommitmentConsistency service (commitment_consistency/workflow.rs), which
performs no I/O. CommitmentConsistencyCheckerExtension creates the actor with the links from
e3_zk_prover::commitment_links::default_links(preset) and the committee roster size H.
The service keeps one cache:
// (address, proof_type) -> entries. One sender can have many entries, for example N - 1 C3a proofs.
verified: HashMap<(Address, ProofType), Vec<VerifiedProofData>>
struct VerifiedProofData {
party_id: u64,
address: Address,
public_signals: ArcBytes,
data_hash: [u8; 32], // keccak256(abi.encode(proof.data, public_signals))
proof_data: ArcBytes, // raw proof bytes, kept for slashing evidence
}| Input event | Action |
|---|---|
CommitmentConsistencyCheckRequested | Pre-ZK gating. Cache the batch, check every link, reply with the result. |
ProofVerificationPassed | Post-ZK check. Cache the proof, check the links for its proof type. |
CommitmentRosterSelected | Store the accepted H-party roster, then check the C2 to C4 links. |
E3RequestComplete | Clear the persisted state of the E3. |
| Output event | Consumer |
|---|---|
CommitmentConsistencyCheckComplete | ShareVerificationActor |
CommitmentConsistencyViolation | AccusationManager |
The service persists CommitmentConsistencySnapshot (version 1), which holds the roster and every
cache entry. It writes the snapshot with the event that changed the state. Event replay starts after
the snapshot cursor, so a restarted checker needs this snapshot to make the same decisions.
Pre-ZK gating
ShareVerificationActor checks the signatures of a batch first. It then publishes
CommitmentConsistencyCheckRequested { e3_id, kind, correlation_id, party_proofs }. Each
PartyProofData entry carries (proof_type, public_signals, data_hash, proof_data) tuples.
The checker caches every proof in the batch and checks every link. It replies with
CommitmentConsistencyCheckComplete { e3_id, kind, correlation_id, inconsistent_parties }. It also
emits a violation for each mismatch, so that parties excluded before ZK still start an accusation.
Then it removes the cache entries of the inconsistent parties. ShareVerificationActor adds those
parties to the dishonest set, and they skip ZK verification.
Post-ZK check
After ZK verification, ProofVerificationPassed delivers each verified proof. The checker caches it
and checks each link whose source or target type equals the new proof type. This path covers proofs
from a later phase and cross-party links whose counterparty proof was not yet present.
Deduplication
One proof can arrive twice: in the pre-ZK batch and in ProofVerificationPassed. insert_verified
keeps one entry for each data_hash under a given (address, proof_type) key. A second arrival
does not change the cache and does not repeat a check. AccusationManager also creates at most one
accusation for each (accused, proof_type) pair.
Link model
Each link implements the CommitmentLink trait in
crates/slashing/src/commitment_consistency/link.rs. A FieldValue is a 32-byte field element.
pub trait CommitmentLink: Send + Sync {
fn name(&self) -> &'static str;
fn source_proof_type(&self) -> ProofType; // produces the commitment
fn target_proof_type(&self) -> ProofType; // consumes or agrees with it
fn scope(&self) -> LinkScope;
fn extract_source_values(&self, public_signals: &[u8]) -> Vec<FieldValue>;
fn check_signals(&self, source_values: &[FieldValue], target_public_signals: &[u8]) -> bool;
fn check_consistency(
&self,
source_values: &[FieldValue],
target_public_signals: &[u8],
src_party_id: u64,
tgt_party_id: u64,
) -> bool;
}The LinkScope decides which targets a source is compared with. Blame always goes to the source
party.
| Scope | Matching rule in find_mismatches |
|---|---|
SameParty | Compare each source entry with each target entry from the same address. The first failed comparison blames the source. |
CrossParty | A source passes if it matches at least one cached target from any party. With no cached target, the check waits. |
SourceMustExistInTargets | The same rule as CrossParty, with two special cases: C3 to C0 never blames, and C2 to C4 waits for the full roster. |
Registered links
default_links(preset) in crates/zk/prover/src/commitment_links/mod.rs registers 12 links. The C2
to C4 links take l = preset.metadata().num_moduli. The preset is the E3 threshold preset, so l
is 3 for insecure-64 and for secure-8192.
DKG links
| Struct | name() | Source to target | Scope | Check |
|---|---|---|---|---|
C3aToC0PkCommitmentLink | C3a->C0 pk_commitment | C3a to C0 | SourceMustExistInTargets | C3 expected_pk_commitment equals a C0 pk_commitment. Never blames (see below). |
C3bToC0PkCommitmentLink | C3b->C0 pk_commitment | C3b to C0 | SourceMustExistInTargets | The same for C3b. Never blames. |
C1ToC2aSkCommitmentLink | C1->C2a sk_commitment | C1 to C2a | SameParty | C1 sk_commitment equals the first C2a signal, expected_secret_commitment. |
C1ToC2bESmCommitmentLink | C1->C2b e_sm_commitment | C1 to C2b | SameParty | C1 e_sm_commitment equals the first C2b signal. |
C1ToC5PkCommitmentLink | C1->C5 pk_commitment | C1 to C5 | CrossParty | C1 pk_commitment is one of the first H C5 signals, the expected key share commitments. |
C3aToC2aShareEncryptionLink | C3a->C2a expected_message_commitment | C3a to C2a | SameParty | C3 expected_message_commitment is one of the C2a share commitments. |
C3bToC2bShareEncryptionLink | C3b->C2b expected_message_commitment | C3b to C2b | SameParty | The same for C3b and C2b. |
C2aToC4aShareCommitmentLink | C2a->C4a share commitments | C2a to C4a | SourceMustExistInTargets | See the C2 to C4 link. |
C2bToC4bShareCommitmentLink | C2b->C4b share commitments | C2b to C4b | SourceMustExistInTargets | The same for the smudging noise branch. |
Threshold decryption links
| Struct | name() | Source to target | Scope | Check |
|---|---|---|---|---|
C4aToC6SkCommitmentLink | C4a->C6 sk_commitment | C4a to C6 | SameParty | The C4a output commitment equals the C6 input expected_sk_commitment. |
C4bToC6ESmCommitmentLink | C4b->C6 e_sm_commitment | C4b to C6 | SameParty | The C4b output commitment equals the C6 input expected_e_sm_commitment. |
C6ToC7DCommitmentLink | C6->C7 d_commitment | C6 to C7 | CrossParty | The C6 d_commitment is one of the expected d commitments at the start of C7. |
The public-signal field names come from crates/zk/config/src/output_layout.rs. Inputs sit at the
start of public_signals and outputs at the end. C3 inputs are expected_pk_commitment and
expected_message_commitment. C6 inputs are expected_sk_commitment, expected_e_sm_commitment,
ct_commitment, domain_hi, and domain_lo. For the C6 to C7 link, the checker derives the number
of d commitments as , where is the number of C7 fields.
The disabled C3 to C0 links
The two C3 to C0 links are registered, but find_mismatches returns no mismatch for them. The C0
set is not closed when members are absent, because an omitted C0 key can arrive later. A missing key
in the local cache therefore cannot prove that a C3 recipient key is wrong.
Two other checks cover this relation. Each recipient compares every C3 proof addressed to it with
its own C0 pk_commitment. A dealer that encrypted for a different key leaves the readiness set of
that recipient, without an accusation. The dkg_aggregator circuit also requires each roster C3 key
slot to equal the recipient's C0 commitment.
The C2 to C4 link
This link crosses two parties: the dealer that made C2 and the recipient that made C4. It is the only link that depends on the accepted roster.
Public signal layouts
C2 (sk_share_computation or e_sm_share_computation) has fields in party-major
order:
[expected_secret_commitment] <- skipped by extract_share_commitments
[party_0_mod_0] ... [party_0_mod_{L-1}]
[party_1_mod_0] ...
[party_{N-1}_mod_{L-1}]C4 (share_decryption) has fields. The rows follow the accepted roster order, not
the full committee order:
[expected_commitments[0][0]] ... [expected_commitments[0][L-1]]
...
[expected_commitments[H-1][0]] ... [expected_commitments[H-1][L-1]]
[commitment] <- aggregate, not comparedCheck logic
Let be a dealer and a C4 producer, both named by full-committee party IDs. Let be the
position of in the accepted roster. check_exact_l_commitments compares values:
The checker evaluates the link only after three conditions are true:
CommitmentRosterSelecteddelivered an accepted roster of exactly H ascending party IDs.- The dealer is in the roster. A C2 source outside the roster is skipped.
- The cache holds C4 targets from all H roster members.
The checker then blames only if no cached C4 target matches. Waiting for every roster target keeps a late C4 proof from looking like a mismatch.
A dealer passes when at least one C4 target matches. The checker therefore blames a dealer whose
commitments match no recipient. The dkg_aggregator circuit is stricter: it requires every roster
pair to match before the key can be published.
The roster is persisted in the checker snapshot. A conflicting roster, a roster of the wrong size, or a roster that is not in ascending order is ignored.
Accusation flow
A violation becomes a slash proposal only after enough committee members confirm it with signed votes.
- DetectThe checker emits a violation.
- AccuseThe node signs and broadcasts an accusation.
- VoteMembers with the same local failure vote.
- DecideH matching votes give AccusedFaulted.
- SlashA voter submits the evidence on-chain.
Violation event
pub struct CommitmentConsistencyViolation {
pub e3_id: E3id,
pub accused_party_id: u64,
pub accused_address: Address,
pub proof_type: ProofType, // the source proof type
pub data_hash: [u8; 32], // keccak256(abi.encode(proof.data, public_signals))
pub evidence: Bytes, // abi.encode(proof.data, public_signals), the preimage of data_hash
}Off-chain voting
AccusationManager (crates/slashing/src/accusation_voting/) handles the violation:
- It ignores a local failure against its own address or finalized party ID. For another member, it
records the failure for
(accused, proof_type)with the data hash and the evidence. - It creates a
ProofFailureAccusationwith an issue time and a deadline, signs it, and broadcasts it. It refuses when the local vote validity is 0. - It casts its own
AccusationVoteand starts a 300-second vote timeout.
A local proof-failure accusation requires the proof's signature from the claimed committee member. The signed E3 and proof type must match the failure. An unrecoverable signature does not identify a dealer through the claimed party ID.
A peer that receives the accusation checks the deadline window, the committee membership of the
accuser and the accused, and the accuser's signature. It rejects an accusation when the accuser and
accused have the same signed address. The accusation digest does not include accused_party_id, so
the peer does not use that field to identify a received self-accusation.
The peer votes only after local verification fails. For C3a and C3b, a peer without a local record can verify a forwarded signed proof. Accusations for other proof types cannot carry a forwarded payload, even if local evidence exists. The peer abstains if verification passes or if it has neither local evidence nor an accepted forwarded proof. The wire has no disagree vote. Every vote is an agreement.
An accusation needs H agreeing votes. H is the roster size from CiphernodesCommitteeSize:
| Votes and data hashes | Outcome | Slashable |
|---|---|---|
| At least H votes, one data hash | AccusedFaulted | Yes |
| At least H votes, more than one data hash | Equivocation | No |
| Fewer than H votes when the timeout expires | Inconclusive | No |
Equivocation means that the accused sent different data to different nodes. The current evidence
format carries one preimage, so it cannot prove every payload.
A process restart does not rebuild a partial vote tally or its timers.
Submission
SlashingManagerSolWriter acts only on AccusedFaulted. It first reads the slash policy for
reason = keccak256(abi.encodePacked(uint256(proofType))):
- If the policy is disabled, the node sends no transaction. It publishes
CommitteeMemberExcluded, and the member is excluded from local work for this E3 only. - If the policy is enabled, the three lowest-address voters can submit. Rank waits
seconds. The on-chain
DuplicateEvidencecheck limits execution to one proposal. - The writer calls
proposeSlashByDkgParty(e3Id, partyId, proof)when the party ID resolves in the DKG anchors. Otherwise it callsproposeSlash(e3Id, operator, proof).
Signed values
Solidity and Rust derive the same accusation ID and vote digest:
accusationId = keccak256(abi.encodePacked(chainId, e3Id, accused, uint256(proofType)))
EIP-712 domain: name "InterfoldSlashing", version "1", chainId, verifyingContract = SlashingManager
AccusationVote(uint256 e3Id,bytes32 accusationId,address voter,bytes32 dataHash,uint256 issuedAt,uint256 deadline)The Rust constants are VOTE_DOMAIN_NAME, VOTE_DOMAIN_VERSION, and VOTE_TYPEHASH_STR in
crates/events/src/interfold_event/accusation_vote.rs. The Solidity constants are
EIP712_DOMAIN_NAME and EIP712_DOMAIN_VERSION in SlashingManager.sol.
On-chain evidence checks
SlashingManager decodes the proof argument as:
abi.encode(
uint256 proofType,
address[] voters,
bytes32[] dataHashes,
bytes evidence,
uint256 issuedAt,
uint256 deadline,
bytes[] signatures
)_proposeSlash and SlashingEvidenceLib.verifyAttestationEvidence require these conditions:
| Check | Error |
|---|---|
The policy for keccak256(abi.encodePacked(proofType)) is enabled and requires proof. | SlashReasonDisabled, InvalidPolicy |
| The operator is a committee member of the E3. | OperatorNotInCommittee |
This (chainId, e3Id, operator, proofType) was not used before. | DuplicateEvidence |
The E3 snapshot and the live registry accusationVoteValidity are not zero. | AccusationSlashingDisabled |
| The time is at or before the E3 slash submission deadline. | SlashSubmissionDeadlinePassed |
issuedAt is at most 30 seconds in the future. | AccusationIssuedInFuture |
deadline - issuedAt is at most the E3 snapshot of the vote validity. | InvalidAccusationWindow |
The time is at or before deadline. | SignatureExpired |
| The vote count is at least H. | InsufficientAttestations |
keccak256(evidence) equals the shared data hash. | InvalidProof |
| Voters are strictly ascending, and none is the accused. | DuplicateVoter, VoterIsAccused |
| Every data hash is equal. | EquivocationDetected |
| Every voter is an active committee member. | VoterNotInCommittee |
Every signature recovers to its voter over the EIP-712 AccusationVote digest. | InvalidVoteSignature |
The slash submission deadline is the E3 lifecycle deadline plus ACCUSATION_REPORTING_WINDOW (1
day). When all checks pass, the manager records a proposal with
executableAt = block.timestamp + appealWindow. Before that time, the operator or its bond owner
can call fileAppeal once. If the policy's appealWindow is 0, the slash executes in the same
transaction and cannot be appealed.
Vote validity window
CiphernodeRegistryOwnable.accusationVoteValidity is the longest signed window for an accusation
vote. initialize sets it to DEFAULT_ACCUSATION_VOTE_VALIDITY (30 minutes). Each E3 snapshots the
value at request time.
setAccusationVoteValiditycan keep or increase the value. It also cancels a pending reduction.- A reduction uses
proposeAccusationVoteValidity, waitsACCUSATION_VOTE_VALIDITY_TIMELOCK(2 days), and then usescommitAccusationVoteValiditywithin the next 2 days. - A value of 0 stops new attestation slashes. Nodes stop creating votes, and the manager rejects proposals, also with signatures from an older window.
Each ciphernode reads the value for each enabled chain at startup. A running node uses a changed nonzero value only after a restart.
An executed slash takes the ticket and bond penalties of the slash policy. The policy can also ban the operator and expel it from the E3 committee.