From 65b3e3d3b64aa085380e44d798fc86b6eff42db8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 10 Aug 2026 03:50:20 +0700 Subject: [PATCH 1/2] feat(wallet): retry stuck asset-lock transfers from the tx detail sheet A funding asset lock can park mid-transfer: built or broadcast but never IS/CL-locked (the "Broadcasting" status), or locked on Core but with the Platform transition never landing (app killed, network drop). Until now the detail sheet just showed the stuck status with no way forward. The sheet now offers a recovery action for those states: "Rebroadcast" while the transaction is unconfirmed (status built/ broadcast), "Complete Transfer" once the lock is on-chain and only the Platform side remains. Both drive the SDK's crash-recovery resume entry points on the EXISTING tracked outpoint - rebroadcast if needed, IS/CL wait, Platform submit, consume - so a retry can never build (and strand) a second lock: - identity top-up locks (types 1/2): resumeTopUpWithAssetLock against the wallet's identity, PIN-gated, refreshing the identity snapshot on success. consumeInvitationVoucher stays false - a generic retry surface must never silently consume an invitation voucher. - Core -> Platform address funding (type 4): ShieldedTransferCoordinator.resumeFundPlatform. - Core -> Shielded funding (type 5): ShieldedTransferCoordinator.resumeAssetLock. - identity registration/invitation locks (types 0/3) are deliberately excluded - they recover through the Join DashPay flow, which owns key preparation. The await spans the whole recovery under a progress HUD (a worst case includes the IS/CL wait), the rows re-derive afterward either way - even a failed retry can have advanced the lock, and the status row should say so - and backing out of the PIN prompt is treated as a non-error. Co-Authored-By: Claude Fable 5 --- DashWallet.xcodeproj/project.pbxproj | 6 + .../AssetLockRecoveryService.swift | 114 ++++++++++++++++++ .../UI/Tx/Details/Model/TxDetailModel.swift | 37 ++++++ .../Tx/Details/TxDetailViewController.swift | 64 ++++++++++ DashWallet/en.lproj/Localizable.strings | 18 +++ 5 files changed, 239 insertions(+) create mode 100644 DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 93b6c473f..ddd1bb292 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -331,6 +331,7 @@ 2E08EC52344E430F9BC8DEAA /* TransferAmountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31AECC5C20684C9293F22A38 /* TransferAmountViewModel.swift */; }; 2EFDEC7DCA624B16DBBB482D /* SwiftDashSDKWalletCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B697D56A525CBF142F4401FE /* SwiftDashSDKWalletCreator.swift */; }; 30C7DE2E26E849E0D76319FA /* PlatformAddressSyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E56673B2BFED8EC0181A1F /* PlatformAddressSyncCoordinator.swift */; }; + A1FA10C00000000000000002 /* AssetLockRecoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1FA10C00000000000000001 /* AssetLockRecoveryService.swift */; }; 3116F910F54D310002090CBB /* PinPromptPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D385FC174E5B887CF3398CCD /* PinPromptPresenter.swift */; }; 35BFE3D1C0B8999FBC8AB180 /* PaymentRequestVerifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59D5720437F8A4137605940 /* PaymentRequestVerifier.swift */; }; 3705C73F15BCD91AF70940B9 /* PlatformCreditsFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C830DD6D63115525925F75 /* PlatformCreditsFormatter.swift */; }; @@ -928,6 +929,7 @@ 8DF3D24EE3B247B79AE9CAB5 /* BuySellPortalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */; }; 8E3AF21ED19AEC0FD89A63FC /* IdentitiesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528594007157A56ABDEAD552 /* IdentitiesScreen.swift */; }; 8EAA05FA797008525050A2A7 /* PlatformAddressSyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E56673B2BFED8EC0181A1F /* PlatformAddressSyncCoordinator.swift */; }; + A1FA10C00000000000000003 /* AssetLockRecoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1FA10C00000000000000001 /* AssetLockRecoveryService.swift */; }; 8F0B3AA027F569D8D2E1F900 /* PaymentRequestVerifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59D5720437F8A4137605940 /* PaymentRequestVerifier.swift */; }; 8FCE113ECEA4B9781B24922C /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EE3302C1C5DF898D1A0F915 /* AuthenticationService.swift */; }; 908AB7EFCC69D3828BBD70A8 /* PaymentsLandingHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B401E660599FEF6B18087F91 /* PaymentsLandingHostingController.swift */; }; @@ -3577,6 +3579,7 @@ E5C4E3EBE8D8A77EBFAA1E86 /* NetworkReachability.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkReachability.swift; sourceTree = ""; }; E66B030D4B574621DA2C5816 /* PaymentProtocolWireFormat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentProtocolWireFormat.swift; sourceTree = ""; }; E6E56673B2BFED8EC0181A1F /* PlatformAddressSyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformAddressSyncCoordinator.swift; sourceTree = ""; }; + A1FA10C00000000000000001 /* AssetLockRecoveryService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AssetLockRecoveryService.swift; sourceTree = ""; }; E7333F2D05D0DD2D4373FF9B /* StorageExplorerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageExplorerView.swift; sourceTree = ""; }; E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentsLandingScreen.swift; sourceTree = ""; }; E83D8CEAFF69B6D666C02116 /* Pods-WatchApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchApp.release.xcconfig"; path = "Target Support Files/Pods-WatchApp/Pods-WatchApp.release.xcconfig"; sourceTree = ""; }; @@ -7112,6 +7115,7 @@ E181EB71C8803101EA789AA4 /* SwiftDashSDKHost.swift */, 35F37C177039C76661FA2DB2 /* SwiftDashSDKSPVCoordinator.swift */, E6E56673B2BFED8EC0181A1F /* PlatformAddressSyncCoordinator.swift */, + A1FA10C00000000000000001 /* AssetLockRecoveryService.swift */, 51AA000D2F97000D005A000D /* ShieldedSyncMonitor.swift */, 43C830DD6D63115525925F75 /* PlatformCreditsFormatter.swift */, 44DCDC3A2B92601BB1E61FD9 /* PlatformSendExecutor.swift */, @@ -10137,6 +10141,7 @@ 19CF9D60B276558AA48380C2 /* SwiftDashSDKSPVCoordinator.swift in Sources */, 85448D7537BBD0AE40682EC8 /* SwiftDashSDKSPVStatusScreen.swift in Sources */, 8EAA05FA797008525050A2A7 /* PlatformAddressSyncCoordinator.swift in Sources */, + A1FA10C00000000000000003 /* AssetLockRecoveryService.swift in Sources */, 2D354A4D6053027CA27F02CB /* PlatformSyncStatusScreen.swift in Sources */, 51AA00222F970022005A0022 /* MasternodesScreen.swift in Sources */, 51AA00022F970002005A0002 /* SyncInfoMenuScreen.swift in Sources */, @@ -11140,6 +11145,7 @@ DD985ADD7FFF3B9E3C344DED /* SwiftDashSDKSPVCoordinator.swift in Sources */, 87AC92AAD9DF8CBF271609AC /* SwiftDashSDKSPVStatusScreen.swift in Sources */, 30C7DE2E26E849E0D76319FA /* PlatformAddressSyncCoordinator.swift in Sources */, + A1FA10C00000000000000002 /* AssetLockRecoveryService.swift in Sources */, 0308E777B29CC6DE1DB30FDD /* PlatformSyncStatusScreen.swift in Sources */, 51AA00212F970021005A0021 /* MasternodesScreen.swift in Sources */, 51AA00032F970003005A0003 /* SyncInfoMenuScreen.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift new file mode 100644 index 000000000..845006485 --- /dev/null +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift @@ -0,0 +1,114 @@ +// +// AssetLockRecoveryService.swift +// DashWallet +// +// User-initiated retry of a funding asset lock parked in a +// non-terminal state — built/broadcast but never IS/CL-locked, or +// locked on Core but whose Platform transition never landed (app +// killed, network drop). Dispatches by funding type to the SDK's +// crash-recovery resume entry points, which pick up the EXISTING +// tracked outpoint and drive whatever stages remain (rebroadcast, +// IS/CL wait, Platform submit, consume) — no second lock is ever +// built, so a retry can't strand more funds. +// +// Routes handled here (the tx-detail "Rebroadcast" button): +// 1/2 — identity top-up (bound / not-bound): +// `resumeTopUpWithAssetLock` against the wallet's identity. +// 4 — Core → Platform address funding: +// `ShieldedTransferCoordinator.resumeFundPlatform`. +// 5 — Core → Shielded funding: +// `ShieldedTransferCoordinator.resumeAssetLock`. +// Deliberately NOT handled: 0 (identity registration) and +// 3 (invitation) — those locks recover through the Join DashPay +// registration flow, which owns key preparation and phase UI. +// + +import Foundation +import OSLog + +@MainActor +struct AssetLockRecoveryService { + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "swift-sdk-migration.asset-lock-recovery") + + enum RecoveryError: LocalizedError { + case notReady + case unsupportedRoute + case failed(String) + + var errorDescription: String? { + switch self { + case .notReady: + return NSLocalizedString("Wallet is not ready", comment: "DashPay") + case .unsupportedRoute: + return NSLocalizedString("This transfer can't be retried from here.", comment: "Asset-lock retry: unsupported funding route") + case .failed(let message): + return message + } + } + } + + /// Funding routes the tx-detail retry button supports. Pure + /// predicate, callable off the main actor (`TxDetailModel` derives + /// rows outside it). + nonisolated static func supportsRetry(fundingTypeRaw: Int) -> Bool { + [1, 2, 4, 5].contains(fundingTypeRaw) + } + + /// Retry the transfer for the tracked lock at (`txidWire`, `vout`). + /// PIN-gated (directly or inside the transfer coordinator). Throws + /// `DWIdentityAuthorizer.AuthError.cancelled` when the user backs + /// out of the PIN prompt — callers treat that as a non-error. + /// Returns only after the resume ran to completion, which for a + /// still-unlocked transaction includes the IS/CL wait. + func retry(fundingTypeRaw: Int, txidWire: Data, vout: UInt32) async throws { + Self.logger.info("🔁 LOCK-RETRY :: type=\(fundingTypeRaw, privacy: .public) vout=\(vout, privacy: .public)") + switch fundingTypeRaw { + case 1, 2: + try await retryIdentityTopUp(txidWire: txidWire, vout: vout) + case 4: + let coordinator = ShieldedTransferCoordinator() + await coordinator.resumeFundPlatform(outPointTxidWire: txidWire, outPointVout: vout) + try Self.checkTerminalPhase(coordinator) + case 5: + let coordinator = ShieldedTransferCoordinator() + await coordinator.resumeAssetLock(outPointTxidWire: txidWire, outPointVout: vout) + try Self.checkTerminalPhase(coordinator) + default: + throw RecoveryError.unsupportedRoute + } + ShieldedTxLookup.shared.refresh() + Self.logger.info("🔁 LOCK-RETRY :: completed type=\(fundingTypeRaw, privacy: .public)") + } + + /// Identity top-up resume: the lock's credit output funds the + /// wallet's own identity — the only identity this app tops up. + /// `consumeInvitationVoucher` stays false: a generic retry surface + /// must never silently consume an invitation lock (the SDK resolver + /// refuses them). + private func retryIdentityTopUp(txidWire: Data, vout: UInt32) async throws { + guard let wallet = SwiftDashSDKHost.shared.wallet, + let identityId = DWCurrentUserIdentityInfo.shared.identityId else { + throw RecoveryError.notReady + } + try await DWIdentityAuthorizer().authorize() + _ = try await wallet.resumeTopUpWithAssetLock( + identityId: identityId, + outPointTxid: txidWire, + outPointVout: vout) + DWCurrentUserIdentityInfo.shared.refreshFromSDK() + } + + /// Map the transfer coordinator's terminal phase to thrown errors, + /// converting its stringified PIN-cancel back into the typed + /// `AuthError.cancelled` so callers keep one cancel contract. + private static func checkTerminalPhase(_ coordinator: ShieldedTransferCoordinator) throws { + guard case .failed(let message) = coordinator.phase else { return } + if message == DWIdentityAuthorizer.AuthError.cancelled.errorDescription { + throw DWIdentityAuthorizer.AuthError.cancelled + } + throw RecoveryError.failed(message) + } +} diff --git a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift index 6e855c8d8..5c625e1d5 100644 --- a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift +++ b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift @@ -486,6 +486,43 @@ extension TxDetailModel { } } + // MARK: Stuck asset-lock retry + + struct StuckAssetLockRetry { + let fundingTypeRaw: Int + let statusRaw: Int + let vout: UInt32 + + /// Button title matching what actually remains: an unlocked + /// transaction is re-broadcast; a locked one only needs the + /// Platform side finished. + var actionTitle: String { + statusRaw <= 1 + ? NSLocalizedString("Rebroadcast", comment: "Retry a stuck balance transfer whose transaction never confirmed") + : NSLocalizedString("Complete Transfer", comment: "Retry a stuck balance transfer whose transaction confirmed but whose Platform side never finished") + } + } + + /// 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 + /// 4 (consumed) and 5 (restored, completion unknown) never qualify: + /// 4 is done, and a restored lock has no tracked local state to + /// resume from. + var stuckAssetLockRetry: StuckAssetLockRetry? { + let info = transaction.identityFundingLockInfo + ?? transaction.platformFundingLockInfo + ?? ShieldedTxLookup.shared.info(forTxidHex: transactionId) + guard let info, + (0...3).contains(info.statusRaw), + AssetLockRecoveryService.supportsRetry(fundingTypeRaw: info.fundingTypeRaw) + else { return nil } + return StuckAssetLockRetry( + fundingTypeRaw: info.fundingTypeRaw, + statusRaw: info.statusRaw, + vout: info.vout) + } + /// Below this (0.0001 DASH) a fee renders as plain duffs — the /// DASH-formatted form reads as zero at a glance. private static let duffsDisplayThreshold: UInt64 = 10_000 diff --git a/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift b/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift index 31557bd26..08fb6e840 100644 --- a/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift +++ b/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift @@ -107,11 +107,14 @@ class TXDetailViewController: BaseTxDetailsViewController { var dataSource: UITableViewDiffableDataSource! = nil var currentSnapshot: NSDiffableDataSourceSnapshot! = nil + /// Single-flight guard for the stuck-lock retry action. + private var isRetryingAssetLock = false enum Section: CaseIterable { case header case info case taxCategory + case recovery case rawTransaction case explorer case swapExplorer @@ -128,6 +131,7 @@ class TXDetailViewController: BaseTxDetailsViewController { case date(DWTitleDetailItem) case taxCategory(DWTitleDetailItem) case shieldedInfo(DWTitleDetailItem) + case rebroadcast(String) case viewTransaction case copyRawTransaction case explorer @@ -150,6 +154,7 @@ class TXDetailViewController: BaseTxDetailsViewController { case .date(let item): return ["Date"] + Self.identity(of: [item]) case .taxCategory(let item): return ["TaxCategory"] + Self.identity(of: [item]) case .shieldedInfo(let item): return ["ShieldedInfo"] + Self.identity(of: [item]) + case .rebroadcast(let title): return ["Rebroadcast", title] case .viewTransaction: return ["ViewTransaction"] case .copyRawTransaction: return ["CopyRawTransaction"] case .explorer: return ["Explorer"] @@ -252,6 +257,48 @@ extension TXDetailViewController { present(hostingController, animated: true) } + /// Retry the stuck asset-lock transfer on its EXISTING outpoint — + /// `AssetLockRecoveryService` drives the SDK resume path + /// (rebroadcast if needed, IS/CL wait, Platform submit). The await + /// spans the whole recovery, so the HUD honestly covers a + /// several-minute worst case rather than claiming early success. + private func retryStuckAssetLock() { + guard !isRetryingAssetLock, let retry = model.stuckAssetLockRetry else { return } + isRetryingAssetLock = true + let txidWire = model.transaction.txHashData + view.dw_showProgressHUD(withMessage: NSLocalizedString("Retrying transfer…", comment: "Asset-lock retry in progress")) + Task { [weak self] in + defer { + self?.isRetryingAssetLock = false + self?.view.dw_hideProgressHUD() + } + do { + try await AssetLockRecoveryService().retry( + fundingTypeRaw: retry.fundingTypeRaw, + txidWire: txidWire, + vout: retry.vout) + self?.view.dw_showInfoHUD(withText: NSLocalizedString("Transfer completed", comment: "Asset-lock retry finished")) + } catch DWIdentityAuthorizer.AuthError.cancelled { + // Backing out of the PIN prompt is not an error state. + } catch { + self?.presentRetryFailure(error) + } + // Re-derive the rows either way — even a failed retry can + // have advanced the lock (e.g. broadcast landed, Platform + // submit didn't), and the status row should say so. + self?.reloadDataSource() + } + } + + private func presentRetryFailure(_ error: Error) { + let alert = UIAlertController( + title: NSLocalizedString("Couldn't complete the transfer", comment: "Asset-lock retry failed"), + message: error.localizedDescription, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel)) + present(alert, animated: true) + } + /// Copies the serialized transaction hex. A missing row (bytes not /// stored on this device) reports itself rather than copying nothing. private func copyRawTransaction() { @@ -298,6 +345,14 @@ extension TXDetailViewController { cell.update(with: item) return cell + case .recovery: + let cell = tableView.dequeueReusableCell(withIdentifier: TxDetailActionCell.reuseIdentifier, + for: indexPath) as! TxDetailActionCell + if case .rebroadcast(let title) = item { + cell.titleLabel.text = title + } + return cell + case .rawTransaction: let cell = tableView.dequeueReusableCell(withIdentifier: TxDetailActionCell.reuseIdentifier, for: indexPath) as! TxDetailActionCell @@ -374,6 +429,13 @@ extension TXDetailViewController { currentSnapshot.appendItems([.date(date)], toSection: .info) currentSnapshot.appendItems([.taxCategory(taxCategory)], toSection: .taxCategory) + // A funding asset lock parked mid-transfer gets a retry action. + // The section is inserted (not pre-appended) so the empty state + // adds no phantom section spacing. + if let retry = model.stuckAssetLockRetry { + currentSnapshot.insertSections([.recovery], afterSection: .taxCategory) + currentSnapshot.appendItems([.rebroadcast(retry.actionTitle)], toSection: .recovery) + } currentSnapshot.appendItems([.viewTransaction, .copyRawTransaction], toSection: .rawTransaction) currentSnapshot.appendItems([.explorer], toSection: .explorer) if let swapLink = model.swapExplorerLink { @@ -398,6 +460,8 @@ extension TXDetailViewController { model.toggleTaxCategoryOnCurrentTransaction() reloadDataSource() break + case .recovery: + retryStuckAssetLock() case .rawTransaction: if let item = dataSource.itemIdentifier(for: indexPath) { switch item { diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 443ad18ca..edf59429b 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -653,6 +653,9 @@ /* DashPay intro: feature title */ "Coming soon" = "Coming soon"; +/* Retry a stuck balance transfer whose transaction confirmed but whose Platform side never finished */ +"Complete Transfer" = "Complete Transfer"; + /* Shielded transfer status */ "Completed" = "Completed"; @@ -665,6 +668,9 @@ /* Identity top-up sheet — body */ "Convert Dash into identity credits to pay for Platform actions like contact requests and profile updates." = "Convert Dash into identity credits to pay for Platform actions like contact requests and profile updates."; +/* Asset-lock retry failed */ +"Couldn't complete the transfer" = "Couldn't complete the transfer"; + /* Identity top-up sheet — free amount chip */ "Custom" = "Custom"; @@ -2335,9 +2341,15 @@ /* Identities: key visible to the wallet but not usable for signing */ "Read-only" = "Read-only"; +/* Retry a stuck balance transfer whose transaction never confirmed */ +"Rebroadcast" = "Rebroadcast"; + /* Identity top-up sheet — shielded source note */ "Recommended: a two-step transfer through your own Platform address keeps your identity unlinked from your transparent coins." = "Recommended: a two-step transfer through your own Platform address keeps your identity unlinked from your transparent coins."; +/* Asset-lock retry in progress */ +"Retrying transfer…" = "Retrying transfer…"; + /* Identities: DPP security level of a public key */ "Security level" = "Security level"; @@ -2371,12 +2383,18 @@ /* SDK identity profile sheet — explains the identity credit balance */ "This balance pays Dash Platform network fees — usernames, contact requests, profile updates. It belongs to your identity, not your wallet: unlike your Transparent, Platform, and Shielded balances it can't be spent as regular Dash. Topping up converts Dash from a balance you choose — Shielded by default — into identity credits." = "This balance pays Dash Platform network fees — usernames, contact requests, profile updates. It belongs to your identity, not your wallet: unlike your Transparent, Platform, and Shielded balances it can't be spent as regular Dash. Topping up converts Dash from a balance you choose — Shielded by default — into identity credits."; +/* Asset-lock retry: unsupported funding route */ +"This transfer can't be retried from here." = "This transfer can't be retried from here."; + /* SDK identity profile sheet — add credits to the identity */ "Top Up" = "Top Up"; /* Identity top-up sheet — title */ "Top Up Identity Balance" = "Top Up Identity Balance"; +/* Asset-lock retry finished */ +"Transfer completed" = "Transfer completed"; + /* DashPay intro: feature title */ "Usernames, not addresses" = "Usernames, not addresses"; From aa08b352a48c1ba890df9b4c92c796c52b46cb38 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 10 Aug 2026 15:17:28 +0700 Subject: [PATCH 2/2] fix(wallet): typed cancel signal for asset-lock retry; distinct no-identity error Addresses the review on #949. - `ShieldedTransferCoordinator` now records the typed error behind a `.failed` phase in `lastFailure`, so `AssetLockRecoveryService` can tell a PIN cancel from a real failure without comparing localized text (the `Phase` payload is display copy and would silently stop matching on any wording or locale change). Cleared on `beginTransfer()`/`reset()`; the single `phase = .failed` assignment site keeps the two in step. - Split the identity top-up preconditions: a missing wallet stays `.notReady`, a missing identity gets its own `.noIdentity` error and log line, so both the log and the alert name the actual cause. - Collapse the two coordinator resume branches (funding types 4 and 5) so the resume call and its terminal-phase check stay a single pair. Clean `dashpay` simulator build. Co-Authored-By: Claude Opus 5 --- .../AssetLockRecoveryService.swift | 38 +++++++++++++------ .../ShieldedTransferCoordinator.swift | 11 ++++++ DashWallet/en.lproj/Localizable.strings | 3 ++ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift index 845006485..e66ac939d 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift @@ -35,6 +35,7 @@ struct AssetLockRecoveryService { enum RecoveryError: LocalizedError { case notReady + case noIdentity case unsupportedRoute case failed(String) @@ -42,6 +43,8 @@ struct AssetLockRecoveryService { switch self { case .notReady: return NSLocalizedString("Wallet is not ready", comment: "DashPay") + case .noIdentity: + return NSLocalizedString("This wallet has no identity to top up.", comment: "Asset-lock retry: identity top-up with no identity") case .unsupportedRoute: return NSLocalizedString("This transfer can't be retried from here.", comment: "Asset-lock retry: unsupported funding route") case .failed(let message): @@ -68,13 +71,17 @@ struct AssetLockRecoveryService { switch fundingTypeRaw { case 1, 2: try await retryIdentityTopUp(txidWire: txidWire, vout: vout) - case 4: + case 4, 5: + // Both coordinator routes report their outcome through the + // terminal phase rather than throwing, so the resume call and + // the phase check stay one pair — a future route added here + // can't forget the check. let coordinator = ShieldedTransferCoordinator() - await coordinator.resumeFundPlatform(outPointTxidWire: txidWire, outPointVout: vout) - try Self.checkTerminalPhase(coordinator) - case 5: - let coordinator = ShieldedTransferCoordinator() - await coordinator.resumeAssetLock(outPointTxidWire: txidWire, outPointVout: vout) + if fundingTypeRaw == 4 { + await coordinator.resumeFundPlatform(outPointTxidWire: txidWire, outPointVout: vout) + } else { + await coordinator.resumeAssetLock(outPointTxidWire: txidWire, outPointVout: vout) + } try Self.checkTerminalPhase(coordinator) default: throw RecoveryError.unsupportedRoute @@ -89,10 +96,14 @@ struct AssetLockRecoveryService { /// must never silently consume an invitation lock (the SDK resolver /// refuses them). private func retryIdentityTopUp(txidWire: Data, vout: UInt32) async throws { - guard let wallet = SwiftDashSDKHost.shared.wallet, - let identityId = DWCurrentUserIdentityInfo.shared.identityId else { + guard let wallet = SwiftDashSDKHost.shared.wallet else { + Self.logger.error("🔁 LOCK-RETRY :: top-up aborted — no active wallet") throw RecoveryError.notReady } + guard let identityId = DWCurrentUserIdentityInfo.shared.identityId else { + Self.logger.error("🔁 LOCK-RETRY :: top-up aborted — wallet has no identity") + throw RecoveryError.noIdentity + } try await DWIdentityAuthorizer().authorize() _ = try await wallet.resumeTopUpWithAssetLock( identityId: identityId, @@ -101,12 +112,15 @@ struct AssetLockRecoveryService { DWCurrentUserIdentityInfo.shared.refreshFromSDK() } - /// Map the transfer coordinator's terminal phase to thrown errors, - /// converting its stringified PIN-cancel back into the typed - /// `AuthError.cancelled` so callers keep one cancel contract. + /// Map the transfer coordinator's terminal phase to thrown errors. + /// `Phase.failed` carries only the display text, so the PIN-cancel is + /// recognized from the coordinator's typed `lastFailure` — never from + /// its localized description — and rethrown as `AuthError.cancelled` + /// so callers keep one cancel contract. private static func checkTerminalPhase(_ coordinator: ShieldedTransferCoordinator) throws { guard case .failed(let message) = coordinator.phase else { return } - if message == DWIdentityAuthorizer.AuthError.cancelled.errorDescription { + if let failure = coordinator.lastFailure as? ShieldedTransferCoordinator.CoordinatorError, + case .authCancelled = failure { throw DWIdentityAuthorizer.AuthError.cancelled } throw RecoveryError.failed(message) diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 36040fe08..15fc6fb40 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -190,6 +190,14 @@ final class ShieldedTransferCoordinator: ObservableObject { @Published private(set) var phase: Phase = .idle + /// The typed error behind the current `.failed(_)` phase. `Phase` + /// carries only display text (that's what the confirm sheets render), so + /// programmatic callers that must branch on the *kind* of failure — + /// `AssetLockRecoveryService` telling a PIN cancel from a real error — + /// read the error itself here rather than matching localized strings. + /// Cleared when a transfer starts and on `reset()`. + private(set) var lastFailure: Error? + private static let logger = Logger( subsystem: "org.dashfoundation.dash", category: "swift-sdk-migration.shielded-transfer") @@ -1064,6 +1072,7 @@ final class ShieldedTransferCoordinator: ObservableObject { func reset() { stopAssetLockPolling() lastAssetLockOutPoint = nil + lastFailure = nil phase = .idle } @@ -1134,6 +1143,7 @@ final class ShieldedTransferCoordinator: ObservableObject { /// first caller wins atomically and the second sees `.signing` + bails. private func beginTransfer() -> Bool { guard phase == .idle else { return false } + lastFailure = nil phase = .signing return true } @@ -1153,6 +1163,7 @@ final class ShieldedTransferCoordinator: ObservableObject { private func handleFailure(_ error: Error) { Self.logger.error("🛡️ SHIELD-TX :: failure \(String(describing: error), privacy: .public)") + lastFailure = error let message: String if let local = error as? LocalizedError, let description = local.errorDescription { message = description diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index edf59429b..a069373fc 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2386,6 +2386,9 @@ /* Asset-lock retry: unsupported funding route */ "This transfer can't be retried from here." = "This transfer can't be retried from here."; +/* Asset-lock retry: identity top-up with no identity */ +"This wallet has no identity to top up." = "This wallet has no identity to top up."; + /* SDK identity profile sheet — add credits to the identity */ "Top Up" = "Top Up";