Enclave is now The Interfold.
Protocol Internals
Commitment Consistency

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.

LayerWhat it checksResult of a mismatch
Inner circuitsEach circuit recomputes the commitments that it consumes.The proof does not verify. SignedProofFailed.
node_foldSame-party links: C1 to C2, C2 to C3, and equal C3a and C3b keys.The node cannot make its fold proof.
dkg_aggregatorCross-party links: C0 to C3, C2 to C4, and C1 to C5.The DKG proof fails. publishCommittee cannot succeed.
decryption_aggregator, BfvDecryptionVerifierC6 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 eventAction
CommitmentConsistencyCheckRequestedPre-ZK gating. Cache the batch, check every link, reply with the result.
ProofVerificationPassedPost-ZK check. Cache the proof, check the links for its proof type.
CommitmentRosterSelectedStore the accepted H-party roster, then check the C2 to C4 links.
E3RequestCompleteClear the persisted state of the E3.
Output eventConsumer
CommitmentConsistencyCheckCompleteShareVerificationActor
CommitmentConsistencyViolationAccusationManager

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.

ScopeMatching rule in find_mismatches
SamePartyCompare each source entry with each target entry from the same address. The first failed comparison blames the source.
CrossPartyA source passes if it matches at least one cached target from any party. With no cached target, the check waits.
SourceMustExistInTargetsThe 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

Structname()Source to targetScopeCheck
C3aToC0PkCommitmentLinkC3a->C0 pk_commitmentC3a to C0SourceMustExistInTargetsC3 expected_pk_commitment equals a C0 pk_commitment. Never blames (see below).
C3bToC0PkCommitmentLinkC3b->C0 pk_commitmentC3b to C0SourceMustExistInTargetsThe same for C3b. Never blames.
C1ToC2aSkCommitmentLinkC1->C2a sk_commitmentC1 to C2aSamePartyC1 sk_commitment equals the first C2a signal, expected_secret_commitment.
C1ToC2bESmCommitmentLinkC1->C2b e_sm_commitmentC1 to C2bSamePartyC1 e_sm_commitment equals the first C2b signal.
C1ToC5PkCommitmentLinkC1->C5 pk_commitmentC1 to C5CrossPartyC1 pk_commitment is one of the first H C5 signals, the expected key share commitments.
C3aToC2aShareEncryptionLinkC3a->C2a expected_message_commitmentC3a to C2aSamePartyC3 expected_message_commitment is one of the C2a share commitments.
C3bToC2bShareEncryptionLinkC3b->C2b expected_message_commitmentC3b to C2bSamePartyThe same for C3b and C2b.
C2aToC4aShareCommitmentLinkC2a->C4a share commitmentsC2a to C4aSourceMustExistInTargetsSee the C2 to C4 link.
C2bToC4bShareCommitmentLinkC2b->C4b share commitmentsC2b to C4bSourceMustExistInTargetsThe same for the smudging noise branch.

Threshold decryption links

Structname()Source to targetScopeCheck
C4aToC6SkCommitmentLinkC4a->C6 sk_commitmentC4a to C6SamePartyThe C4a output commitment equals the C6 input expected_sk_commitment.
C4bToC6ESmCommitmentLinkC4b->C6 e_sm_commitmentC4b to C6SamePartyThe C4b output commitment equals the C6 input expected_e_sm_commitment.
C6ToC7DCommitmentLinkC6->C7 d_commitmentC6 to C7CrossPartyThe 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 (F−100)/2(F - 100) / 2, where FF 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 1+N⋅L1 + N \cdot L 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 H⋅L+1H \cdot L + 1 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 compared

Check logic

Let XX be a dealer and RR a C4 producer, both named by full-committee party IDs. Let kXk_X be the position of XX in the accepted roster. check_exact_l_commitments compares LL values:

match(X,R)  ⟺  C2X[R⋅L+ℓ]=C4R[kX⋅L+ℓ]for all 0≤ℓ<L\text{match}(X, R) \iff C2_X[R \cdot L + \ell] = C4_R[k_X \cdot L + \ell] \quad \text{for all } 0 \le \ell < L

The checker evaluates the link only after three conditions are true:

  1. CommitmentRosterSelected delivered an accepted roster of exactly H ascending party IDs.
  2. The dealer XX is in the roster. A C2 source outside the roster is skipped.
  3. The cache holds C4 targets from all H roster members.

The checker then blames XX 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.

  1. DetectThe checker emits a violation.
  2. AccuseThe node signs and broadcasts an accusation.
  3. VoteMembers with the same local failure vote.
  4. DecideH matching votes give AccusedFaulted.
  5. 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:

  1. 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.
  2. It creates a ProofFailureAccusation with an issue time and a deadline, signs it, and broadcasts it. It refuses when the local vote validity is 0.
  3. It casts its own AccusationVote and 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 hashesOutcomeSlashable
At least H votes, one data hashAccusedFaultedYes
At least H votes, more than one data hashEquivocationNo
Fewer than H votes when the timeout expiresInconclusiveNo

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 rr waits 30r30r seconds. The on-chain DuplicateEvidence check limits execution to one proposal.
  • The writer calls proposeSlashByDkgParty(e3Id, partyId, proof) when the party ID resolves in the DKG anchors. Otherwise it calls proposeSlash(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:

CheckError
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.

  • setAccusationVoteValidity can keep or increase the value. It also cancels a pending reduction.
  • A reduction uses proposeAccusationVoteValidity, waits ACCUSATION_VOTE_VALIDITY_TIMELOCK (2 days), and then uses commitAccusationVoteValidity within 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.

Next steps