Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
be631ce
fix(state): allow bucket capacity to reach 2^40
flyq Aug 31, 2026
80d22c0
docs(salt): state the 2^40 bucket-capacity ceiling consistently
flyq Aug 31, 2026
282d385
docs(trie): don't overstate what bounds subtree_root_level's input
flyq Aug 31, 2026
4b5270a
test(state): decouple the capacity test from the load-factor threshold
flyq Aug 31, 2026
802e30d
fix(proof): reject out-of-range subtree levels on decode
flyq Sep 2, 2026
0ab6a66
fix(types): bound SaltValue's declared lengths on decode
flyq Sep 2, 2026
b331179
docs(state): describe what a skipped load-factor resize costs
flyq Sep 2, 2026
8811188
fix(types): bound BucketMeta capacity on decode
flyq Sep 2, 2026
adb61d2
fix(trie): keep subtree-local node arithmetic in u64
flyq Sep 2, 2026
cdad45b
refactor(types): validate SaltValue through a field-level deserializer
flyq Sep 2, 2026
088def6
docs(salt): qualify the resize backstop and the README growth example
flyq Sep 2, 2026
e44090c
test(proof): keep levels fixtures inside the valid range
flyq Sep 2, 2026
a1ddd44
chore(mutants): re-pin line-scoped suppressions after line drift
flyq Sep 2, 2026
a93e212
fix(proof): subtract child offsets in u64 before narrowing to usize
flyq Sep 2, 2026
8a8d398
fix(trie): fail fast when get_parent_node lands on the wrong level
flyq Sep 2, 2026
ebe1039
refactor(salt): pin the capacity ceiling at compile time and drop the…
flyq Sep 8, 2026
f93abed
fix(constant): pin the deepest subtree node exactly so the spec-gate …
flyq Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The main trie's internal nodes also use homomorphic commitments. After bucket co
While each step up the trie costs one ECMul, updates from multiple distinct child nodes are batched into a single update for their common parent. This optimization is extremely effective because the trie's width shrinks dramatically at higher levels, consolidating many changes at the leaf level into a small number of updates near the root. For example, updating 200,000 random keys in SALT requires a total of approximately 460,000 ECMul operations, or an amortized cost of about **2.3 ECMuls** per key.

### Bucket Growth
While the main SALT tree is static, the buckets are not. A bucket is initialized with 256 slots. When it fills up, it can be resized to a multiple of 256. If a bucket grows beyond 256 slots, it is partitioned into 256-slot segments. A new complete 256-ary **bucket tree** is built on top of these segments, and the root of this new tree becomes the bucket's new commitment (also the new leaf in the main trie). The diagram below shows a bucket tree of 768 slots.
While the main SALT tree is static, the buckets are not. A bucket is initialized with 256 slots. When it fills up, its capacity is doubled, so every capacity is a power of two from 256 up to 2^40 slots (the most a 5-level bucket tree can address). If a bucket grows beyond 256 slots, it is partitioned into 256-slot segments. A new complete 256-ary **bucket tree** is built on top of these segments, and the root of this new tree becomes the bucket's new commitment (also the new leaf in the main trie). The diagram below shows a bucket tree of 768 slots (three segments) purely to illustrate the shape; a bucket the protocol has grown holds 512, 1024, ... slots.

```
(In Main SALT Trie)
Expand Down
14 changes: 7 additions & 7 deletions mutants/suppressions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ reviewer = "krabat/feat/mutation-testing review 2026-07-05"
kind = "line"
category = "equivalent"
file = "salt/src/constant.rs"
line = 221
line = 263
mutant = "replace + with * in default_commitment"
justification = "Turns the level-0 boundary from STARTING_NODE_ID[0]+1 into STARTING_NODE_ID[0]*1 = 0, but the level-0 tuple carries identical left/right commitments, so the selected value is unchanged for every node id. Pinned to the level-0 site; the level 1-3 boundary additions are killed by test_default_commitment_boundary_selection."
reviewer = "full-run triage 2026-07-08 (issue #144)"
Expand Down Expand Up @@ -151,7 +151,7 @@ reviewer = "full-run triage 2026-07-08 (issue #144)"
kind = "line"
category = "equivalent"
file = "salt/src/state/hasher.rs"
line = 71
line = 73
mutant = "replace + with * in hash_with_nonce"
justification = "Pinned to the buffer-selection guard (line 71): key_len + 4 <= 64 vs key_len * 4 <= 64 only moves the stack/heap buffer split; both paths hash the identical byte sequence. The `key_len + 4` slice-length sites (lines 74-75) are non-equivalent and are killed by tests (verified by a cargo-mutants run: only line 71 survives), so the pin cannot mask them."
reviewer = "full-run triage 2026-07-08 (issue #144)"
Expand Down Expand Up @@ -200,7 +200,7 @@ reviewer = "full-run triage 2026-07-08 (issue #144)"
kind = "line"
category = "dead"
file = "salt/src/state/hasher.rs"
line = 51
line = 53
mutant = "replace bucket_id -> BucketId with Default::default()"
justification = "The test-bucket-resize cfg variant is not compiled under the canonical mutation feature set. Pinned to that variant's site so the production bucket_id, whose function-replacement mutant is killed by the pinned bucket-id tests, can never be covered by this entry."
reviewer = "full-run triage 2026-07-08 (issue #144)"
Expand All @@ -209,7 +209,7 @@ reviewer = "full-run triage 2026-07-08 (issue #144)"
kind = "line"
category = "dead"
file = "salt/src/state/hasher.rs"
line = 58
line = 60
mutant = "replace % with + in bucket_id"
justification = "test-bucket-resize cfg variant, not compiled under the canonical mutation feature set; line-pinned so the production arithmetic is never covered."
reviewer = "full-run triage 2026-07-08 (issue #144)"
Expand All @@ -218,7 +218,7 @@ reviewer = "full-run triage 2026-07-08 (issue #144)"
kind = "line"
category = "dead"
file = "salt/src/state/hasher.rs"
line = 58
line = 60
mutant = "replace % with / in bucket_id"
justification = "test-bucket-resize cfg variant, not compiled under the canonical mutation feature set; line-pinned so the production arithmetic is never covered."
reviewer = "full-run triage 2026-07-08 (issue #144)"
Expand All @@ -227,7 +227,7 @@ reviewer = "full-run triage 2026-07-08 (issue #144)"
kind = "line"
category = "dead"
file = "salt/src/state/hasher.rs"
line = 58
line = 60
mutant = "replace + with * in bucket_id"
justification = "test-bucket-resize cfg variant, not compiled under the canonical mutation feature set; line-pinned so the production arithmetic is never covered."
reviewer = "full-run triage 2026-07-08 (issue #144)"
Expand All @@ -236,7 +236,7 @@ reviewer = "full-run triage 2026-07-08 (issue #144)"
kind = "line"
category = "dead"
file = "salt/src/state/hasher.rs"
line = 58
line = 60
mutant = "replace + with - in bucket_id"
justification = "test-bucket-resize cfg variant, not compiled under the canonical mutation feature set; line-pinned so the production arithmetic is never covered."
reviewer = "full-run triage 2026-07-08 (issue #144)"
Expand Down
53 changes: 48 additions & 5 deletions salt/src/constant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,44 @@ pub const MIN_BUCKET_SIZE: usize = 1 << MIN_BUCKET_SIZE_BITS;
/// Set equal to MIN_BUCKET_SIZE since metadata buckets don't need to resize
/// and maintaining uniform size simplifies the implementation.
pub const META_BUCKET_SIZE: usize = MIN_BUCKET_SIZE;
/// Maximum capacity of a SALT bucket (2^40 = 1,099,511,627,776 slots).
///
/// A bucket keeps its slots in the deepest level of its subtree, which holds
/// `TRIE_WIDTH^(MAX_SUBTREE_LEVELS - 1)` = 256^4 = 2^32 segments of `MIN_BUCKET_SIZE`
/// = 256 slots each. So a bucket can address 2^32 * 2^8 = 2^40 slots, and
/// `subtree_root_level(MAX_BUCKET_SIZE)` is 0, the topmost subtree level.
///
/// This equals `1 << BUCKET_SLOT_BITS`: slot IDs run over `0..MAX_BUCKET_SIZE`, whose
/// largest member is `BUCKET_SLOT_ID_MASK`, so every slot ID still fits in the low
/// `BUCKET_SLOT_BITS` bits of a `SaltKey`.
///
/// **Note**: `MAX_BUCKET_SIZE` is a slot *count*, whereas `BUCKET_SLOT_ID_MASK` is the
/// largest slot *index*. Bounding a capacity by the mask caps it one doubling short,
/// at 2^39, because capacities only ever double up from `MIN_BUCKET_SIZE`.
pub const MAX_BUCKET_SIZE: u64 =
1 << ((MAX_SUBTREE_LEVELS - 1) * TRIE_WIDTH_BITS + MIN_BUCKET_SIZE_BITS);
Comment thread
flyq marked this conversation as resolved.

// `MAX_BUCKET_SIZE` is derived from the subtree shape while `BUCKET_SLOT_BITS` is the
// `SaltKey` layout; tie them together at compile time so neither can drift.
const _: () = {
// The shift form above is the segment count times the segment size.
assert!(
MAX_BUCKET_SIZE
== (TRIE_WIDTH as u64).pow((MAX_SUBTREE_LEVELS - 1) as u32) * MIN_BUCKET_SIZE as u64
);
// Every slot index of a maximally expanded bucket fits the slot field, and the
// largest one is exactly `BUCKET_SLOT_ID_MASK`.
assert!(MAX_BUCKET_SIZE == 1 << BUCKET_SLOT_BITS);
// That bucket's segments fill the deepest subtree level exactly: its last node
// is the one just before a sixth level would begin, so the ceiling has no slack
// and a wrong level base fails to compile here.
const MAX_SUBTREE_NODE_ID: u64 = STARTING_NODE_ID[MAX_SUBTREE_LEVELS - 1] as u64
+ MAX_BUCKET_SIZE / MIN_BUCKET_SIZE as u64
- 1;
assert!(MAX_SUBTREE_NODE_ID + 1 == leftmost_node(MAX_SUBTREE_LEVELS as u32).unwrap());
// And that node must not bleed into the bucket-id bits of a `NodeId`.
assert!(MAX_SUBTREE_NODE_ID <= BUCKET_SLOT_ID_MASK);
};

// ============================================================================
// Trie Structure Constants
Expand All @@ -54,11 +92,14 @@ pub const MAIN_TRIE_LEVELS: usize = 4;
/// leaf nodes at the deepest level (level 4) of the MAXIMAL subtree structure. As
/// bucket capacity increases, the subtree root moves UP to accommodate more leaves.
///
/// Structure evolution by capacity:
/// Structure evolution by capacity (see `subtree_root_level`):
/// - 256 slots (1 segment): Single-node subtree, root at level 4
/// - 512 slots (2 segments): Root at level 3, 2 leaf nodes at level 4
/// - 768-65536 slots: Root at level 2, internal nodes at level 3, leaves at level 4
/// - 65537+ slots: Root at higher levels as needed
/// - 512-65,536 slots: Root at level 3, leaves at level 4
/// - 65,537-16,777,216 slots: Root at level 2
/// - 16,777,217-4,294,967,296 slots: Root at level 1
/// - 4,294,967,297-1,099,511,627,776 slots: Root at level 0, the full 5-level subtree
///
/// The last row ends at [`MAX_BUCKET_SIZE`], whose doc derives that ceiling.
///
/// Example for 512-slot bucket (2 segments):
/// ```text
Expand Down Expand Up @@ -129,7 +170,8 @@ pub const STARTING_NODE_ID: [usize; MAX_SUBTREE_LEVELS] = [
pub const BUCKET_ID_BITS: usize = 24;

/// Maximum number of bits to represent a slot index in a bucket.
/// 40 bits supports up to ~1 trillion slots per bucket, providing ample room for growth.
/// 40 bits holds every slot index of a maximally expanded bucket, which has
/// `MAX_BUCKET_SIZE` = 2^40 (~1.1 trillion) slots indexed `0..=BUCKET_SLOT_ID_MASK`.
pub const BUCKET_SLOT_BITS: usize = 40;

/// Mask to extract the slot ID from a NodeId or SaltKey.
Expand Down Expand Up @@ -269,6 +311,7 @@ mod tests {
assert_eq!(MIN_BUCKET_SIZE_BITS, 8);
assert_eq!(MIN_BUCKET_SIZE, 256);
assert_eq!(META_BUCKET_SIZE, 256);
assert_eq!(MAX_BUCKET_SIZE, 1_099_511_627_776);
assert_eq!(MAIN_TRIE_LEVELS, 4);
assert_eq!(MAX_SUBTREE_LEVELS, 5);
assert_eq!(TRIE_WIDTH_BITS, 8);
Expand Down
74 changes: 59 additions & 15 deletions salt/src/proof/prover.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Prover for the Salt proof
use crate::{
constant::{BUCKET_SLOT_ID_MASK, DOMAIN_SIZE, STARTING_NODE_ID},
constant::{BUCKET_SLOT_ID_MASK, DOMAIN_SIZE, MAX_SUBTREE_LEVELS, STARTING_NODE_ID},
proof::{
shape::{connect_parent_id, logic_parent_id, parents_and_points},
subtrie::create_sub_trie,
Expand Down Expand Up @@ -152,6 +152,14 @@ pub struct SaltProof {
/// and breaks downstream alloy-tx-macros 1.0.23). Entries are emitted in
/// ascending key order to keep proof bytes deterministic across provers.
/// Also reused downstream (e.g. `stateless-core::LightWitness`) via `#[serde(with = "salt::fx_hashmap_serde")]`.
///
/// Deserialization rejects a duplicate `BucketId` and any level outside
/// `1..=MAX_SUBTREE_LEVELS`. A bucket at `MIN_BUCKET_SIZE` capacity has one
/// level (its single segment) and a bucket at `MAX_BUCKET_SIZE` has
/// `MAX_SUBTREE_LEVELS`, so no prover emits anything else, and the code that
/// turns a level back into a subtree shape (`parents_and_points`, and the
/// trie's `update_bucket_subtrees` via `get_subtree_levels`) is only defined on
/// that range.
pub mod fx_hashmap_serde {
use super::*;

Expand All @@ -177,12 +185,20 @@ pub mod fx_hashmap_serde {
type Value = FxHashMap<BucketId, u8>;

fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a map of BucketId to u8")
write!(
f,
"a map of BucketId to a subtree level in 1..={MAX_SUBTREE_LEVELS}"
)
}

fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut map: FxHashMap<BucketId, u8> = FxHashMap::default();
while let Some((k, v)) = access.next_entry::<BucketId, u8>()? {
if !(1..=MAX_SUBTREE_LEVELS as u8).contains(&v) {
return Err(A::Error::custom(format!(
"level {v} for BucketId {k} is outside 1..={MAX_SUBTREE_LEVELS}"
)));
}
if map.insert(k, v).is_some() {
return Err(A::Error::custom("duplicate BucketId in levels"));
}
Expand Down Expand Up @@ -1515,8 +1531,12 @@ mod tests {
/// Serializing then deserializing must yield an equivalent map.
#[test]
fn round_trip_preserves_entries() {
let original =
levels_wrapper([(0u32, 0u8), (42, 3), (1_000_000, 7), (BucketId::MAX, 255)]);
let original = levels_wrapper([
(0u32, 1u8),
(42, 3),
(1_000_000, 4),
(BucketId::MAX, MAX_SUBTREE_LEVELS as u8),
]);

let bytes =
bincode::serde::encode_to_vec(&original, bincode::config::legacy()).unwrap();
Expand Down Expand Up @@ -1550,7 +1570,7 @@ mod tests {
(4, 3),
(1_000_000, 4),
(5, 5),
(BucketId::MAX, 6),
(BucketId::MAX, 1),
];

let forward = levels_wrapper(entries);
Expand All @@ -1573,18 +1593,42 @@ mod tests {
assert_eq!(forward_bytes, reverse_bytes);
}

/// Bincode (legacy config) bytes of a `levels` map, built by hand so a test
/// can present entries no prover produces: a duplicate bucket, or a level
/// outside the valid range (the serializer writes any u8 unchanged; only
/// deserialization validates).
fn levels_bytes(entries: &[(BucketId, u8)]) -> Vec<u8> {
let mut bytes = (entries.len() as u64).to_le_bytes().to_vec();
for (bucket_id, level) in entries {
bytes.extend_from_slice(&bucket_id.to_le_bytes());
bytes.push(*level);
}
bytes
}

fn decode(bytes: &[u8]) -> Result<LevelsWrapper, bincode::error::DecodeError> {
bincode::serde::decode_from_slice(bytes, bincode::config::legacy())
.map(|(decoded, _)| decoded)
}

#[test]
fn rejects_duplicate_bucket_id() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&2u64.to_le_bytes());
bytes.extend_from_slice(&7u32.to_le_bytes());
bytes.push(1u8);
bytes.extend_from_slice(&7u32.to_le_bytes());
bytes.push(2u8);

let result: Result<(LevelsWrapper, _), _> =
bincode::serde::decode_from_slice(&bytes, bincode::config::legacy());
assert!(result.is_err(), "duplicate BucketId must be rejected");
assert!(
decode(&levels_bytes(&[(7, 1), (7, 2)])).is_err(),
"duplicate BucketId must be rejected"
);
}

/// Levels outside `1..=MAX_SUBTREE_LEVELS` are refused; both ends of the
/// range decode in `round_trip_preserves_entries`.
#[test]
fn rejects_out_of_range_levels() {
for level in [0, MAX_SUBTREE_LEVELS as u8 + 1] {
assert!(
decode(&levels_bytes(&[(7, level)])).is_err(),
"level {level} must be rejected"
);
}
}
}

Expand Down
14 changes: 9 additions & 5 deletions salt/src/proof/shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ use rustc_hash::FxBuildHasher;
type FxHashMap<K, V> = HashMap<K, V, FxBuildHasher>;

use crate::{
constant::{BUCKET_SLOT_BITS, MAX_SUBTREE_LEVELS, STARTING_NODE_ID},
constant::{
BUCKET_SLOT_BITS, MAIN_TRIE_LEVELS, MAX_SUBTREE_LEVELS, ROOT_NODE_ID, STARTING_NODE_ID,
},
trie::node_utils::{
bucket_root_node_id, get_parent_node, subtree_leaf_for_key, vc_position_in_parent,
},
Expand Down Expand Up @@ -71,11 +73,12 @@ pub(crate) fn parents_and_points(
// ============================================================================
// Phase 1: Main Trie Traversal
// ============================================================================
// Walk from the bucket root up to the main trie root (node 0), recording
// each parent-child relationship. This captures the path through the fixed
// 4-level main trie structure that leads to this bucket.
// Walk from the bucket root up to the main trie root, recording each
// parent-child relationship. The main trie has a fixed depth, so the walk
// is exactly `MAIN_TRIE_LEVELS - 1` steps; bounding it by that count rather
// than by reaching the root keeps a corrupt parent id from spinning forever.
let mut node = bucket_root_node_id(salt_key.bucket_id());
while node != 0 {
for _ in 0..MAIN_TRIE_LEVELS - 1 {
let parent_node = get_parent_node(&node);
// Record that this parent needs to prove the child at this position
internal_nodes
Expand All @@ -85,6 +88,7 @@ pub(crate) fn parents_and_points(

node = parent_node;
}
debug_assert_eq!(node, ROOT_NODE_ID);

// ============================================================================
// Phase 2: Bucket Tree Traversal
Expand Down
5 changes: 4 additions & 1 deletion salt/src/proof/subtrie.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ where

// Replace defaults with actual commitments where they exist
for (absolute_node_id, commitment_bytes) in children {
let relative_index = absolute_node_id as usize - child_idx as usize;
// Subtract in u64 before narrowing: a 256-child range near the top of
// a level-4 subtree straddles 2^32, so casting each id to a 32-bit
// `usize` first would underflow.
let relative_index = (absolute_node_id - child_idx) as usize;
child_commitments[relative_index] = to_element(commitment_bytes);
}

Expand Down
4 changes: 3 additions & 1 deletion salt/src/state/hasher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ use crate::constant::NUM_META_BUCKETS;
use crate::types::BucketId;
use core::hash::{BuildHasher, Hasher};

/// Fixed seeds derived from the lower 32 bytes of keccak256("Make Ethereum Great Again").
/// Fixed seeds: the low 128 bits of keccak256("Make Ethereum Great Again")
/// (`0xfd3d34b57e26ebb766fefcc2225e73fc921321f42ccb667e60d68842077ada9d`),
/// read as four big-endian 32-bit words.
const HASHER_SEEDS: [u64; 4] = [0x921321f4, 0x2ccb667e, 0x60d68842, 0x077ada9d];

/// Computes a deterministic 64-bit hash of the input bytes.
Expand Down
Loading
Loading