Skip to content

Feat/higher arity fri#464

Closed
diegokingston wants to merge 14 commits into
mainfrom
feat/higher-arity-fri
Closed

Feat/higher arity fri#464
diegokingston wants to merge 14 commits into
mainfrom
feat/higher-arity-fri

Conversation

@diegokingston

Copy link
Copy Markdown
Collaborator

No description provided.

New configuration fields for higher-arity FRI folding:
- fri_log_arity: log2 of folding arity per round (1=arity-2 default)
- fri_log_final_poly_len: log2 of final polynomial length (0=fold to constant)

Defaults reproduce current arity-2 behavior exactly.
- StarkProof/Round4: fri_last_value -> fri_final_poly: Vec<FE>
- FriDecommitment: layers_evaluations_sym -> Vec<Vec<FE>> (K-1 siblings)
- FriLayerMerkleTreeBackend: PairKeccak256Backend -> BatchKeccak256Backend
- Leaf construction: fixed [FE; 2] arrays -> Vec<FE>

Defaults produce vec![single_value] and vec![single_sibling], preserving
arity-2 behavior. All 129 tests pass unchanged.
Rewrite commit_phase_from_evaluations to support configurable folding arity:
- Each round samples ONE challenge, applies log_arity sequential binary
  folds with squaring challenges, then builds one Merkle tree
- Leaf groups are 2^log_arity elements (variable-size Vec leaves)
- Final polynomial sent as Vec of coefficients (supports early termination)

Rewrite query_phase for variable-size siblings:
- Each layer returns K-1 siblings (all group elements except the queried one)
- Leaf index = query_index / group_size

With default log_arity=1, this produces identical results to the
previous arity-2 implementation. All 129 tests pass.
Rewrite verify_query_and_sym_openings to support configurable folding arity:
- Initial fold remains arity-2 (combining DEEP evaluations)
- Each committed layer reconstructs the full 2^log_arity element group
  from the queried value + K-1 siblings
- Verifies Merkle opening against the reconstructed group
- fold_group_verifier applies log_arity sequential binary folds with
  squaring challenges and domain-computed inverse twiddles
- Twiddles computed on-the-fly from coset_offset + primitive root + bit-reversal
- Tracks domain state (log_size, offset) across layers

With default log_arity=1, this produces identical verification to the
previous arity-2 implementation. All 129 tests pass.
Three new tests:
- test_fri_arity_4_prove_verify: 64-row Fibonacci AIR with log_arity=2
- test_fri_arity_8_prove_verify: same with log_arity=3
- test_fri_arity_4_has_fewer_merkle_roots_than_arity_2: verifies arity-4
  produces strictly fewer FRI layer commitments

Also fixes two bugs found by the tests:
- Prover: round 0 must fold exactly 1x (initial arity-2), not log_arity
- Verifier: fold_group_verifier must not halve leaf_index in sub-fold loop
Change GoldilocksCubicProofOptions default fri_log_arity from 1 to 2
(arity-4). This halves the number of FRI Merkle trees committed during
the proof, reducing FRI Merkle hashing work by ~50%.

All 132 tests pass including arity-specific roundtrip tests.
@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions

Copy link
Copy Markdown

Codex Code Review

Findings

  1. High - Verifier can panic on malformed proofs due to unchecked indexing (DoS)

    • In verifier.rs:653, verifier.rs:654, verifier.rs:666, verifier.rs:642, and verifier.rs:698, the code indexes proof-provided vectors directly (layers_evaluations_sym, layers_auth_paths, sibling entries, fri_final_poly) without validating lengths first.
    • Since proofs are untrusted input, this allows a crafted proof to trigger out-of-bounds panic instead of clean verification failure (false).
    • Action: add explicit shape checks before indexing and return false on mismatch.
  2. Medium - fri_log_arity is not validated; 0 causes division-by-zero panic in prover

    • In fri/mod.rs:65 and fri/mod.rs:73, log_arity is used as a divisor; fri_log_arity = 0 will panic.
    • ProofOptions exposes this as a public field with no validation path in options.rs.
    • Action: validate fri_log_arity >= 1 (and ideally bounded to safe values) at options construction / prover entry.

No other concrete security, correctness, or significant performance issues stood out in the diff.

// the final value directly here.
if fri_layers_merkle_roots.is_empty() {
return v == proof.fri_last_value;
return v == proof.fri_final_poly[index];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Panic on malicious proof input

proof.fri_final_poly[index] panics if a malicious prover sends a fri_final_poly that is shorter than index. Since index at this point is still the original iota (unchanged — the initial fold does not update index), and iota can be up to lde_length/2 - 1, any proof with fri_final_poly.len() < iota + 1 panics the verifier.

The same issue exists at line 698 (after the committed-layers loop).

Suggested change
return v == proof.fri_final_poly[index];
return proof.fri_final_poly.get(index).map_or(false, |val| &v == val);

0
} else {
// Largest T ≤ max_folds with T = 1 + n*log_arity for integer n ≥ 0.
let n = (max_folds - 1) / log_arity;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Division by zero when fri_log_arity = 0

If ProofOptions::fri_log_arity is set to 0, log_arity = 0 here and this line panics (integer division by zero). There is also a second division at line 73 ((total_folds - 1) / log_arity) with the same problem. Additionally, if somehow execution reached the while loop with log_arity = 0, all rounds after the first would have folds_this_round = 0, making folds_done never increase — an infinite loop.

fri_log_arity should be validated to be >= 1 in ProofOptions, or guarded here:

assert!(log_arity >= 1, "fri_log_arity must be at least 1 (arity >= 2)");

}

// Check that the final value matches the final polynomial at the residual index.
result &= v == proof.fri_final_poly[index];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Same unchecked index panic as line 642

After the committed-layers loop, index is the leaf_index from the last layer. A malicious prover can send fri_final_poly with fewer elements than index + 1, causing a panic.

Suggested change
result &= v == proof.fri_final_poly[index];
result &= proof.fri_final_poly.get(index).map_or(false, |val| &v == val);

let mut result = true;

for (layer_idx, merkle_root) in fri_layers_merkle_roots.iter().enumerate() {
let siblings = &fri_decommitment.layers_evaluations_sym[layer_idx];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Panic on malformed decommitment

layers_evaluations_sym[layer_idx] and layers_auth_paths[layer_idx] (line 654) both panic if a malicious or malformed proof has fewer decommitment entries than fri_layers_merkle_roots. Consider returning false instead:

let Some(siblings) = fri_decommitment.layers_evaluations_sym.get(layer_idx) else { return false; };
let Some(auth_path) = fri_decommitment.layers_auth_paths.get(layer_idx) else { return false; };

// Each successive sub-fold halves `half`, so the product `leaf_index * half` correctly
// tracks the pair position in the correspondingly smaller domain.
let mut inv_twiddles: Vec<FieldElement<Field>> = Vec::with_capacity(half);
let half_log_bits = current_domain_size / 2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: Misleading variable name

half_log_bits sounds like a log or bit count, but it's actually the half-domain size (current_domain_size / 2). The reverse_index function takes a size (not a bit count), so the value passed is correct — but the name will confuse readers. Consider half_domain_size or just inline the expression.

@claude

claude Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Review: Feat/higher arity FRI. Good implementation overall - the protocol structure is clearly documented and the roundtrip tests are a nice addition. Issues found: (1) HIGH - Verifier panics on malicious proof input: proof.fri_final_poly[index] at lines 642 and 698 has no bounds check, and fri_decommitment.layers_evaluations_sym[layer_idx] at 653-654 can also panic. A crafted proof can crash the verifier. All should return false rather than panicking. (2) HIGH - Division by zero when fri_log_arity=0: commit_phase_from_evaluations divides by log_arity at lines 65 and 73 with no guard. Also causes an infinite loop in the while loop since folds_this_round=0. Add assert or validate in ProofOptions. (3) LOW - Misleading name half_log_bits at verifier line 747: holds current_domain_size/2 which is a domain SIZE not a log value. Suggest half_domain_size.

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench 10

@jotabulacios

Copy link
Copy Markdown
Collaborator

/bench 5

@github-actions

github-actions Bot commented Mar 26, 2026

Copy link
Copy Markdown

Benchmark — fib_iterative_8M (median of 3)

Table parallelism: 32 (auto = cores / 3)

Metric main PR Δ
Peak heap 68340 MB 67530 MB -810 MB (-1.2%) ⚪
Prove time 33.922s 33.319s -0.603s (-1.8%) ⚪

✅ No significant change.

⚠️ Heap spread: 7.6% (66263 MB / 71429 MB / 67530 MB)
⚠️ Baseline heap spread: 5.8% (66969 MB / 68340 MB / 70933 MB) — comparison may be less reliable
Consider re-running /bench

Commit: d7318ad · Baseline: cached · Runner: self-hosted bench

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench 3

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench 3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants