Version
main branch, commit 597e354 (2026-08-09)
Other packages versions
N/A store crate internal bug
What happened?
When querying nullifiers via select_nullifiers_by_prefix, if a single block produces more nullifiers matching the requested prefix set than MAX_ROWS, the sync client enters an infinite loop.
Current code (crates/store/src/db/models/queries/nullifiers.rs:100-115):
if let Some(last) = raw.last() && raw.len() > MAX_ROWS {
let last_block_num_i64 = last.block_num;
let nullifiers = vec_raw_try_into(
raw.into_iter().take_while(|row| row.block_num != last_block_num_i64),
)?;
let last_block_included = BlockNumber::from_raw_sql(last_block_num_i64.saturating_sub(1))?;
Ok((nullifiers, last_block_included))
}
If ALL returned rows belong to the same block N (≥ MAX_ROWS+1 nullifiers):
- take_while drops everything → empty nullifiers vector
- last_block_included = N - 1
- Client thinks: "synced to N-1, no nullifiers"
- Client re-requests [N..chain_tip]
- Same result → infinite loop. Client permanently stalled.
What should have happened?
The function should either:
- Return a non empty response with progress guarantee, OR
- Return an explicit error (e.g. BlockTooLargeToPage) so the client knows it cannot page through that block with the current protocol.
The client should never be told "you are synced to N-1" when block N actually contains matching nullifiers.
How can this be reproduced?
Trigger condition: A single block must contain > MAX_ROWS nullifiers for the queried prefix set.
MAX_RESPONSE_PAYLOAD_BYTES / 36 ≈ 116,508 nullifiers per block for a prefix set.
With 16-bit prefixes (65,536 space) and up to 1,000 prefixes requested, a wide-range scanning client can easily hit this.
Test that should fail on current code:
#[test]
fn supersaturated_single_block_nullifier_does_not_stall() {
// Insert MAX_ROWS + 100 nullifiers all in block N for prefix P
// Query select_nullifiers_by_prefix for P
// Must return non-empty AND allow progress to N+1
}
No such test exists in tests.rs today.
Relevant log output
// No runtime log this is a silent correctness bug.
// The buggy path returns Ok(([], N-1)) which looks valid to the caller.
Version
main branch, commit 597e354 (2026-08-09)
Other packages versions
N/A store crate internal bug
What happened?
When querying nullifiers via select_nullifiers_by_prefix, if a single block produces more nullifiers matching the requested prefix set than MAX_ROWS, the sync client enters an infinite loop.
Current code (crates/store/src/db/models/queries/nullifiers.rs:100-115):
if let Some(last) = raw.last() && raw.len() > MAX_ROWS {
let last_block_num_i64 = last.block_num;
let nullifiers = vec_raw_try_into(
raw.into_iter().take_while(|row| row.block_num != last_block_num_i64),
)?;
let last_block_included = BlockNumber::from_raw_sql(last_block_num_i64.saturating_sub(1))?;
Ok((nullifiers, last_block_included))
}
If ALL returned rows belong to the same block N (≥ MAX_ROWS+1 nullifiers):
What should have happened?
The function should either:
The client should never be told "you are synced to N-1" when block N actually contains matching nullifiers.
How can this be reproduced?
Trigger condition: A single block must contain > MAX_ROWS nullifiers for the queried prefix set.
MAX_RESPONSE_PAYLOAD_BYTES / 36 ≈ 116,508 nullifiers per block for a prefix set.
With 16-bit prefixes (65,536 space) and up to 1,000 prefixes requested, a wide-range scanning client can easily hit this.
Test that should fail on current code:
#[test]
fn supersaturated_single_block_nullifier_does_not_stall() {
// Insert MAX_ROWS + 100 nullifiers all in block N for prefix P
// Query select_nullifiers_by_prefix for P
// Must return non-empty AND allow progress to N+1
}
No such test exists in tests.rs today.
Relevant log output
// No runtime log this is a silent correctness bug. // The buggy path returns Ok(([], N-1)) which looks valid to the caller.