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..e66ac939d --- /dev/null +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift @@ -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) + } +} 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/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..a069373fc 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,21 @@ /* 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."; + +/* 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"; /* 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";