Skip to content

fix(salt): allow 2^40 bucket capacity and validate what proofs and witnesses decode - #150

Open
flyq wants to merge 17 commits into
mainfrom
liquan/fix/max-bucket-capacity
Open

fix(salt): allow 2^40 bucket capacity and validate what proofs and witnesses decode#150
flyq wants to merge 17 commits into
mainfrom
liquan/fix/max-bucket-capacity

Conversation

@flyq

@flyq flyq commented Aug 31, 2026

Copy link
Copy Markdown
Member

Summary

A bucket's capacity should be able to reach 2^40 slots, the most a bucket subtree can address. It topped out at 2^39 instead.
The review rounds on this PR widened it: it now also rejects, at the decode boundary, a subtree level, a SaltValue length pair, or a BucketMeta capacity that the trie cannot represent, and keeps subtree node arithmetic in u64 so a 32-bit target is correct at the new ceiling. See "Also in this PR" and "Review follow-ups" below.

Root cause

shi_rehash bounded the new capacity with BUCKET_SLOT_ID_MASK:

assert!(new_capacity <= BUCKET_SLOT_ID_MASK, ...)  // 2^40 - 1

BUCKET_SLOT_ID_MASK is the largest slot index; capacity is a slot count. The two differ by one, and that one matters here: capacities start at MIN_BUCKET_SIZE and only ever double (compute_resize_capacity, salt/src/state/state.rs:1054), so every reachable capacity is a power of two. 2^40 > 2^40 - 1 fails the assert, which leaves 2^39 as the largest capacity a bucket can reach — half the addressable space.

2^40 is the real ceiling: a bucket's slots live in the deepest level of its subtree, which holds 256^(MAX_SUBTREE_LEVELS - 1) = 2^32 segments of MIN_BUCKET_SIZE = 256 slots each, so 2^32 × 256 = 2^40. Slot IDs then run 0..2^40, whose largest member is exactly BUCKET_SLOT_ID_MASK — so they still fit the 40-bit slot field of a SaltKey.

Fix

Add MAX_BUCKET_SIZE (salt/src/constant.rs:52), derived from the subtree structure rather than restated as a literal, and bound the capacity with it (salt/src/state/state.rs:682).

Verification

The ceiling has zero slack — 2^40 is exactly right, not merely larger:

  • At capacity 2^40 the deepest node's local BFS number is 16,843,009 + 2^32 - 1 = 4,311,810,304, which is precisely leftmost_node(5) - 1, the last valid level-4 id. One more segment would classify as BFS level 5 and index SUBTREE_DEFAULT_COMMITMENT[5] out of bounds.
  • It is also where subtree_root_level stops: 2^41 drives level -= 1 at level 0. So the assert sits exactly at the structural limit.

Raising the cap opens no new code path. subtree_root_level already returns 0 for every capacity above 2^32, so the level-0 subtree root and its (bucket_id << 40) | 0 logical address have been live since 2^33 — seven doublings below the old cap. Expansion/contraction (trie.rs), proof shape (shape.rs), and sub-trie/prover addressing (subtrie.rs, prover.rs) were each traced at level 0 and are unaffected; create_sub_trie was exercised at capacities 2^32/2^33/2^39/2^40 and produced identical shapes.

Docs

Three capacity→level ladders trailed off before the top of the range (65537+ slots: Root at higher levels as needed, Capacity > 4,294,967,296, And so on), so none stated the ceiling. The MAX_SUBTREE_LEVELS ladder also mapped 768–65,536 slots to level 2; it is level 3. The table above subtree_root_level was inverted on all five rows — it read as depth-from-root while the function returns the root's level. The README said a bucket resizes "to a multiple of 256"; it doubles, which is the property that made the old bound bite.

Testing

Green on all four feature combinations CI runs — default (210 passed), --features test-bucket-resize (199), --no-default-features (209), and --no-default-features --features test-bucket-resize (198) — with clippy and fmt clean. New coverage:

  • test_max_bucket_size_matches_subtree_capacity — derives 2^40 from the subtree structure and pins it against the slot field.
  • test_max_bucket_capacity_is_a_reachable_power_of_two — 2^40 is one doubling past 2^39, and the old bound was one slot short. Stated through BUCKET_RESIZE_MULTIPLIER rather than a compute_resize_capacity result, since test-bucket-resize overrides the load-factor threshold.
  • test_shi_rehash_rejects_capacity_above_max — the assert still fires past the ceiling.
  • test_subtree_root_level — adds 2^39, 2^40 - 1, 2^40.
  • test_subtrie_change_info_capacity_boundaries — level-0 transitions incl. 2^39 → 2^40, plus the zero-slack check above.

There is deliberately no positive test that rehashes at 2^40: shi_rehash iterates 0..old_capacity, so it would never finish.

Also in this PR: validate what a proof or witness supplies

Two decode-time bounds in the same family as the capacity assert, and two comment fixes, from the SALT spec review:

  • fix(proof): fx_hashmap_serde::deserialize now rejects a subtree level outside 1..=MAX_SUBTREE_LEVELS (salt/src/proof/prover.rs:197). Previously any byte decoded; parents_and_points embeds a level ≥ 2 into an encoded parent id and logic_parent_id decodes it with STARTING_NODE_ID[MAX_SUBTREE_LEVELS - level] (salt/src/proof/shape.rs:298), which underflows for 6 and 7, and the trie's update_bucket_subtrees turns a witnessed level into TRIE_WIDTH^level (salt/src/trie/trie.rs:513), which for ≥ 6 drives subtree_root_level below level 0 (level 0 makes it treat an expanded bucket as unexpanded). Wire format unchanged; the check also reaches stateless-core::LightWitness, whose round-trip test uses levels 0/7/255 and will need in-range values when its salt pin moves past v1.0.5.
  • fix(types): SaltValue deserialization now rejects 2 + key_len + value_len > MAX_SALT_VALUE_BYTES; key()/value() slice data by those prefixes, so an overrun was a panic, not a rejection. The bound lives in TryFrom<[u8; 94]> (salt/src/types.rs:326) and is applied by a field-level deserialize_with (salt/src/types.rs:316) that keeps the derive and the wire shape (a later commit in this PR moved it there from a hand-written Deserialize). Zero padding past data_len() is deliberately not checked (it never enters kv_hash). Also fixes the 40-vs-72-byte account-value doc: the branch is empty code hash vs any other, not EOA vs contract (EIP-7702-delegated EOAs take the 72-byte form).
  • docs(state): the count-unavailable branch of shi_upsert (salt/src/state/state.rs:554) no longer claims the witness "may omit" the count as an optimization or that a skipped resize "can only delay" it; a builder witnesses every slot of a bucket the block resizes, and a skipped resize persists on the delta path and is exposed only by stateless validation against a complete witness from an independent builder. hasher.rs:14 now states the seeds are the low 128 bits of the keccak digest as big-endian 32-bit words, not its "lower 32 bytes".

New tests: rejects_zero_level, rejects_level_above_max, accepts_boundary_levels (prover.rs) and salt_value_deserialize_bounds_declared_lengths (types.rs, also pins the bincode bytes to the raw 94-byte array and a JSON round-trip). Gates re-run on the full branch: default 214 passed, --features test-bucket-resize 203, --no-default-features 213, --no-default-features --features test-bucket-resize 202 (each the earlier count plus the 4 new tests); clippy, fmt, cargo-sort and the riscv64 no_std check clean.

Review follow-ups

From the /code-review high pass on the branch:

  • fix(types): BucketMeta::try_from (salt/src/types.rs:95) now rejects capacity == 0 and capacity > MAX_BUCKET_SIZE (:125). Capacity 0 ran shi_upsert's probe loop zero times, then compute_resize_capacity(0, 0) = 0 passed the assert, rehashed to 0 and recursed without bound; above 2^40 subtree_root_level has no level to return. This is the range the trie can represent, not the protocol's domain (256 doubled n times): tests drive the SHI logic at capacities 3/5/8/4 through the same decoder, so the stricter check stays separate. Two fixtures moved into range.
  • fix(trie): vc_position_in_parent and get_parent_node (salt/src/trie/node_utils.rs:224, :288) did their level-relative subtraction after an as usize cast. At 2^40 the deepest subtree-local number is 4,311,810,304, past u32::MAX, so a 32-bit target (wasm32 is named as one in state::ahash::convert) truncated. The arithmetic now stays in u64; a test pins both functions at that node.
  • refactor(types): the SaltValue length bound moved from a hand-written Deserialize plus mirror struct to a field-level deserialize_with (salt/src/types.rs:316), keeping the derive and the wire shape; TryFrom reads the bound through data_len(). The struct doc now says the zero padding is not checked on decode.
  • docs(salt): the shi_upsert backstop comment is qualified to below the capacity ceiling (at 2^40 shi_rehash asserts rather than resizing; the spec has no rule for that case either), and the README marks the 768-slot diagram as an illustration rather than a protocol-produced capacity.
  • test(proof): the order-independence fixture no longer carries a level of 6, and the single_entry_bytes comment no longer claims the serializer filters levels.
  • chore(mutants): the suppression hygiene job failed on the previous head because seven line-pinned entries in mutants/suppressions.toml drifted (the hasher.rs comment rewrite moved its lines down by two, the constant.rs doc additions moved DEFAULT_COMMITMENT_AT_LEVEL down by 21). Each is re-pinned to the site it was reviewed for, identified by content; verified locally against a cargo mutants --list --package salt universe with scripts/mutation_gate.py orphans, 0/38 stale.
  • fix(proof): Codex's inline finding, the last 32-bit-narrowing site: multi_commitments_to_scalars computed a child's slot as absolute_node_id as usize - child_idx as usize (salt/src/proof/subtrie.rs:126). The 256-child range that begins at local node 0xffffff01 ends at 0x100000000, so on a 32-bit target the casts drop the high bits and the subtraction underflows. It now subtracts in u64 and narrows the sub-256 result.
  • fix(trie): spec-gate failed on the previous head with one timed-out mutant, the trie pack's STARTING_NODE_ID[level - 1] -> [level] rewrite in get_parent_node, which returns a same-level node and makes every walk to the root spin. A debug_assert_eq! on the returned node's level in get_parent_node (salt/src/trie/node_utils.rs, debug builds only) turns that into an immediate failure; applying the mutation by hand now ends the nextest run in 7 s with exit 100 instead of hanging. The expression predates this PR and only entered the diff gate's scope through the u64 rewrite.

Not taken: the stateless-validator round_trip_through_bincode fixture (other repo, pinned at v1.0.5), the spec-faithful capacity-domain check, and a rule for resizing at the ceiling, which needs a spec decision first. Gates after these commits (re-run unchanged after the two Codex/spec-gate follow-ups): default 216 passed, --features test-bucket-resize 205, --no-default-features 215, --no-default-features --features test-bucket-resize 204 (two new tests each); clippy, fmt, cargo-sort and the riscv64 no_std check clean.

Scope

stateless-validator needs nothing: it has no mirrored capacity bound anywhere, overrides get_subtree_levels to read a witnessed levels byte instead of calling subtree_root_level, and pins salt at tag v1.0.5 — so this change does not reach it until that pin moves.

Notes (pre-existing, not addressed here)

  • BucketMeta::try_from bounded capacity nowhere when this PR was opened; the review follow-ups above close the crash cases (0 and above 2^40). What remains separate is the protocol's exact capacity domain, 256 doubled n times, which the decoder still does not enforce because tests drive the SHI logic at smaller capacities.
  • salt/src/types.rs says the (bucket_id << 40) | 0 form is forbidden for bucket roots, while the node_utils module doc prescribes it as the level-0 subtree root. Both are defensible — the former governs persistent addressing, the latter a transient navigation alias — but the wording contradicts itself.
  • subtrie.rs:343 and prover.rs:533 discriminate main-trie from subtree nodes with < BUCKET_SLOT_ID_MASK where < (1 << BUCKET_SLOT_BITS) is meant. Behaviourally identical (no reachable node id is 2^40 - 1), so a readability wart only.

🤖 Generated with Claude Code

https://claude.ai/code/session_013Spbs54VBmctgmRydZqNfj
https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb

flyq and others added 4 commits August 31, 2026 15:29
`shi_rehash` bounded a new capacity with `BUCKET_SLOT_ID_MASK` (2^40 - 1), the
largest slot *index*, but capacity is a slot *count*. Since capacities only ever
double up from `MIN_BUCKET_SIZE`, the largest one the assert admitted was 2^39 —
half the 2^40 a bucket subtree can actually address.

Introduce `MAX_BUCKET_SIZE`, derived from the subtree structure (2^32 segments of
256 slots), and bound the capacity with it. Also correct the capacity-to-level
table above `subtree_root_level`, which was inverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Spbs54VBmctgmRydZqNfj
The capacity->subtree-level ladders in `MAX_SUBTREE_LEVELS`, the `node_utils`
module doc and `get_subtree_levels` all trailed off before the top of the range
("65537+ slots: Root at higher levels as needed", "Capacity > 4,294,967,296",
"And so on"), so none of them stated the ceiling this fix is about. The
`MAX_SUBTREE_LEVELS` ladder also mapped 768-65,536 slots to level 2; it is
level 3. The README said a bucket resizes "to a multiple of 256" when it in
fact doubles - the very property that made the old bound bite at 2^39.

Also pin the level-0 node-id arithmetic at max capacity in
`test_subtrie_change_info_capacity_boundaries`: the deepest local node number
at 2^40 is exactly `leftmost_node(5) - 1`, so the ceiling has zero slack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Spbs54VBmctgmRydZqNfj
The `# Panics` note added in the previous commit told callers that
`shi_rehash` bounds capacity for them. It bounds only the `new_capacity` it is
asked to resize to; a capacity read back from a stored `BucketMeta` is
validated nowhere, and `BucketMeta::try_from` accepts any u64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Spbs54VBmctgmRydZqNfj
`test_max_bucket_capacity_is_a_reachable_power_of_two` asserted that
`compute_resize_capacity(2^39, 2^39)` returns 2^40, which holds only at the
default 80% load factor. The `test-bucket-resize` feature drops the threshold
to 1%, so the same call doubles to 2^46 and the test failed in the CI job that
enables it.

State the claim through `BUCKET_RESIZE_MULTIPLIER` instead — one doubling past
2^39 is 2^40, whatever the threshold — and keep `compute_resize_capacity`
covered with a threshold-independent assertion that a resize lands on a power
of two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Spbs54VBmctgmRydZqNfj
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Performance Benchmark Comparison

Compared 5 benchmark(s) against the latest main baseline.

Detailed Comparison
Benchmark Baseline Throughput (Kelem/s) New Throughput (Kelem/s) Change
update 10000 KVs/1 threads 63.01 73.42 +16.52%
update 10000 KVs/2 threads 121.50 139.67 +14.95%
update 10000 KVs/4 threads 224.12 257.71 +14.99%
update 10000 KVs/8 threads 387.53 431.35 +11.31%
update 10000 KVs/16 threads 536.66 580.05 +8.09%

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Mutation testing - PASS

Mutation score: 100.0% (5/5 viable mutants killed)

  • caught: 5
  • survived, real gaps: 0
  • timed out, inconclusive: 0
  • suppressed: 0
  • unviable: 5
  • timeout total: 0

No unsuppressed survivors or timeouts remain.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Mutation testing - PASS

Mutation score: 100.0% (23/23 viable mutants killed)

  • caught: 23
  • survived, real gaps: 0
  • timed out, inconclusive: 0
  • suppressed: 0
  • unviable: 49
  • timeout total: 0

No unsuppressed survivors or timeouts remain.

flyq and others added 9 commits September 2, 2026 15:14
`fx_hashmap_serde::deserialize` accepted any u8 as a bucket's subtree level,
but a prover only ever emits 1 (a bucket at MIN_BUCKET_SIZE, including every
metadata bucket) through MAX_SUBTREE_LEVELS (a bucket at MAX_BUCKET_SIZE), and
nothing downstream is defined outside that range. `parents_and_points` embeds
any level >= 2 into an encoded parent id, and `logic_parent_id` decodes it back
with `STARTING_NODE_ID[MAX_SUBTREE_LEVELS - level]`, which underflows for 6 and
7; the trie's `update_bucket_subtrees` turns a witnessed level into a capacity
of `TRIE_WIDTH^level`, which for 6 or more sends `subtree_root_level` below
level 0; and level 0 makes `update_bucket_subtrees` treat an expanded bucket as
unexpanded while `parents_and_points` derives an inconsistent node set for it.
A proof or witness from an untrusted source could therefore panic a verifier
(levels 6 and 7) or drive it through a shape it was never meant to handle
(level 0), instead of being rejected at the decode boundary.

Reject a level outside `1..=MAX_SUBTREE_LEVELS` in the visitor, next to the
existing duplicate-key check. The wire format is unchanged. The check also
lands in `stateless-core::LightWitness`, which reuses the module through
`#[serde(with = "salt::fx_hashmap_serde")]`; its bincode round-trip test
currently feeds levels 0, 7 and 255 and will need in-range values once it moves
past salt v1.0.5.

`round_trip_preserves_entries` used 0 and 255 as levels and now uses in-range
ones; new tests cover 0, MAX_SUBTREE_LEVELS + 1 and both boundaries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb
`SaltValue` derived `Deserialize`, so any 94-byte payload decoded, including
one whose `key_len + value_len` exceeds the 92 bytes that follow the two length
prefixes. `key()`, `value()` and `data_len()` trust the prefixes and slice
`data` with them, so a witness or proof from an untrusted source could make a
consumer panic on an out-of-range slice rather than reject the value.

Hand-write `Deserialize` around the same one-field struct shape the derive
produced (bincode bytes and self-describing encodings are unchanged) and
validate through a new `TryFrom<[u8; MAX_SALT_VALUE_BYTES]>` that refuses
`2 + key_len + value_len > MAX_SALT_VALUE_BYTES`. The zero padding past
`data_len()` is not checked here; it never enters `kv_hash`, which hashes only
the trimmed key and value bytes.

Also correct the `MAX_SALT_VALUE_BYTES` doc: the 40- versus 72-byte account
value is selected by whether the code hash is the empty-code hash, not by EOA
versus contract; an EIP-7702-delegated EOA carries its delegation designator's
code hash and takes the 72-byte form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb
The comment on the count-unavailable branch of `shi_upsert` said the witness
"may omit" the usage count as a size optimization, and that skipping the resize
"can only delay" it as a "temporary deviation" the next honest insertion
self-heals. Neither matches the protocol. A witness builder includes every slot
of any bucket the block's insertions resize, so the count is absent exactly
when no resize is due. And a resize a sequencer skips is not corrected on the
delta path at all: canonicalization rebuilds only buckets actually resized in
the block, and delta consumers reproduce the transmitted layout verbatim, so
only stateless validation against a complete witness from a builder
independent of the sequencer exposes it. The exhaustion-case resize and the
next counted insertion remain as structural backstops, and the comment now says
so in those terms.

Also fix the seed note in `hasher.rs`: the four seeds are the low 128 bits of
keccak256("Make Ethereum Great Again") read as big-endian 32-bit words, not
"the lower 32 bytes" of a digest that is 32 bytes long.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb
`BucketMeta::try_from` accepted any u64 as a capacity. A decoded capacity of 0
runs `shi_upsert`'s probe loop zero times, so the exhaustion path computes a
new capacity of `compute_resize_capacity(0, 0) = 0`, passes the
`<= MAX_BUCKET_SIZE` assert, rehashes to 0 and recurses into `shi_upsert`
without bound; a capacity above `MAX_BUCKET_SIZE` drives `subtree_root_level`
below level 0 and the trie indexes `STARTING_NODE_ID` out of range. Both enter
through the same decoder, which is the entry point for witness-supplied
metadata.

Reject `capacity == 0` and `capacity > MAX_BUCKET_SIZE` there. This is the
range the trie can represent, not the protocol's capacity domain: the protocol
only produces `MIN_BUCKET_SIZE` doubled some number of times, but tests
exercise the SHI logic at capacities such as 3, 5, 8 and 4, so that stricter
check is left for a separate change. The `# Panics` note on
`subtree_root_level` no longer says a stored capacity is validated nowhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb
`vc_position_in_parent` and `get_parent_node` cast a subtree-local node number
to `usize` before subtracting the level's starting id. Every local number fit
in 32 bits while capacity topped out at 2^39, but at `MAX_BUCKET_SIZE` the
deepest one is 4,311,810,304, past `u32::MAX`, so on a 32-bit target such as
wasm32 (which `state::ahash::convert` names as a deployment target) the cast
truncates and both functions return the wrong node. Do the arithmetic in u64
and cast only the sub-256 result.

Adds a test pinning both functions at that deepest node. On 64-bit hosts it
passed before as well, so it documents the boundary rather than reproducing
the truncation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb
The previous commit hand-wrote `Deserialize` for `SaltValue` around a private
mirror struct to keep the derived wire shape while adding the length bound. A
field-level `deserialize_with` does the same with the derive kept: the derive
still emits the one-field struct, `serde_arrays` still decodes the array, and
`TryFrom<[u8; MAX_SALT_VALUE_BYTES]>` applies the bound. `TryFrom` now reads
the declared length through `data_len()`, the quantity `key()` and `value()`
slice by, instead of recomputing it.

Also say in the struct doc that the zero padding past `data_len()` is not
checked on decode; the earlier wording could be read as promising that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb
The `shi_upsert` comment called the exhaustion-case resize a structural
backstop without saying where it stops: at `MAX_BUCKET_SIZE` there is no
larger capacity to resize to, `compute_resize_capacity` still doubles, and
`shi_rehash` asserts. Say so. The README's growth paragraph states that every
capacity is a power of two and then draws a 768-slot bucket tree; mark the
diagram as an illustration of the shape rather than a capacity the protocol
produces.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb
`serialization_is_order_independent` still carried a level of 6, which the
deserializer now rejects; the test only serializes, so it passed, but the
fixture contradicted the range the module documents. The `single_entry_bytes`
helper's comment also claimed a level "no serializer would emit", which is
false: `fx_hashmap_serde::serialize` writes any u8 unchanged, and it is the
prover that never produces such a level.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb
The `suppression hygiene` job failed on this branch with seven stale
line-pinned entries in `mutants/suppressions.toml`: the comment rewrite at
the top of `hasher.rs` moved every line below it down by two, and the doc
additions in `constant.rs` moved `DEFAULT_COMMITMENT_AT_LEVEL` down by 21.
Each entry is re-pinned to the same site it was reviewed for, identified by
content rather than offset: the level-0 `STARTING_NODE_ID[0] + 1` boundary
(221 -> 242), the `key_len + 4 <= STACK_BUFFER_SIZE` branch of
`hash_with_nonce` (71 -> 73), and the `bucket_id` sites (51 -> 53, 58 -> 60).
No justification changes. Verified locally with a `cargo mutants --list
--package salt` universe and `scripts/mutation_gate.py orphans`: 0/38 stale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xfA5h7MHVfiKE6kPbVJGb

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1ddd441e3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread salt/src/constant.rs
flyq and others added 2 commits September 2, 2026 18:16
`multi_commitments_to_scalars` computed a child's index within its parent's
256-slot commitment as `absolute_node_id as usize - child_idx as usize`. Both
ids carry the bucket in their high bits and, at the top of a level-4 subtree,
a local number past u32::MAX: the 256-child range that begins at local node
0xffffff01 ends at 0x100000000. On a 32-bit target each cast drops the high
bits first, so that last child reads as 0, the subtraction underflows, and
proof generation panics under overflow checks (or wraps to the right answer
by accident without them). Subtract in u64 and narrow the sub-256 result, as
`vc_position_in_parent` and `get_parent_node` now do.

Answers the Codex review thread anchored at `constant.rs:53`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The spec-gate's trie operator pack rewrites `STARTING_NODE_ID[level - 1]` to
`STARTING_NODE_ID[level]` in `get_parent_node`, which returns a node on the
child's own level. Every walk to the root then spins forever, so the mutant
was reported as a timeout, which the gate treats as inconclusive rather than
killed. It surfaced now because the u64 rewrite put that line into the PR's
diff scope; the same expression was there before, untested by the diff gate.

Check the returned node's level with a `debug_assert_eq!` (debug builds only,
one `get_bfs_level` per call). Under the mutant the first `get_parent_node`
call now panics and nextest finishes with a failure instead of hanging,
verified by applying the mutation by hand: exit 100 after 7 seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@flyq flyq changed the title fix(state): allow bucket capacity to reach 2^40 fix(salt): allow 2^40 bucket capacity and validate what proofs and witnesses decode Sep 2, 2026
flyq and others added 2 commits September 8, 2026 15:00
… review scaffolding

- `MAX_BUCKET_SIZE == 1 << BUCKET_SLOT_BITS`, its segment-count derivation, and
  the deepest-subtree-node-fits-the-slot-field bound are now `const` assertions
  next to the definition; the three tests that restated them are removed.
- `parents_and_points` walks the fixed-depth main trie for exactly
  `MAIN_TRIE_LEVELS - 1` steps instead of until it reaches node 0, so a corrupt
  parent id (or the trie pack's `STARTING_NODE_ID[level - 1] -> [level]` mutant)
  fails fast instead of spinning. The `debug_assert` in `get_parent_node` that
  stood in for that is removed; the hand-applied mutant now ends the suite in
  18 s with exit 100.
- `SaltValue` deserialization checks `data_len` inline instead of through a
  `TryFrom<[u8; MAX_SALT_VALUE_BYTES]>` layer, and `BucketMeta::try_from` drops
  two error arms the length guard already makes unreachable.
- Tests reuse `subtree_leaf_for_key`, `get_child_node`, `From<BucketMeta>` and a
  single `levels_bytes` builder instead of restating their arithmetic or byte
  layout; the three near-identical level-range tests collapse into one.
- The ceiling derivation lives in the `MAX_BUCKET_SIZE` doc alone; the other
  docs link to it, and `subtree_root_level` no longer enumerates its callers.
- Re-pin the `default_commitment` suppression after the constant.rs line shift.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mutant is unviable

The spec-gate's trie pack rewrites `STARTING_NODE_ID[MAX_SUBTREE_LEVELS - 1]`
to `[MAX_SUBTREE_LEVELS - 2]` inside the `MAX_BUCKET_SIZE` const block. That
only lowered the node checked against `BUCKET_SLOT_ID_MASK` from the level-4
base to the level-3 base, so the `<=` bound still held, the crate still
compiled, and no runtime test can observe a weaker compile-time guard: the
mutant survived (PR #150 spec-gate).

Assert the zero-slack fact instead: the last node of a maximally expanded
bucket is the one just before a sixth subtree level would begin
(`leftmost_node(MAX_SUBTREE_LEVELS)`), then bound that node by the mask as
before. With the wrong level base the equality fails at compile time (E0080),
so the mutant is now unviable, the same outcome the sibling asserts in this
block already produce under the same operator. `umutate.py run --diff
origin/main` reports 5 caught, 0 missed, 5 unviable.

The rewrite adds two lines above `default_commitment`, so the line-pinned
`+ with *` suppression moves from 261 to 263; `mutation_gate.py orphans`
reports 0/38 stale against the combined universe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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