Skip to content

repro: the spend path discards the remove_mark outcome - #2730

Draft
dorianvp wants to merge 1 commit into
devfrom
repro/remove-mark-bool-discarded
Draft

repro: the spend path discards the remove_mark outcome#2730
dorianvp wants to merge 1 commit into
devfrom
repro/remove-mark-bool-discarded

Conversation

@dorianvp

Copy link
Copy Markdown
Member

Claim

pepper-sync/src/sync/spend.rs:366-368 (update_spent_notes_by_protocol) calls shard_tree.remove_mark(position, Some(&spending_height)).expect("infallible") and discards the returned bool. In shardtree 0.7.1 (src/lib.rs, ShardTree::remove_mark) that bool carries two silent outcomes:

  • (a) When spending_height is at or above the oldest checkpoint id but no checkpoint with exactly that id exists, update_checkpoint_with finds nothing, remove_mark returns Ok(false), and the mark is kept. Nothing records the spend in the tree.
  • (b) When spending_height is below the oldest checkpoint id, the mark is cleared directly and Ok(true) is returned. No checkpoint records the removal, so a rollback cannot restore it.

The crate's raw_boolean_calls_are_confined_to_this_module lint test in pepper-sync/src/shardtree_ext.rs wraps checkpoint and truncate_to_checkpoint and does not cover remove_mark.

What the tests do

All three tests live in pepper-sync/src/sync/spend.rs, module remove_mark_outcome_repro.

  • library_remove_mark_without_checkpoint_at_height_is_silent_noop is a library fact. A sapling MemoryShardStore tree with a marked leaf checkpointed at height 10 and a second checkpoint at 30 receives remove_mark(0, Some(&20)). It returns Ok(false), the leaf keeps RetentionFlags::MARKED, no checkpoint records the position, and witness_at_checkpoint_id still succeeds. This test passes and documents what pepper-sync silently depends on.
  • library_remove_mark_below_oldest_checkpoint_is_not_restored_by_rollback is a library fact. With checkpoints 6..10 and the marked leaf, remove_mark(0, Some(&2)) returns Ok(true), the flag is cleared, no checkpoint records it, and after rollback_to_checkpoint(6) the leaf cannot be witnessed. This test passes.
  • update_spent_notes_drops_remove_mark_outcome is the crate path. A MockWallet holds a confirmed funding transaction with a sapling note at position 0 and a known nullifier, a spending transaction confirmed at height 20, a nullifier map entry pointing at it, and a sapling tree whose checkpoints are 0, 10 and 30. Spend detection and update_spent_notes(.., remove_marks = true) run. The note's spending_transaction is set, and the test then asserts the intended invariant that the mark is either cleared or recorded for removal in a checkpoint. This test fails on current code.

Observed failing output

test sync::spend::remove_mark_outcome_repro::update_spent_notes_drops_remove_mark_outcome ... FAILED
test sync::spend::remove_mark_outcome_repro::library_remove_mark_without_checkpoint_at_height_is_silent_noop ... ok
test sync::spend::remove_mark_outcome_repro::library_remove_mark_below_oldest_checkpoint_is_not_restored_by_rollback ... ok

panicked at pepper-sync/src/sync/spend.rs:693:9:
spent note at position 0 is still marked and no checkpoint records the removal: remove_mark returned Ok(false) and the spend path ignored it

Which part is library behaviour and which part is pepper-sync

Outcomes (a) and (b) are shardtree behaviour and are documented by the two passing library tests. The pepper-sync defect is that update_spent_notes_by_protocol cannot tell outcome (a) apart from a successful removal, so a retention leak for a spent note passes without any signal. Outcome (b) is the intended shardtree behaviour for heights older than the reorg window and is included for completeness, since pepper-sync does not record which path was taken.

Reachability note for (a) in production: SyncShardTrees::update_shard_trees adds a checkpoint at every height in the top MAX_REORG_ALLOWANCE scanned blocks, so the checkpoint set is dense there and a spend inside that window finds its checkpoint. The failing test sets up a sparse checkpoint set directly. A sparse set needs fewer than MAX_REORG_ALLOWANCE window checkpoints plus block-level checkpoints surviving from an out-of-order lower scan, which this PR does not drive end to end.

This PR is a reproduction and not a fix. The sync-bench A/B rule was not run because the commit adds a test only.

🤖 Generated with Claude Code

Co-Authored-By: Claude Fable 5 <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.

1 participant