Skip to content

Commit ae8541d

Browse files
committed
fix(sortition): derive committee seed after request
1 parent 1a29064 commit ae8541d

38 files changed

Lines changed: 801 additions & 181 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ sequenceDiagram
224224
Interfold->>E3Program: validate(e3ProgramParams)
225225
Interfold->>ComputeProvider: validate(computeProviderParams)
226226
ComputeProvider-->>Interfold: decryptionVerifier
227-
Interfold->>CiphernodeRegistry: requestCommittee(e3Id, seed, threshold)
227+
Interfold->>CiphernodeRegistry: requestCommittee(e3Id, legacySeed, threshold)
228+
CiphernodeRegistry->>CiphernodeRegistry: commit future entropy block
228229
CiphernodeRegistry-->>Interfold: success
229230
Interfold-->>Users: e3Id, E3 struct
230231

agent/INVARIANTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ skip-proof feature containment (`pnpm check:invariants`, baselines in
7878
`N <= numActiveOperators` at `requestCommittee`. — `flow-trace/03`
7979
- Sortition score is deterministic and identical on- and off-chain:
8080
`score = keccak256(address ‖ ticket ‖ e3Id ‖ seed)`,
81-
`seed = uint256(keccak256(block.prevrandao, e3Id))`; top-N lowest win. — `flow-trace/03`
81+
`seed = uint256(keccak256(blockhash(entropyBlock), e3Id))`; top-N lowest win. `entropyBlock` is
82+
the block after the request. The requester must commit the paid request before that block hash
83+
exists. EIP-2935 extends the lookup window where the chain supports it. The E3 computation seed
84+
remains separate. — `flow-trace/03`
8285
- **Per-E3 sortition state is immutable:** for request timestamp `T`, the request-time eligible
8386
count, each operator's eligibility, and each ticket balance come from `T-1`. The request also
8487
freezes `ticketPrice`, and Rust consumes the same timepoint and price. Current registration and

agent/flow-trace/00_INDEX.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ _Found during source-code cross-referencing of these trace documents._
207207

208208
| # | Concern | Severity | Detail |
209209
| ---- | --------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
210+
| Z-05 | **Requester seed grinding** | Resolved | Committee ranking uses a seed derived from a block committed after the paid E3 request. The request exposes only the separate E3 computation seed. Rust waits for the delayed seed, and the first ticket stores the same value on-chain. The requester cannot inspect a committee seed and revert the request atomically. |
210211
| 1 | **Deregister-before-slash race** | Resolved | One unresolved-proposal counter covers both lanes. Every authorized current or retained historical slashing manager participates in the BondingRegistry exit gate, so rotation cannot release collateral belonging to an in-flight E3. Ticket withdrawal, license unbonding, deregistration, and exit claims remain blocked until execution, upheld appeal, or permissionless expiry terminates the proposal. |
211212
| 2 | **Committee publication decentralized** | Resolved | `publishCommittee()` is permissionless. Off-chain role selection chooses the active aggregator, while on-chain C5 proof verification and the single-publish guard prevent invalid or duplicate committee publication. |
212213
| 3 | **`gracePeriod` is dead code** | Medium | `gracePeriod` is stored and validated during config updates but never actually used in any timeout check. Either the deadlines already bake in sufficient buffer, or this is a missing feature. |

agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,7 @@ Requester calls: Interfold.request({
8282
│ │ request-time Interfold, committee registry, bonding registry,
8383
│ │ and slashing manager
8484
│ ├─ seed = uint256(keccak256(block.prevrandao, e3Id))
85-
│ │ → Shared per-E3 ticket-scoring input only; not BFV key material and
86-
│ │ not relied upon for cryptographic unpredictability.
85+
│ │ → Shared input for the E3 computation. Committee selection does not use it.
8786
│ │
8887
│ ├─ encryptionSchemeId = e3Program.validate(
8988
│ │ e3Id, seed, e3ProgramParams, computeProviderParams, customParams
@@ -105,11 +104,12 @@ Requester calls: Interfold.request({
105104
│ └─ _e3Stages[e3Id] = E3Stage.Requested
106105
107106
├─ COMMITTEE REQUEST:
108-
│ ├─ ciphernodeRegistry.requestCommittee(e3Id, seed, threshold)
107+
│ ├─ ciphernodeRegistry.requestCommittee(e3Id, legacySeed, threshold)
109108
│ │ │
110109
│ │ │ ┌─── CiphernodeRegistryOwnable ──────────────────────┐
111110
│ │ │ │ │
112-
│ │ │ │ requestCommittee(e3Id, seed, threshold) { │
111+
│ │ │ │ requestCommittee(e3Id, legacySeed, threshold) { │
112+
│ │ │ │ → legacySeed is ignored for ticket sortition │
113113
│ │ │ │ 1. require(!committees[e3Id].initialized) │
114114
│ │ │ │ 2. Snapshot request-time Interfold, bonding, │
115115
│ │ │ │ slashing manager, and fold verifier │
@@ -121,7 +121,8 @@ Requester calls: Interfold.request({
121121
│ │ │ │ → Count and submissions use one boundary │
122122
│ │ │ │ 4. committees[e3Id] = Committee { │
123123
│ │ │ │ initialized: true, │
124-
│ │ │ │ seed: seed, │
124+
│ │ │ │ seed: unresolved, │
125+
│ │ │ │ entropyBlock: block.number + 1, │
125126
│ │ │ │ requestBlock: block.timestamp, // H-26 │
126127
│ │ │ │ committeeDeadline: │
127128
│ │ │ │ block.timestamp + sortitionWindow, │
@@ -136,8 +137,9 @@ Requester calls: Interfold.request({
136137
│ │ │ │ → Only nodes in tree at request time eligible │
137138
│ │ │ │ 7. Emit DkgFoldAttestationContextEstablished( │
138139
│ │ │ │ e3Id, registry, foldVerifier) │
139-
│ │ │ │ Emit CommitteeRequested(e3Id, seed, threshold,│
140-
│ │ │ │ requestBlock, committeeDeadline, │
140+
│ │ │ │ Emit CommitteeRequested(e3Id, entropyBlock, │
141+
│ │ │ │ threshold, requestBlock, │
142+
│ │ │ │ committeeDeadline, │
141143
│ │ │ │ ticketPrice) │
142144
│ │ │ │ BondingRegistry records this request-time │
143145
│ │ │ │ registry as the E3's obligation owner │
@@ -171,6 +173,11 @@ the latest snapshot then replay in order and add any newer E3 contexts.
171173
CiphernodeRegistrySolReader decodes DkgFoldAttestationContextEstablished
172174
173175
└─ Stores the E3's request-time registry and verifier for signing, validation, and publication
176+
177+
├─ Decodes CommitteeRequested and waits until entropyBlock is sealed
178+
├─ Reads the entropy block through the execution RPC and derives
179+
│ keccak256(blockHash, e3Id) without sending a transaction
180+
└─ Publishes CommitteeRequested with the resolved committee seed
174181
175182
InterfoldSolReader decodes IInterfold::E3Requested log
176183
@@ -180,7 +187,7 @@ InterfoldSolReader decodes IInterfold::E3Requested log
180187
181188
├─ Publishes InterfoldEvent::E3Requested {
182189
│ e3_id, threshold_m, threshold_n,
183-
seed, params, error_size, esi_per_ct
190+
computation_seed, params, error_size, esi_per_ct
184191
│ }
185192
186193
├─ FheExtension.on_event():
@@ -193,7 +200,9 @@ InterfoldSolReader decodes IInterfold::E3Requested log
193200
194201
└─ Sortition actor receives E3Requested:
195202
203+
├─ Waits for CommitteeRequested if the delayed committee seed is not ready
196204
├─ Loads the request timepoint and frozen ticket price from CommitteeRequested
205+
├─ Uses the CommitteeRequested seed for ticket ranking
197206
├─ Calculates buffer = calculate_buffer_size(M, N)
198207
199208
├─ ScoreBackend.get_committee():
@@ -280,22 +289,29 @@ CiphernodeRegistrySolWriter receives TicketGenerated event
280289
│ │ require(ticketNumber >= 1) │
281290
│ │ require(ticketNumber <= availableTickets) │
282291
│ │ │
283-
│ │ 7. score = uint256(keccak256( │
292+
│ │ 7. If this is the first ticket, resolve and store: │
293+
│ │ seed = keccak256( │
294+
│ │ blockhash(sortitionEntropyBlocks[e3Id]), e3Id │
295+
│ │ ) │
296+
│ │ → The entropy block is after the paid request │
297+
│ │ → No separate seed transaction is required │
298+
│ │ │
299+
│ │ 8. score = uint256(keccak256( │
284300
│ │ msg.sender, ticketNumber, e3Id, seed │
285301
│ │ )) │
286302
│ │ → SAME formula as Rust-side computation │
287303
│ │ → Both sides agree on scores │
288304
│ │ │
289-
│ │ 8. submitted[msg.sender] = true │
305+
│ │ 9. submitted[msg.sender] = true │
290306
│ │ scoreOf[msg.sender] = score │
291307
│ │ │
292-
│ │ 9. _insertTopN(e3Id, msg.sender, score): │
308+
│ │ 10. _insertTopN(e3Id, msg.sender, score): │
293309
│ │ Maintains array of N lowest-scoring nodes: │
294310
│ │ - If < N nodes: just insert │
295311
│ │ - If N nodes: replace highest if new score lower │
296312
│ │ - O(N) linear scan per insertion │
297313
│ │ │
298-
│ │ 10. Emit TicketSubmitted(e3Id, msg.sender, score) │
314+
│ │ 11. Emit TicketSubmitted(e3Id, msg.sender, score) │
299315
│ │ } │
300316
│ └─────────────────────────────────────────────────────────┘
301317
```
@@ -466,7 +482,9 @@ The registry must finalize a ready committee.
466482

467483
1. **Deterministic sortition**: Both Rust and Solidity compute
468484
`keccak256(address, ticket, e3Id, seed)`. The on-chain contract verifies what the off-chain node
469-
computed.
485+
computed. The seed comes from the committed next-block hash. The requester cannot inspect it and
486+
revert the request in the same transaction. The first ticket stores the seed, so no separate
487+
randomness transaction is needed.
470488

471489
2. **Snapshot-based eligibility**: The eligible count, operator eligibility, and ticket balances use
472490
`requestBlock - 1`. The ticket price is frozen in the request transaction. Rust and Solidity
@@ -517,6 +535,20 @@ The registry must finalize a ready committee.
517535

518536
## Cluster 7 audit additions (post-fix semantics)
519537

538+
### Z-05 — request seed grinding
539+
540+
The E3 computation seed is still created during Interfold.request, but it no longer ranks committee
541+
tickets. The registry commits the next block as the entropy block. Rust waits until that block is
542+
sealed and reads the committee seed. The first ticket stores the same seed before it calculates the
543+
score. A requester can revert the request or learn the committee seed, but it cannot do both in one
544+
transaction.
545+
546+
The basic EVM source uses a block hash, so the seed must be resolved while that hash remains in the
547+
chain's history. The contract first uses the EVM's recent-block lookup and then tries the EIP-2935
548+
history contract. Chains without EIP-2935 retain the 256-block limit. This removes requester-side
549+
conditional-revert grinding. It does not claim the stronger proposer-resistance of a verifiable
550+
randomness service.
551+
520552
### H-04 — snapshot-based eligibility
521553

522554
`CiphernodeRegistryOwnable._validateNodeEligibility` derives the per-node ticket weight from the

crates/ciphernode-builder/src/ciphernode_builder.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,7 @@ async fn setup_evm_system(
10371037
for chain in chains.iter().filter(|chain| chain.enabled.unwrap_or(true)) {
10381038
let provider = provider_cache.ensure_read_provider(chain).await?;
10391039
let chain_id = provider.chain_id();
1040+
let reorg_confirmations = chain.reorg_confirmations.unwrap_or(0);
10401041
evm_config.insert(chain_id, chain.try_into()?);
10411042

10421043
let rpc_url = chain.rpc_url()?;
@@ -1045,7 +1046,7 @@ async fn setup_evm_system(
10451046

10461047
let mut system = EvmSystemChainBuilder::new(bus, &provider);
10471048
system
1048-
.with_provider_factory(provider_factory)
1049+
.with_provider_factory(provider_factory.clone())
10491050
.with_buffer_limit(max_buffered_evm_events);
10501051

10511052
if contract_components.interfold {
@@ -1074,9 +1075,18 @@ async fn setup_evm_system(
10741075

10751076
if contract_components.ciphernode_registry {
10761077
let contract = &chain.contracts.ciphernode_registry;
1077-
1078-
system.with_contract(contract.address()?, move |next| {
1079-
CiphernodeRegistrySolReader::setup(&next).recipient()
1078+
let contract_address = contract.address()?;
1079+
let registry_provider = provider.clone();
1080+
let registry_provider_factory = provider_factory.clone();
1081+
1082+
system.with_contract(contract_address, move |next| {
1083+
CiphernodeRegistrySolReader::setup_with_factory(
1084+
&next,
1085+
registry_provider,
1086+
Some(registry_provider_factory),
1087+
reorg_confirmations,
1088+
)
1089+
.recipient()
10801090
});
10811091

10821092
// TODO: Should we not let this pass and just use '?'?

crates/events/src/interfold_event/e3_requested.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub struct E3Requested {
2020
pub threshold_m: usize,
2121
/// The total committee size for the round
2222
pub threshold_n: usize,
23-
/// Shared per-E3 seed for deterministic ticket scoring.
23+
/// Shared per-E3 seed for the E3 computation.
2424
pub seed: Seed,
2525
/// Timestamp-mode checkpoint recorded when the E3 was requested.
2626
pub request_block: u64,

0 commit comments

Comments
 (0)