Skip to content

fix(platform-wallet): auto-release a stranded shielded-spend reservation on sync#3982

Merged
QuantumExplorer merged 4 commits into
v4.0-devfrom
fix/shielded-spend-reservation-auto-release
Jul 2, 2026
Merged

fix(platform-wallet): auto-release a stranded shielded-spend reservation on sync#3982
QuantumExplorer merged 4 commits into
v4.0-devfrom
fix/shielded-spend-reservation-auto-release

Conversation

@shumkov

@shumkov shumkov commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto v4.0-dev after #3977 squash-merged. The stored anchor must be a Platform-recorded one, which #3977 guarantees.

Issue being fixed or feature implemented

Second half of the TestFlight shielded-withdrawal investigation (bug A). A shielded spend that returns ShieldedSpendUnconfirmed — broadcast accepted, but the result-wait failed ambiguously — intentionally keeps the spent notes' reservation (pending_nullifiers) so a retry can't double-spend. That reservation was released only when the tx lands (a later sync marks the note spent) or on app restart. So a spend that is broadcast-accepted but never lands (lost ACK / mempool eviction) stranded its notes for the whole session — the funds looked stuck, with the "may have gone through — do not retry" message and no in-session resolution.

(#3977 removed the dominant trigger — anchor-mismatch spends are now refused pre-broadcast with the notes freed — so this is the residual case.)

What was done?

Remember the Platform-recorded anchor each spend was built against, then on each sync — after the normal spent-note reconcile — release any still-pending reservation whose anchor is no longer in Platform's recorded set (GetShieldedAnchors). Once the anchor is pruned (shielded_anchor_retention_blocks = 1000) the transition can never execute, so with the nullifier still unspent (which "still pending after the reconcile" already implies) the spend is provably dead and its notes are freed; the linked activity flips Pending → Failed.

  • SubwalletState.pending_nullifiers: BTreeSet<[u8;32]>BTreeMap<[u8;32], PendingSpend { anchor, activity_id }> (in-memory, as before — a restart still frees everything).
  • arm_pending_release fills in the anchor + activity id across all four spend flows (unshield / transfer / withdraw / identity_create_from_shielded_pool), after extract_spends_and_anchor + record_pending_activity, before broadcast.
  • coordinator::release_stranded_spends runs the release pass in sync(); it skips the network call entirely when no armed reservations exist, and a fetch hiccup only warns (never fails the sync).

Fund-safety (please review): release fires only when the anchor is absent from the freshly-fetched recorded set — never a still-recorded (slow-but-landing) spend. The on-chain nullifier set stays the authoritative double-spend guard. The spent-note reconcile runs before the release pass, so a landed tx's reservation is already cleared → no race. Designed + spec'd in the working notes archived in this PR comment.

Review hardening (post-submission)

Two ordering races found in review are fixed in 61e6973:

  1. clear_pending's Ok(false) ("already resolved concurrently") is now honored — previously a spend whose result-wait confirmed during the release pass's network await still had its activity row flipped to Failed.
  2. The armed-reservation snapshot and the recorded-anchor fetch now both run before the note scan. An anchor absent from a pre-scan set was pruned before the scan began, so any execution predates scan coverage and the reconcile has already cleared it — closing the window where a spend executing at its anchor's retention edge could be released with its nullifier already consumed. Reservations armed mid-scan are excluded from the snapshot (their anchors may postdate the set) and are checked next pass.

Known remaining gap (follow-up): reservations are in-memory, so after an app restart a never-landing spend's persisted activity row stays "Pending" — the release pass can no longer see it. Needs either persisted pending-spend links or expiry of restored Pending rows on load.

How Has This Been Tested?

  • Store units: a reservation with a still-recorded anchor is retained, with a pruned anchor is released; unspent_notes excludes a pending nullifier and re-includes it after clear; an unarmed (None-anchor) reservation is never surfaced.
  • Activity: record_activity_status_by_id flips Pending → Failed (the exact call the release pass makes); missing-entry is a no-op.
  • Coordinator: the release pass no-ops (no fetch, no panic) when there are no armed reservations.
  • cargo test -p platform-wallet --lib --features shielded312 passed (incl. the fix(platform-wallet): build shielded spends against a Platform-recorded anchor #3977 tests); fmt + clippy --all-features clean.

Not automated (network-gated): the full fetch→release→flip seam against a live GetShieldedAnchors (mocking it is deliberately avoided in this codebase); its parts are each unit-tested.

Breaking Changes

None externally. Two new ShieldedStore trait methods (set_pending_spend, stale_pending_spends); both in-tree impls updated. In-memory data-model change only.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

🤖 Generated with Claude Code

@thepastaclaw

thepastaclaw commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit a270f3d)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

The auto-release design (arm-with-anchor, sync-time prune-check, fund-safe fallback to on-chain nullifier set) is sound. Main residual issues are a real-but-narrow TOCTOU/discarded-bool combination in the release loop (log misinformation + edge-case wrong activity flip), a non-idempotent mark_pending that today is only unreachable due to a cross-module invariant, plus minor efficiency and type-safety nits. No blocking issues survive verification.

🟡 2 suggestion(s) | 💬 4 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:810-846: Release loop discards `clear_pending`'s bool and never re-validates the snapshot against the current entry
  `release_stranded_spends` snapshots `(nullifier, anchor, activity_id)` under a read lock at line 772-787, drops it, awaits a network anchor fetch at line 796, and only then per-entry write-locks to call `clear_pending`. The result is not re-tied to the snapshot:

  1. `clear_pending` returns `Result<bool, _>` where `Ok(false)` means "nullifier was no longer pending" (e.g. a concurrent `mark_spent`/`finalize_pending`/`cancel_pending` already cleared it). This code only branches on `Err`, so `Ok(false)` falls through: it still logs the misleading `"shielded reservation released...freeing the notes"` info line and still calls `record_activity_status_by_id(..., Failed)`.
  2. `clear_pending(id, &nullifier)` matches by nullifier only. If a concurrent flow cancelled the snapshotted reservation and re-armed a new one at the same nullifier with a fresh anchor (e.g. definite-failure path → retry), this loop iteration would clear the brand-new reservation based on the stale anchor's pruned status.

  Fund-safety is preserved by other guards (the on-chain nullifier set is authoritative, and `record_activity_status`'s `Confirmed && block_height.is_some()` guard blocks the worst scan-derived overwrite), so the impact is bounded to a misleading log line and a narrow-window wrong Pending→Failed / Confirmed(no-height)→Failed activity flip. Still, the fix is cheap: consult the returned bool, and prefer re-checking that the current pending entry still matches the snapshot before clearing.

In `packages/rs-platform-wallet/src/wallet/shielded/store.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/store.rs:484-488: `mark_pending` uses `BTreeMap::insert`, which silently resets an already-armed entry
  The rewritten `mark_pending` calls `self.pending_nullifiers.insert(*nullifier, PendingSpend::default())`, which **replaces** any existing value. If it is ever invoked on an already-armed reservation, both `anchor` and `activity_id` get silently reset to `None`, wiping the release pass's only handle on the stranded spend. The doc comment even labels this behavior "idempotent", but it isn't — it's only unreachable-because-`unspent_notes()`-excludes-pending-nullifiers-from-selection.

  That invariant is load-bearing across two modules; any future selector that bypasses the unspent filter, or a subtle race between select and reserve, would silently disarm every affected reservation. Making the operation actually idempotent via the entry API costs nothing and localizes the invariant.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/shielded/store.rs
Comment thread packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/shielded/store.rs
Comment thread packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
Comment thread packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs Outdated
Base automatically changed from fix/shielded-withdrawal-stranded-reservation to v4.0-dev July 2, 2026 19:34
@github-actions github-actions Bot added this to the v4.0.0 milestone Jul 2, 2026
shumkov and others added 3 commits July 3, 2026 02:36
…ion on sync

A shielded spend that returns ShieldedSpendUnconfirmed (broadcast accepted, but
the result-wait failed ambiguously) keeps the spent notes' reservation
(pending_nullifiers) so a retry can't double-spend. That reservation was
released only when the tx lands (a later sync marks the note spent) or on app
restart. A broadcast-accepted-but-never-lands spend therefore stranded its
notes for the whole session — funds looked stuck with no in-session resolution.

Fix: remember the Platform-recorded anchor each spend was built against (the
anchor-selection fix guarantees it's a recorded root), then on each sync —
after the normal spent-note reconcile — release any still-pending reservation
whose anchor is no longer in Platform's recorded set. Once the anchor is pruned
(shielded_anchor_retention_blocks = 1000) the transition can never execute, so
with the nullifier still unspent (which "still pending after the reconcile"
already implies) the spend is provably dead and its notes can be freed; the
linked activity row flips Pending -> Failed so the UI shows a clear, retryable
failure. Reuses the GetShieldedAnchors query — no protocol change.

Fund-safety: releases ONLY when the anchor is absent from the freshly-fetched
recorded set (never a still-recorded, slow-but-landing spend); the on-chain
nullifier set stays the authoritative double-spend guard. The spent-note
reconcile runs first, so a landed tx's reservation is already cleared before
the release pass — no race. pending_nullifiers stays in-memory (a restart still
frees everything); the anchor fetch is best-effort (a hiccup never fails sync).

Data model: SubwalletState.pending_nullifiers BTreeSet -> BTreeMap<[u8;32],
PendingSpend { anchor, activity_id }>; arm_pending_release fills it in across
all four spend flows before broadcast. Wallet suite 312 tests pass; fmt +
clippy (--all-features) clean.

Stacks on the shielded anchor-selection fix (PR #3977) — the stored anchor must
be a recorded one, which that PR guarantees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two ordering gaps in release_stranded_spends, found in review:

1. clear_pending's Ok(false) ("already resolved concurrently") was
   discarded, so a spend whose result-wait confirmed during the release
   pass's network await still had its activity row flipped to Failed —
   a landed spend displayed and persisted as failed until a later scan
   healed it. The return value is now honored: nothing released, row
   untouched.

2. The recorded-anchor set was fetched AFTER the scan while the
   reconcile ran to scan-end, so a spend executing at its anchor's
   retention edge inside that gap could be released as "provably dead"
   with its nullifier already consumed. The armed-reservation snapshot
   and the anchor fetch now both happen BEFORE the scan: an anchor
   absent from a pre-scan set was pruned before the scan began, so any
   execution predates scan coverage and the reconcile has already
   cleared it (which fix 1 now respects). Reservations armed mid-scan
   are deliberately excluded — their anchors may postdate the set —
   and are checked next pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same treatment as #3977's docs: docs/ holds durable architecture
references; the spec is archived in the PR discussion and the shipped
mechanism is documented on prefetch_stranded_release /
release_stranded_spends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer force-pushed the fix/shielded-spend-reservation-auto-release branch from 553fabd to 66a6d94 Compare July 2, 2026 19:42
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5c9afd95-b3e4-4ea2-ace7-305ba51b384b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/shielded-spend-reservation-auto-release

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@QuantumExplorer

Copy link
Copy Markdown
Member

Working docs removed from the tree in 66a6d94 (same treatment as #3977docs/ is for durable architecture references); archiving the spec here for the record. Note: the shipped ordering differs from the spec in one review-hardened way — the armed-reservation snapshot and the recorded-anchor fetch now happen before the note scan (see 61e6973), not after, and clear_pending's Ok(false) is honored so a concurrently-landed spend is never flipped to Failed.

SHIELDED_SPEND_RESERVATION_AUTORELEASE_SPEC.md

Spec — auto-release a stranded shielded-spend reservation on sync (bug A)

Problem

A shielded spend that returns ShieldedSpendUnconfirmed (broadcast accepted, but
the result-wait failed ambiguously) intentionally keeps the spent notes'
reservation (SubwalletState.pending_nullifiers) so a retry can't double-spend.
That reservation is released only when:

  • the tx lands (a later sync sees the note spent → mark_spent clears it), or
  • the app restarts (pending_nullifiers is in-memory, never persisted).

So a spend that is broadcast-accepted but never lands (lost ACK / mempool
eviction) strands its notes for the whole session — the funds look stuck, and the
UI says "may have gone through — do not retry" with no in-session resolution.

Bug B (PR #3977) already removed the dominant trigger (anchor-mismatch
spends are now refused pre-broadcast with the notes released), so A is the
residual case. This fix closes it.

Chosen approach: release when the spend's anchor is provably pruned

When a spend reaches ShieldedSpendUnconfirmed, remember the recorded anchor
it was built against (post-B, always a Platform-recorded root). On each shielded
sync, after the normal spent-note reconcile, release any still-pending reservation
whose anchor is no longer in Platform's recorded anchor set.

Rationale (fund-safe, no double-spend): Platform's validate_anchor_exists accepts
a shielded spend only while its anchor is retained (shielded_anchor_retention_blocks = 1000). Once the anchor is pruned, the transition can never execute — so if
the nullifier is also still unspent (which "still pending after the sync reconcile"
already implies), the spend is provably dead and its notes can be freed. The
authoritative double-spend guard remains the on-chain nullifier set.

Reuses B's GetShieldedAnchors query (ShieldedAnchors::fetch_current), so no new
query type and no protocol change. "Anchor pruned" is the same ~1000-block bound as
a height window, but detected directly (no height arithmetic / new watermark).

Data model

SubwalletState.pending_nullifiers: BTreeSet<[u8;32]>
BTreeMap<[u8;32], PendingSpend> where

struct PendingSpend { anchor: Option<[u8;32]>, activity_id: Option<[u8;32]> }
  • mark_pending(nullifier) inserts { anchor: None, activity_id: None } (a
    just-reserved, not-yet-built spend).
  • A new set_pending_spend(nullifier, anchor, activity_id) fills them in once the
    spend is built (anchor known, activity row created).
  • clear_pending / mark_spent remove the entry (unchanged semantics).
  • unspent_notes filter: !pending_nullifiers.contains_key(&n.nullifier).
    In-memory only, as today (not persisted → a restart still frees everything).

Data flow

  • Spend paths (unshield, shielded_transfer, withdraw,
    identity_create_from_shielded_pool): after extract_spends_and_anchor returns
    the anchor and the record_pending_activity entry exists, call
    set_pending_spend(nullifier, anchor, activity_id) for each selected note —
    inside the async block, before broadcast_shielded_spend. A definite-failure
    path (cancel_pending) still removes the entry; a ShieldedSpendUnconfirmed
    keeps it (now carrying the anchor).
  • Sync reconcile (coordinator.rs, right after the note.is_spent → mark_spent loop): collect still-pending (nullifier, anchor) pairs across the
    synced subwallets; if any exist, fetch the recorded set once
    (ShieldedAnchors::fetch_current); for each pair whose anchor ∉ recorded,
    clear_pending(nullifier) and record_activity_status(activity_id, Failed)
    so the UI shows a clear, retryable failure instead of "Pending" forever.
    Skip the fetch entirely when no anchored reservations exist (the common case).

Fund safety

  • Release only when anchor ∉ recorded (pruned) → the spend can never execute →
    no double-spend. The nullifier set stays authoritative.
  • Never releases a still-valid (recorded-anchor) reservation, so a slow-but-landing
    tx is not re-spendable while it could still confirm.
  • None-anchor entries (reserved but not yet built) are transient and handled by
    the existing error paths; the sync release ignores them.

Test plan (Rust, --features shielded)

  • Store unit: a reservation with a recorded anchor survives a sync where the
    anchor is still recorded; is released when the anchor is absent from the
    recorded set; a reservation with a still-recorded anchor is NOT released.
  • unspent_notes excludes a pending nullifier and re-includes it after release.
  • Activity: on release the linked activity flips Pending → Failed.
  • Full platform-wallet --features shielded suite green; fmt + clippy; iOS build.

Scope / not in scope

  • This PR: the reservation auto-release only. No protocol/DAPI change.
  • Not: the ShieldedSpendUnconfirmed message wording — it is honest for this
    residual (genuinely-ambiguous) case now that B handles the anchor-mismatch case.
    A softer "Pending — funds free automatically if it doesn't confirm" is an
    optional Swift follow-up.
  • Stacks on fix(platform-wallet): build shielded spends against a Platform-recorded anchor #3977 (B); the anchor stored must be a recorded one, which only B
    guarantees.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Two prior blockers were fixed in 61e6973: clear_pending's Ok(false) is now honored (skips the misleading log + activity flip), and the armed-reservation snapshot + recorded-anchor prefetch now run before sync_notes_across (closes the retention-edge landed-spend window). Six residual suggestion/nitpick items from earlier rounds still stand — same lines, same reasoning. No new defects; fund-safety is bounded by the on-chain nullifier set. Action: COMMENT.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5[high] (general, failed), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5[high] (rust-quality, failed); verifier: opus; specialists: rust-quality

🟡 3 suggestion(s) | 💬 3 nitpick(s)

Carried-forward findings already raised (5)

These findings were not re-posted as new inline comments because an existing review thread already covers them.

  • [SUGGESTION] (deduped existing open thread) packages/rs-platform-wallet/src/wallet/shielded/store.rs:484-488: mark_pending uses BTreeMap::insert, which silently resets an already-armed PendingSpendself.pending_nullifiers.insert(*nullifier, PendingSpend::default()) replaces any existing value. If ever called on an already-armed reservation, both anchor and activity_id are wiped, dropping the release pass's only handle on that stranded spend. The doc-comment argues this is unreachable...
  • [NITPICK] (deduped existing open thread) packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:852-903: Multi-note stranded spend flips the same activity row (and requeues its changeset) N timesarm_pending_release stamps every reserved note of a single spend with the same activity_id, so stale_pending_spends yields N tuples that share one id. The release loop calls record_activity_status_by_id(..., Failed) once per nullifier — N read/write lock cycles and N persister enqueues fo...
  • [NITPICK] (deduped existing open thread) packages/rs-platform-wallet/src/wallet/shielded/store.rs:119-124: StalePendingSpend tuple alias has two adjacent [u8;32] fields with only positional semanticspub type StalePendingSpend = ([u8; 32], [u8; 32], Option<[u8; 32]>); is re-exported through the public ShieldedStore trait. nullifier and anchor are adjacent same-typed fields, so a transposition at any call site or in a future third-party impl would compile silently while corrupting the...
  • [SUGGESTION] (deduped existing open thread) packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:1303-1329: Coordinator release path only has a fast-path (no-op) testrelease_stranded_spends_no_op_without_anchored_reservation pins only the empty-snapshot short-circuit before the network round-trip. The value-adding branches — (a) armed reservation whose anchor is still recorded (must retain), (b) armed reservation whose anchor was pruned (must release + flip...
  • [NITPICK] (deduped existing open thread) packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:893: Persister map read-lock is re-acquired per released entryself.persisters.read().await.get(&id.wallet_id).cloned() sits inside the release loop, so the lock is taken and dropped once per snapshot tuple — and given the multi-note dedup issue above, the same wallet_id typically repeats across the notes of one spend. WalletPersister: Clone and the ma...
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:852-881: Release loop keys clear_pending by nullifier only, with no revalidation against the snapshot's anchor/activity_id
  The `Ok(false)` half of prior-1 is fixed, but `clear_pending(id, &nullifier)` at line 858 still matches purely by nullifier — nothing re-checks that the entry present under the write lock still carries the snapshotted `(anchor, activity_id)`. Concrete interleaving: reservation `(N, A1, AID1)` is snapshotted with A1 already pruned. Before this iteration acquires its write lock, a concurrent definite-failure path calls `cancel_pending` for N, the user retries, and the same note is re-selected and armed as `(N, A2, AID2)` with a still-recorded A2 via `set_pending_spend`. This loop's `clear_pending(id, &N)` returns `Ok(true)` on the *new* reservation, freeing its notes and flipping the OLD `AID1` row to Failed. Fund-safety is preserved by the on-chain nullifier set, but the freshly-armed live spend loses its reservation and an unrelated activity row gets an incorrect Pending→Failed flip. The window widened with this push — previously it was one network round-trip, now it spans the whole note scan (which can be a long cold sync). Cheap fix: re-verify under the write lock that the current pending entry still matches the snapshot's `anchor`/`activity_id` before clearing.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs:1303-1329: Coordinator release path only has a fast-path (no-op) test
  `release_stranded_spends_no_op_without_anchored_reservation` pins only the empty-snapshot short-circuit before the network round-trip. The value-adding branches — (a) armed reservation whose anchor is still recorded (must retain), (b) armed reservation whose anchor was pruned (must release + flip activity to Failed), (c) anchor-armed nullifier that concurrent code has already cleared so `clear_pending` returns `Ok(false)` (must skip the flip, honoring the fix in 61e6973be1) — are only covered indirectly by pure-store tests in `store.rs`. The `Ok(true)`/`Ok(false)`/`Err` three-way match added by this push is thus unverified at the coordinator level. A test that constructs the snapshot manually and calls `release_stranded_spends` directly (bypassing the SDK anchor fetch) would pin the whole path without introducing a live `GetShieldedAnchors` network dependency. This is also the exact layer where the nullifier-only-match TOCTOU concern lives.

In `packages/rs-platform-wallet/src/wallet/shielded/store.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/store.rs:484-488: mark_pending uses BTreeMap::insert, which silently resets an already-armed PendingSpend
  `self.pending_nullifiers.insert(*nullifier, PendingSpend::default())` replaces any existing value. If ever called on an already-armed reservation, both `anchor` and `activity_id` are wiped, dropping the release pass's only handle on that stranded spend. The doc-comment argues this is unreachable because `unspent_notes()` excludes pending nullifiers from selection — true today, but that invariant now spans selector→reserver across two modules and is what makes `release_stranded_spends` functional. A future selector that bypasses the unspent filter, or a subtle select/reserve race, would silently disarm every affected reservation and turn the release pass into a no-op for them. Switching to the `Entry` API makes the operation genuinely idempotent and localizes the invariant in one place.

Comment thread packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.19%. Comparing base (5033f87) to head (a270f3d).
⚠️ Report is 1 commits behind head on v4.0-dev.

Additional details and impacted files
@@            Coverage Diff            @@
##           v4.0-dev    #3982   +/-   ##
=========================================
  Coverage     87.19%   87.19%           
=========================================
  Files          2634     2634           
  Lines        327718   327718           
=========================================
  Hits         285748   285748           
  Misses        41970    41970           
Components Coverage Δ
dpp 87.70% <ø> (ø)
drive 86.14% <ø> (ø)
drive-abci 89.45% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.20% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.55% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- release_stranded_spends: check-and-clear under a single store write
  guard, validating the snapshot's exact (nullifier, anchor, activity)
  before clearing — a reservation cleared mid-scan and re-armed by a
  retry (fresh anchor) is skipped and re-checked next pass instead of
  being judged against the pre-scan anchor set.
- mark_pending: entry()-based insert so re-reserving can never wipe an
  armed entry's anchor/activity link (unreachable today via the
  selection invariant; now impossible by construction).
- One activity flip per spend: released entries dedupe on activity id
  instead of flipping the same row once per nullifier.
- Persister map read lock hoisted out of the release loop.
- New coordinator test drives release_stranded_spends end-to-end:
  pruned-anchor reservation released, recorded-anchor one retained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

This delta (a270f3d) cleanly resolves five of six prior review threads: mark_pending is genuinely idempotent via BTreeMap::Entry, the release loop performs check-and-clear under a single store write guard that re-validates the snapshot's nullifier+anchor+activity_id (closing the retry-race TOCTOU), multi-note stranded spends dedup activity flips via a HashSet, the persister read-lock is hoisted, and a real coordinator-level release/retain test exists. Only the positional StalePendingSpend tuple alias — a pre-existing nitpick unrelated to this delta — remains carried forward. No new defects introduced.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5[high] (general, failed: ACP initialize stuck; terminated rc=130), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5[high] (rust-quality, failed: ACP initialize stuck; terminated rc=130); verifier: opus; specialists: rust-quality

💬 1 nitpick(s)

Carried-forward findings already raised (1)

These findings were not re-posted as new inline comments because an existing review thread already covers them.

  • [NITPICK] (deduped existing open thread) packages/rs-platform-wallet/src/wallet/shielded/store.rs:119-124: StalePendingSpend is a positional 3-tuple with two adjacent [u8; 32] fields — Carried forward from the prior round (not touched by this delta). pub type StalePendingSpend = ([u8; 32], [u8; 32], Option<[u8; 32]>); puts nullifier and anchor as adjacent same-typed positional fields. Every consumer destructures positionally — including the new still_same closure `|(n,...

@QuantumExplorer
QuantumExplorer merged commit 05b71ea into v4.0-dev Jul 2, 2026
19 checks passed
@QuantumExplorer
QuantumExplorer deleted the fix/shielded-spend-reservation-auto-release branch July 2, 2026 21:39
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.

3 participants