Skip to content

fix(wallet): reload the runtime when an unconfirmed transaction is removed - #1122

Open
llbartekll wants to merge 2 commits into
developfrom
fix/tx-remove-runtime-reload
Open

fix(wallet): reload the runtime when an unconfirmed transaction is removed#1122
llbartekll wants to merge 2 commits into
developfrom
fix/tx-remove-runtime-reload

Conversation

@llbartekll

@llbartekll llbartekll commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

"Remove if Not on Network" (tx detail sheet, #954) and "Drop unconfirmed" (Core Sync Status, #982) never actually reloaded the runtime, so the coins they promise to free stayed unspendable until the app was relaunched.

There is no removal API at any FFI layer, so removal is done as persistence surgery plus a full runtime reload — on load the Rust wallet rehydrates its tx set, UTXOs and spent_outpoints from the rows. UnconfirmedTransactionRemover's header states this contract, and the comment directly above the call describes the reload.

The reload was requested via rearmPlatformSync():

finishRemoval() → rearmPlatformSync() → refresh(.platformSyncRearm)
                                          → shouldSkipRefresh()
                                            → case .platformSyncRearm: isRuntimeReady(for:)

isRuntimeReady is true when the host has a bound wallet and BLAST runs on the network and Core SPV runs. At removal time all three hold, so every removal logged "refresh is already satisfied" and returned without a reload. Consequences until the next launch:

  • the Rust wallet keeps the removed transaction in its in-memory tx set
  • its inputs stay marked spent — the freed coins are not actually spendable
  • dash-spv's mempool tracker keeps rebroadcasting the removed transaction

The UI meanwhile said "Transaction removed", and the confirmation dialog promised "the coins it was trying to spend become available again".

This is a seam between two individually-correct changes: rearmPlatformSync was added in dde57909d for PlatformAddressSyncCoordinator.syncNow(), which reaches it only when BLAST is down — there the elision is right and desirable. The remover reused it two weeks later for a case where the same elision defeats the operation. Nothing in the name or signature signals that the call can legitimately do nothing.

What was done?

1. A trigger that is never elided. .walletRowsChanged joins .walletMaterialChanged and .walletDidChange on the never-elide arm of shouldSkipRefresh, reached through a new reloadAfterWalletRowsChanged(). The semantics mirror .walletDidChange: there the runtime looks ready but the registry points at another wallet; here it looks ready but the rows changed underneath it. In both cases the "ready" runtime is exactly the one holding stale state.

rearmPlatformSync keeps its elision — unchanged for its own caller. Both entry points now share one awaitRefresh(trigger:) helper rather than a second copy of the enqueue-and-await shape.

2. Rescan depth is derived before the reload. This is required for the fix, not incidental. Once the reload really runs, fullReset stops SPV and resetPublishedState zeroes the coordinator's published tipHeight, which only a later $spvProgress tick re-seeds. The existing code read the tip after the call — fine while nothing reloaded, but it would now find 0 and skip the rescan on every removal, returning rescanArmed = false and telling users to run Rescan Filters by hand.

That rescan is the recovery step that restores a wrongly-removed on-chain transaction, and on the bulk path it is the only safety rail because that path never explorer-checks. Fixing the reload without this would have traded one broken guarantee for another. Anchoring on the pre-reload tip is strictly conservative: the tip only advances, so the older value rewinds deeper, never shallower, inside a window already floored at 720 blocks with a 576-block margin and documented as uncapped in depth.

The skip log now names which of the two causes fired instead of asserting "SPV not running after reload", which stopped being true for one of them.

Scope: iOS only. No platform/FFI changes and no DashSDKFFI.xcframework rebuild — this fixes only when the app reloads its own runtime.

How Has This Been Tested?

Build: dashpay scheme, Debug, iPhone 17 simulator, ARCHS=arm64BUILD SUCCEEDED. Both changed files appear in the compiler invocations. The only warning in either file (entryQueue / dispatchOnPipeline, SwiftDashSDKWalletRuntime.swift:378) is pre-existing and identical on develop, several hundred lines from any change here.

Unit tests: could not be run. DashWalletTests does not compile on develop, independent of this PR. The dashwallet-dashpay scheme builds only dashpay.app + DashWalletTests.xctest — the dashwallet target never builds — yet 14 of the test files @testable import dashwallet (39 import dashpay), so the module cannot resolve:

DashWalletTests/CoinbaseTransactionMetadataTests.swift:9:18:
error: unable to resolve module dependency: 'dashwallet'

That file carries the same import on clean develop, and this PR touches no test file and no scheme. -only-testing does not help, since the whole target must compile first. Worth a separate fix.

Not yet done — manual smoke. Reproducing a genuinely stuck unconfirmed transaction takes a real network-dropped send, so the end-to-end proof is still outstanding. What to check:

  • log shows 🧭 RUNTIME :: refreshing runtime for walletRowsChanged not followed by "refresh is already satisfied", bracketed by 🛰️ SPVCOORD :: stopped / :: started
  • 🗑️ TX-REMOVE :: filter rescan armed from height N still appears (proves the pre-reload tip ordering)
  • without relaunching, the coins the stuck transaction held are spendable again and it is gone from the feed

Happy to hold the merge until someone runs that.

Breaking Changes

None. rearmPlatformSync and its caller are behaviourally unchanged.

The intended behaviour change: removing an unconfirmed transaction now really tears down and restarts the runtime, which takes seconds. Both callers already cover it (progress HUD on the tx sheet, button spinner on the Core Sync screen) and both already describe the await as spanning the reload.

Noted, not fixed here: neither caller takes the WalletLifecycleTransitionState admission gate that network and wallet switches use, so a removal can overlap a switch. The serial lifecycle queue still orders them, so this is not a correctness bug, but the overlay can now show a switch while a removal reload is in flight. Harmless while the reload was a no-op; worth its own fix now that it isn't.

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 made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved wallet recovery after unconfirmed transactions are removed.
    • Wallet data now refreshes correctly after changes made during synchronization.
    • Rescan status is determined more reliably after removing transactions.
  • User Experience

    • Added clearer messages for successful rescans, unavailable rescans, and stopped wallets.
    • When the wallet stops, guidance now recommends reopening the app or using Sync Now in Platform sync.
    • Transaction details and sync screens now display the appropriate outcome after removal.

…moved

Removing a never-accepted transaction edits the persistence layer and
relies on a full runtime reload to make the Rust wallet forget it —
there is no removal API at any FFI layer. That reload was requested
through `rearmPlatformSync()`, whose `.platformSyncRearm` trigger
`shouldSkipRefresh` elides whenever `isRuntimeReady` holds. At removal
time the host, Core SPV and BLAST are all running, so the refresh was
always elided and no reload ran: the Rust wallet kept the transaction in
its in-memory tx set and its inputs stayed marked spent — so the coins
the action promises to free stayed unspendable — while dash-spv's
mempool tracker kept rebroadcasting, until the next app launch.

Give the remover its own `.walletRowsChanged` trigger on the never-elide
arm of `shouldSkipRefresh`, next to `.walletMaterialChanged` and
`.walletDidChange`, reached through `reloadAfterWalletRowsChanged()`.
`rearmPlatformSync` keeps its elision, which is correct for its own
caller: `syncNow` reaches it only when BLAST is already down.

Deriving the rescan depth moves ahead of the reload. `fullReset` stops
SPV, and `resetPublishedState` zeroes the coordinator's `tipHeight`;
only a later progress tick re-seeds it. Reading the tip after a reload
that now really happens would find 0 and arm no rescan on any removal —
and on the bulk path that rescan is the only safety rail, because it
never explorer-checks. Anchoring on the pre-reload tip rewinds deeper,
never shallower.

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

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: c31d8679-d148-4af2-9d31-6ac7b6db8c37

📥 Commits

Reviewing files that changed from the base of the PR and between 3921fa9 and 9cb02e4.

📒 Files selected for processing (4)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift
  • DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift
  • DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The wallet runtime now reloads after SwiftData wallet-row changes. Unconfirmed transaction removal returns explicit recovery outcomes. Sync status and transaction detail screens display messages for armed rescans, unavailable rescans, and stopped runtimes.

Changes

Wallet recovery flow

Layer / File(s) Summary
Runtime reload after wallet row changes
DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift
The runtime adds a serialized .walletRowsChanged refresh path, prevents that refresh from being skipped, and publishes the active-wallet change after rebinding.
Removal outcome and rescan recovery
DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift
Removal APIs return RemovalOutcome. The recovery path captures the chain tip before reload, reloads after wallet-row changes, and distinguishes stopped runtime, unavailable rescan, and armed rescan states.
Outcome-specific UI handling
DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift, DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift
The UI switches on RemovalOutcome and displays state-specific messages, including guidance for a stopped wallet runtime.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 9cb02

This change reloads the wallet runtime after transaction-row removal and presents recovery-specific status messages. No current merge-blocking risk is identified.

Sequence Diagram(s)

sequenceDiagram
  participant UserInterface
  participant UnconfirmedTransactionRemover
  participant SwiftDashSDKWalletRuntime
  participant SPVCoordinator
  UserInterface->>UnconfirmedTransactionRemover: remove unconfirmed transactions
  UnconfirmedTransactionRemover->>SPVCoordinator: capture pre-reload tipHeight
  UnconfirmedTransactionRemover->>SwiftDashSDKWalletRuntime: reloadAfterWalletRowsChanged()
  SwiftDashSDKWalletRuntime-->>UnconfirmedTransactionRemover: runtime readiness
  UnconfirmedTransactionRemover->>SPVCoordinator: arm rescan when available
  SPVCoordinator-->>UnconfirmedTransactionRemover: RemovalOutcome
  UnconfirmedTransactionRemover-->>UserInterface: outcome-specific message
Loading

Suggested reviewers: jeanpierreroma, quantumexplorer

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: reloading the runtime after removing an unconfirmed transaction.
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.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tx-remove-runtime-reload

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.

@thepastaclaw

thepastaclaw commented Sep 9, 2026

Copy link
Copy Markdown

🕓 Queued for automated review — 17th in line, estimated start in ~5 h (commit 9cb02e4)
Estimated review time once started: ~35 min (two-phase automated review; median of recent runs).

  • Request priority review — tick this box and the review moves to the front of the queue.

@romchornyi romchornyi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at head 4b15786f. The core fix is sound, and I verified the load-bearing claims rather than taking them on trust:

  • shouldSkipRefresh really did elide .platformSyncRearm whenever isRuntimeReady held, so the removal really was a no-op in the healthy case. The diagnosis is right.
  • performStopresetPublishedState() really does zero tipHeight (SwiftDashSDKSPVCoordinator.swift:624-627), and it is re-seeded only from $spvProgress (:582) — so moving the tip read before the reload is required, not incidental, and anchoring on the older tip is the conservative direction.
  • The rewind is safe on the Rust side too: spv_rescan_filters_blocking (rs-platform-wallet/src/manager/accessors.rs:595) ignores any from_height >= current_height, so a stale tip cannot advance the checkpoint and skip filters.
  • manager.shutdown() does not flush in-memory wallet state to SwiftData, so the reload cannot resurrect the deleted rows.
  • RefreshTrigger has exactly one switch over it, so the new case breaks no other exhaustive switch, and no test references these symbols.

One inline comment: making the reload real also means a failed reload is now real, and that path currently ends with the user stranded.

Non-blocking recommendation:

  • SwiftDashSDKWalletRuntime.swift:553 — the PR body notes the missing WalletLifecycleTransitionState gate and concludes the serial queue makes it harmless. There is one consequence that survives the ordering argument. switchNetwork writes the network key (:253) before enqueueing its own .networkDidChange refresh (:258). If a lifecycle op is already running when a removal enqueues its rows-changed refresh, that refresh is still queued when the user starts a network switch; it then resolves the already-flipped key, never elides, and rebuilds for the destination network. switchNetwork's own refresh then finds isRuntimeReady(destination) == true and elides — so publishActiveWalletDidChange(reason: "network-changed") never fires, even though the switch reports success and finish()es the transition. That publish is deliberately deferred until the destination runtime is bound (see the comment at :464-470) so consumers re-read destination state, and SwiftDashSDKContactsService (:136) observes only activeWalletDidChangeNotification — it would keep the pre-switch contact/ownerId snapshot until some unrelated SwiftData save refreshed it. Taking the same admission gate the switches use, or publishing for .walletRowsChanged as well, closes it.

🤖 Reviewed with Claude Code

Making the reload real also made a failed reload real. `refresh` starts
Core SPV and then BLAST and falls back to `fullReset` if either throws, so
a Platform outage — routine on a flaky DAPI path, and unrelated to the
Core-side row being repaired — stops the host and Core SPV as well. The
removal still reported plain success and sent the user to Rescan Filters,
which refuses while SPV is stopped: a stopped wallet, a zero balance, and
an instruction that cannot be followed until relaunch.

Replace the `rescanArmed` boolean with a `RemovalOutcome` that separates
`.runtimeStopped` from `.rescanUnavailable`, decided by
`isRuntimeReady(for:)` after the reload. Both callers now name the remedy
that actually works in that state — reopen the app, or Sync Now, which
rebuilds the runtime in-session — instead of Rescan Filters.

Also publish `activeWalletDidChange` for `.walletRowsChanged`. A queued
rows-changed refresh can resolve a network key an interactive switch has
already flipped and rebuild for the destination; the switch's own refresh
then finds that runtime ready and elides, dropping its "network-changed"
publish, and consumers that observe only this notification keep their
pre-switch snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants