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.
…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
66a6d94
Compare
|
Important 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 |
|
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.
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,...
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