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..338f9d9222 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift @@ -707,11 +707,10 @@ 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 - // 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". + // statusRaw 1...3 (still pending), 5 (Core-final — consumption + // 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 15fc6fb403..cb29b29890 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) } @@ -505,6 +509,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,9 +519,18 @@ final class ShieldedTransferCoordinator: ObservableObject { outPointTxid: outPointTxidWire, outPointVout: outPointVout, recipients: [recipient]) + terminalPhase = .success } catch { - handleSpendError(error, manager: env.manager) - return + guard let mappedPhase = Self.alreadyConsumedAssetLockResumePhase(for: error) else { + handleSpendError(error, manager: env.manager) + return + } + // 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") @@ -524,10 +538,24 @@ 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, + object: nil) scheduleShieldedResync(manager: env.manager) } + /// 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 + } + 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/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 466f679a99..74d17ea5df 100644 --- a/DashWalletTests/PassiveWalletStateUITailTests.swift +++ b/DashWalletTests/PassiveWalletStateUITailTests.swift @@ -5,13 +5,25 @@ // 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 testAlreadyConsumedAssetLockMapsToUnconfirmedResume() { + let error = PlatformWalletError.assetLockAlreadyConsumed("test outpoint") + + XCTAssertEqual( + ShieldedTransferCoordinator.alreadyConsumedAssetLockResumePhase(for: error), + .submittedUnconfirmed) + XCTAssertNil( + ShieldedTransferCoordinator.alreadyConsumedAssetLockResumePhase( + for: PlatformWalletError.invalidParameter("unrelated"))) + } + func testHomeBalanceChangesSkipInitialAndDuplicateSnapshots() { let balances = CurrentValueSubject(nil) var reloadCount = 0