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
6 changes: 6 additions & 0 deletions DashWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3577,6 +3579,7 @@
E5C4E3EBE8D8A77EBFAA1E86 /* NetworkReachability.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkReachability.swift; sourceTree = "<group>"; };
E66B030D4B574621DA2C5816 /* PaymentProtocolWireFormat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentProtocolWireFormat.swift; sourceTree = "<group>"; };
E6E56673B2BFED8EC0181A1F /* PlatformAddressSyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformAddressSyncCoordinator.swift; sourceTree = "<group>"; };
A1FA10C00000000000000001 /* AssetLockRecoveryService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AssetLockRecoveryService.swift; sourceTree = "<group>"; };
E7333F2D05D0DD2D4373FF9B /* StorageExplorerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageExplorerView.swift; sourceTree = "<group>"; };
E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentsLandingScreen.swift; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// 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 noIdentity
case unsupportedRoute
case failed(String)

var errorDescription: String? {
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):
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, 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()
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
}
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 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,
outPointTxid: txidWire,
outPointVout: vout)
DWCurrentUserIdentityInfo.shared.refreshFromSDK()
}

/// 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 let failure = coordinator.lastFailure as? ShieldedTransferCoordinator.CoordinatorError,
case .authCancelled = failure {
throw DWIdentityAuthorizer.AuthError.cancelled
}
throw RecoveryError.failed(message)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -1064,6 +1072,7 @@ final class ShieldedTransferCoordinator: ObservableObject {
func reset() {
stopAssetLockPolling()
lastAssetLockOutPoint = nil
lastFailure = nil
phase = .idle
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading