@@ -108,15 +108,19 @@ pub fn insert_subtree_root_to_snapshot_with_hasher<let TreeHeight: u32, let Subt
108108/// Appends up to `MaxLeaves` leaves to a poseidon append-only tree at its current (arbitrary) next-available index.
109109///
110110/// Unlike `insert_subtree_root_to_snapshot`, the insertion index does not need to be aligned to a power-of-two
111- /// boundary: leaves are absorbed one at a time into the tree's frontier (the incremental-merkle-tree "filled
112- /// subtrees"), so a compact bundle can start wherever the previous append left off. Only `leaves[0..num_leaves]` are
113- /// inserted; the remaining lanes are no-ops and must be zero.
111+ /// boundary: the whole batch is merged into the tree's frontier (the incremental-merkle-tree "filled subtrees") level
112+ /// by level, so a compact bundle can start wherever the previous append left off. Only `leaves[0..num_leaves]` are
113+ /// inserted; the remaining lanes are no-ops and must be zero. The pair-hash lanes halve at each level as the batch
114+ /// rises, so the total cost is ~`MaxLeaves + 3 * TreeHeight` hashes rather than `MaxLeaves * TreeHeight`.
114115///
115116/// `frontier_hint` is the frontier at `snapshot.next_available_leaf_index`, i.e. `frontier_hint[level]` is the pending
116117/// left-sibling node at each level. It is validated against `snapshot.root` before use, pinning it to the snapshot the
117- /// same way sibling-path hints are pinned elsewhere. Only the hint entries at levels where the start index is a right
118- /// child are read during the append, and those are exactly the entries the root recomputation pins, so an incorrect
119- /// hint cannot produce a wrong result: it either fails validation or is overwritten before it is read.
118+ /// same way sibling-path hints are pinned elsewhere. The merge only ever reads hint entries at levels where the start
119+ /// index is a right child, which are exactly the entries the validation pins, so an incorrect hint cannot produce a
120+ /// wrong result: it either fails validation or is never read.
121+ ///
122+ /// A completely full tree (`next_available_leaf_index == 2^TreeHeight`) has no representable frontier, so even a
123+ /// `num_leaves = 0` call against one fails hint validation. This fails closed and is accepted behavior.
120124pub fn append_leaves_to_snapshot <let TreeHeight : u32 , let MaxLeaves : u32 >(
121125 snapshot : AppendOnlyTreeSnapshot ,
122126 leaves : [Field ; MaxLeaves ],
@@ -143,11 +147,11 @@ pub fn append_leaves_to_snapshot<let TreeHeight: u32, let MaxLeaves: u32>(
143147 let start_index = snapshot .next_available_leaf_index as u64 ;
144148 let end_index = start_index + num_leaves as u64 ;
145149 // Guard against wrapping past the tree capacity, which would otherwise silently produce a wrong root.
150+ let mut tree_is_filled_exactly = false ;
146151 if TreeHeight < 64 {
147- assert (
148- end_index <= (1 as u64 << TreeHeight as u64 ),
149- "append_leaves_to_snapshot exceeds the tree capacity" ,
150- );
152+ let capacity = 1 as u64 << TreeHeight as u64 ;
153+ assert (end_index <= capacity , "append_leaves_to_snapshot exceeds the tree capacity" );
154+ tree_is_filled_exactly = end_index == capacity ;
151155 }
152156
153157 // Validate the hint pins to the snapshot: recompute the root of a tree holding `start_index` leaves from the hint's
@@ -158,28 +162,59 @@ pub fn append_leaves_to_snapshot<let TreeHeight: u32, let MaxLeaves: u32>(
158162 "Frontier hint does not match the snapshot root" ,
159163 );
160164
161- // Absorb each real leaf into the frontier, tracking the running root; padding lanes are skipped.
165+ // Merge the batch into the frontier one level at a time. At each level, `working` holds the batch's nodes,
166+ // starting at absolute position `s` with `c` real entries (lanes past `c` hold garbage that is never read):
167+ // - If `s` is odd the batch starts as a right child, so its pending left sibling — the validated hint entry — is
168+ // prepended via a mux shift (no hashes) to make the batch start even.
169+ // - If the count is then odd, the dangling last node sits at an even position: it is the level's new pending left
170+ // child, so it becomes the frontier entry and drops out of pairing. On an even count the old entry is left in
171+ // place; at the new tree size it is unused garbage, just like unread hint lanes.
172+ // - The remaining nodes are hashed pairwise into the next level.
162173 let mut frontier = frontier_hint ;
163- let mut index = start_index ;
164- let mut root = snapshot .root ;
174+ let mut working = [0 ; MaxLeaves + 2 ];
165175 for i in 0 ..MaxLeaves {
166- if i < num_leaves {
167- let mut node = leaves [i ];
168- let mut idx = index ;
169- for level in 0 ..TreeHeight {
170- if idx % 2 == 0 {
171- frontier [level ] = node ;
172- node = merkle_hash (node , zero_subtree_roots [level ]);
173- } else {
174- node = merkle_hash (frontier [level ], node );
175- }
176- idx = idx / 2 ;
177- }
178- root = node ;
179- index += 1 ;
176+ working [i ] = leaves [i ];
177+ }
178+ let mut s = start_index ;
179+ let mut c = num_leaves ;
180+ for level in 0 ..TreeHeight {
181+ let prepend = (s % 2 == 1 ) & (c != 0 );
182+ let mut shifted = [0 ; MaxLeaves + 2 ];
183+ shifted [0 ] = if prepend { frontier [level ] } else { working [0 ] };
184+ // The loop bounds below only need to upper-bound the batch size at this level; the shift amounts are clamped
185+ // below 32 because a u32 shift by 32+ does not const-fold, and `MaxLeaves >> 31` is already 0.
186+ for j in 1 ..(MaxLeaves >> std::cmp:: min (level , 31 )) + 2 {
187+ shifted [j ] = if prepend { working [j - 1 ] } else { working [j ] };
180188 }
189+ let s2 = s - prepend as u64 ;
190+ let c2 = c + prepend as u32 ;
191+
192+ // `c2 - 1` is only meaningful when `c2` is odd (hence >= 1); the subtraction is kept unconditionally safe.
193+ let dangling_index = c2 - (c2 != 0 ) as u32 ;
194+ frontier [level ] = if c2 % 2 == 1 {
195+ shifted [dangling_index ]
196+ } else {
197+ frontier [level ]
198+ };
199+
200+ for i in 0 ..(MaxLeaves >> std::cmp:: min (level + 1 , 31 )) + 1 {
201+ working [i ] = merkle_hash (shifted [2 * i ], shifted [2 * i + 1 ]);
202+ }
203+ s = s2 / 2 ;
204+ c = c2 / 2 ;
181205 }
182206
207+ // The frontier is now the end-state frontier, so the final root falls out of the same recomputation used to
208+ // validate the hint. The exception is an append that fills the tree exactly: a full tree has no representable
209+ // frontier, but in that case the batch was merged all the way up and `working[0]` is the root itself.
210+ let root = if num_leaves == 0 {
211+ snapshot .root
212+ } else if tree_is_filled_exactly {
213+ working [0 ]
214+ } else {
215+ recompute_root_from_frontier (frontier , zero_subtree_roots , end_index )
216+ };
217+
183218 AppendOnlyTreeSnapshot {
184219 root ,
185220 next_available_leaf_index : snapshot .next_available_leaf_index + num_leaves as Field ,
@@ -549,4 +584,72 @@ mod append_leaves_tests {
549584 let leaves = [42 , 0 , 99 , 0 , 0 , 0 , 0 , 0 ];
550585 let _ = append_leaves_to_snapshot ::<5 , 8 >(snapshot , leaves , 1 , [0 ; 5 ]);
551586 }
587+
588+ /// Extracts the frontier at `index` from a height-4 reference tree: the node at each level's position
589+ /// `(index >> level) - 1` when that position is odd, i.e. the pending left sibling.
590+ unconstrained fn frontier_of_height_4_tree (tree : MerkleTree <16 >, index : u32 ) -> [Field ; 4 ] {
591+ // Level offsets into `MerkleTree.nodes` for N = 16: level 1 starts at 0, level 2 at 8, level 3 at 12.
592+ let offsets = [0 , 8 , 12 ];
593+ let mut frontier = [0 ; 4 ];
594+ if index % 2 == 1 {
595+ frontier [0 ] = tree .leaves [index - 1 ];
596+ }
597+ for level in 1 ..4 {
598+ let p = index >> level ;
599+ if p % 2 == 1 {
600+ frontier [level ] = tree .nodes [offsets [level - 1 ] + p - 1 ];
601+ }
602+ }
603+ frontier
604+ }
605+
606+ #[test]
607+ unconstrained fn append_exhaustive_small_tree_sweep () {
608+ // Height-4 tree (capacity 16) with MaxLeaves = 6: every (start, num) with start + num <= capacity, compared
609+ // against a reference tree built over the concatenated leaves. Exercises every parity combination of the
610+ // prepend/dangling logic, including appends that fill the tree exactly.
611+ for start in 0 ..16 {
612+ for num in 0 ..7 {
613+ if start + num <= 16 {
614+ let mut pre_leaves = [0 ; 16 ];
615+ for i in 0 ..start {
616+ pre_leaves [i ] = (100 + i ) as Field ;
617+ }
618+ let pre_tree = MerkleTree ::new (pre_leaves );
619+ let snapshot = AppendOnlyTreeSnapshot {
620+ root : pre_tree .get_root (),
621+ next_available_leaf_index : start as Field ,
622+ };
623+ let frontier = frontier_of_height_4_tree (pre_tree , start );
624+
625+ let mut batch = [0 ; 6 ];
626+ let mut all_leaves = pre_leaves ;
627+ for i in 0 ..num {
628+ let value = (200 + start + i ) as Field ;
629+ batch [i ] = value ;
630+ all_leaves [start + i ] = value ;
631+ }
632+
633+ let result = append_leaves_to_snapshot ::<4 , 6 >(snapshot , batch , num , frontier );
634+
635+ let expected_tree = MerkleTree ::new (all_leaves );
636+ assert_eq (result .root , expected_tree .get_root ());
637+ assert_eq (result .next_available_leaf_index , (start + num ) as Field );
638+ }
639+ }
640+ }
641+ }
642+
643+ #[test(should_fail_with = "Frontier hint does not match the snapshot root")]
644+ fn append_to_completely_full_tree_fails () {
645+ // A full tree has no representable frontier; even a num_leaves = 0 call fails hint validation (fail-closed).
646+ let mut leaves = [0 ; 16 ];
647+ for i in 0 ..16 {
648+ leaves [i ] = (100 + i ) as Field ;
649+ }
650+ let tree = MerkleTree ::new (leaves );
651+ let snapshot =
652+ AppendOnlyTreeSnapshot { root : tree .get_root (), next_available_leaf_index : 16 };
653+ let _ = append_leaves_to_snapshot ::<4 , 6 >(snapshot , [0 ; 6 ], 0 , [0 ; 4 ]);
654+ }
552655}
0 commit comments