Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand All @@ -514,20 +519,43 @@ 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")
// The single resume FFI call covered proof + submit; advance through
// .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` ("<txid display hex>:<vout>")
/// into the (wire-order txid, vout) that `shieldedResumeFundFromAssetLock`
/// expects. The stored txid is display order (reversed wire); the FFI wants
Expand Down
7 changes: 4 additions & 3 deletions DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion DashWallet/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -3583,7 +3583,7 @@
/* Wallets */
"Restore an existing wallet" = "Restore an existing wallet";

"Restored — completion unknown" = "Restored — completion unknown";
"Completion unknown" = "Completion unknown";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/* Location Service Status */

Expand Down
14 changes: 13 additions & 1 deletion DashWalletTests/PassiveWalletStateUITailTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<WalletBalance?, Never>(nil)
var reloadCount = 0
Expand Down
Loading