Uh oh!
There was an error while loading. Please reload this page.
fix(platform-wallet): auto-release a stranded shielded-spend reservation on sync - #3982
Conversation
✅ Review complete (commit a270f3d) |
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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>
553fabd to
66a6d94CompareImportant Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
QuantumExplorer
commented
Jul 2, 2026
Working docs removed from the tree in 66a6d94 (same treatment as #3977 — SHIELDED_SPEND_RESERVATION_AUTORELEASE_SPEC.mdSpec — auto-release a stranded shielded-spend reservation on sync (bug A)ProblemA shielded spend that returns
So a spend that is broadcast-accepted but never lands (lost ACK / mempool Bug B (PR #3977) already removed the dominant trigger (anchor-mismatch Chosen approach: release when the spend's anchor is provably prunedWhen a spend reaches Rationale (fund-safe, no double-spend): Platform's Reuses B's Data model
Data flow
Fund safety
Test plan (Rust, |
thepastaclaw
left a comment
There was a problem hiding this comment.
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 PendingSpend —self.pending_nullifiers.insert(*nullifier, PendingSpend::default())replaces any existing value. If ever called on an already-armed reservation, bothanchorandactivity_idare 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 times —arm_pending_releasestamps every reserved note of a single spend with the sameactivity_id, sostale_pending_spendsyields N tuples that share one id. The release loop callsrecord_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 semantics —pub type StalePendingSpend = ([u8; 32], [u8; 32], Option<[u8; 32]>);is re-exported through the publicShieldedStoretrait.nullifierandanchorare 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) test —release_stranded_spends_no_op_without_anchored_reservationpins 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 entry —self.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 samewallet_idtypically repeats across the notes of one spend.WalletPersister: Cloneand 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.
Uh oh!
There was an error while loading. Please reload this page.
Codecov Report✅ All modified and coverable lines are covered by tests. 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
🚀 New features to boost your workflow:
|
- 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
left a comment
There was a problem hiding this comment.
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]>);putsnullifierandanchoras adjacent same-typed positional fields. Every consumer destructures positionally — including the newstill_sameclosure `|(n,...
Uh oh!
There was an error while loading. Please reload this page.
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 flipsPending → 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_releasefills in the anchor + activity id across all four spend flows (unshield/transfer/withdraw/identity_create_from_shielded_pool), afterextract_spends_and_anchor+record_pending_activity, before broadcast.coordinator::release_stranded_spendsruns the release pass insync(); 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:
clear_pending'sOk(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.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?
unspent_notesexcludes a pending nullifier and re-includes it after clear; an unarmed (None-anchor) reservation is never surfaced.record_activity_status_by_idflipsPending → Failed(the exact call the release pass makes); missing-entry is a no-op.cargo test -p platform-wallet --lib --features shielded→ 312 passed (incl. the fix(platform-wallet): build shielded spends against a Platform-recorded anchor #3977 tests);fmt+clippy --all-featuresclean.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
ShieldedStoretrait methods (set_pending_spend,stale_pending_spends); both in-tree impls updated. In-memory data-model change only.Checklist:
🤖 Generated with Claude Code