From bbdde15d4b11c05772cfb715b4a4f419f3c0cbb2 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 10 Aug 2026 15:54:14 +0200 Subject: [PATCH 1/4] fix(shielded): finish consumed asset-lock recovery --- .../ShieldedTransferCoordinator.swift | 25 +++++++++++++++++-- .../PassiveWalletStateUITailTests.swift | 12 +++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 15fc6fb403..57586578c9 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -515,8 +515,15 @@ final class ShieldedTransferCoordinator: ObservableObject { outPointVout: outPointVout, recipients: [recipient]) } catch { - handleSpendError(error, manager: env.manager) - return + guard Self.idempotentAssetLockResumePhase(for: error) == .success else { + handleSpendError(error, manager: env.manager) + return + } + // Platform confirmed this exact outpoint was already consumed by + // an earlier attempt. Rust reconciled its tracked lock to the + // terminal Consumed status before returning the typed error, so + // recovery is complete rather than retryable. + Self.logger.info("🛡️ SHIELD-TX :: resume found asset lock already consumed — treating as complete") } Self.logger.info("🛡️ SHIELD-TX :: resume completed") @@ -525,9 +532,23 @@ final class ShieldedTransferCoordinator: ObservableObject { // `performShield`). No intermediate signal exists for this opaque call. phase = .broadcasting phase = .success + ShieldedTxLookup.shared.refresh() + NotificationCenter.default.post( + name: .swiftDashSDKTransactionProjectionDidChange, + object: nil) scheduleShieldedResync(manager: env.manager) } + /// An exact-outpoint resume is idempotently complete when Platform says + /// that lock was already consumed. The SDK persists status 4 before + /// returning this typed error; every other error remains a real failure. + static func idempotentAssetLockResumePhase(for error: Error) -> Phase? { + if case PlatformWalletError.assetLockAlreadyConsumed = error { + return .success + } + return nil + } + /// Parse a `PersistentAssetLock.outPointHex` (":") /// into the (wire-order txid, vout) that `shieldedResumeFundFromAssetLock` /// expects. The stored txid is display order (reversed wire); the FFI wants diff --git a/DashWalletTests/PassiveWalletStateUITailTests.swift b/DashWalletTests/PassiveWalletStateUITailTests.swift index 466f679a99..4fdb51dace 100644 --- a/DashWalletTests/PassiveWalletStateUITailTests.swift +++ b/DashWalletTests/PassiveWalletStateUITailTests.swift @@ -6,12 +6,24 @@ // import Combine +import SwiftDashSDK import XCTest @testable import dashwallet @MainActor final class PassiveWalletStateUITailTests: XCTestCase { + func testAlreadyConsumedAssetLockMapsToSuccessfulResume() { + let error = PlatformWalletError.assetLockAlreadyConsumed("test outpoint") + + XCTAssertEqual( + ShieldedTransferCoordinator.idempotentAssetLockResumePhase(for: error), + .success) + XCTAssertNil( + ShieldedTransferCoordinator.idempotentAssetLockResumePhase( + for: PlatformWalletError.invalidParameter("unrelated"))) + } + func testHomeBalanceChangesSkipInitialAndDuplicateSnapshots() { let balances = CurrentValueSubject(nil) var reloadCount = 0 From 673f55781e66e7ed7a72fa5a167764b3f3309149 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 10 Aug 2026 18:20:27 +0200 Subject: [PATCH 2/4] fix(wallet): keep reported consumption unconfirmed --- .../PlatformAddressSyncCoordinator.swift | 9 ++++--- .../InternalTransferConfirmSheet.swift | 4 +-- .../ShieldedTransferCoordinator.swift | 27 ++++++++++--------- .../UI/Tx/Details/Model/TxDetailModel.swift | 7 ++--- DashWallet/en.lproj/Localizable.strings | 2 +- .../PassiveWalletStateUITailTests.swift | 10 +++---- 6 files changed, 32 insertions(+), 27 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index 404adf467f..c58ed4ec11 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -1483,10 +1483,11 @@ final class ShieldedTxLookup { /// Snapshot value for one shielded funding tx: the locked amount plus the /// asset lock's current status and funding output index. `statusRaw` /// distinguishes a still-pending/stuck shield (1…3 — Broadcast/IS/CL) from - /// a consumed, successful one (4) and from a restore-scan recovery whose - /// Platform-side consumption is unknown (5 — RecoveredFromChain, neither - /// pending nor done); `vout` + the txid form the outpoint a recovery - /// resume needs. + /// a consumed, successful one (4) and from a chain-final lock whose + /// Platform-side consumption is unknown (5 — RecoveredFromChain, either + /// reconstructed during restore or reconciled from an unauthenticated + /// already-consumed report; neither pending nor done); `vout` + the txid + /// form the outpoint a recovery resume needs. struct ShieldedLockInfo: Sendable { let amountDuffs: UInt64 let statusRaw: Int diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift index 661149e853..a378009b86 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift @@ -707,8 +707,8 @@ struct ShieldedRecoverySheet: View { alreadyComplete = true return } - // statusRaw 1...3 (still pending), 5 (restored from chain — consumption - // unknown; a resume either completes it or Platform rejects the spent + // statusRaw 1...3 (still pending), 5 (Core-final — consumption + // unknown; a resume either completes it or Platform reports the spent // outpoint with a typed error), or nil/0 (status unavailable, e.g. a // failed refresh): attempt the resume. A genuinely gone/consumed lock // surfaces a real SDK error rather than a false "complete". diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 57586578c9..32fa838b3a 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -505,6 +505,7 @@ final class ShieldedTransferCoordinator: ObservableObject { // no asset-lock polling: there's no new lock to track). phase = .proving + let terminalPhase: Phase do { let recipient = ShieldedFundFromAssetLockRecipient( recipientRaw43: recipientOverride ?? env.shieldedRecipient, @@ -514,16 +515,18 @@ final class ShieldedTransferCoordinator: ObservableObject { outPointTxid: outPointTxidWire, outPointVout: outPointVout, recipients: [recipient]) + terminalPhase = .success } catch { - guard Self.idempotentAssetLockResumePhase(for: error) == .success else { + guard let mappedPhase = Self.alreadyConsumedAssetLockResumePhase(for: error) else { handleSpendError(error, manager: env.manager) return } - // Platform confirmed this exact outpoint was already consumed by - // an earlier attempt. Rust reconciled its tracked lock to the - // terminal Consumed status before returning the typed error, so - // recovery is complete rather than retryable. - Self.logger.info("🛡️ SHIELD-TX :: resume found asset lock already consumed — treating as complete") + // A DAPI endpoint reported this exact outpoint as consumed, but + // rejection responses are not quorum-authenticated. Rust retains + // the ChainLock proof and records nonterminal consumption-unknown + // state, so suppress retries without claiming verified success. + terminalPhase = mappedPhase + Self.logger.info("🛡️ SHIELD-TX :: resume found asset lock reported consumed — completion remains unconfirmed") } Self.logger.info("🛡️ SHIELD-TX :: resume completed") @@ -531,7 +534,7 @@ final class ShieldedTransferCoordinator: ObservableObject { // .broadcasting so the step checklist completes naturally (mirrors // `performShield`). No intermediate signal exists for this opaque call. phase = .broadcasting - phase = .success + phase = terminalPhase ShieldedTxLookup.shared.refresh() NotificationCenter.default.post( name: .swiftDashSDKTransactionProjectionDidChange, @@ -539,12 +542,12 @@ final class ShieldedTransferCoordinator: ObservableObject { scheduleShieldedResync(manager: env.manager) } - /// An exact-outpoint resume is idempotently complete when Platform says - /// that lock was already consumed. The SDK persists status 4 before - /// returning this typed error; every other error remains a real failure. - static func idempotentAssetLockResumePhase(for error: Error) -> Phase? { + /// An already-consumed report suppresses a potentially destructive retry, + /// but is not authenticated proof of completion. Every other error remains + /// a normal failure. + static func alreadyConsumedAssetLockResumePhase(for error: Error) -> Phase? { if case PlatformWalletError.assetLockAlreadyConsumed = error { - return .success + return .submittedUnconfirmed } return nil } diff --git a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift index b9133f96ca..5fcce8d60f 100644 --- a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift +++ b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift @@ -502,9 +502,10 @@ extension TxDetailModel { return NSLocalizedString("Completed", comment: "Shielded transfer status") case 5: // RecoveredFromChain: the lock is final on Core, but whether it - // completed on Platform is unknown after a restore — claiming - // neither "Pending" nor "Completed" is deliberate. - return NSLocalizedString("Restored — completion unknown", comment: "Status of an asset lock recovered from on-chain data after a wallet restore; whether the transfer finished cannot be determined") + // completed on Platform is unknown after a restore or an + // unauthenticated already-consumed report. Claiming neither + // "Pending" nor "Completed" is deliberate. + return NSLocalizedString("Completion unknown", comment: "Status of a chain-locked asset lock whose Platform-side consumption cannot be authenticated") default: return nil } diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index d74a6a9846..59aa14be67 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -3583,7 +3583,7 @@ /* Wallets */ "Restore an existing wallet" = "Restore an existing wallet"; -"Restored — completion unknown" = "Restored — completion unknown"; +"Completion unknown" = "Completion unknown"; /* Location Service Status */ diff --git a/DashWalletTests/PassiveWalletStateUITailTests.swift b/DashWalletTests/PassiveWalletStateUITailTests.swift index 4fdb51dace..74d17ea5df 100644 --- a/DashWalletTests/PassiveWalletStateUITailTests.swift +++ b/DashWalletTests/PassiveWalletStateUITailTests.swift @@ -5,22 +5,22 @@ // Copyright © 2026 Dash Core Group. All rights reserved. // +@testable import dashwallet import Combine import SwiftDashSDK import XCTest -@testable import dashwallet @MainActor final class PassiveWalletStateUITailTests: XCTestCase { - func testAlreadyConsumedAssetLockMapsToSuccessfulResume() { + func testAlreadyConsumedAssetLockMapsToUnconfirmedResume() { let error = PlatformWalletError.assetLockAlreadyConsumed("test outpoint") XCTAssertEqual( - ShieldedTransferCoordinator.idempotentAssetLockResumePhase(for: error), - .success) + ShieldedTransferCoordinator.alreadyConsumedAssetLockResumePhase(for: error), + .submittedUnconfirmed) XCTAssertNil( - ShieldedTransferCoordinator.idempotentAssetLockResumePhase( + ShieldedTransferCoordinator.alreadyConsumedAssetLockResumePhase( for: PlatformWalletError.invalidParameter("unrelated"))) } From edf6be1c480e442f9b5a03c20ad2d353c6fa83f7 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 10 Aug 2026 18:24:34 +0200 Subject: [PATCH 3/4] fix(wallet): refresh fresh shielded funding projection --- .../InternalTransfer/ShieldedTransferCoordinator.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 32fa838b3a..278e8322ee 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -464,6 +464,10 @@ final class ShieldedTransferCoordinator: ObservableObject { stopAssetLockPolling() Self.logger.info("🛡️ SHIELD-TX :: asset-lock route completed") phase = .success + ShieldedTxLookup.shared.refresh() + NotificationCenter.default.post( + name: .swiftDashSDKTransactionProjectionDidChange, + object: nil) scheduleShieldedResync(manager: env.manager) } From c9164c962aa4cd890ac6db73d8a01037317be101 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 10 Aug 2026 19:34:06 +0200 Subject: [PATCH 4/4] docs(wallet): clarify consumed-lock recovery --- .../InternalTransfer/InternalTransferConfirmSheet.swift | 7 +++---- .../InternalTransfer/ShieldedTransferCoordinator.swift | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift index a378009b86..338f9d9222 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift @@ -708,10 +708,9 @@ struct ShieldedRecoverySheet: View { return } // statusRaw 1...3 (still pending), 5 (Core-final — consumption - // unknown; a resume either completes it or Platform reports the spent - // outpoint with a typed error), or nil/0 (status unavailable, e.g. a - // failed refresh): attempt the resume. A genuinely gone/consumed lock - // surfaces a real SDK error rather than a false "complete". + // unknown), or nil/0 (status unavailable, e.g. a failed refresh): + // attempt the resume. Both a local Consumed tombstone and a remote + // already-consumed report map to unconfirmed rather than false success. await coordinator.resumeAssetLock(outPointTxidWire: op.txidWire, outPointVout: op.vout) // On success the shield ST consumed the lock; refresh the snapshot so // the history row flips pending → completed even before the next diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 278e8322ee..cb29b29890 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -546,9 +546,9 @@ final class ShieldedTransferCoordinator: ObservableObject { scheduleShieldedResync(manager: env.manager) } - /// An already-consumed report suppresses a potentially destructive retry, - /// but is not authenticated proof of completion. Every other error remains - /// a normal failure. + /// Both a local `Consumed` tombstone and a remote already-consumed report + /// arrive through this typed error. Neither proves that this particular + /// shield completed, so both suppress retry without claiming success. static func alreadyConsumedAssetLockResumePhase(for error: Error) -> Phase? { if case PlatformWalletError.assetLockAlreadyConsumed = error { return .submittedUnconfirmed