PVDKG and ZK Proof Pipeline
This page describes the publicly verifiable distributed key generation (PVDKG) and the zero-knowledge proof pipeline of the Interfold. It is for auditors and core contributors. It covers the circuits, the DKG state machine, the phases P1 to P4, proof aggregation, on-chain anchoring, and the behavior when committee members crash. At the end, you can follow every proof from the ciphernode that makes it to the contract that checks it.
Key generation. Up to 19 members publish proven contributions. The key uses an accepted roster of 14.
The scheme is threshold BFV. After sortition finalizes a committee of N
ciphernodes, the committee selects an accepted roster of H dealers. The threshold public key is the
sum of the H roster contributions. Each roster member holds a Shamir share of the matching secret
key, and any T + 1 roster members can decrypt together. The circuits are written in Noir and proved
with Barretenberg (bb) as UltraHonk proofs. For the cryptographic specification, read the
PVDKG article (opens in a new tab).
Terminology
| Term | Definition |
|---|---|
| Individual key pair | A BFV key pair that one ciphernode makes for one DKG. It encrypts the Shamir shares sent to that node. |
| Secret key contribution | One ciphernode's part of the threshold secret key. |
| Public key share | The threshold BFV public key that matches one secret key contribution. |
| Smudging noise contribution | One ciphernode's part of the smudging noise that protects partial decryptions. |
| Secret key share | The Shamir share of the summed secret key that one roster member holds after C4. |
| Smudging noise share | The Shamir share of the summed smudging noise that one roster member holds after C4. |
| Threshold public key | The sum of the roster public key shares. Data providers encrypt to this key. |
| Partial decryption | One roster member's decryption share of a ciphertext, before reconstruction. |
| Committee size N | The number of ciphernodes that finalizeCommittee selects. |
| Threshold T | The polynomial degree of the Shamir sharing. Decryption needs T + 1 valid shares. |
| Roster size H | The exact number of dealers whose contributions form the threshold key. |
| Accepted roster S | The ascending list of H party IDs that the active aggregator proposes and every node accepts. |
| Party ID | The index of a member in the address-sorted committee. The lowest address is party 0. |
| Active aggregator | The member with the lowest eligible party ID. It coordinates the roster and the aggregate proofs. |
Committee shapes and fault budget
crates/committee/src/lib.rs defines three committee shapes. The on-chain registry stores
threshold as [H, N]. The runtime field threshold_m carries T and threshold_n carries N.
| Committee | N | T | H | Members that can drop before the roster () | Roster members that can drop before decryption () |
|---|---|---|---|---|---|
minimum | 3 | 1 | 2 | 1 | 0 |
micro | 9 | 4 | 5 | 4 | 0 |
small | 19 | 9 | 14 | 5 | 4 |
These numbers describe the circuit shape and the liveness budget. An omitted member is not proven dishonest.
Circuit map
The ProofType enum in crates/events/src/interfold_event/signed_proof.rs names each node-made
proof. Its numeric value is the proofType that slashing evidence uses. The CircuitName enum in
crates/zk/config/src/circuit.rs maps each circuit to a package under circuits/bin/.
| ID | ProofType (value) | CircuitName | Package | Phase | Proves |
|---|---|---|---|---|---|
| C0 | C0PkBfv (0) | PkBfv | dkg/pk | P1 | Commits to the individual public key used for share encryption. |
| C1 | C1PkGeneration (1) | PkGeneration | threshold/pk_generation | P1 | The public key share comes from the committed secret key contribution. |
| C2a | C2aSkShareComputation (2) | SkShareComputation | dkg/sk_share_computation | P1 | The secret key Shamir shares match the C1 commitment and are a valid sharing. |
| C2b | C2bESmShareComputation (3) | ESmShareComputation | dkg/e_sm_share_computation | P1 | The same for the smudging noise shares. |
| C3a | C3aSkShareEncryption (4) | ShareEncryption | dkg/share_encryption | P1 | A secret key share is BFV-encrypted under the recipient's C0 key. |
| C3b | C3bESmShareEncryption (5) | ShareEncryption | dkg/share_encryption | P1 | The same for a smudging noise share. |
| C4a | C4aSkShareDecryption (6) | DkgShareDecryption | dkg/share_decryption | P1 | The decrypted roster shares match their C2a commitments. Commits to their sum. |
| C4b | C4bESmShareDecryption (7) | DkgShareDecryption | dkg/share_decryption | P1 | The same for the smudging noise shares. |
| C5 | C5PkAggregation (8) | PkAggregation | threshold/pk_aggregation | P2 | The threshold public key is the sum of the H roster shares committed in C1. |
| C6 | C6ThresholdShareDecryption (9) | ThresholdShareDecryption | threshold/share_decryption | P4 | A partial decryption uses the C4 secret key and smudging noise shares. |
| C7 | C7DecryptedSharesAggregation (10) | DecryptedSharesAggregation | threshold/decrypted_shares_aggregation | P4 | Lagrange interpolation, CRT reconstruction, and decoding give the plaintext. |
P3 uses the user-data encryption packages threshold/user_data_encryption_ct0,
threshold/user_data_encryption_ct1, and the wrapper threshold/user_data_encryption. They have no
ProofType because data providers, not ciphernodes, make these proofs.
Recursive aggregation circuits
The packages under circuits/bin/recursive_aggregation/ fold the inner proofs into two on-chain
proofs.
| Package | CircuitName | Role |
|---|---|---|
c2ab_fold | C2abFold | Folds C2a and C2b. |
c3_fold, c3_fold_kernel | C3Fold, C3FoldKernel | Folds the C3 proofs of one branch in sequence. The kernel makes the first accumulator. |
c3ab_fold | C3abFold | Folds the final secret key and smudging noise C3 folds. |
c4ab_fold | C4abFold | Folds C4a and C4b. |
node_fold | NodeFold | Folds C0, C1, c2ab_fold, c3ab_fold, and c4ab_fold for one node. |
nodes_fold, nodes_fold_kernel | NodesFold, NodesFoldKernel | Folds the H node_fold proofs in sequence. |
dkg_aggregator | DkgAggregator | Verifies nodes_fold and C5. Its EVM proof goes to publishCommittee. |
c6_fold, c6_fold_kernel | C6Fold, C6FoldKernel | Folds the T + 1 C6 proofs of one ciphertext. |
decryption_aggregator | DecryptionAggregator | Verifies c6_fold and C7. Its EVM proof goes to publishPlaintextOutput. |
Proof count per E3
L is the number of threshold CRT moduli: 3 for insecure-64 and for secure-8192. The current
TrBFV code makes one smudging noise share set, so the smudging multiplicity Z is 1.
| Circuit | Count for one node | Count for the committee |
|---|---|---|
| C0 | 1 | Up to N |
| C1 | 1 | Up to N |
| C2a | 1 | Up to N |
| C2b | 1 | Up to N |
| C3a | Up to | |
| C3b | Up to | |
| C4a | 1 | H used in the key |
| C4b | H used in the key | |
| C5 | 1 (active aggregator) | 1 |
| C6 | 1 for each ciphertext output | T + 1 used for each ciphertext output |
| C7 | 1 for each ciphertext output (active aggregator) | 1 for each ciphertext output |
C3 is the most expensive circuit. Its witness is N wide, also when some C0 keys are missing. A dealer proves every slot except its own. For a recipient without a C0 key, the dealer encrypts the share under its own C0 key to fill the slot. The dealer does not deliver that placeholder.
Two key hierarchies
The protocol uses two different BFV key pairs. They use different parameter sets.
| Key | Preset (BfvPreset) | Purpose | Lifetime |
|---|---|---|---|
| Individual key | InsecureDkg64 or SecureDkg8192 | Encrypts the Shamir shares sent to one node. C0 commits to it. | One DKG |
| Threshold key | InsecureThreshold64 or SecureThreshold8192 | Data providers encrypt to it. C1 proves each contribution. | One E3 |
The threshold key uses a common random polynomial (CRP) . Every node derives the same CRP from
the default seed with create_deterministic_crp_from_default_seed. The node encrypts its individual
secret key at rest with its local Cipher (AES-256-GCM with an Argon2id key).
DKG state machine
Each selected ciphernode runs one ThresholdKeyshare actor for the E3. The actor moves through the
KeyshareState enum in crates/keyshare/src/threshold_keyshare/state.rs.
| State | Work in this state |
|---|---|
CollectingEncryptionKeys | Make the individual key pair, request C0, and collect peer C0 keys. |
GeneratingThresholdShare | Make the TrBFV contribution and the Shamir shares, then request C1, C2, and C3. |
AggregatingDecryptionKey | Collect and verify peer shares, publish signed readiness, accept one roster, and compute local key shares. |
ReadyForDecryption | Prove C4, exchange DecryptionKeyShared, verify peer C4 proofs, and publish KeyshareCreated. |
Decrypting | Compute the partial decryption of each ciphertext output. |
GeneratingDecryptionProof | Wait for the signed C6 proofs. |
Completed | The node published its decryption share. |
Every state except Completed can move to Failed { failed_at_stage, reason }. A persisted failure
can only be written again with the same payload, so a restart cannot resume an earlier phase.
P1: Distributed key generation
Frozen DKG timing
When CiphernodeSelected arrives, the node reads getDeadlines(e3Id) and
getE3TimeoutConfig(e3Id) from the Interfold contract. It persists the frozen DKG deadline and
window before it makes keys. The DKG deadline is the ticket deadline plus dkgWindow. Each
collector cutoff is a fraction of that window, measured from the ticket deadline:
| Collector | Cutoff (bps) | Kind | Override variable |
|---|---|---|---|
EncryptionKeyCollector | 1000 (10%) | Hard cutoff for C0 keys | E3_ENCRYPTION_KEY_COLLECTION_TIMEOUT_SECS |
ThresholdShareCollector | 7500 (75%) | Soft checkpoint. The hard stop is the DKG deadline. | E3_THRESHOLD_SHARE_COLLECTION_TIMEOUT_SECS |
DecryptionKeySharedCollector | 10000 (100%) | Hard cutoff at the DKG deadline | E3_DECRYPTION_KEY_SHARED_COLLECTION_TIMEOUT_SECS |
An override variable can only make a cutoff earlier. After a restart, a collector uses the time that
remains, not a new window. The mainnet configuration sets dkgWindow to 21600 seconds. The C0
cutoff is then 36 minutes after the ticket deadline, and the soft checkpoint is at 4.5 hours.
C0: individual key commitment
The node makes its individual BFV key pair and publishes EncryptionKeyPending. ProofRequestActor
requests ZkRequest::PkBfv. C0 takes the individual public key polynomials and over
the DKG moduli and outputs pk_commitment. C0 does not prove correct key generation. It binds the
key so that C3 can prove which key encrypted each share.
The node publishes EncryptionKeyCreated with the signed C0 proof. Receivers verify it in
ProofVerificationActor before they store the key. Before reserving a verification slot, the
receiver checks the committee signer, E3, C0 proof type, and commitment to the advertised key. The
EncryptionKeyCollector finishes when all N keys arrive. At the C0 cutoff, it continues if it has
at least H keys that include the node's own key. With fewer keys, the node records DKGTimeout and
stops.
A local verifier error leaves C0 verification pending. The receiver retains the authenticated input
and retries after five seconds. Each failed attempt doubles the delay, up to 60 seconds, and logs a
warning with the attempt count. Verification completion, a canonical stage past DKG, or
E3RequestComplete clears the request. Startup restores unresolved inputs from durable events,
including inputs before the snapshot cursor, and resumes verification after EffectsEnabled. The
retry delay and attempt count reset on restart. Recovery continues past legacy records that the
event-store router quarantines, including whole pages limited by record count or bytes. It checks
the physical log head before it ends the scan. Other sequence gaps remain startup errors. A missing
verifier binary, missing verification key, or local I/O error produces no peer-failure evidence.
Only a completed invalid check starts the accusation path.
C1: threshold share generation
The node requests GenPkShareAndSkSss and then GenEsiSss from TrBFV. It gets its secret key
contribution , its public key share, its smudging noise contribution , and N Shamir
shares of each secret. C1 proves the BFV key generation relation for each CRT modulus :
Here is the ring degree, and and are quotient witnesses. The circuit checks the
relation at one Fiat-Shamir challenge point (the Schwartz-Zippel method). C1 outputs three
commitments in this order: sk_commitment for C2a, pk_commitment for C5, and e_sm_commitment
for C2b.
C2: share computation
C2a proves the secret key sharing and C2b proves the smudging noise sharing. For each coefficient and modulus , the share vector is with equal to the secret coefficient. Each circuit checks four properties:
- The secret hashes to
expected_secret_commitmentfrom C1. - The first entry of each share vector equals the secret coefficient.
- Every share is in .
- The shares lie on a polynomial of degree at most T. The Reed-Solomon parity check is , with a parity matrix of size .
C2 outputs one commitment for each (party, modulus) pair, an grid. C3 and C4 consume this grid.
C3: share encryption
C3a encrypts one secret key share row and C3b one smudging noise share row. Each proof takes two
public inputs: expected_pk_commitment from the recipient's C0 and expected_message_commitment
from the sender's C2. It proves the BFV encryption relation at a Fiat-Shamir challenge point and
outputs ct_commitment. The proof therefore shows that the correct share is encrypted under the
correct recipient key.
Share publication
ProofRequestActor makes all C1, C2, and C3 proofs in parallel. It publishes nothing until the
complete proof plan is ready. It then signs each proof and publishes one recipient-scoped
ThresholdShareCreated for each recipient with a C0 key. That event carries:
- The public key share and the recipient's encrypted shares.
- The signed C2a and C2b proofs.
- The signed C3a and C3b proofs for that recipient.
- A dealer signature over the E3, dealer, recipient, share bytes, and complete proof bundle.
The signed C1 proof does not travel in ThresholdShareCreated. It travels later in
KeyshareCreated.
Share verification
Before saving or collecting a share, ThresholdKeyshare checks its complete message signature
against the dealer's finalized committee address. A rejected message leaves the dealer's slot
available for an authenticated share. Signature rejection does not accuse the claimed dealer.
ThresholdShareCollector keeps authenticated shares addressed to this node. When all
external shares arrive, it closes. At the 75% checkpoint, it starts verification if it has at least
external shares, and it keeps collecting. Later shares extend the verified dealer set until
all arrive, local key calculation completes, or the DKG deadline passes. Local key
calculation completion stops the collector and its timers. Only the DKG deadline can emit
ThresholdShareCollectionFailed from an active collector. The parent ignores that failure after it
reaches ReadyForDecryption or a later phase.
ShareVerificationActor verifies each batch in three phases:
- Signatures. It recovers the signer of each proof, requires one signer for each party, and checks the circuit names.
- Commitment consistency. It sends
CommitmentConsistencyCheckRequestedto the per-E3 checker. Parties with inconsistent commitments skip the ZK step. See commitment consistency. - ZK verification. It runs
bb verifyfor the remaining parties and publishesShareVerificationCompletewith the dishonest parties.
A batch with no proofs that pass local prechecks still produces and saves a verification outcome. A later authenticated share can extend the batch, including after a restart.
The keyshare actor drops failed dealers and C3 proofs that target a different recipient key. It
saves each verified dealer as a DkgDealer { party_id, contribution_hash }, where:
The value is ProofPayload.statement_digest(). It covers the E3, the proof type, the
circuit, and the public signals, but not the randomized proof bytes. A replay of the same statement
therefore cannot make a second dealer identity.
Readiness and roster selection
When at least H dealers, including the node itself, pass verification, the node publishes a signed
DkgCoordination message of kind Ready. The message lists the verified dealers in ascending party
ID order. The signature covers this digest:
keccak256(abi.encode(
keccak256("InterfoldDkgCoordination(uint256 chainId,address interfold,uint256 e3Id,uint256 partyId,uint256 kind,bytes32 dealersHash)"),
chainId, interfold, e3Id, partyId, kind, dealersHash))The active aggregator runs select_ready_roster. It returns the first H-party set, in ascending
party ID order, that is mutually ready:
Each member's own dealer entry must be in its own list, with the same contribution hash that the
other members hold. The aggregator publishes DkgCoordination of kind Roster. A receiver accepts
a roster only when all of these conditions are true:
- The signer is the committee member at the proposer's party ID.
- The proposer's party ID is at most the active party ID from
AggregatorChanged. - The roster has exactly H dealers, and neither the proposer nor a dealer is expelled.
- The proposer's Ready list and the receiver's own Ready list contain the roster.
- Every Ready list that the receiver holds for a roster dealer contains the roster.
Before C4 starts, a roster from a lower party ID replaces a roster from a higher party ID. After C4
starts, the roster is fixed. A node starts C4 only if its own Ready list contains every roster
contribution. On acceptance, the node emits CommitmentRosterSelected for the commitment checker.
The network actor sends the latest local Ready and Roster messages again every 30 seconds. Each repeat uses a fresh transport delivery ID, so the libp2p duplicate cache does not drop it.
This coordination is not Byzantine agreement. A malicious active aggregator can delay progress until
failover. It cannot publish two accepted keys, because the H-row proof enforces one roster and
publishCommittee accepts one result.
C4: share decryption and aggregation
Each roster member decrypts the shares that the H roster dealers sent to it, with its individual
secret key. The decryption itself is outside the circuit. The node requests CalculateDecryptionKey
and gets its secret key share and smudging noise share. C4a and C4b then prove, for the H roster
rows:
- Each decrypted share hashes to the commitment that its dealer published in C2.
- The aggregate is the coefficient-wise sum: .
- The aggregate is reduced, reversed, and centered to match the representation of C6.
- The output is
commit(agg_sk)for C4a andcommit(agg_e_sm)for C4b.
The node publishes DecryptionKeyShared with its signed C4 proofs. It collects and verifies the C4
proofs of the other roster members. It then publishes KeyshareCreated with its public key
share and its signed C1 proof. A node outside the roster makes C4 only if it holds all H roster
shares. Its C4 does not add a row to the key.
The C4 message also carries a dealer signature over its E3, party ID, node address, and complete ordered proof bundle. The receiver checks this signature before saving the first message or forwarding it to the C4 collector. A rejected message leaves that party's slot available.
P2: Public key aggregation (C5)
Every committee member buffers KeyshareCreated from verified members. After all H roster members
submit a keyshare, each member persists VerifyingC1 and publishes
AggregationInputsReady(PublicKey). Only the active aggregator then verifies the H C1 proofs. A
failed C1 proof ends the DKG with DKGInvalidShares.
The active aggregator sums the H roster shares in roster order:
C5 checks each share against its C1 pk_commitment and proves the sum. It does not take as a
witness. It uses the circuit-constant CRP directly. C5 outputs commit(pk0_agg, crp). This
value is the pkCommitment that the registry stores.
P3: User encryption
Data providers encrypt inputs to the threshold public key with the user-data encryption circuits,
which follow the Greco (opens in a new tab) design.
The ct0 and ct1 proofs share a u_commitment. The wrapper user_data_encryption outputs
(pk_commitment, ct_commitment, k1_commitment). An E3 program binds the input proof to the key. For
example, CRISP passes the on-chain committeePublicKey commitment as a public input.
The compute provider runs the E3 program over the ciphertexts. publishCiphertextOutput then stores
the output hash and its SAFE ciphertext commitment on-chain.
P4: Threshold decryption (C6, C7)
C6: partial decryption
After CiphertextOutputPublished, each roster member computes one partial decryption for each
ciphertext output. For each modulus , C6 proves:
The smudging noise hides the secret key share. C6 first checks that and match
the C4 commitments. It binds the witness ciphertext to ct_commitment and checks the relation at a
Fiat-Shamir challenge point. Its public inputs are expected_sk_commitment,
expected_e_sm_commitment, ct_commitment, domain_hi, and domain_lo. Its output is
d_commitment, a commitment to the first 50 coefficients of for each modulus.
The decryption domain binds each C6 proof to one E3:
The node publishes DecryptionshareCreated with one signed C6 proof for each ciphertext output.
Plaintext aggregation
Every committee member stores valid shares. A valid share comes from an accepted roster member. Its
signature, E3, proof type, raw share commitment, ciphertext position, and canonical decryption
domain must also be correct. These checks run before the share reserves a party slot. When T + 1
distinct roster shares are durable, the member publishes AggregationInputsReady(Plaintext). For
small, that is 10 shares, not all 14. Shares that arrive during C6 verification go to a durable
backup queue. If a proof or a raw share check fails, the active aggregator uses backups while T + 1
valid roster shares are still possible.
C7: reconstruction
C7 takes the T + 1 shares with 1-indexed party IDs in strictly increasing order. It proves three steps:
- Lagrange interpolation at zero for each modulus, with coefficients computed in the circuit: , where .
- CRT reconstruction: for every modulus.
- Decoding with the precomputed , where and is the plaintext modulus.
C7 compares every decoded coefficient with the claimed message, including coefficients claimed as zero.
Crash tolerance and liveness
The DKG is crash tolerant: it can finish when some members are offline. The protocol omits missing members at defined points. It does not accuse or slash them, because a local timeout does not prove that a peer failed to publish.
| Member stops | Protocol behavior | Result |
|---|---|---|
| Before its C0 key arrives | Other members continue at the C0 cutoff with at least H keys. They fill its C3 slots with placeholders. | It cannot be a dealer or a roster member. |
| Before its threshold shares arrive | Other members start roster work with at least external shares and accept later shares. | It is not in the roster. |
| A member has fewer than H verified dealers | It stays outside C4. It does not fail the E3. | The E3 continues if a roster forms. |
| Active aggregator, during roster selection | After 10 minutes, the next eligible party becomes active and uses the saved Ready reports. | The roster forms if H members are mutually ready. |
| A roster member, after roster acceptance and before key publication | No replacement exists. The fixed H-row proof needs every roster member. | The E3 fails with DKGTimeout after the DKG deadline. |
| A roster member is expelled or excluded before key publication | The public-key aggregator stops the DKG because the fixed H-row proof needs every selected member. | The E3 fails. |
| A roster member is expelled or excluded after key publication | Public-key aggregators retain canonical publication, including after restart. Decryption still needs T + 1 valid roster shares. | Decryption continues if enough valid shares remain and the chain has not failed the E3. |
| Active aggregator, during C5 or publication | After 40 minutes, the next eligible party resumes from its persisted phase. | Publication continues if the DKG deadline leaves time. |
| Up to roster members, before decryption | The aggregator starts at T + 1 valid roster shares. | Decryption continues. |
| Active aggregator, during plaintext aggregation | After 10 minutes, the next eligible party resumes. | Decryption continues. |
Before key publication, each of the H selected members is necessary for the DKG. A roster member that stops permanently can fail the E3, even if other committee members stay online.
Public-key aggregators log raw expulsions and exclusions at INFO. Each record identifies the removal kind, node, E3, and, for an exclusion, the proof type. After canonical key publication, the record states that DKG handling is skipped because the key is already published. Chain failures and decryption failures from insufficient valid roster shares still apply.
Restart recovery
A member that restarts recovers its work from the durable event log:
- It restores collectors with the remaining time to each cutoff. Every new threshold-share collector receives saved expulsions, then all retained authenticated contributions, including when a later share starts collection after restart.
- Public-key aggregators restore canonical key publication from the lifecycle projection. This prevents a later expulsion or exclusion from failing completed DKG work, including on standby nodes.
- It holds replayed
GenPkShareAndSkSssandGenEsiSssresponses until their prerequisites return. It does not make new random shares that conflict with its published C1 to C3 proofs. NodeProofAggregatorrestores saved inner proofs and fold metadata, then issues the fold again or publishes the saved result.- A persisted
Failedstate publishes the sameE3Failedpayload again afterEffectsEnabled. - A local prover, verifier, or worker failure retries the same request. It never counts as proof that a peer sent invalid data.
The node accepts a collector failure only for the same E3 while that collection remains current:
- Encryption-key failures apply in
InitandCollectingEncryptionKeys. - Threshold-share failures apply from
InitthroughAggregatingDecryptionKey. - C4 failures apply in
ReadyForDecryptionuntil C4 verification completes or keyshare publication is authorized.
Canonical key publication supersedes all three collectors. A PublicKeyAggregated intent and its
saved public-key context do not suppress a current collector failure. The node records canonical
publication for the matching E3 even when PublicKeyAggregated has not arrived. An earlier stage
does not reopen collection. On restart, the node restores publication awareness from the saved E3
lifecycle projection. An ignored failure changes no state and publishes no failure event.
When a current collector timeout is fatal, the node records a local E3Failed event. On-chain, the
E3 stays at CommitteeFinalized until the DKG deadline. After that, markE3Failed records
DKGTimeout.
Proof aggregation and on-chain anchoring
The contracts accept only aggregated proofs. The active aggregator submits one recursive proof for the DKG and one for the decryption. Production nodes always aggregate. The EVM writer publishes a single ciphertext output only. It refuses to publish an E3 result that has more than one decrypted output.
ZkRequest | Made by | Output |
|---|---|---|
NodeDkgFold | Each roster member | A NodeFold proof. The node then signs a fold attestation. |
DkgAggregation | Active aggregator | NodesFold, C5, and the DkgAggregator EVM proof |
DecryptionAggregation | Active aggregator | C6Fold, C7, and the DecryptionAggregator EVM proof for each output |
Committee hash
Both final proofs bind the ordered committee. The hash covers the raw 20-byte addresses of
topNodes in ascending order, without padding:
Solidity computes it in CommitteeHashLib.hash. Rust computes it in
e3_committee_hash::hash_committee_addresses (crates/committee-hash). Noir computes it in
committee_hash_limbs. A shared test vector checks that the three implementations agree.
What the DKG proof enforces
node_fold verifies the five inner proofs of one node and asserts these same-party links:
- C1
sk_commitmentequals the C2aexpected_secret_commitment, and C1e_sm_commitmentequals the C2b one. - The C3a and C3b recipient key commitments are equal for every slot.
- For every slot except the node's own, the C3 message commitment equals the C2 share commitment.
dkg_aggregator verifies nodes_fold and C5, then asserts these cross-party links:
- The committee hash limbs match the N committee addresses.
- The H party IDs are strictly increasing and less than N.
- Each folded row carries the matching party ID.
- For each roster sender and recipient , the C3 key slot of for equals the C0 commitment of .
- For the same pairs, the C4 rows of for equal the C2 commitments of for . This applies to both branches.
- Row of C5 equals the C1
pk_commitmentof roster member .
DKG proof public inputs
The DkgAggregator EVM proof has public inputs:
| Index | Value |
|---|---|
| 0 | nodes_fold_key_hash |
| 1 | c5_key_hash |
| 2 to | party_ids (0-indexed committee slots) |
committee_hash_hi | |
committee_hash_lo | |
| Aggregate verification key hash | |
| to | sk_agg_commits |
| to | esm_agg_commits |
| Aggregated public key commitment |
Fold attestations
After its NodeFold proof, each roster member signs an EIP-712 attestation and gossips it in
DKGRecursiveAggregationComplete. The domain and struct come from DkgFoldAttestationLib:
// EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
// name = "InterfoldDkgFoldAttestation", version = "1",
// verifyingContract = the DkgFoldAttestationVerifier frozen for this E3
DkgFoldAttestation(address registry,uint256 e3Id,uint256 partyId,bytes32 skAggCommit,bytes32 esmAggCommit)The aggregator collects exactly H attestations and builds the bundle
(Attestation[], PartySlotBinding[]). Each binding maps a partyId to an operator address.
publishCommittee verification
Any caller can call publishCommittee(e3Id, pkCommitment, proof, dkgAttestationBundle). The
registry checks, in this order:
- The committee is
Finalized,activeCountis at least H, no key is published yet, andpkCommitmentis not zero. - It computes the committee hash from
topNodesand storespkCommitment. - The E3
pkVerifier(BfvPkVerifier) checks the public input count and the committee hash limbs. It compares the two VK hashes with its immutableexpectedNodesFoldKeyHashandexpectedC5KeyHash, and the last input withpkCommitment. Then the Honk verifier checks the proof. - The E3's frozen
DkgFoldAttestationVerifierderives and requires strictly increasing proof party IDs and H bindings. For each binding, it requires:canonicalCommitteeNodeAt(e3Id, partyId)equals the bound node.- The node is an active committee member.
- The EIP-712 signer is the bound node.
- The signed
skAggCommitandesmAggCommitequal the proof values at that party's slot.
- The registry stores
dkgPartyIds,dkgSkAggCommits, anddkgEsmAggCommitsfor the E3. Interfold.onCommitteePublishedmoves the E3 toKeyPublished. It reverts after the DKG deadline, and after the input window end withInputWindowClosedBeforeKeyPublication.- The registry emits
CommitteeProofPublished(e3Id, nodes, pkCommitment, proof).
BfvPkVerifier.verify accepts e3Id, committeeRoot, and sortedNodes but does not use them.
The current DKG circuit has no public input for them. The proof binds to the committee through the
committee hash. The fold attestations bind the registry, the E3, the chain, and the verifier.
The slot binding stops an aggregator from moving a signed attestation to a different party ID. The
anchors let later steps map each DKG output to topNodes[partyId].
Public key transport
publishCommittee stores only the commitment. Committee members then send the serialized key with
publishCommitteePublicKey in 90 KiB chunks, with a maximum of 512 KiB, while the E3 is at
KeyPublished. The registry emits CommitteePublicKeyChunkPublished. A consumer assembles a
complete chunk set, checks keccak256(publicKey) == candidateHash, and recomputes the commitment
before it uses the key.
Ciphernodes also check peer key publications against the registry commitment, finalized committee, and DKG anchor party IDs. They recompute the commitment from the key bytes and retain the first matching key. Peer publications cannot replace the decryption domain or either committee roster. Nodes derive these facts from confirmed registry events. After a restart, they rebuild the projection and validated key bytes from retained event and chunk history before proof replay. They repair retained C6 intents before deduplication and reject logged compute requests with noncanonical public inputs. This recovery needs no historical registry storage reads or new key publication.
Plaintext hydration recovers deferred ciphertext from retained history, including events before the snapshot cursor. It also recovers authenticated shares received before that cursor. A missing plaintext actor can therefore resume without another ciphertext or share publication. An existing snapshot remains dormant until confirmed key authority arrives. Startup continues, and the node retains the snapshot, a durable-history range, and one effects-enabled signal. It reads deferred payloads in bounded pages when authority arrives. Hydration also checks saved C6 shares and the C6 inputs retained in later aggregation phases. If a domain is invalid, the node clears the affected verification results and derived proofs, including a retained final proof. It rebuilds collection from signed history with effects disabled. A corrected share can then occupy the same party slot. Valid retained C6 work keeps its phase. Retained C7 proofs must also match that batch and its plaintext. A mismatch regenerates C7 and the final proof, including for a saved completed actor. The EVM writer separately checks each final proof against the canonical decryption domain before it retains a publication intent. It waits for missing authority and discards mismatched intents. A corrected local result can then proceed to publication. Confirmed terminal stages retire deferred and pending plaintext publication work, including after a restart. Later intents for a terminal E3 are discarded. A waiting active E3 retains one event-log sequence range, and admission reads that range in bounded pages when canonical authority arrives.
Decryption proof
publishPlaintextOutput computes the decryption domain on-chain and calls the E3 decryption
verifier (BfvDecryptionVerifier). The DecryptionAggregator EVM proof has
public inputs:
| Index | Value |
|---|---|
| 0, 1 | c6_fold and C7 VK hashes |
| 2, 3 | committee_hash_hi, committee_hash_lo |
| 4, 5 | domain_hi, domain_lo |
| 6 | ciphertext_commitment |
| 7 | Aggregate verification key hash |
| next | party_ids, then expected_sk, then expected_esm |
| last 50 | Plaintext coefficients |
decryption_aggregator requires one domain and one ciphertext commitment across all C6 leaves. It
requires each C6 d_commitment to equal the matching C7 input. Its party IDs must be 1-indexed,
strictly increasing, and at most N. The wrapper then checks:
- Every public input is a canonical BN254 field element, and each plaintext coefficient fits in 64 bits.
- The VK hashes, the committee hash limbs, the domain limbs, and the stored ciphertext commitment.
keccak256of the 50 coefficients, each as 8 little-endian bytes, equals the plaintext hash.- For each party,
party_id - 1is in the DKG anchors, and the proof commitments equal the storeddkgSkAggCommitsanddkgEsmAggCommits.
Verifier sync
The Honk verifiers DkgAggregatorVerifier.sol and DecryptionAggregatorVerifier.sol are generated
from the recursive VKs of dkg_aggregator and decryption_aggregator with
bb write_solidity_verifier. The files in
packages/interfold-contracts/contracts/verifiers/bfv/honk/ are the insecure-64 and minimum
pair. The other pairs are in honk/<preset>/<committee>/. A deployment must use the pair that
matches its preset and committee.
pnpm generate:verifiers has two modes. --check regenerates in memory and fails on any
difference. tests/integration/lib/prebuild.sh uses it. --write overwrites the committed files.
Both modes refuse to run unless dist/circuits/<preset>/<committee>/.build-stamp.json exists and
circuits/bin/.active-preset.json names the same pair. The toolchain versions are pinned in
crates/zk/prover/versions.json.
Verifier rotation
Each E3 freezes its registry and its DkgFoldAttestationVerifier at request time and announces both
in DkgFoldAttestationContextEstablished. A node restores this context after a restart. A verifier
change uses proposeDkgFoldAttestationVerifier, then commitDkgFoldAttestationVerifier after
DKG_FOLD_VERIFIER_TIMELOCK (2 days). The owner can cancel a proposal with
cancelDkgFoldAttestationVerifierProposal. The one-time setInitialDkgFoldAttestationVerifier
works only when no verifier is set. A new verifier applies only to later E3 requests.
Test-only aggregation bypass
A binary built with the Cargo feature test-only-skip-proof-aggregation accepts the node setting
skip_proof_aggregation. The node wraps C5 and C7 proof bytes in payloads for mock verifiers. The
DKG placeholder carries the final roster, committee hash, and key commitment fields. The decryption
placeholder carries the verified C6 domain. These fields let confirmed ingestion and the plaintext
writer keep their admission checks. A production binary rejects this setting at startup, and
production verifiers reject the placeholder bytes. The contracts still call their verifiers.
Proof lifecycle
Generation
A *Pending event reaches ProofRequestActor, which dispatches ComputeRequest::zk with a
ZkRequest. ZkActor writes the witness and runs bb prove, then returns
Proof { data, public_signals }. A failed attempt retries the same request. Attempts after the
first use the Barretenberg low-memory mode.
C7 requests bind to the exact share batch and plaintext. A replacement request invalidates earlier worker responses. Plaintext aggregation checks C7 commitments, party IDs, and plaintext against the selected C6 batch. Restart recovery regenerates mismatched C7 work before final aggregation.
Signing
The node signs every proof with its operator key before it sends the proof. The digest is:
keccak256(abi.encode(
keccak256("ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)"),
chainId, e3Id, proofType, keccak256(proof.data), keccak256(proof.public_signals)))SignedProofPayload::sign signs this digest as an EIP-191 personal message and stores the 65-byte
signature. The signature binds the proof to one chain, one E3, and one proof type, and it identifies
the prover.
Transport
| Event | Proofs | Path |
|---|---|---|
EncryptionKeyCreated | C0 | DHT document, announced over gossip |
ThresholdShareCreated | C2a, C2b, and the recipient's C3a, C3b | DHT document, announced over gossip |
DecryptionKeyShared | C4a, C4b | DHT document, announced over gossip |
KeyshareCreated | C1 | Gossip |
DecryptionshareCreated | C6 for each ciphertext output | Gossip |
C5 and C7 do not travel as separate proofs. They enter the recursive proofs of the active aggregator.
A node checks each document notification's structure and expiry before gossip forwards it. Valid notifications for other parties still relay. Malformed messages are rejected. A well-formed notification that expires between hops is ignored without a penalty to the relay's score. Admission does not impose message-count or byte-rate throttles on a relay. Each message must still fit the wire size limit.
Document fetches use up to eight active reads and 512 waiting documents. A peer can use all idle capacity. When peers compete, the next due peer with the fewest active reads gets the next slot; ties rotate. Each read releases its slot after one attempt, within 90 seconds. Failed reads return to the queue with backoff. At capacity, a peer below its fair share can reclaim space from the largest owner. Notifications repeat to recover queue overflow.
Each document retains up to 128 announcing peers through queueing and retries, including announcements that arrive during a read. A new announcer replaces the oldest one after the first when this limit is reached. Any retained announcer can supply its next fair slot. The node still fetches each document once at a time, and duplicates do not advance retry deadlines. The retained announcer with the least queued work supplies queue capacity, including for retries. Before eviction, shared work moves to a less loaded announcer. Queue growth from one peer cannot discard another announcer's only pending document.
Capacity planning uses four concurrent N=19, H=14 E3s and a 2x margin. Each E3 can emit 380 document notifications and 173 ordinary publications, including six Ready updates per party. This gives 4,424 publications per round with the margin. Each seen-ID cache reserves four rounds for initial traffic and early retries, plus 15 IDs/s for six hours: 341,696 entries. A node needs 54 remote documents per E3; four E3s with the margin need 432 queue slots, rounded up to 512. Early notifications have 3,040 slots because the recipient is not yet known. These entries distinguish the announcing peer, document, and party filter, so committee selection preserves each peer's attribution.
Seen-ID caches admit every new ID. Peers borrow idle space; at capacity, a peer at its fair share
replaces its own oldest ID. A peer below its share reclaims space from the largest owner. Early
eviction shortens retention and increments the seen_ids_early_evictions_total counter in WARN
logs. Cache pressure never discards accepted startup events. Gossip tracks transport delivery IDs;
storage tracks stable event IDs, so a retransmission does not add another logical contribution. A
restart starts new windows. Message IDs, event IDs, and serialized data remain unchanged.
Verification
ProofVerificationActor verifies C0. ShareVerificationActor verifies the other node proofs with
four kinds of batches: ShareProofs (C2, C3), DecryptionProofs (C4), PkGenerationProofs (C1),
and ThresholdDecryptionProofs (C6). A passed proof emits ProofVerificationPassed, which the
commitment checker and AccusationManager cache. An invalid proof emits SignedProofFailed, which
starts the accusation pipeline. A local verifier process error retries and does not accuse the
sender.
Commitment chain
Each circuit commits to its outputs with SAFE, a sponge API over Poseidon2 that
circuits/lib/src/math/safe.nr implements. A later circuit takes the expected commitment as a
public input, recomputes it from private inputs, and requires equality. Only compact commitments go
on-chain.
C0 ──→ C3 (recipient pk_commitment)
C1 ──→ C2a (sk_commitment)
C1 ──→ C2b (e_sm_commitment)
C1 ──→ C5 (pk_commitment)
C2a ──→ C3a (share commitment)
C2b ──→ C3b (share commitment)
C2a ──→ C4a (expected_commitments for all L moduli)
C2b ──→ C4b (expected_commitments for all L moduli)
C4a ──→ C6 (expected_sk_commitment)
C4b ──→ C6 (expected_e_sm_commitment)
C6 ──→ C7 (d_commitment)The recursive circuits enforce these links for the published result. The off-chain commitment consistency checker checks the same links to find the party at fault.
BFV parameter presets
crates/fhe-params/src/constants.rs defines the presets. Version-7 E3 requests use paramSet 0 for
insecure-64 and 2 for secure-8192. Index 1 remains in the registry for older E3s.
| Parameter | insecure-64 | secure-8192 |
|---|---|---|
| Ring degree | 64 | 8192 |
| Threshold CRT moduli L | 3 | 3 |
| DKG CRT moduli | 2 | 2 |
| Threshold plaintext modulus | 17000000 | 17000000 |
| Statistical security parameter | 2 | 45 |
| Use | Local development and tests only | Production |
Circuit artifacts are compiled separately for each preset and committee. The prover and the verifier must use the same pair.
Next steps
How nodes cross-check commitments between proofs and turn a mismatch into an accusation.
InternalsSortitionHow the committee and its party IDs are selected before the DKG starts.
InternalsCryptographyThe threshold BFV model and the phases P1 to P4 at a higher level.
ConceptsNoir circuitsInstall the toolchain and compile the circuit artifacts.
Build