fix(salt): allow 2^40 bucket capacity and validate what proofs and witnesses decode - #150
Open
flyq wants to merge 17 commits into
Open
fix(salt): allow 2^40 bucket capacity and validate what proofs and witnesses decode#150flyq wants to merge 17 commits into
flyq wants to merge 17 commits into
Conversation
`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
Performance Benchmark ComparisonCompared Detailed Comparison
|
Mutation testing - PASSMutation score: 100.0% (5/5 viable mutants killed)
No unsuppressed survivors or timeouts remain. |
Mutation testing - PASSMutation score: 100.0% (23/23 viable mutants killed)
No unsuppressed survivors or timeouts remain. |
`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
There was a problem hiding this comment.
💡 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".
`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>
vincent-k2026
approved these changes
Sep 3, 2026
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
SaltValuelength pair, or aBucketMetacapacity 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_rehashbounded the new capacity withBUCKET_SLOT_ID_MASK:BUCKET_SLOT_ID_MASKis the largest slot index; capacity is a slot count. The two differ by one, and that one matters here: capacities start atMIN_BUCKET_SIZEand 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 - 1fails 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 ofMIN_BUCKET_SIZE= 256 slots each, so 2^32 × 256 = 2^40. Slot IDs then run0..2^40, whose largest member is exactlyBUCKET_SLOT_ID_MASK— so they still fit the 40-bit slot field of aSaltKey.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:
16,843,009 + 2^32 - 1 = 4,311,810,304, which is preciselyleftmost_node(5) - 1, the last valid level-4 id. One more segment would classify as BFS level 5 and indexSUBTREE_DEFAULT_COMMITMENT[5]out of bounds.subtree_root_levelstops: 2^41 driveslevel -= 1at level 0. So the assert sits exactly at the structural limit.Raising the cap opens no new code path.
subtree_root_levelalready returns 0 for every capacity above 2^32, so the level-0 subtree root and its(bucket_id << 40) | 0logical 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_triewas 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. TheMAX_SUBTREE_LEVELSladder also mapped 768–65,536 slots to level 2; it is level 3. The table abovesubtree_root_levelwas 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 throughBUCKET_RESIZE_MULTIPLIERrather than acompute_resize_capacityresult, sincetest-bucket-resizeoverrides 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_rehashiterates0..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::deserializenow rejects a subtree level outside1..=MAX_SUBTREE_LEVELS(salt/src/proof/prover.rs:197). Previously any byte decoded;parents_and_pointsembeds a level ≥ 2 into an encoded parent id andlogic_parent_iddecodes it withSTARTING_NODE_ID[MAX_SUBTREE_LEVELS - level](salt/src/proof/shape.rs:298), which underflows for 6 and 7, and the trie'supdate_bucket_subtreesturns a witnessed level intoTRIE_WIDTH^level(salt/src/trie/trie.rs:513), which for ≥ 6 drivessubtree_root_levelbelow level 0 (level 0 makes it treat an expanded bucket as unexpanded). Wire format unchanged; the check also reachesstateless-core::LightWitness, whose round-trip test uses levels 0/7/255 and will need in-range values when itssaltpin moves past v1.0.5.fix(types):SaltValuedeserialization now rejects2 + key_len + value_len > MAX_SALT_VALUE_BYTES;key()/value()slicedataby those prefixes, so an overrun was a panic, not a rejection. The bound lives inTryFrom<[u8; 94]>(salt/src/types.rs:326) and is applied by a field-leveldeserialize_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-writtenDeserialize). Zero padding pastdata_len()is deliberately not checked (it never enterskv_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 ofshi_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:14now 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) andsalt_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-resize203,--no-default-features213,--no-default-features --features test-bucket-resize202 (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 highpass on the branch:fix(types):BucketMeta::try_from(salt/src/types.rs:95) now rejectscapacity == 0andcapacity > MAX_BUCKET_SIZE(:125). Capacity 0 ranshi_upsert's probe loop zero times, thencompute_resize_capacity(0, 0) = 0passed the assert, rehashed to 0 and recursed without bound; above 2^40subtree_root_levelhas 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_parentandget_parent_node(salt/src/trie/node_utils.rs:224,:288) did their level-relative subtraction after anas usizecast. At 2^40 the deepest subtree-local number is 4,311,810,304, pastu32::MAX, so a 32-bit target (wasm32 is named as one instate::ahash::convert) truncated. The arithmetic now stays in u64; a test pins both functions at that node.refactor(types): theSaltValuelength bound moved from a hand-writtenDeserializeplus mirror struct to a field-leveldeserialize_with(salt/src/types.rs:316), keeping the derive and the wire shape;TryFromreads the bound throughdata_len(). The struct doc now says the zero padding is not checked on decode.docs(salt): theshi_upsertbackstop comment is qualified to below the capacity ceiling (at 2^40shi_rehashasserts 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 thesingle_entry_bytescomment no longer claims the serializer filters levels.chore(mutants): thesuppression hygienejob failed on the previous head because seven line-pinned entries inmutants/suppressions.tomldrifted (thehasher.rscomment rewrite moved its lines down by two, theconstant.rsdoc additions movedDEFAULT_COMMITMENT_AT_LEVELdown by 21). Each is re-pinned to the site it was reviewed for, identified by content; verified locally against acargo mutants --list --package saltuniverse withscripts/mutation_gate.py orphans, 0/38 stale.fix(proof): Codex's inline finding, the last 32-bit-narrowing site:multi_commitments_to_scalarscomputed a child's slot asabsolute_node_id as usize - child_idx as usize(salt/src/proof/subtrie.rs:126). The 256-child range that begins at local node0xffffff01ends at0x100000000, 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-gatefailed on the previous head with one timed-out mutant, the trie pack'sSTARTING_NODE_ID[level - 1] -> [level]rewrite inget_parent_node, which returns a same-level node and makes every walk to the root spin. Adebug_assert_eq!on the returned node's level inget_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_bincodefixture (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-resize205,--no-default-features215,--no-default-features --features test-bucket-resize204 (two new tests each); clippy, fmt, cargo-sort and the riscv64 no_std check clean.Scope
stateless-validatorneeds nothing: it has no mirrored capacity bound anywhere, overridesget_subtree_levelsto read a witnessedlevelsbyte instead of callingsubtree_root_level, and pinssaltat tagv1.0.5— so this change does not reach it until that pin moves.Notes (pre-existing, not addressed here)
BucketMeta::try_frombounded 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.rssays the(bucket_id << 40) | 0form is forbidden for bucket roots, while thenode_utilsmodule 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:343andprover.rs:533discriminate main-trie from subtree nodes with< BUCKET_SLOT_ID_MASKwhere< (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