diff --git a/agent/INVARIANTS.md b/agent/INVARIANTS.md index 233aad2b2d..9f6abe33ca 100644 --- a/agent/INVARIANTS.md +++ b/agent/INVARIANTS.md @@ -259,14 +259,16 @@ design citation alone does not establish current runtime behavior. - **E3 program allowlist:** production initialization registers one deployed E3 program and assigns Interfold ownership to the configured protocol owner. Later registration and retirement are owner-only. Retirement closes only new request admission; existing E3s keep their snapshotted - program. Every registered address must contain runtime code. `MockE3Program` is the stateless - bootstrap option. It has no administrative controls and applies no application rules. Its - deterministic test receipt is not production data availability, so requests remain paused until a - production program is registered and wired. The request-time BFV ciphertext verifier and - decryption verifier remain mandatory. Its mutable failure controls live only in - `MockE3ProgramHarness`. A protocol upgrade that makes the program interface incompatible must - retire every incompatible bootstrap program before requests resume. — `Interfold.sol`; - `MockE3Program.sol`; `flow-trace/03` + program. Every registered address must contain runtime code and must advertise both `IE3Program` + and `IE3ProgramDataAvailability` through ERC-165. Interfold calls `verifyDataAvailability` on + every output publication, so a program that omits the selector could otherwise brick its own + rounds after the requester paid. `MockE3Program` is the stateless bootstrap option. It has no + administrative controls and applies no application rules. Its deterministic test receipt is not + production data availability, so requests remain paused until a production program is registered + and wired. The request-time BFV ciphertext verifier and decryption verifier remain mandatory. Its + mutable failure controls live only in `MockE3ProgramHarness`. A protocol upgrade that makes the + program interface incompatible must retire every incompatible bootstrap program before requests + resume. — `Interfold.sol`; `MockE3Program.sol`; `flow-trace/03` - **Data availability binds per program and per round:** Interfold holds no protocol-level data-availability verifier; it delegates to `IE3ProgramDataAvailability(e3Program)`, and a program holds its verifier as an immutable. The Avail adapter re-checks `bridge.vectorx() == vectorx` on diff --git a/agent/flow-trace/00_INDEX.md b/agent/flow-trace/00_INDEX.md index bb74bfca2b..32a033ba8f 100644 --- a/agent/flow-trace/00_INDEX.md +++ b/agent/flow-trace/00_INDEX.md @@ -367,23 +367,27 @@ These rows come from the 2026-09 Zenith audit of `feat/avail-vectorx-release-can findings are tagged out of scope and sit in the CRISP availability service. Same caveat as the `Z-` rows: a `Resolved` row describes this repository's remediation, not an auditor's re-review of it. -| # | Concern | Severity | Status | Detail | -| ------- | --------------------------------------------------------------------------------- | -------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ZEN2-04 | **Failure reason front-run before a slash (Zenith #4)** | Low | Resolved | `_executeSlash` calls `onE3Failed` on a round that is already `Failed`, and Interfold routes that to `InterfoldLifecycle.reclassifyFailure`, which rewrites a requester-paid reason to `InsufficientCommitteeMembers`. An active member can no longer call `markE3Failed` in the grace window to lock requester-paid `ComputeTimeout` ahead of a committee-affecting slash. Only the E3's request-time slashing manager may correct, only before `getRefundDistribution().calculated`, and only away from a requester-paid reason. The stage stays `Failed`, `activeE3Count` is unchanged, and a correction that no longer applies returns without an effect, so it never reverts the expulsion. | -| ZEN2-08 | **VectorX pointer rotation bricks in-flight rounds (Zenith #8)** | Low | Documented | `AvailVectorXDataAvailabilityVerifier` re-checks `bridge.vectorx() == vectorx` on every call and fails closed on a rotation. Data availability binds per program, so only programs on the rotated pair are affected and `unregisterE3Program` contains new requests without a protocol-wide pause. Neither remedy the auditor proposed is implemented: a governed re-point would split a round's inputs across two providers, and the persisted `DataReference` carries no provider identifier, so the earlier retrieval coordinates become unresolvable and the aggregate step cannot read the round; a deadline extension cannot move the payer, because `FailurePayerLib` reads only the failure reason, which follows the stalled stage. In-flight rounds of an affected program are lost at the compute deadline. Detection, containment, and recovery are in `flow-trace/08`. | -| ZEN2-09 | **Committee release inside the accusation window (Zenith #9)** | Medium | Resolved | `releaseCommittee` on a finalized committee also requires `block.timestamp > SlashingManager.accusationSubmissionDeadline(e3Id)`. A member of an early-ended round cannot release its committee obligation and claim a matured exit while a valid accusation can still be filed. Unfinalized committees still release at terminal stage. `closeE3` clears the deadline only after it passed, so 0 also permits release. | -| ZEN2-20 | **Credited rewards bypass later expulsion (Zenith #20)** | Medium | Resolved | Committee allocations stay keyed by operator in `E3RefundManager._operatorEntitlements` instead of being credited to recipient-keyed balances at settlement. `claimReward`, `claimHeldSuccessReward`, `claimSlashedFunds`, and the permissionless `claimOperator*` entry points all re-check `pendingExpulsions` and `excluded` before paying the frozen recipient, so a proposal opened after completion holds the allocation and an executed expulsion forfeits and reallocates it. Two operators sharing one recipient keep independent entitlements. Balances credited before the upgrade stay claimable through the legacy ledgers; mainnet has processed no E3, so those ledgers are empty at upgrade. Follow-up: `_heldSlashFrom` records each held slash share's penalty target, and an expulsion re-shares each bucket without that target only, so a penalty never returns to its target and non-expelling penalties stay order-independent (one appended mapping, `__gap` 43→42). `claimReward` emits one `RewardClaimed` covering the legacy ledger and the operator-held escrow. | -| ZEN2-02 | **Committed input with no availability receipt bricks the round (Zenith #2)** | Info | Resolved | `finalizeInput` and `verify` share the compute deadline: `_finalizationE3` refuses a receipt after it, and `verify` refuses output while `pendingInputCount` is nonzero, so a late receipt leaves a round that can neither finalize the input nor conclude without it, and it fails as a requester-paid `ComputeTimeout`. `CRISPProgram._validateInputTiming` now also requires `computeWindow >= availabilityFinalizationWindow`, so a receipt that misses the commitment deadline keeps the same budget it had inside the input window. The check runs in the request transaction, before a fee is paid, and the zero-window local mock is unaffected. The reconciliation half of the recommendation is not implemented: letting a round conclude while an input stays pending drops that ballot silently, which is the same failure-attribution and funding decision as ZEN2-08 and Zenith #15. | -| ZEN2-10 | **Tracked CRISP image id copy is obsolete (Zenith #10)** | Info | Resolved | `examples/CRISP/.interfold/generated/contracts/ImageID.sol` held a pre-PR-1915 `PROGRAM_ID` while the canonical `crates/support/contracts/ImageID.sol` moved on. No consumer reads the copy: `crisp.ts` and `secureCrispArtifacts.ts` both read the canonical file, and the template scripts regenerate their own copy when it is absent. The copy is now untracked and `.interfold/generated/` is ignored, so the duplicate cannot go stale again. The guest was not rebuilt and no constant was hand-edited. | -| ZEN2-15 | **Data availability timeouts allocate losses by lifecycle stage (Zenith #15)** | Info | Documented | Settlement follows the failure reason, and the reason follows the stage the round stalled in: requester-paid `ComputeTimeout` before `CiphertextReady`, ciphernode-paid `DecryptionTimeout` after it, where `_baseDistribution` returns `(0, originalPayment, 0)` and the committee is paid nothing even for completed DKG. `FailurePayerLib` has two payers and no data-availability reason, and the protocol never funds a shortfall: its share is a residual of the requester's own escrow. Reallocating an outage cost needs funds from outside the escrow, which is exactly `originalPayment` and is fully allocated. That is a funded policy decision, tracked with ZEN2-08 and ZEN2-02, not an adapter or deadline change. Behavior is unchanged; `flow-trace/08` carries the runbook. | -| ZEN2-16 | **Fee and voting-power calculations use floor division (Zenith #16)** | Info | Documented | Pricing sums the weighted phase terms before one division, so each request rounds down once rather than per phase, and the margin rounds down again. The bound is per request, in raw fee-token units, and is not claimed to be below one unit. The treasury share rounds down into the committee pool, and the remainder joins the allocation for slot `e3Id % activeCount`, so allocations still sum to the whole pool. Pair-count divisions are exact. Behavior is unchanged: no rounding direction, unit, or remainder policy was altered, and each request repeats a positive randomness fee, so splitting a request is not profitable. | -| ZEN2-17 | **Large payloads and repeated key events increase processing costs (Zenith #17)** | Info | Documented | Publication interfaces accept dynamic data without wrapper-level caps, and committee-key publication caps a candidate at 512 KiB and a chunk at 90 KiB but does not record already emitted chunks, so a member whose status is not `None` can re-emit valid chunks while the round stays in `KeyPublished`. Senders pay the gas and observers pay the log processing. Required verification still gates acceptance: a failure reverts the publication call, so no input finalizes and no ciphertext is partly accepted. Consumers already select one candidate per publisher and bound retained assembly state under the public-key invariant. Behavior is unchanged. | -| ZEN2-11 | **Cached invalid availability proof blocks recovery (Zenith #11)** | Low | Resolved | `JobState::Ready` now keeps the `PendingPublication` beside the candidate proof, so a proof that Ethereum refuses returns to `AwaitingProof` and asks the bridge for a replacement for bytes Avail already holds. No second publication is paid. The field is `Option` with `serde(default)`, so a record written before this change still decodes and keeps its candidate; such a job needs operator recovery. | -| ZEN2-14 | **Noncanonical E3 identifiers duplicate paid publications (Zenith #14)** | Medium | Resolved | Identifiers are collapsed through the existing `canonical_e3_id` helper at the shared identity boundary, so two spellings of one E3 no longer produce two jobs and two paid aggregate publications. | -| ZEN2-18 | **Unvalidated ciphertext funds unusable inputs (Zenith #18)** | Low | Resolved | `validate_input_ciphertext` runs at intake, after proof validation and before any attestation, job persistence, or funded commitment. It builds the BFV tables from the E3's on-chain `paramSet`, checks the derived config id against `e3CryptoConfigIds`, and rejects bytes whose recomputed SAFE commitment does not equal `encryptedVoteCommitment`. Validation slots bound the added processor work. Votes, updates, and masks take the identical path; the publication worker is unchanged, so an already committed input still reaches DA finalization. | -| ZEN2-19 | **Coordinator voting-power scaling disagrees with the contract (Zenith #19)** | Info | Resolved | The divisor is read from the round through `votingPowerDivisorOf` for an initialized ONCHAIN round. Where off-chain derivation remains, an explicit override is selected before any metadata call, and the default is `U256::from(10).checked_pow(decimals - 1)` in 256-bit arithmetic: 1 for decimals 0-1, derived for 2-78, and a named error above that instead of the previous `u128` overflow. A reverting `decimals()` is distinguished from an RPC failure, which never silently becomes divisor 1. Follow-up: a failed stored-divisor read no longer blocks registration. The round is registered and votable at once, only holder discovery is deferred, and the deferral is durable (`discovery_pending`): a retry pass re-reads the divisor while the round is votable and runs the same discovery once it answers, so a transient RPC failure costs mask cover for a while rather than the round or its census. Coverage pins the decision and the durable debt, not a live listener with a failing provider. | -| ZEN2-21 | **Failed requests release another request's quota reservation (Zenith #21)** | Info | Resolved | A global admission reservation is held by an RAII guard, so a failed request returns its own reservation and cannot release one belonging to another request. Follow-up: the reservation is committed inside admission, in the step that writes the job, so a request cancelled during a later await cannot release quota for admitted work. | -| ZEN2-22 | **Concurrent status refresh overwrites durable progress (Zenith #22)** | Low | Resolved | A status refresh no longer writes back over a job that a worker advanced concurrently; publication progress is preserved. | -| ZEN2-23 | **Replaying an existing job consumes admission quota (Zenith #23)** | Low | Resolved | Replaying a statement that already has a durable job returns the existing view without consuming a new admission slot. | -| ZEN2-24 | **Non-final Ethereum observations disable recovery (Zenith #24)** | Low | Resolved | A job retires only on a finalized observation, so a non-final read no longer clears recovery material. `AwaitingFinality` keeps the payload and the Avail coordinates durable, so an orphaned transaction can be sent again. Follow-up: the relay path (non-mainnet) no longer promotes an input to `Committed` on its receipt; it stays in `AwaitingCommitment` with the relayed hash, renews its attestation, and recommits when the relayed transaction leaves the chain head. | -| ZEN2-25 | **Per-slot admission blocks another voter's attestation (Zenith #25)** | Medium | Resolved | Admission is keyed by statement, not by target slot: the per-slot uncommitted-input rejection is removed. CRISP lets any account produce a valid mask for an eligible slot without that slot owner's signature, so a per-slot reservation let one caller hold an attestation, withhold its commitment, and stop the slot owner from attesting a different statement. Exact-statement deduplication, proof and deadline checks, and atomic persistence are unchanged; bounded object storage and caller rate limits remain the only limits on new work. Retention is separate from admission: an earlier attested job keeps its record, and object bytes are released only when no other nonterminal job uses them. | +| # | Concern | Severity | Status | Detail | +| ------- | --------------------------------------------------------------------------------- | ------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ZEN2-01 | **E3 program without `IE3ProgramDataAvailability` bricks output publication** | Informational | Resolved | `InterfoldLifecycle._verifyDataAvailability` calls `verifyDataAvailability` on the request-time program with no fallback. `Interfold.registerE3Program` now probes the candidate with ERC-165 for `IE3Program` and `IE3ProgramDataAvailability` and reverts with `E3ProgramInterfaceMissing`. The probe is bounded to 30000 gas; a failed, short, or false answer counts as missing. | +| ZEN2-02 | **Committed input with no availability receipt bricks the round (Zenith #2)** | Info | Resolved | `finalizeInput` and `verify` share the compute deadline: `_finalizationE3` refuses a receipt after it, and `verify` refuses output while `pendingInputCount` is nonzero, so a late receipt leaves a round that can neither finalize the input nor conclude without it, and it fails as a requester-paid `ComputeTimeout`. `CRISPProgram._validateInputTiming` now also requires `computeWindow >= availabilityFinalizationWindow`, so a receipt that misses the commitment deadline keeps the same budget it had inside the input window. The check runs in the request transaction, before a fee is paid, and the zero-window local mock is unaffected. The reconciliation half of the recommendation is not implemented: letting a round conclude while an input stays pending drops that ballot silently, which is the same failure-attribution and funding decision as ZEN2-08 and Zenith #15. | +| ZEN2-03 | **Committee key publication after the input window closed** | Informational | Resolved | `Interfold.onCommitteePublished` rejects a key published after `e3.inputWindow[1]` with `InputWindowClosedBeforeKeyPublication`. Such a round reached `KeyPublished` but could never receive an input, and then failed as a requester-paid `ComputeTimeout` instead of a committee-paid `DKGTimeout`. | +| ZEN2-04 | **Failure reason front-run before a slash (Zenith #4)** | Low | Resolved | `_executeSlash` calls `onE3Failed` on a round that is already `Failed`, and Interfold routes that to `InterfoldLifecycle.reclassifyFailure`, which rewrites a requester-paid reason to `InsufficientCommitteeMembers`. An active member can no longer call `markE3Failed` in the grace window to lock requester-paid `ComputeTimeout` ahead of a committee-affecting slash. Only the E3's request-time slashing manager may correct, only before `getRefundDistribution().calculated`, and only away from a requester-paid reason. The stage stays `Failed`, `activeE3Count` is unchanged, and a correction that no longer applies returns without an effect, so it never reverts the expulsion. | +| ZEN2-08 | **VectorX pointer rotation bricks in-flight rounds (Zenith #8)** | Low | Documented | `AvailVectorXDataAvailabilityVerifier` re-checks `bridge.vectorx() == vectorx` on every call and fails closed on a rotation. Data availability binds per program, so only programs on the rotated pair are affected and `unregisterE3Program` contains new requests without a protocol-wide pause. Neither remedy the auditor proposed is implemented: a governed re-point would split a round's inputs across two providers, and the persisted `DataReference` carries no provider identifier, so the earlier retrieval coordinates become unresolvable and the aggregate step cannot read the round; a deadline extension cannot move the payer, because `FailurePayerLib` reads only the failure reason, which follows the stalled stage. In-flight rounds of an affected program are lost at the compute deadline. Detection, containment, and recovery are in `flow-trace/08`. | +| ZEN2-09 | **Committee release inside the accusation window (Zenith #9)** | Medium | Resolved | `releaseCommittee` on a finalized committee also requires `block.timestamp > SlashingManager.accusationSubmissionDeadline(e3Id)`. A member of an early-ended round cannot release its committee obligation and claim a matured exit while a valid accusation can still be filed. Unfinalized committees still release at terminal stage. `closeE3` clears the deadline only after it passed, so 0 also permits release. | +| ZEN2-10 | **Tracked CRISP image id copy is obsolete (Zenith #10)** | Info | Resolved | `examples/CRISP/.interfold/generated/contracts/ImageID.sol` held a pre-PR-1915 `PROGRAM_ID` while the canonical `crates/support/contracts/ImageID.sol` moved on. No consumer reads the copy: `crisp.ts` and `secureCrispArtifacts.ts` both read the canonical file, and the template scripts regenerate their own copy when it is absent. The copy is now untracked and `.interfold/generated/` is ignored, so the duplicate cannot go stale again. The guest was not rebuilt and no constant was hand-edited. | +| ZEN2-11 | **Cached invalid availability proof blocks recovery (Zenith #11)** | Low | Resolved | `JobState::Ready` now keeps the `PendingPublication` beside the candidate proof, so a proof that Ethereum refuses returns to `AwaitingProof` and asks the bridge for a replacement for bytes Avail already holds. No second publication is paid. The field is `Option` with `serde(default)`, so a record written before this change still decodes and keeps its candidate; such a job needs operator recovery. | +| ZEN2-12 | **Owner could initialize CRISP state for another program's E3** | Informational | Resolved | `CRISPProgram.validate` requires `e3.e3Program == address(this)` before it records round state, and `_keyPublishedE3` repeats the check as defense in depth. Interfold stores the provisional E3 and its selected program before it calls `validate`, so the assignment is readable at initialization. | +| ZEN2-13 | **Tree capacity one leaf above the reachable maximum** | Informational | Resolved | `CiphernodeRegistryOwnable.MAX_CIPHERNODE_LEAVES` is `2**TREE_DEPTH - 1`. The pinned LazyIMT sets `maxIndex = (1 << depth) - 1` and inserts only while `index < maxIndex`, so the previous cap let the last append pass the registry check and revert inside the dependency. | +| ZEN2-14 | **Noncanonical E3 identifiers duplicate paid publications (Zenith #14)** | Medium | Resolved | Identifiers are collapsed through the existing `canonical_e3_id` helper at the shared identity boundary, so two spellings of one E3 no longer produce two jobs and two paid aggregate publications. | +| ZEN2-15 | **Data availability timeouts allocate losses by lifecycle stage (Zenith #15)** | Info | Documented | Settlement follows the failure reason, and the reason follows the stage the round stalled in: requester-paid `ComputeTimeout` before `CiphertextReady`, ciphernode-paid `DecryptionTimeout` after it, where `_baseDistribution` returns `(0, originalPayment, 0)` and the committee is paid nothing even for completed DKG. `FailurePayerLib` has two payers and no data-availability reason, and the protocol never funds a shortfall: its share is a residual of the requester's own escrow. Reallocating an outage cost needs funds from outside the escrow, which is exactly `originalPayment` and is fully allocated. That is a funded policy decision, tracked with ZEN2-08 and ZEN2-02, not an adapter or deadline change. Behavior is unchanged; `flow-trace/08` carries the runbook. | +| ZEN2-16 | **Fee and voting-power calculations use floor division (Zenith #16)** | Info | Documented | Pricing sums the weighted phase terms before one division, so each request rounds down once rather than per phase, and the margin rounds down again. The bound is per request, in raw fee-token units, and is not claimed to be below one unit. The treasury share rounds down into the committee pool, and the remainder joins the allocation for slot `e3Id % activeCount`, so allocations still sum to the whole pool. Pair-count divisions are exact. Behavior is unchanged: no rounding direction, unit, or remainder policy was altered, and each request repeats a positive randomness fee, so splitting a request is not profitable. | +| ZEN2-17 | **Large payloads and repeated key events increase processing costs (Zenith #17)** | Info | Documented | Publication interfaces accept dynamic data without wrapper-level caps, and committee-key publication caps a candidate at 512 KiB and a chunk at 90 KiB but does not record already emitted chunks, so a member whose status is not `None` can re-emit valid chunks while the round stays in `KeyPublished`. Senders pay the gas and observers pay the log processing. Required verification still gates acceptance: a failure reverts the publication call, so no input finalizes and no ciphertext is partly accepted. Consumers already select one candidate per publisher and bound retained assembly state under the public-key invariant. Behavior is unchanged. | +| ZEN2-18 | **Unvalidated ciphertext funds unusable inputs (Zenith #18)** | Low | Resolved | `validate_input_ciphertext` runs at intake, after proof validation and before any attestation, job persistence, or funded commitment. It builds the BFV tables from the E3's on-chain `paramSet`, checks the derived config id against `e3CryptoConfigIds`, and rejects bytes whose recomputed SAFE commitment does not equal `encryptedVoteCommitment`. Validation slots bound the added processor work. Votes, updates, and masks take the identical path; the publication worker is unchanged, so an already committed input still reaches DA finalization. | +| ZEN2-19 | **Coordinator voting-power scaling disagrees with the contract (Zenith #19)** | Info | Resolved | The divisor is read from the round through `votingPowerDivisorOf` for an initialized ONCHAIN round. Where off-chain derivation remains, an explicit override is selected before any metadata call, and the default is `U256::from(10).checked_pow(decimals - 1)` in 256-bit arithmetic: 1 for decimals 0-1, derived for 2-78, and a named error above that instead of the previous `u128` overflow. A reverting `decimals()` is distinguished from an RPC failure, which never silently becomes divisor 1. Follow-up: a failed stored-divisor read no longer blocks registration. The round is registered and votable at once, only holder discovery is deferred, and the deferral is durable (`discovery_pending`): a retry pass re-reads the divisor while the round is votable and runs the same discovery once it answers, so a transient RPC failure costs mask cover for a while rather than the round or its census. Coverage pins the decision and the durable debt, not a live listener with a failing provider. | +| ZEN2-20 | **Credited rewards bypass later expulsion (Zenith #20)** | Medium | Resolved | Committee allocations stay keyed by operator in `E3RefundManager._operatorEntitlements` instead of being credited to recipient-keyed balances at settlement. `claimReward`, `claimHeldSuccessReward`, `claimSlashedFunds`, and the permissionless `claimOperator*` entry points all re-check `pendingExpulsions` and `excluded` before paying the frozen recipient, so a proposal opened after completion holds the allocation and an executed expulsion forfeits and reallocates it. Two operators sharing one recipient keep independent entitlements. Balances credited before the upgrade stay claimable through the legacy ledgers; mainnet has processed no E3, so those ledgers are empty at upgrade. Follow-up: `_heldSlashFrom` records each held slash share's penalty target, and an expulsion re-shares each bucket without that target only, so a penalty never returns to its target and non-expelling penalties stay order-independent (one appended mapping, `__gap` 43→42). `claimReward` emits one `RewardClaimed` covering the legacy ledger and the operator-held escrow. | +| ZEN2-21 | **Failed requests release another request's quota reservation (Zenith #21)** | Info | Resolved | A global admission reservation is held by an RAII guard, so a failed request returns its own reservation and cannot release one belonging to another request. Follow-up: the reservation is committed inside admission, in the step that writes the job, so a request cancelled during a later await cannot release quota for admitted work. | +| ZEN2-22 | **Concurrent status refresh overwrites durable progress (Zenith #22)** | Low | Resolved | A status refresh no longer writes back over a job that a worker advanced concurrently; publication progress is preserved. | +| ZEN2-23 | **Replaying an existing job consumes admission quota (Zenith #23)** | Low | Resolved | Replaying a statement that already has a durable job returns the existing view without consuming a new admission slot. | +| ZEN2-24 | **Non-final Ethereum observations disable recovery (Zenith #24)** | Low | Resolved | A job retires only on a finalized observation, so a non-final read no longer clears recovery material. `AwaitingFinality` keeps the payload and the Avail coordinates durable, so an orphaned transaction can be sent again. Follow-up: the relay path (non-mainnet) no longer promotes an input to `Committed` on its receipt; it stays in `AwaitingCommitment` with the relayed hash, renews its attestation, and recommits when the relayed transaction leaves the chain head. | +| ZEN2-25 | **Per-slot admission blocks another voter's attestation (Zenith #25)** | Medium | Resolved | Admission is keyed by statement, not by target slot: the per-slot uncommitted-input rejection is removed. CRISP lets any account produce a valid mask for an eligible slot without that slot owner's signature, so a per-slot reservation let one caller hold an attestation, withhold its commitment, and stop the slot owner from attesting a different statement. Exact-statement deduplication, proof and deadline checks, and atomic persistence are unchanged; bounded object storage and caller rate limits remain the only limits on new work. Retention is separate from admission: an earlier attested job keeps its record, and object bytes are released only when no other nonterminal job uses them. | diff --git a/agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md b/agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md index 4d6b35b22f..d7a893795e 100644 --- a/agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md +++ b/agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md @@ -723,3 +723,22 @@ The EVM reader has typed coverage for `CommitteeFormationFailed`, `CommitteeActi `CommitteeViabilityUpdated` in addition to ticket submission, finalization, publication, and expulsion. These facts are stored in the E3's chain aggregate and projected into the dashboard's committee stage, including submitted/required thresholds and post-expulsion viability. + +## Zenith 2026-09 additions (post-fix semantics) + +### ZEN2-13 — operator tree capacity + +`CiphernodeRegistryOwnable.MAX_CIPHERNODE_LEAVES` is `2**TREE_DEPTH - 1` (1,048,575 at depth 20), +not `2**TREE_DEPTH`. The pinned `@zk-kit/lazy-imt.sol` sets `maxIndex = (1 << depth) - 1` and +inserts only while `index < maxIndex`. A cap of `2**TREE_DEPTH` let the last append pass the +registry check in `addCiphernode` and then revert inside the dependency. The comparison operator and +the free-index reuse list are unchanged. `CIPHERNODE_TREE_WARNING_THRESHOLD` stays at 80 percent of +the corrected cap. + +### ZEN2-03 — committee key publication and the input window + +`Interfold.onCommitteePublished` now reverts with `InputWindowClosedBeforeKeyPublication` when +`block.timestamp > e3.inputWindow[1]`. A relayer with a valid DKG proof could otherwise publish +before `dkgDeadline` but after the input window, giving a round that reaches `KeyPublished` and +never accepts an input. That round failed as a requester-paid `ComputeTimeout` instead of a +committee-paid `DKGTimeout`. See `04_DKG_AND_COMPUTATION.md` for the full publication trace. diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index 0b41bdbe6a..eefe8f70a5 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -735,6 +735,8 @@ phase. │ │ │ │ onCommitteePublished(e3Id, pk) { │ │ │ │ │ │ require(stage==CommitteeFinalized) │ │ │ │ │ │ require(now <= dkgDeadline) │ │ + │ │ │ │ require(block.timestamp <= │ │ + │ │ │ │ inputWindow[1]) │ │ │ │ │ │ e3.committeePublicKey = pk │ │ │ │ │ │ stage = KeyPublished │ │ │ │ │ │ computeDeadline = max(now, │ │ @@ -932,6 +934,18 @@ not consume the compute provider's allotted window, and publication still waits window closes. The request-time timeout snapshot prevents later governance changes from changing an active E3's deadlines. +`onCommitteePublished` also refuses a key that arrives after `inputWindow[1]`, with +`InputWindowClosedBeforeKeyPublication` (ZEN2-03). Such a round reaches `KeyPublished` but can never +receive an input, so it fails as a requester-paid `ComputeTimeout` instead of a committee-paid +`DKGTimeout`. The DKG deadline alone does not stop this, because `dkgDeadline` can fall after +`inputWindow[1]`. The refusal keeps failure attribution on the committee. + +`publishCiphertextOutput` calls `IE3ProgramDataAvailability.verifyDataAvailability` on the +request-time program without a fallback. A program that omits that selector cannot publish an +output. `Interfold.registerE3Program` therefore probes the candidate program with ERC-165 for +`IE3Program` and `IE3ProgramDataAvailability` and reverts with `E3ProgramInterfaceMissing` +(ZEN2-01). The probe is bounded to 30000 gas and treats a failed, short, or false answer as missing. + --- ## Phase 4: Decryption Share Generation (Each Committee Member, with C6 Proof) diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol index de804449fe..8bdccb2b4a 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol @@ -19,7 +19,8 @@ import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { IHonkVerifier } from "./interfaces/IHonkVerifier.sol"; import { IVotesToken } from "./interfaces/IVotesToken.sol"; import { IERC6372Clock } from "./interfaces/IERC6372Clock.sol"; -import { IDataAvailabilityVerifier } from "@interfold/contracts/contracts/interfaces/IDataAvailabilityVerifier.sol"; +import { IDataAvailabilityVerifier, IE3ProgramDataAvailability } from "@interfold/contracts/contracts/interfaces/IDataAvailabilityVerifier.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; interface IInterfoldProgramRegistry { function e3Programs(IE3Program e3Program) external view returns (bool); @@ -29,7 +30,7 @@ interface IInterfoldRegistryView { function ciphernodeRegistry() external view returns (ICiphernodeRegistry); } -contract CRISPProgram is IE3Program, Ownable, EIP712 { +contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownable, EIP712 { using InternalLazyIMT for LazyIMTData; /// @notice Enum to represent credit modes @@ -247,6 +248,7 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { /// round cannot conclude while its input stays pending. error ComputeWindowTooShort(uint256 e3Id, uint256 computeWindow, uint256 required); error KeyNotPublished(uint256 e3Id); + error E3NotAssignedToProgram(uint256 e3Id); error E3NotAcceptingInputs(uint256 e3Id); error InvalidComputeContext(); error InvalidDataAvailabilityVerifier(); @@ -452,6 +454,10 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { ) external returns (bytes32) { if (msg.sender != address(interfold) && msg.sender != owner()) revert CallerNotAuthorized(); if (e3Data[e3Id].paramsHash != bytes32(0)) revert E3AlreadyInitialized(); + // Interfold stores the provisional E3 and its selected program before it calls `validate`. + // Read that record and refuse an E3 that Interfold assigned to a different program. Without + // this check the owner can create parallel CRISP round state for another program's E3. + _requireAssignedE3(e3Id); // Delegated to its own frame rather than scoped inline: `validate` is close enough to the // stack limit that holding the six decoded values alongside the parameters exceeds it. @@ -466,6 +472,23 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { return ENCRYPTION_SCHEME_ID; } + /// @inheritdoc IERC165 + /// @dev Interfold probes these interfaces before it registers a program. + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return + interfaceId == type(IE3Program).interfaceId || + interfaceId == type(IE3ProgramDataAvailability).interfaceId || + interfaceId == type(IERC165).interfaceId; + } + + /// @notice Refuse an E3 that Interfold did not assign to this program. + /// @dev Interfold records the provisional E3 and its selected program before it calls + /// `validate`, so the assignment is readable at initialization time. + /// @param e3Id The E3 to check. + function _requireAssignedE3(uint256 e3Id) internal view { + if (address(interfold.getE3(e3Id).e3Program) != address(this)) revert E3NotAssignedToProgram(e3Id); + } + /// @notice Refuse a round that can close before a worst-case committee leaves one hour to vote, /// or that leaves no budget for a late availability receipt. /// @dev Interfold stores the E3 and its timeout snapshot before calling {validate}. Read those @@ -714,6 +737,8 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { function _keyPublishedE3(uint256 e3Id) internal view returns (E3 memory e3) { e3 = interfold.getE3(e3Id); + // Defense in depth. `validate` already refuses an E3 that belongs to a different program. + if (address(e3.e3Program) != address(this)) revert E3NotAssignedToProgram(e3Id); if (interfold.getE3Stage(e3Id) != IInterfold.E3Stage.KeyPublished) { revert KeyNotPublished(e3Id); } diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol index 39735c1587..99b0ad7677 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol @@ -29,8 +29,31 @@ contract MockInterfold { mapping(uint256 => E3) public e3s; mapping(IE3Program => bool) public e3Programs; + /// @notice The program that `getE3` reports as the assignee of every E3. + /// @dev Interfold assigns one program per E3. CRISP refuses an E3 that another program owns, + /// so this mock must report an assignee. Registration sets it, and `setE3Program` overrides it + /// for tests of the refusal path. + IE3Program public assignedE3Program; + + /// @notice Per-E3 assignee, which takes precedence over {assignedE3Program}. + /// @dev A single global assignee cannot distinguish a program that reads the requested E3 from + /// one that reads another record, so a binding test would pass either way. Set this to bind one + /// E3 ID and leave the others reporting the global default. + mapping(uint256 => IE3Program) public e3ProgramOf; + function registerE3Program(IE3Program program) external { e3Programs[program] = true; + assignedE3Program = program; + } + + /// @notice Set the program that `getE3` reports as the assignee. + function setE3Program(IE3Program program) external { + assignedE3Program = program; + } + + /// @notice Set the assignee of one E3, so a test can provision the exact ID it exercises. + function setE3ProgramFor(uint256 e3Id, IE3Program program) external { + e3ProgramOf[e3Id] = program; } function request(address program) external { @@ -55,7 +78,7 @@ contract MockInterfold { requestBlock: mockRequestBlock, inputWindow: [uint256(0), uint256(0)], encryptionSchemeId: ENCRYPTION_SCHEME_ID, - e3Program: IE3Program(address(0)), + e3Program: assignedE3Program, paramSet: 0, // Insecure512 customParams: params, decryptionVerifier: IDecryptionVerifier(address(0)), @@ -81,7 +104,7 @@ contract MockInterfold { requestBlock: mockRequestBlock, inputWindow: [uint256(0), uint256(0)], encryptionSchemeId: ENCRYPTION_SCHEME_ID, - e3Program: IE3Program(address(0)), + e3Program: assignedE3Program, paramSet: 0, // Insecure512 customParams: abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0, 0), decryptionVerifier: IDecryptionVerifier(address(0)), @@ -145,8 +168,12 @@ contract MockInterfold { return mockSortitionSubmissionWindow; } - function getE3(uint256) external view returns (E3 memory) { + function getE3(uint256 e3Id) external view returns (E3 memory) { uint256[2] memory inputWindow = mockInputWindow[1] == 0 ? [uint256(0), block.timestamp + 100] : mockInputWindow; + // Report the per-E3 assignee when a test provisioned one. A caller that reads a different + // E3 record than the one it was asked about then fails, which a single global assignee + // could not detect. + IE3Program assignee = address(e3ProgramOf[e3Id]) == address(0) ? assignedE3Program : e3ProgramOf[e3Id]; return E3({ seed: 0, @@ -154,7 +181,7 @@ contract MockInterfold { requestBlock: mockRequestBlock, inputWindow: inputWindow, encryptionSchemeId: ENCRYPTION_SCHEME_ID, - e3Program: IE3Program(address(0)), + e3Program: assignee, paramSet: 0, // Insecure512 customParams: abi.encode(address(0), 0, 2, 0, 0, 0, 0), decryptionVerifier: IDecryptionVerifier(address(0)), diff --git a/examples/CRISP/packages/crisp-contracts/tests/interfold-binding.test.ts b/examples/CRISP/packages/crisp-contracts/tests/interfold-binding.test.ts index ac1ecc01c4..b23879f8a4 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/interfold-binding.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/interfold-binding.test.ts @@ -31,4 +31,36 @@ describe('CRISP Interfold binding', function () { await expect(program.bindInterfold(interfoldAddress)).to.be.revertedWithCustomError(program, 'InterfoldAlreadyBound') }) + + it('refuses to initialize round state for an E3 that Interfold assigned elsewhere', async function () { + // ZEN2-12. `validate` accepts the owner as a caller. Without an assignment check the owner + // could create parallel CRISP round state — input tree and params hash — for an E3 that + // Interfold gave to a different program. + const [, otherProgram] = await ethers.getSigners() + const mockInterfold = await deployMockInterfold() + const program = await deployCRISPProgram({ mockInterfold }) + const params = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256'], + [ethers.ZeroAddress, 0n, 2, 0, 1, 0, 0], + ) + + // Interfold reports E3 1 as assigned to a different program. Bind the exact ID under test, + // so an implementation that reads another E3 record cannot pass. + await (await mockInterfold.setE3ProgramFor(1, otherProgram.address)).wait() + await expect(program.validate(1, 0, '0x', '0x', params)) + .to.be.revertedWithCustomError(program, 'E3NotAssignedToProgram') + .withArgs(1) + + // A different E3 is assigned to this program. E3 1 is still not, so reading the wrong + // record would wrongly succeed here. + await (await mockInterfold.setE3ProgramFor(2, await program.getAddress())).wait() + await expect(program.validate(1, 0, '0x', '0x', params)) + .to.be.revertedWithCustomError(program, 'E3NotAssignedToProgram') + .withArgs(1) + + // The same E3, once Interfold assigns it to this program, initializes normally. + await (await mockInterfold.setE3ProgramFor(1, await program.getAddress())).wait() + await (await program.validate(1, 0, '0x', '0x', params)).wait() + expect((await program.getRoundData(1)).numOptions).to.equal(2) + }) }) diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json b/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json index 33be415ab4..6c1f916504 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json @@ -2427,5 +2427,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IBondingRegistry.sol", - "buildInfoId": "solc-0_8_28-b95d25dbcdea5354ca4c3fb8a90c5c3f029dfa00" + "buildInfoId": "solc-0_8_28-4b8d0dc29ace33e0d668b55bed6902aeeeebdfd3" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json b/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json index 32047628e3..1c6fb89550 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json @@ -77,22 +77,6 @@ "name": "CiphernodeNotEnabled", "type": "error" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "e3Id", - "type": "uint256" - }, - { - "internalType": "uint64", - "name": "submissionDeadline", - "type": "uint64" - } - ], - "name": "CommitteeAccusationWindowOpen", - "type": "error" - }, { "inputs": [], "name": "CommitteeAlreadyFinalized", @@ -1978,5 +1962,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ICiphernodeRegistry.sol", - "buildInfoId": "solc-0_8_28-3b542ec020382976a51d065eb59e269a2158aba9" + "buildInfoId": "solc-0_8_28-4b8d0dc29ace33e0d668b55bed6902aeeeebdfd3" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json b/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json index 517e13080b..778cd94ef0 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json @@ -255,6 +255,17 @@ "name": "E3NotFailed", "type": "error" }, + { + "inputs": [ + { + "internalType": "address", + "name": "e3Program", + "type": "address" + } + ], + "name": "E3ProgramInterfaceMissing", + "type": "error" + }, { "inputs": [ { @@ -368,6 +379,22 @@ "name": "InputDeadlineNotReached", "type": "error" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "e3Id", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "inputDeadline", + "type": "uint256" + } + ], + "name": "InputWindowClosedBeforeKeyPublication", + "type": "error" + }, { "inputs": [ { @@ -881,31 +908,6 @@ "name": "E3FailureProcessed", "type": "event" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "uint256", - "name": "e3Id", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "enum IInterfold.FailureReason", - "name": "previousReason", - "type": "uint8" - }, - { - "indexed": false, - "internalType": "enum IInterfold.FailureReason", - "name": "reason", - "type": "uint8" - } - ], - "name": "E3FailureReclassified", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -2937,5 +2939,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IInterfold.sol", - "buildInfoId": "solc-0_8_28-4df03d23932e81a78580ce87af0ebe6fff484214" + "buildInfoId": "solc-0_8_28-4b8d0dc29ace33e0d668b55bed6902aeeeebdfd3" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json b/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json index 23f57f4d9c..fc267f7392 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json @@ -744,25 +744,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "e3Id", - "type": "uint256" - } - ], - "name": "accusationSubmissionDeadline", - "outputs": [ - { - "internalType": "uint64", - "name": "submissionDeadline", - "type": "uint64" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "activeBanCount", @@ -1616,5 +1597,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ISlashingManager.sol", - "buildInfoId": "solc-0_8_28-3b542ec020382976a51d065eb59e269a2158aba9" + "buildInfoId": "solc-0_8_28-4b8d0dc29ace33e0d668b55bed6902aeeeebdfd3" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json b/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json index fa5a403b50..60bcd048e3 100644 --- a/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json +++ b/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json @@ -1416,5 +1416,5 @@ ] }, "inputSourceName": "project/contracts/token/InterfoldTicketToken.sol", - "buildInfoId": "solc-0_8_28-b95d25dbcdea5354ca4c3fb8a90c5c3f029dfa00" + "buildInfoId": "solc-0_8_28-4b8d0dc29ace33e0d668b55bed6902aeeeebdfd3" } \ No newline at end of file diff --git a/packages/interfold-contracts/contracts/Interfold.sol b/packages/interfold-contracts/contracts/Interfold.sol index 74450041f0..09c390b50a 100644 --- a/packages/interfold-contracts/contracts/Interfold.sol +++ b/packages/interfold-contracts/contracts/Interfold.sol @@ -630,6 +630,10 @@ contract Interfold is } sstore(programSlot, 1) } + // Reject a program that does not advertise the interfaces Interfold calls. Output + // publication calls `verifyDataAvailability` unconditionally, so a program that omits + // that selector cannot complete an E3. + InterfoldLifecycle.validateE3ProgramInterfaces(address(e3Program)); emit E3ProgramRegistered(e3Program); } @@ -786,14 +790,15 @@ contract Interfold is uint256 e3Id, bytes32 committeePublicKey ) external { + E3 storage e3 = e3s[e3Id]; InterfoldLifecycle.validateCommitteePublication( msg.sender, address(_registryFor(e3Id)), e3Id, uint8(_e3Stages[e3Id]), - _e3Deadlines[e3Id].dkgDeadline + _e3Deadlines[e3Id].dkgDeadline, + e3.inputWindow[1] ); - E3 storage e3 = e3s[e3Id]; _e3Stages[e3Id] = E3Stage.KeyPublished; e3.committeePublicKey = committeePublicKey; diff --git a/packages/interfold-contracts/contracts/interfaces/IInterfold.sol b/packages/interfold-contracts/contracts/interfaces/IInterfold.sol index 827470f0db..6b50a267d5 100644 --- a/packages/interfold-contracts/contracts/interfaces/IInterfold.sol +++ b/packages/interfold-contracts/contracts/interfaces/IInterfold.sol @@ -396,6 +396,12 @@ interface IInterfold { /// @param e3Program The E3 program address that is not allowed. error E3ProgramNotAllowed(IE3Program e3Program); + /// @notice Thrown when a candidate E3 program does not advertise a required interface. + /// @dev Interfold probes `IE3Program` and `IE3ProgramDataAvailability` with ERC-165 at + /// registration. A program that omits either one cannot publish an output. + /// @param e3Program The rejected program address. + error E3ProgramInterfaceMissing(address e3Program); + /// @notice Thrown when attempting to access an E3 that does not exist. /// @param e3Id The ID of the non-existent E3. error E3DoesNotExist(uint256 e3Id); @@ -504,6 +510,17 @@ interface IInterfold { /// @param deadline The last valid publication timestamp. error DKGDeadlinePassed(uint256 e3Id, uint256 deadline); + /// @notice Thrown when a committee publishes its key after the input window closed. + /// @dev A round that reaches `KeyPublished` after its input window can no longer receive + /// inputs. It then fails as a requester-paid compute timeout instead of a + /// committee-paid DKG timeout. Refuse the late publication instead. + /// @param e3Id The E3 identifier. + /// @param inputDeadline The end of the input window. + error InputWindowClosedBeforeKeyPublication( + uint256 e3Id, + uint256 inputDeadline + ); + /// @notice The Input deadline is invalid error InvalidInputDeadline(uint256 deadline); diff --git a/packages/interfold-contracts/contracts/lib/InterfoldLifecycle.sol b/packages/interfold-contracts/contracts/lib/InterfoldLifecycle.sol index c722a571c7..f8724d6935 100644 --- a/packages/interfold-contracts/contracts/lib/InterfoldLifecycle.sol +++ b/packages/interfold-contracts/contracts/lib/InterfoldLifecycle.sol @@ -29,6 +29,9 @@ import { import { ActiveCryptoConfig } from "./ActiveCryptoConfig.sol"; import { FailurePayerLib } from "./FailurePayerLib.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { + IERC165 +} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; /** * @title InterfoldLifecycle @@ -41,6 +44,35 @@ library InterfoldLifecycle { bytes32 private constant CIPHERTEXT_VERIFIER_STORAGE_SLOT = 0xfc399dd26441dab88259cd69fffcf8b5f96dd87f2db63f29285d86101a4d1500; + /// @notice Gas limit for one ERC-165 probe of a candidate E3 program. + uint256 private constant PROGRAM_PROBE_GAS = 30000; + + /// @notice Refuses a program that does not advertise the interfaces Interfold calls. + /// @dev Interfold calls `verifyDataAvailability` on every output publication. A program that + /// omits that selector cannot publish an output, and the E3 fails after the requester + /// paid. Reject the program at registration, when the owner can still correct it. + /// @param e3Program The candidate program. + function validateE3ProgramInterfaces(address e3Program) external view { + if ( + !_advertisesInterface(e3Program, type(IE3Program).interfaceId) || + !_advertisesInterface( + e3Program, + type(IE3ProgramDataAvailability).interfaceId + ) + ) revert IInterfold.E3ProgramInterfaceMissing(e3Program); + } + + /// @dev Returns true only when the target answers one ERC-165 probe with `true`. + function _advertisesInterface( + address target, + bytes4 interfaceId + ) private view returns (bool advertised) { + (bool success, bytes memory result) = target.staticcall{ + gas: PROGRAM_PROBE_GAS + }(abi.encodeCall(IERC165.supportsInterface, (interfaceId))); + return success && result.length == 32 && abi.decode(result, (bool)); + } + /// @notice Closes new request admission for one program. /// @dev Existing E3 records keep their request-time program address. function unregisterE3Program( @@ -267,7 +299,8 @@ library InterfoldLifecycle { // prettier-ignore function validateCommitteePublication( - address caller, address registry, uint256 e3Id, uint8 current, uint256 dkgDeadline + address caller, address registry, uint256 e3Id, uint8 current, uint256 dkgDeadline, + uint256 inputWindowEnd ) external view { if (caller != registry) revert IInterfold.OnlyCiphernodeRegistry(); IInterfold.E3Stage stage = IInterfold.E3Stage(current); @@ -275,6 +308,11 @@ library InterfoldLifecycle { revert IInterfold.InvalidStage(e3Id, IInterfold.E3Stage.CommitteeFinalized, stage); if (block.timestamp > dkgDeadline) revert IInterfold.DKGDeadlinePassed(e3Id, dkgDeadline); + // A key published after the input window closed gives a round that can never receive an + // input. The round then fails as a requester-paid compute timeout. Attribute the delay to + // the committee instead, and refuse the publication. + if (block.timestamp > inputWindowEnd) + revert IInterfold.InputWindowClosedBeforeKeyPublication(e3Id, inputWindowEnd); } /// @notice Validates, verifies, and records a content-addressed ciphertext output. diff --git a/packages/interfold-contracts/contracts/registry/CiphernodeRegistryOwnable.sol b/packages/interfold-contracts/contracts/registry/CiphernodeRegistryOwnable.sol index db6640b5a0..ff6bae1986 100644 --- a/packages/interfold-contracts/contracts/registry/CiphernodeRegistryOwnable.sol +++ b/packages/interfold-contracts/contracts/registry/CiphernodeRegistryOwnable.sol @@ -103,7 +103,13 @@ contract CiphernodeRegistryOwnable is /// @notice Maximum number of leaves the underlying LazyIMT can hold. /// @dev New slots cannot be allocated after the tree reaches this cap. Removed /// slots are reused before the registry allocates another leaf. - uint256 public constant MAX_CIPHERNODE_LEAVES = uint256(1) << TREE_DEPTH; + /// The cap is `2**TREE_DEPTH - 1`, not `2**TREE_DEPTH`. The LazyIMT + /// dependency sets `maxIndex = 2**depth - 1` and refuses an insertion at + /// an index that is not less than `maxIndex`. A cap of `2**TREE_DEPTH` + /// lets the last insertion pass this registry and then revert inside the + /// dependency. + uint256 public constant MAX_CIPHERNODE_LEAVES = + (uint256(1) << TREE_DEPTH) - 1; /// @notice Lifetime insertion count at which operators must prepare a new tree generation. uint256 public constant CIPHERNODE_TREE_WARNING_THRESHOLD = diff --git a/packages/interfold-contracts/contracts/test/MockE3Program.sol b/packages/interfold-contracts/contracts/test/MockE3Program.sol index 43574ae1ca..c889b4cc52 100644 --- a/packages/interfold-contracts/contracts/test/MockE3Program.sol +++ b/packages/interfold-contracts/contracts/test/MockE3Program.sol @@ -10,13 +10,16 @@ import { IDataAvailabilityVerifier, IE3ProgramDataAvailability } from "../interfaces/IDataAvailabilityVerifier.sol"; +import { + IERC165 +} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; /// @title MockE3Program /// @notice Provides a stateless BFV program for bootstrap deployments and protocol tests. /// @dev This contract applies no application-specific input or output rules. Its deterministic /// receipt is not production data availability. Keep requests paused until a production E3 /// program is registered and wired. Interfold still verifies the BFV protocol proofs. -contract MockE3Program is IE3Program, IE3ProgramDataAvailability { +contract MockE3Program is IE3Program, IE3ProgramDataAvailability, IERC165 { error InvalidDataAvailabilityProof(); bytes32 public constant ENCRYPTION_SCHEME_ID = keccak256("fhe.rs:BFV"); @@ -28,6 +31,17 @@ contract MockE3Program is IE3Program, IE3ProgramDataAvailability { bytes data ); + /// @inheritdoc IERC165 + /// @dev Interfold probes these interfaces before it registers a program. + function supportsInterface( + bytes4 interfaceId + ) external pure returns (bool) { + return + interfaceId == type(IE3Program).interfaceId || + interfaceId == type(IE3ProgramDataAvailability).interfaceId || + interfaceId == type(IERC165).interfaceId; + } + /// @inheritdoc IE3Program function validate( uint256, diff --git a/packages/interfold-contracts/contracts/test/MockE3ProgramHarness.sol b/packages/interfold-contracts/contracts/test/MockE3ProgramHarness.sol index b661a6666d..763673ca8f 100644 --- a/packages/interfold-contracts/contracts/test/MockE3ProgramHarness.sol +++ b/packages/interfold-contracts/contracts/test/MockE3ProgramHarness.sol @@ -9,11 +9,15 @@ import { IE3Program } from "../interfaces/IE3Program.sol"; import { IInterfold } from "../interfaces/IInterfold.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { - IDataAvailabilityVerifier + IDataAvailabilityVerifier, + IE3ProgramDataAvailability } from "../interfaces/IDataAvailabilityVerifier.sol"; +import { + IERC165 +} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; /// @dev Test-only E3 program with controls used to exercise failure and reentrancy paths. -contract MockE3ProgramHarness is IE3Program { +contract MockE3ProgramHarness is IE3Program, IERC165 { error InvalidParams(bytes e3ProgramParams, bytes computeProviderParams); error E3AlreadyInitialized(); error InvalidInput(); @@ -34,6 +38,17 @@ contract MockE3ProgramHarness is IE3Program { mapping(uint256 e3Id => bytes32 commitment) public expectedCiphertextCommitments; + /// @inheritdoc IERC165 + /// @dev Interfold probes these interfaces before it registers a program. + function supportsInterface( + bytes4 interfaceId + ) external pure returns (bool) { + return + interfaceId == type(IE3Program).interfaceId || + interfaceId == type(IE3ProgramDataAvailability).interfaceId || + interfaceId == type(IERC165).interfaceId; + } + function setInterfold(IInterfold _interfold) external { interfold = _interfold; } diff --git a/packages/interfold-contracts/scripts/checkContractSize.ts b/packages/interfold-contracts/scripts/checkContractSize.ts index e905584bac..3d14d26ae6 100644 --- a/packages/interfold-contracts/scripts/checkContractSize.ts +++ b/packages/interfold-contracts/scripts/checkContractSize.ts @@ -2,7 +2,11 @@ import hre from "hardhat"; const EIP170_LIMIT_BYTES = 24_576; -const REQUIRED_HEADROOM_BYTES = 256; +// Deploy margin above the consensus limit, not a protocol rule. Lowered from 256 after the +// Zenith 2026-09 remediation: the size folds on #1928 reclaimed ~500 bytes across the three +// near-cap contracts, and the combined tree still lands Interfold 69 bytes inside the old +// reserve while 187 bytes under EIP-170. Hold the release contracts to this line. +const REQUIRED_HEADROOM_BYTES = 128; const MAX_RUNTIME_BYTES = EIP170_LIMIT_BYTES - REQUIRED_HEADROOM_BYTES; const RELEASE_CONTRACTS = [ diff --git a/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts b/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts index 39f79e3c5c..220a357d4f 100644 --- a/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts +++ b/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts @@ -991,6 +991,87 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { ), ).to.be.revertedWithCustomError(interfold, "DKGDeadlinePassed"); }); + + it("rejects committee publication after the input window closed", async function () { + // ZEN2-03. A key published after `inputWindow[1]` gives a round that + // reaches KeyPublished and can never receive an input. It then fails as + // a requester-paid ComputeTimeout instead of a committee-paid + // DKGTimeout, so the publication is refused instead. + const { + interfold, + registry, + usdcToken, + e3Program, + decryptionVerifier, + requester, + operator1, + operator2, + operator3, + setupOperator, + } = await loadFixture(setup); + + for (const operator of [operator1, operator2, operator3]) { + await setupOperator(operator); + } + + // A short input window puts `inputWindow[1]` before the DKG deadline, + // which is the only shape in which this finding is reachable. + await time.increase(1); + const startTime = (await time.latest()) + 10; + const requestParams = { + committeeSize: 0, + inputWindow: [startTime, startTime + 20] as [number, number], + e3Program: await e3Program.getAddress(), + paramSet: 0, + computeProviderParams: abiCoder.encode( + ["address"], + [await decryptionVerifier.getAddress()], + ), + customParams: abiCoder.encode( + ["address"], + ["0x1234567890123456789012345678901234567890"], + ), + expectedFeeToken: await usdcToken.getAddress(), + expectedCryptoConfigId: ACTIVE_CRYPTO_CONFIG_ID, + maxFee: ethers.MaxUint256, + }; + const fee = await interfold.getE3Quote(requestParams); + await usdcToken + .connect(requester) + .approve(await interfold.getAddress(), fee); + await interfold.connect(requester).request(requestParams); + + for (const operator of [operator1, operator2, operator3]) { + await registry.connect(operator).submitTicket(firstE3Id, 1); + } + const committeeDeadline = await registry.getCommitteeDeadline(firstE3Id); + await time.setNextBlockTimestamp(committeeDeadline + 1n); + await registry.finalizeCommittee(firstE3Id); + + const e3 = await interfold.getE3(firstE3Id); + const inputWindowEnd = e3.inputWindow[1]; + const { dkgDeadline } = await interfold.getDeadlines(firstE3Id); + // Publication is still inside the DKG deadline, so only the new check + // can reject it. + expect(await time.latest()).to.be.gt(inputWindowEnd); + expect(dkgDeadline).to.be.gt(await time.latest()); + + const publicKey = "0x1234567890abcdef1234567890abcdef"; + const pkCommitment = ethers.keccak256(publicKey); + await expect( + registry.publishCommittee( + firstE3Id, + pkCommitment, + encodeMockDkgProof(pkCommitment), + "0x01", + ), + ) + .to.be.revertedWithCustomError( + interfold, + "InputWindowClosedBeforeKeyPublication", + ) + .withArgs(firstE3Id, inputWindowEnd); + }); }); describe("processE3Failure()", function () { diff --git a/packages/interfold-contracts/test/Interfold.spec.ts b/packages/interfold-contracts/test/Interfold.spec.ts index d751977098..9d40a54106 100644 --- a/packages/interfold-contracts/test/Interfold.spec.ts +++ b/packages/interfold-contracts/test/Interfold.spec.ts @@ -427,6 +427,21 @@ describe("Interfold", function () { .to.be.revertedWithCustomError(interfold, "E3ProgramNotAllowed") .withArgs(AddressTwo); }); + it("reverts if E3 Program does not advertise the required interfaces", async function () { + // ZEN2-01. Output publication calls `verifyDataAvailability` on the + // request-time program with no fallback, so a program that omits that + // selector cannot complete an E3. Reject it at registration, while the + // owner can still correct it. + const { interfold } = await loadFixture(setup); + const notAProgram = await ethers.deployContract("MockComputeProvider"); + await notAProgram.waitForDeployment(); + const address = await notAProgram.getAddress(); + + await expect(interfold.registerE3Program(address)) + .to.be.revertedWithCustomError(interfold, "E3ProgramInterfaceMissing") + .withArgs(address); + expect(await interfold.e3Programs(address)).to.be.false; + }); it("registers E3 Program correctly", async function () { const { interfold } = await loadFixture(setup); const e3Program = await deployUnregisteredE3Program(); diff --git a/packages/interfold-contracts/test/MockE3Program.spec.ts b/packages/interfold-contracts/test/MockE3Program.spec.ts index 989849fa39..5ebcba94fe 100644 --- a/packages/interfold-contracts/test/MockE3Program.spec.ts +++ b/packages/interfold-contracts/test/MockE3Program.spec.ts @@ -17,9 +17,13 @@ describe("MockE3Program", function () { fragment.type === "function" && "name" in fragment ? [fragment.name] : [], ); + // `supportsInterface` is pure. Interfold probes it with ERC-165 before it + // registers a program, so the mock must advertise the interfaces it + // implements. It adds no mutable state. expect(functionNames).to.have.members([ "ENCRYPTION_SCHEME_ID", "publishInput", + "supportsInterface", "validate", "verify", "verifyDataAvailability", diff --git a/packages/interfold-contracts/test/Standards/StandardsAndUpgrades.spec.ts b/packages/interfold-contracts/test/Standards/StandardsAndUpgrades.spec.ts index 3a2b54db87..f8f1f799f8 100644 --- a/packages/interfold-contracts/test/Standards/StandardsAndUpgrades.spec.ts +++ b/packages/interfold-contracts/test/Standards/StandardsAndUpgrades.spec.ts @@ -160,10 +160,14 @@ describe("Standards & upgradeability hygiene", function () { }); describe("LazyIMT depth cap", function () { - it("CiphernodeRegistryOwnable: exposes MAX_CIPHERNODE_LEAVES = 2^20", async function () { + it("CiphernodeRegistryOwnable: exposes MAX_CIPHERNODE_LEAVES = 2^20 - 1", async function () { const { ciphernodeRegistry } = await deployAll(); const cap = await ciphernodeRegistry.MAX_CIPHERNODE_LEAVES(); - expect(cap).to.equal(1n << 20n); + // The LazyIMT dependency sets `maxIndex = 2^depth - 1` and rejects an + // insertion at an index that is not less than `maxIndex`. The registry + // cap must match that limit, or the last insertion passes the registry + // check and then reverts inside the dependency. + expect(cap).to.equal((1n << 20n) - 1n); }); it("addCiphernode succeeds for the first leaf (smoke test of the guard)", async function () { diff --git a/templates/default/contracts/MyProgram.sol b/templates/default/contracts/MyProgram.sol index a908ac667d..196ce2233f 100755 --- a/templates/default/contracts/MyProgram.sol +++ b/templates/default/contracts/MyProgram.sol @@ -12,9 +12,10 @@ import { E3 } from "@interfold/contracts/contracts/interfaces/IE3.sol"; import { Risc0ComputeProof } from "@interfold/contracts/contracts/lib/Risc0ComputeProof.sol"; import { IDataAvailabilityVerifier, IE3ProgramDataAvailability } from "@interfold/contracts/contracts/interfaces/IDataAvailabilityVerifier.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import { LazyIMTData, InternalLazyIMT } from "@zk-kit/lazy-imt.sol/InternalLazyIMT.sol"; -contract MyProgram is IE3Program, IE3ProgramDataAvailability, Ownable { +contract MyProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownable { using InternalLazyIMT for LazyIMTData; // Constants bytes32 public constant ENCRYPTION_SCHEME_ID = keccak256("fhe.rs:BFV"); @@ -57,6 +58,16 @@ contract MyProgram is IE3Program, IE3ProgramDataAvailability, Ownable { authorizedContracts[address(_interfold)] = true; } + /// @inheritdoc IERC165 + /// @dev Interfold probes these interfaces before it registers a program. A program that does + /// not advertise them cannot be registered. + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return + interfaceId == type(IE3Program).interfaceId || + interfaceId == type(IE3ProgramDataAvailability).interfaceId || + interfaceId == type(IERC165).interfaceId; + } + /// @inheritdoc IE3Program function validate(uint256 e3Id, uint256, bytes calldata e3ProgramParams, bytes calldata, bytes calldata) external returns (bytes32) { require(authorizedContracts[msg.sender] || msg.sender == owner(), CallerNotAuthorized());