Skip to content

feat: recovery actions for network-dropped unconfirmed transactions - #982

Merged
QuantumExplorer merged 3 commits into
developfrom
claude/transaction-grouping-day-a9fa49
Aug 11, 2026
Merged

feat: recovery actions for network-dropped unconfirmed transactions#982
QuantumExplorer merged 3 commits into
developfrom
claude/transaction-grouping-day-a9fa49

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 11, 2026

Copy link
Copy Markdown
Member

What

Two user-facing recovery paths for transactions stuck in mempool state ("Sending" forever — never InstantSend-locked, never mined), e.g. CoinJoin sweep chunks whose broadcasts the network dropped:

  1. Tx detail — "Remove if not on Blockchain" for any stuck transaction. Previously the recovery section only appeared for parked asset-lock transfers; a plain classic send stuck at mempool context had no action at all. Now any transaction in .processing state gets the removal action, with the existing rails unchanged: the local row must still be unconfirmed, a block explorer is consulted first (on-chain ⇒ removal refused), and a compact-filter rescan afterward restores the tx if the explorer was wrong. Since the CoinJoin Withdrawals group sheet already navigates into tx detail, each stuck sweep chunk is now individually recoverable.

  2. Core Sync Status — "Drop Unconfirmed & Rescan". A new "Unconfirmed Transactions" card shows a live count of the active wallet's mempool-context transactions and offers a confirmed, destructive bulk action: drop them all in one pass, free the TXOs they tried to spend, do the full runtime reload, and arm a filter rescan reaching back past the oldest dropped transaction. The bulk path deliberately skips the per-tx explorer check — the rescan is the safety net that restores anything actually mined — and the confirmation dialog says exactly that. Nothing is ever sent to the network.

Why

dash-spv rebroadcasts unconfirmed self-sent transactions every 10 minutes, but only in-memory for the session that broadcast them — after an app restart nothing resends them, so a dropped tx is permanently stalled and the coins it tried to spend stay locked. This shipped state was observed in the field: 10 CoinJoin sweep chunks (82 UTXOs, ~36.8 DASH) stuck at "Sending" with no recovery affordance.

Implementation notes

  • UnconfirmedTransactionRemover refactored so the single-tx and bulk paths share one surgery: un-spend inputs (explicit isSpent flip — the .nullify inverse alone isn't enough), delete rows, drop asset-lock bookmarks, metadata cleanup, runtime reload, rescan arm.
  • SwiftDashSDKWalletSource.isWalletMember promoted from private to internal and reused for the wallet-scoped bulk fetch (no copy-then-adapt).
  • New strings registered in DashWallet/en.lproj/Localizable.strings.

Verification

  • Clean dashpay scheme build (ARCHS=arm64, iphonesimulator). Unit-test target is pre-existing-broken per CLAUDE.md.
  • Not yet smoke-tested on a simulator with stuck txs; the drop path reuses the already-shipped single-tx removal surgery.

Follow-up (not in this PR)

The durable upstream fix is for dash-spv to repopulate its rebroadcast set from persisted unconfirmed self-authored transactions on wallet load, so stalls of this class stop happening.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added the ability to remove unconfirmed transactions individually from transaction details.
    • Added a bulk cleanup option in SPV diagnostics with confirmation prompts, progress updates, and transaction counts.
    • Automatically refreshes wallet data and rescans after bulk removal when applicable.
    • Displays success or failure states, including guidance when recovery scanning cannot be started.
  • Bug Fixes

    • Improved cleanup of related wallet data when unconfirmed transactions are removed.
  • Documentation

    • Added English text for cleanup actions, statuses, progress, failures, and empty states.

A wallet with stalled sends (e.g. CoinJoin sweep chunks stuck at
"Sending") had no way out: dash-spv only rebroadcasts unconfirmed
self-sent txs in the session that broadcast them, and the tx-detail
recovery section was gated to stuck asset locks.

- Tx detail: offer "Remove if not on Blockchain" for ANY transaction
  still in mempool context (never IS-locked or mined), not just parked
  asset locks. The existing rails stay: explorer check first, refusal
  when the tx is on-chain, filter-rescan safety net after.
- Core Sync Status: new "Unconfirmed Transactions" card with a live
  count and a confirmed, destructive "Drop Unconfirmed & Rescan"
  action that drops every mempool-context tx of the active wallet in
  one pass, frees the TXOs they tried to spend, reloads the runtime,
  and rescans filters back past the oldest dropped tx. The bulk path
  deliberately skips the per-tx explorer check - the rescan restores
  anything that was actually mined.
- UnconfirmedTransactionRemover: single-tx and bulk paths share one
  surgery (un-spend inputs, delete rows, asset-lock bookmark +
  metadata cleanup, runtime reload, rescan arm).
- SwiftDashSDKWalletSource.isWalletMember promoted to internal for the
  wallet-scoped bulk fetch instead of duplicating it.

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

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 46 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 500b08b4-541e-4f43-9280-afb6ff792f78

📥 Commits

Reviewing files that changed from the base of the PR and between 25a2fd8 and b7cccee.

📒 Files selected for processing (1)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift
📝 Walkthrough

Walkthrough

Adds single and bulk removal for active-wallet unconfirmed transactions. Shared helpers clean local state and rescan filters. The SPV status screen and transaction details expose recovery actions and rescan results.

Changes

Unconfirmed Transaction Recovery

Layer / File(s) Summary
Removal validation and shared recovery flow
DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift, DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
Single removal revalidates wallet membership and transaction state after the explorer check. Bulk removal handles all active-wallet unconfirmed transactions. Shared helpers remove records and bookmarks, reload state, rescan filters, refresh caches, and return rescan status.
SPV diagnostics recovery action
DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift, DashWallet/en.lproj/Localizable.strings
The SPV status screen displays the unconfirmed count, confirms removal, runs the asynchronous workflow, prevents duplicate runs, and shows progress or results.
Transaction detail recovery action
DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift, DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift, DashWallet/en.lproj/Localizable.strings
Processing transactions expose unconfirmed-removal support. Transaction details show a recovery action and report when the rescan could not start.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SwiftDashSDKSPVStatusScreen
  participant UnconfirmedTransactionRemover
  participant WalletRuntime
  User->>SwiftDashSDKSPVStatusScreen: Confirm removal
  SwiftDashSDKSPVStatusScreen->>UnconfirmedTransactionRemover: Remove active-wallet unconfirmed transactions
  UnconfirmedTransactionRemover->>WalletRuntime: Reload state and rescan filters
  WalletRuntime-->>UnconfirmedTransactionRemover: Return rescan status
  UnconfirmedTransactionRemover-->>SwiftDashSDKSPVStatusScreen: Return removed count and rescan status
  SwiftDashSDKSPVStatusScreen-->>User: Show progress or result
Loading

Possibly related PRs

Suggested reviewers: llbartekll

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the pull request's recovery actions for network-dropped unconfirmed transactions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/transaction-grouping-day-a9fa49

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

@QuantumExplorer QuantumExplorer changed the title Recovery actions for network-dropped unconfirmed transactions feat: recovery actions for network-dropped unconfirmed transactions Aug 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift (1)

91-130: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Re-fetch and revalidate the transaction after the explorer await.

explorerKnowsTransaction suspends while row and walletId are captured. During that suspension, main-actor work can change the row or active wallet. Immediately before excise, re-fetch the transaction and require wallet membership, context == 0, blockHeight == 0, and the same active wallet. Abort without deleting if any check fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift`
around lines 91 - 130, The remove flow in remove(txidWire:) must re-fetch and
revalidate state immediately after explorerKnowsTransaction returns and before
excise. Re-read the active wallet and transaction using the existing context,
require wallet membership, unchanged active wallet, context == 0, blockHeight ==
0, and the same transaction identity; abort with the appropriate existing error
without deleting if any check fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift`:
- Around line 5-14: Update remove(txidWire:) and dropAllUnconfirmedAndRescan()
so every successful removal arms a recovery rescan extending past the oldest
removed transaction, without maxRescanBlocks truncating fromHeight. Persist the
rescan request and retry or propagate arm failures instead of swallowing them;
non-running-SPV and catch paths must not report success unless a full recovery
path is guaranteed.

In `@DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift`:
- Around line 780-807: Move the recovery state and business logic out of the
SwiftUI view into an `@MainActor` ObservableObject ViewModel: in
SwiftDashSDKSPVStatusScreen.swift lines 69-76, relocate the recovery state; in
lines 780-807, move dropUnconfirmedAndRescan() and its service calls/result
handling into that ViewModel; in lines 471-529, render ViewModel state and
invoke its intent. In TxDetailViewController.swift lines 505-511, implement the
recovery UI as SwiftUI backed by a ViewModel rather than extending UIKit UI
logic.

---

Outside diff comments:
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift`:
- Around line 91-130: The remove flow in remove(txidWire:) must re-fetch and
revalidate state immediately after explorerKnowsTransaction returns and before
excise. Re-read the active wallet and transaction using the existing context,
require wallet membership, unchanged active wallet, context == 0, blockHeight ==
0, and the same transaction identity; abort with the appropriate existing error
without deleting if any check fails.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 424a3c7d-164f-4519-8f12-6fe0ba43fc40

📥 Commits

Reviewing files that changed from the base of the PR and between 963aefa and d591e64.

📒 Files selected for processing (6)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift
  • DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
  • DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift
  • DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift
  • DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift
  • DashWallet/en.lproj/Localizable.strings

- remove(txidWire:): the explorer check suspends the main actor, so
  re-fetch and re-validate everything before surgery (row still
  mempool-context, wallet membership, active wallet unchanged).
- The recovery rescan depth is no longer capped at ~5 weeks: it always
  reaches back past the oldest removed transaction's first appearance
  (the SDK floors at birth height / stored chain data), and a row with
  no usable first-seen rescans from the floor.
- A rescan that fails to arm is surfaced instead of swallowed: both
  removal paths return the arm outcome and the tx-detail HUD / Core
  Sync result banner direct the user to run Rescan Filters manually.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift`:
- Around line 273-285: Update the rescan-bound comment near the rescan logic in
UnconfirmedTransactionRemover to remove the obsolete five-week maximum and
describe the implemented minimum rescan window with uncapped recovery depth.
Keep the comment consistent with blocksBack and the wallet
birth-height/chain-data floors.
- Line 173: Update the zero-row guard in the unconfirmed transaction removal
method to return rescanArmed: false, since no recovery rescan runs when rows is
empty. Preserve the existing dropped count and rescanArmed: true result for
paths that perform the recovery rescan.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9654ddb4-4067-474e-a447-54aad1093eb8

📥 Commits

Reviewing files that changed from the base of the PR and between d591e64 and 25a2fd8.

📒 Files selected for processing (4)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift
  • DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift
  • DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift
  • DashWallet/en.lproj/Localizable.strings
🚧 Files skipped from review as they are similar to previous changes (2)
  • DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift
  • DashWallet/en.lproj/Localizable.strings

- Zero-row bulk drop returns rescanArmed: false (no rescan ran).
- minRescanBlocks doc no longer claims a ~5-week maximum; depth is
  uncapped, floored only by birth height / stored chain data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer merged commit 56dc10e into develop Aug 11, 2026
2 checks passed
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