diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift index 490344e97..5178afb12 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift @@ -2,11 +2,16 @@ // UnconfirmedTransactionRemover.swift // DashWallet // -// Removes a never-accepted transaction from local wallet state — the -// tx-detail "Remove if not on Blockchain" action for a stuck asset -// lock the network keeps rejecting (rebroadcasts end "outcome -// uncertain", the tx is on no explorer, and the coins it tried to -// spend stay locked forever). +// Removes never-accepted transactions from local wallet state. Two +// entry points share the surgery: +// - `remove(txidWire:)` — the tx-detail "Remove if not on Blockchain" +// action for one stuck transaction (a parked asset lock, or any +// network-dropped send such as a stalled CoinJoin sweep chunk); +// explorer-checked per the rails below. +// - `dropAllUnconfirmedAndRescan()` — the Core Sync screen's bulk +// variant over every mempool-context row of the active wallet; it +// skips the per-tx explorer check and relies on the filter rescan +// to restore anything that was actually on-chain. // // There is no removal API at any FFI layer (verified against the // pinned rust-dashcore/platform revs: key-wallet has no @@ -74,16 +79,23 @@ struct UnconfirmedTransactionRemover { } } - /// Compact-filter rescan window bounds, in blocks (~2.5 min each): - /// at least ~30 hours even for a fresh transaction, at most ~5 weeks - /// for a long-stuck one. + /// Minimum compact-filter rescan window, in blocks (~2.5 min each): + /// at least ~30 hours even for a fresh transaction. There is no + /// maximum — recovery depth is uncapped and reaches back past the + /// oldest removed transaction's first appearance, bounded only by + /// the SDK's wallet birth-height / stored-chain-data floors. private static let minRescanBlocks: UInt32 = 720 - private static let maxRescanBlocks: UInt32 = 20_000 /// Extra rewind margin below the transaction's first-seen height /// estimate (~1 day), covering clock skew and variable block times. private static let rescanMarginBlocks: UInt32 = 576 - func remove(txidWire: Data) async throws { + /// - Returns: whether the recovery filter rescan was armed. `false` + /// means the removal itself succeeded but the rescan didn't start + /// (SPV not running / arm threw) — the caller should tell the user + /// to run Rescan Filters manually so the safety net isn't silently + /// skipped. + @discardableResult + func remove(txidWire: Data) async throws -> Bool { guard let container = SwiftDashSDKHost.shared.modelContainer, let network = WalletEnvironment.network, let walletId = WalletEnvironment.activeWalletId(for: WalletEnvironment.networkKind) else { @@ -103,7 +115,6 @@ struct UnconfirmedTransactionRemover { guard row.context == 0, row.blockHeight == 0 else { throw RemovalError.confirmedLocally } - let firstSeen: UInt64 = row.firstSeen // 2. The claim in the button title is checked, not assumed: a // transaction the explorer knows (mempool or mined) is never @@ -113,72 +124,183 @@ struct UnconfirmedTransactionRemover { throw RemovalError.transactionOnChain } - // 3. Persistence surgery. Inputs first: the `.nullify` inverse on - // `PersistentTransaction.inputs` only clears the relationship — - // the denormalized `isSpent` column must be flipped explicitly - // or the restore buffer would keep excluding these TXOs from - // the spendable set. - let unspentInputCount = row.inputs.count + // 3. The explorer check suspended the main actor, so nothing from + // step 1 is trusted anymore: a filter match can have confirmed + // the transaction, or the active wallet can have switched. + // Re-resolve and re-validate everything before touching rows. + guard WalletEnvironment.activeWalletId(for: WalletEnvironment.networkKind) == walletId else { + throw RemovalError.notReady + } + guard let freshRow = try context.fetch(descriptor).first else { + throw RemovalError.transactionNotFound + } + guard freshRow.context == 0, freshRow.blockHeight == 0 else { + throw RemovalError.confirmedLocally + } + guard SwiftDashSDKWalletSource.isWalletMember(freshRow, walletId: walletId) else { + throw RemovalError.transactionNotFound + } + let firstSeen: UInt64 = freshRow.firstSeen + + // 4. Persistence surgery, then the shared reload + rescan + + // cache-refresh tail. + Self.excise(freshRow, in: context) + try Self.deleteAssetLockBookmarks(forDisplayTxids: [displayTxid], walletId: walletId, in: context) + try context.save() + Self.logger.notice("🗑️ TX-REMOVE :: deleted \(displayTxid, privacy: .public)") + + return await Self.finishRemoval( + txidsWire: [txidWire], walletId: walletId, oldestFirstSeen: firstSeen) + } + + /// Bulk diagnostic for the Core Sync screen: drop EVERY unconfirmed + /// (mempool-context, no block) transaction of the active wallet, free + /// the TXOs they tried to spend, and rescan recent filters back past + /// the oldest dropped transaction. Unlike `remove(txidWire:)` there is + /// no per-transaction explorer check — the rescan is the safety rail: + /// a dropped transaction that IS on the blockchain is re-matched and + /// restored by it. Nothing is sent to the network. + /// + /// - Returns: how many transactions were dropped (0 = nothing to + /// drop; no reload or rescan runs, so `rescanArmed` is `false`), + /// and whether the recovery rescan was armed — `false` after a + /// non-zero drop means the caller must tell the user to run + /// Rescan Filters manually. + func dropAllUnconfirmedAndRescan() async throws -> (dropped: Int, rescanArmed: Bool) { + guard let container = SwiftDashSDKHost.shared.modelContainer, + let walletId = WalletEnvironment.activeWalletId(for: WalletEnvironment.networkKind) else { + throw RemovalError.notReady + } + let context = container.mainContext + let rows = try Self.unconfirmedRows(in: context, walletId: walletId) + guard !rows.isEmpty else { return (dropped: 0, rescanArmed: false) } + + var oldestFirstSeen = UInt64.max + var displayTxids: [String] = [] + var txidsWire: [Data] = [] + for row in rows { + oldestFirstSeen = min(oldestFirstSeen, row.firstSeen) + displayTxids.append(Transaction.displayHex(row.txid)) + txidsWire.append(row.txid) + Self.excise(row, in: context) + } + try Self.deleteAssetLockBookmarks(forDisplayTxids: displayTxids, walletId: walletId, in: context) + try context.save() + Self.logger.notice("🗑️ TX-REMOVE :: bulk-dropped \(rows.count, privacy: .public) unconfirmed tx(s): \(displayTxids.joined(separator: ","), privacy: .public)") + + let rescanArmed = await Self.finishRemoval( + txidsWire: txidsWire, walletId: walletId, oldestFirstSeen: oldestFirstSeen) + return (dropped: rows.count, rescanArmed: rescanArmed) + } + + /// The active wallet's unconfirmed (mempool-context, no block) rows — + /// exactly what `dropAllUnconfirmedAndRescan` would remove. Membership + /// is relationship-based (`SwiftDashSDKWalletSource.isWalletMember`), + /// so call on the main context's thread. + static func unconfirmedRows(in context: ModelContext, walletId: Data) throws -> [PersistentTransaction] { + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.context == 0 && $0.blockHeight == 0 })) + .filter { SwiftDashSDKWalletSource.isWalletMember($0, walletId: walletId) } + } + + /// Count variant of `unconfirmedRows` for UI display; 0 when no wallet + /// is bound or the fetch fails. + static func unconfirmedCount() -> Int { + guard let container = SwiftDashSDKHost.shared.modelContainer, + let walletId = WalletEnvironment.activeWalletId(for: WalletEnvironment.networkKind) else { + return 0 + } + return (try? unconfirmedRows(in: container.mainContext, walletId: walletId).count) ?? 0 + } + + /// Persistence surgery for one row. Inputs first: the `.nullify` + /// inverse on `PersistentTransaction.inputs` only clears the + /// relationship — the denormalized `isSpent` column must be flipped + /// explicitly or the restore buffer would keep excluding these TXOs + /// from the spendable set. Deleting the row then cascades its own + /// outputs and unresolved pending-input placeholders. The caller + /// saves the context. + private static func excise(_ row: PersistentTransaction, in context: ModelContext) { for spent in row.inputs { spent.isSpent = false spent.spendingTransaction = nil spent.spendingInputIndex = nil spent.lastUpdated = Date() } - // Deleting the row cascades its own outputs and unresolved - // pending-input placeholders. context.delete(row) - // The asset-lock bookmark (any vout of this txid): the row that - // makes launch-time recovery re-track and re-broadcast the lock. - // Tiny table — fetch by wallet and filter in Swift, same as - // ShieldedTxLookup. - let lockPrefix = displayTxid.lowercased() + ":" + } + + /// Delete the asset-lock bookmarks (any vout of the given txids): the + /// rows that make launch-time recovery re-track and re-broadcast a + /// lock. Tiny table — fetch by wallet and filter in Swift, same as + /// ShieldedTxLookup. The caller saves the context. + private static func deleteAssetLockBookmarks( + forDisplayTxids displayTxids: [String], walletId: Data, in context: ModelContext + ) throws { + let prefixes = displayTxids.map { $0.lowercased() + ":" } let locks = try context.fetch(FetchDescriptor( predicate: PersistentAssetLock.predicate(walletId: walletId))) - for lock in locks where lock.outPointHex.lowercased().hasPrefix(lockPrefix) { - context.delete(lock) + for lock in locks { + let outPoint = lock.outPointHex.lowercased() + if prefixes.contains(where: { outPoint.hasPrefix($0) }) { + context.delete(lock) + } } - try context.save() - Self.logger.notice("🗑️ TX-REMOVE :: deleted \(displayTxid, privacy: .public) — \(unspentInputCount, privacy: .public) input(s) unspent") + } + /// Shared removal tail, after the rows are deleted and saved: + /// app-side metadata cleanup, full runtime reload, filter rescan, + /// and cache refresh. Returns whether the rescan was armed — the + /// rescan is the recovery step that restores a wrongly-removed + /// on-chain transaction, so callers surface `false` to the user + /// instead of claiming a complete recovery. + private static func finishRemoval( + txidsWire: [Data], walletId: Data, oldestFirstSeen: UInt64 + ) async -> Bool { // App-side metadata (tax category override) keyed by the same // hash — a fresh install knows nothing about a removed tx, and // neither should this one. - if let metadata = TransactionMetadataDAOImpl.shared.get(by: txidWire) { - TransactionMetadataDAOImpl.shared.delete(dto: metadata) + for txidWire in txidsWire { + if let metadata = TransactionMetadataDAOImpl.shared.get(by: txidWire) { + TransactionMetadataDAOImpl.shared.delete(dto: metadata) + } } - // 4. Full runtime reload — the same serialized stop → load → - // start lifecycle a network switch runs. The reloaded Rust - // wallet rebuilds its tx set, UTXOs and spent_outpoints from - // the rows as they now are, and dash-spv's mempool tracker - // (which kept rebroadcasting) restarts without the tx. + // Full runtime reload — the same serialized stop → load → start + // lifecycle a network switch runs. The reloaded Rust wallet + // rebuilds its tx set, UTXOs and spent_outpoints from the rows as + // they now are, and dash-spv's mempool tracker (which kept + // rebroadcasting) restarts without the removed transactions. await SwiftDashSDKWalletRuntime.shared.rearmPlatformSync() - // 5. Rescan recent compact filters, reaching back past the - // removed transaction's first appearance: if the explorer was - // wrong and the tx IS mined, the re-match finds it and the - // wallet state repairs itself. Best-effort — the removal - // already verified off-chain status; a failed arm is logged, - // not surfaced as a failed removal. + // Rescan compact filters, reaching back past the OLDEST removed + // transaction's first appearance — uncapped in depth, because + // this is the step that restores a removed tx that actually IS + // mined (the bulk path never explorer-checked, and the single + // path's explorer can be wrong). The SDK floors the rescan at + // the wallet's birth height and the locally stored chain data; + // a row with no usable first-seen time rescans from the floor. + var rescanArmed = false let tip = SwiftDashSDKSPVCoordinator.shared.tipHeight if tip > 0, let manager = SwiftDashSDKHost.shared.manager { - let ageSeconds = max(0, Date().timeIntervalSince1970 - TimeInterval(firstSeen)) - let ageBlocks = UInt32(clamping: Int(ageSeconds / 150)) + Self.rescanMarginBlocks - let blocksBack = min(Self.maxRescanBlocks, max(Self.minRescanBlocks, ageBlocks)) + let ageSeconds = max(0, Date().timeIntervalSince1970 - TimeInterval(oldestFirstSeen)) + let ageBlocks = UInt32(clamping: Int(ageSeconds / 150)) + rescanMarginBlocks + let blocksBack = max(minRescanBlocks, ageBlocks) let fromHeight = tip > blocksBack ? tip - blocksBack : 1 do { try manager.spvRescanFilters(walletId: walletId, fromHeight: fromHeight) - Self.logger.notice("🗑️ TX-REMOVE :: filter rescan armed from height \(fromHeight, privacy: .public) (tip \(tip, privacy: .public))") + rescanArmed = true + logger.notice("🗑️ TX-REMOVE :: filter rescan armed from height \(fromHeight, privacy: .public) (tip \(tip, privacy: .public))") } catch { - Self.logger.error("🗑️ TX-REMOVE :: filter rescan arm failed: \(String(describing: error), privacy: .public)") + logger.error("🗑️ TX-REMOVE :: filter rescan arm failed: \(String(describing: error), privacy: .public)") } } else { - Self.logger.error("🗑️ TX-REMOVE :: filter rescan skipped — SPV not running after reload") + logger.error("🗑️ TX-REMOVE :: filter rescan skipped — SPV not running after reload") } - // 6. App caches that mirror the deleted rows. + // App caches that mirror the deleted rows. ShieldedTxLookup.shared.refresh() + return rescanArmed } /// One GET against the network's Insight API. 200 = the explorer diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 2110afca8..634ef943b 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -2407,7 +2407,9 @@ class SwiftDashSDKWalletSource: TransactionSource { /// union: wallet-scoped TXOs or an account's involved-transactions /// relation. Accept both so an out-of-order receipt is not discarded. /// Reads relationships — call on the row's fetch thread. - private static func isWalletMember(_ row: PersistentTransaction, walletId: Data) -> Bool { + /// Internal: also the membership test for the bulk unconfirmed-tx + /// drop (`UnconfirmedTransactionRemover`). + static func isWalletMember(_ row: PersistentTransaction, walletId: Data) -> Bool { row.outputs.contains(where: { $0.walletId == walletId }) || row.inputs.contains(where: { $0.walletId == walletId }) || row.involvedAccounts.contains(where: { $0.wallet.walletId == walletId }) diff --git a/DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift b/DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift index c03de8f9c..5e2121842 100644 --- a/DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift +++ b/DashWallet/Sources/UI/Menu/SyncInfo/SwiftDashSDKSPVStatusScreen.swift @@ -66,6 +66,15 @@ struct SwiftDashSDKSPVStatusScreen: View { /// the current height is the only way to express that in this UI. @State private var pendingResyncConfirmHeight: UInt32? + /// Presents the drop-unconfirmed confirmation dialog. + @State private var showDropUnconfirmedConfirm = false + /// True while the bulk drop + runtime reload + rescan runs; disables + /// the button and shows its progress spinner. + @State private var isDroppingUnconfirmed = false + /// Result banner under the drop button; coloured via `dropResultIsError`. + @State private var dropResultMessage: String? + @State private var dropResultIsError = false + init(vc: UINavigationController) { self.vc = vc } @@ -108,6 +117,7 @@ struct SwiftDashSDKSPVStatusScreen: View { heightsCard perPhaseCard rescanFiltersCard + dropUnconfirmedCard connectedPeersCard if let lastError = coordinator.lastError { errorCard(message: lastError) @@ -180,6 +190,23 @@ struct SwiftDashSDKSPVStatusScreen: View { "The wallet's creation height — the floor for filter scans and \"From wallet creation\" rescans. Set it at or below the wallet's first funding; imports default to 200000 on mainnet and 0 on testnet. Saving a height at or below the current one clears this network's chain data at the next launch and rescans from it.", comment: "SPV diagnostics")) } + .confirmationDialog( + NSLocalizedString("Drop unconfirmed transactions?", comment: "SPV diagnostics"), + isPresented: $showDropUnconfirmedConfirm, + titleVisibility: .visible + ) { + Button( + NSLocalizedString("Drop & rescan", comment: "SPV diagnostics"), + role: .destructive + ) { + dropUnconfirmedAndRescan() + } + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} + } message: { + Text(NSLocalizedString( + "Deletes this wallet's unconfirmed transactions from this device only and frees the coins they tried to spend. The filter rescan restores any of them that is actually on the blockchain. Nothing is sent to the network.", + comment: "SPV diagnostics")) + } .alert( NSLocalizedString("Clear chain data and resync?", comment: "SPV diagnostics"), isPresented: Binding( @@ -441,6 +468,66 @@ struct SwiftDashSDKSPVStatusScreen: View { .cornerRadius(12) } + private var dropUnconfirmedCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("Unconfirmed Transactions", comment: "SPV diagnostics")) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.primaryText) + + Text(NSLocalizedString( + "Drop this wallet's transactions still waiting for the network (never locked or mined) from this device, make the coins they tried to spend available again, and rescan recent filters. A dropped transaction that is actually on the blockchain comes back on its own during the rescan. Nothing is sent to the network.", + comment: "SPV diagnostics")) + .font(.system(size: 12)) + .foregroundColor(Color.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + row( + title: NSLocalizedString("Unconfirmed now", comment: "SPV diagnostics"), + value: "\(UnconfirmedTransactionRemover.unconfirmedCount())") + + Button(action: { + dropResultMessage = nil + showDropUnconfirmedConfirm = true + }) { + HStack(spacing: 8) { + if isDroppingUnconfirmed { + SwiftUI.ProgressView() + .controlSize(.small) + } + Text(isDroppingUnconfirmed + ? NSLocalizedString("Dropping…", comment: "SPV diagnostics") + : NSLocalizedString("Drop Unconfirmed & Rescan", comment: "SPV diagnostics")) + .font(.system(size: 14, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.dash.gray300.opacity(0.3)) + .foregroundColor(rescanEnabled && !isDroppingUnconfirmed ? .red : .secondary) + .cornerRadius(8) + } + .disabled(!rescanEnabled || isDroppingUnconfirmed) + + if !rescanEnabled { + Text(NSLocalizedString( + "Available only while SPV is running with a wallet bound.", + comment: "SPV diagnostics")) + .font(.system(size: 12)) + .foregroundColor(Color.dash.secondaryText) + } + + if let message = dropResultMessage { + Text(message) + .font(.system(size: 12)) + .foregroundColor(dropResultIsError ? .red : .green) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + } + private func errorCard(message: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text("Last Error") @@ -690,6 +777,47 @@ extension SwiftDashSDKSPVStatusScreen { } } + /// Run the bulk unconfirmed-transaction drop + rescan. The await + /// spans the persistence surgery, the runtime reload, and the rescan + /// arm, so the button's spinner honestly covers the whole operation. + func dropUnconfirmedAndRescan() { + guard !isDroppingUnconfirmed else { return } + isDroppingUnconfirmed = true + Task { @MainActor in + do { + let outcome = try await UnconfirmedTransactionRemover().dropAllUnconfirmedAndRescan() + if outcome.dropped == 0 { + dropResultIsError = false + dropResultMessage = NSLocalizedString( + "No unconfirmed transactions to drop.", + comment: "SPV diagnostics") + } else if outcome.rescanArmed { + dropResultIsError = false + dropResultMessage = String( + format: NSLocalizedString( + "Dropped %d unconfirmed transaction(s) — rescanning filters, watch the Filters row.", + comment: "SPV diagnostics"), + outcome.dropped) + } else { + // The drop finished but the recovery rescan didn't + // arm — flag it instead of claiming the safety net ran. + dropResultIsError = true + dropResultMessage = String( + format: NSLocalizedString( + "Dropped %d unconfirmed transaction(s), but the filter rescan couldn't start — run Rescan Filters above.", + comment: "SPV diagnostics"), + outcome.dropped) + } + Self.logger.info("🛰️ SPV-STATUS :: bulk unconfirmed drop finished — \(outcome.dropped, privacy: .public) tx(s), rescanArmed=\(outcome.rescanArmed, privacy: .public)") + } catch { + dropResultIsError = true + dropResultMessage = error.localizedDescription + Self.logger.error("🛰️ SPV-STATUS :: bulk unconfirmed drop failed: \(String(describing: error), privacy: .public)") + } + isDroppingUnconfirmed = false + } + } + /// Validate the birth-height text field and route it. The row is the /// durable source the SDK's `loadWallets` restore feeds back to Rust at /// every launch; the LIVE session can never honor an edit (the Rust diff --git a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift index e8b74e0a8..0901a1c82 100644 --- a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift +++ b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift @@ -536,6 +536,17 @@ extension TxDetailModel { var supportsRemoval: Bool { statusRaw <= 1 } } + /// True when the "Remove if not on Blockchain" action applies outside + /// the asset-lock retry route: the local store still has this + /// transaction in mempool context (`.processing` — never IS-locked, + /// never mined), which is exactly the state a network-dropped send + /// (e.g. a stalled CoinJoin sweep chunk) is stuck in. + /// `UnconfirmedTransactionRemover` re-verifies the local state and + /// checks a block explorer before touching anything. + var supportsUnconfirmedRemoval: Bool { + transaction.state == .processing + } + /// Non-nil when this transaction is a funding asset lock parked in a /// non-terminal state (built/broadcast/IS-locked/CL-locked but never /// consumed) on a route `AssetLockRecoveryService` can retry. Status diff --git a/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift b/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift index 4b7cf9a14..66bd33fb0 100644 --- a/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift +++ b/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift @@ -330,8 +330,12 @@ extension TXDetailViewController { self?.view.dw_hideProgressHUD() } do { - try await UnconfirmedTransactionRemover().remove(txidWire: txidWire) - self?.view.dw_showInfoHUD(withText: NSLocalizedString("Transaction removed", comment: "Remove never-accepted transaction: success")) + let rescanArmed = try await UnconfirmedTransactionRemover().remove(txidWire: txidWire) + // Never claim the rescan safety net ran when it didn't — + // point at the manual Rescan Filters action instead. + self?.view.dw_showInfoHUD(withText: rescanArmed + ? NSLocalizedString("Transaction removed", comment: "Remove never-accepted transaction: success") + : NSLocalizedString("Transaction removed — rescan couldn't start, run Rescan Filters in Core Sync Status", comment: "Remove never-accepted transaction: removed but the recovery rescan did not arm")) // The row this sheet describes no longer exists. self?.closeAction() } catch UnconfirmedTransactionRemover.RemovalError.transactionOnChain { @@ -502,6 +506,13 @@ extension TXDetailViewController { if retry.supportsRemoval { currentSnapshot.appendItems([.removeUnconfirmed], toSection: .recovery) } + } else if model.supportsUnconfirmedRemoval { + // Any other transaction stuck in mempool context (a + // network-dropped classic send — e.g. a stalled CoinJoin sweep + // chunk) gets the removal action alone: there is no retry + // route for it, but deleting the local row frees its inputs. + currentSnapshot.insertSections([.recovery], afterSection: .taxCategory) + currentSnapshot.appendItems([.removeUnconfirmed], toSection: .recovery) } currentSnapshot.appendItems([.viewTransaction, .copyRawTransaction], toSection: .rawTransaction) currentSnapshot.appendItems([.explorer], toSection: .explorer) diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 5b2d02c04..24107fa1f 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -1303,6 +1303,9 @@ /* No comment provided by engineer. */ "Delete Wallet?" = "Delete Wallet?"; +/* SPV diagnostics */ +"Deletes this wallet's unconfirmed transactions from this device only and frees the coins they tried to spend. The filter rescan restores any of them that is actually on the blockchain. Nothing is sent to the network." = "Deletes this wallet's unconfirmed transactions from this device only and frees the coins they tried to spend. The filter rescan restores any of them that is actually on the blockchain. Nothing is sent to the network."; + /* Location Service Status */ "Denied" = "Denied"; @@ -1387,6 +1390,27 @@ SDK identity profile sheet — usernames list */ "DPNS Names" = "DPNS Names"; +/* SPV diagnostics */ +"Drop & rescan" = "Drop & rescan"; + +/* SPV diagnostics */ +"Drop this wallet's transactions still waiting for the network (never locked or mined) from this device, make the coins they tried to spend available again, and rescan recent filters. A dropped transaction that is actually on the blockchain comes back on its own during the rescan. Nothing is sent to the network." = "Drop this wallet's transactions still waiting for the network (never locked or mined) from this device, make the coins they tried to spend available again, and rescan recent filters. A dropped transaction that is actually on the blockchain comes back on its own during the rescan. Nothing is sent to the network."; + +/* SPV diagnostics */ +"Drop Unconfirmed & Rescan" = "Drop Unconfirmed & Rescan"; + +/* SPV diagnostics */ +"Drop unconfirmed transactions?" = "Drop unconfirmed transactions?"; + +/* SPV diagnostics */ +"Dropped %d unconfirmed transaction(s) — rescanning filters, watch the Filters row." = "Dropped %d unconfirmed transaction(s) — rescanning filters, watch the Filters row."; + +/* SPV diagnostics */ +"Dropped %d unconfirmed transaction(s), but the filter rescan couldn't start — run Rescan Filters above." = "Dropped %d unconfirmed transaction(s), but the filter rescan couldn't start — run Rescan Filters above."; + +/* SPV diagnostics */ +"Dropping…" = "Dropping…"; + /* CrowdNode */ "Due to CrowdNode’s terms of service users can withdraw no more than:" = "Due to CrowdNode’s terms of service users can withdraw no more than:"; @@ -2578,6 +2602,9 @@ /* Remove never-accepted transaction: success */ "Transaction removed" = "Transaction removed"; +/* Remove never-accepted transaction: removed but the recovery rescan did not arm */ +"Transaction removed — rescan couldn't start, run Rescan Filters in Core Sync Status" = "Transaction removed — rescan couldn't start, run Rescan Filters in Core Sync Status"; + /* Remove never-accepted transaction: confirmation body */ "The wallet first checks a block explorer — a transaction that is on the blockchain is never removed. If it isn't found, the transaction is deleted from this wallet on this device and the coins it was trying to spend become available again. The wallet then rescans recent blocks, so if the transaction does turn out to be on the blockchain, it comes back on its own. Nothing is sent to the network." = "The wallet first checks a block explorer — a transaction that is on the blockchain is never removed. If it isn't found, the transaction is deleted from this wallet on this device and the coins it was trying to spend become available again. The wallet then rescans recent blocks, so if the transaction does turn out to be on the blockchain, it comes back on its own. Nothing is sent to the network."; @@ -2844,6 +2871,9 @@ /* (List of) New (notifications) */ "New" = "New"; +/* SPV diagnostics */ +"No unconfirmed transactions to drop." = "No unconfirmed transactions to drop."; + /* CrowdNode */ "New CrowdNode Account" = "New CrowdNode Account"; @@ -4674,6 +4704,12 @@ /* Voting */ "Unblocked '%@' username" = "Unblocked '%@' username"; +/* SPV diagnostics */ +"Unconfirmed now" = "Unconfirmed now"; + +/* SPV diagnostics */ +"Unconfirmed Transactions" = "Unconfirmed Transactions"; + /* DashPay Contacts */ "Unhide Contact" = "Unhide Contact";