diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index e3ee61419..f4fb65b0a 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -226,20 +226,27 @@ final class WalletSendService: NSObject { super.init() } - /// Core sends are blocked until the L1 chain sync completes: before - /// `.syncDone` the persisted UTXO set can be stale (already-spent inputs, - /// missing recent funds), so a built tx could be rejected — or worse, - /// double-spend a UTXO consumed while offline. Gate on - /// `SyncingActivityMonitor` per the repo guardrail (never raw SPV state). - /// UI entry points disable Continue with the same message; this is the - /// boundary backstop for programmatic callers. - private static func ensureChainSynced() throws { - guard SyncingActivityMonitor.shared.state == .syncDone else { + /// A normal foreground catch-up must not delay a payment. Only the first + /// historical sync after restoring a wallet blocks new Core spends. + static func isBlockedByInitialRestoreSync( + isResyncingWallet: Bool, + isChainSynced: Bool + ) -> Bool { + isResyncingWallet && !isChainSynced + } + + /// Boundary backstop for programmatic callers. This runs before + /// authentication and before inputs are selected or reserved. + private static func ensureInitialRestoreSyncCompleted() throws { + guard !isBlockedByInitialRestoreSync( + isResyncingWallet: DWGlobalOptions.sharedInstance().isResyncingWallet, + isChainSynced: SyncingActivityMonitor.shared.state == .syncDone + ) else { throw Self.makeError( - code: .chainNotSynced, + code: .initialRestoreSync, description: NSLocalizedString( - "Your wallet is still syncing with the Dash network. Sending will be available once syncing completes.", - comment: "Core send blocked until chain sync completes")) + "Your restored wallet is completing its initial sync. Sending from your Transparent balance will be available once it finishes.", + comment: "Core send blocked during a restored wallet's initial sync")) } } @@ -262,7 +269,7 @@ final class WalletSendService: NSObject { func prepareStandardSendForConfirmation(address: String, amount: UInt64, sessionAuthSufficient: Bool = false) async throws -> PreparedStandardSend { Self.logger.info("💸 TXSEND :: preparing standard send") - try Self.ensureChainSynced() + try Self.ensureInitialRestoreSyncCompleted() try await sendAuthorizer.authorizeSend(spendAmount: amount, sessionAuthSufficient: sessionAuthSufficient) let prepared = try buildPreparedStandardSend(address: address, amount: amount) Self.logger.info("💸 TXSEND :: standard send prepared") @@ -278,7 +285,7 @@ final class WalletSendService: NSObject { adjustAmountDownwards: Bool = false, sessionAuthSufficient: Bool = false ) async throws -> Data { - try Self.ensureChainSynced() + try Self.ensureInitialRestoreSyncCompleted() // Also covers the selected-input path below, whose `buildAndSignFromAddress` // broadcasts internally and never reaches `PreparedStandardSend.broadcast()`. try Self.ensureOnline() @@ -325,7 +332,7 @@ final class WalletSendService: NSObject { /// - Returns: the wire-order txid of the broadcast transaction /// (`Transaction.txHashData` convention). func sendSwapDeposit(vaultAddress: String, amount: UInt64, memo: String) async throws -> Data { - try Self.ensureChainSynced() + try Self.ensureInitialRestoreSyncCompleted() try Self.ensureOnline() try await sendAuthorizer.authorizeSend(spendAmount: amount) @@ -433,7 +440,7 @@ final class WalletSendService: NSObject { memo: String? = nil ) async throws -> (txid: Data, feeDuffs: UInt64) { Self.logger.info("💸 TXSEND :: pay-to-contact starting — \(amount, privacy: .public) duffs") - try Self.ensureChainSynced() + try Self.ensureInitialRestoreSyncCompleted() // spendAmount engages the biometric spending limit (C7.4) — // without it the gate is non-monetary and Face ID alone would // authorize a contact payment of any size. @@ -650,7 +657,7 @@ private extension WalletSendService { case coinJoinSweepUnavailable = 4 case alreadyBroadcast = 5 case dashPayPaymentUnavailable = 6 - case chainNotSynced = 7 + case initialRestoreSync = 7 case offline = 8 case broadcastRejected = 9 case broadcastUnknown = 10 diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift index d30cdd23b..4d0023533 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -543,11 +543,8 @@ final class InternalTransferViewModel: ObservableObject { /// balance mirror. Updates whenever a shielded sync pass completes. @Published private(set) var shieldedBalance: UInt64 = 0 - /// True once the L1 chain sync completed (`SyncingActivityMonitor` - /// `.syncDone`). Core-funded routes (asset locks spend BIP44 UTXOs) - /// can't Continue before that — the UTXO set may be stale. Mirrors - /// `SendViewModel.isChainSynced`; `WalletSendService` guards the - /// classic path at the boundary. + /// Drives the one-time restore gate reactively. A normal catch-up may set + /// this to false, but it only blocks while the recovery marker is active. @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone private var cancellables = Set() @@ -624,13 +621,13 @@ final class InternalTransferViewModel: ObservableObject { /// currently-selected source bucket. Each route has its own balance /// envelope — asset-lock spends BIP44 duffs, transparent shield spends /// DIP-17 credits. - /// True when the picked route spends Core UTXOs but the chain hasn't - /// finished syncing — Continue stays disabled and the screen explains - /// why (a stale UTXO set can't safely fund an asset lock). + /// Only Core-funded routes during a restored wallet's first sync block. var isBlockedBySync: Bool { switch route { case .coreToShielded, .coreToPlatform: - return !isChainSynced + return WalletSendService.isBlockedByInitialRestoreSync( + isResyncingWallet: DWGlobalOptions.sharedInstance().isResyncingWallet, + isChainSynced: isChainSynced) default: return false } diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift index 8e4dfc2a2..435dee006 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift @@ -1131,10 +1131,7 @@ struct SendConfirmSheet: View { } -/// Inline explanation for a Continue disabled by the chain-sync gate: -/// Core-funded sends stay off until `SyncingActivityMonitor` reports -/// `.syncDone` (a stale UTXO set can't safely fund a spend). Shared by -/// the Send and Internal transfer screens. +/// Shared explanation for the restored-wallet initial-sync gate. struct SyncGateNote: View { var body: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { @@ -1142,8 +1139,8 @@ struct SyncGateNote: View { .font(.system(size: 13)) .foregroundColor(.orange) Text(NSLocalizedString( - "Your wallet is still syncing. Sending from your Transparent balance will be available once syncing completes.", - comment: "Core send blocked until chain sync completes")) + "Your restored wallet is completing its initial sync. Sending from your Transparent balance will be available once it finishes.", + comment: "Core send blocked during a restored wallet's initial sync")) .font(.caption) .foregroundColor(.dash.secondaryText) .fixedSize(horizontal: false, vertical: true) diff --git a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift index d80f52c6f..ca15026fb 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift @@ -92,10 +92,8 @@ final class SendViewModel: ObservableObject { @Published private(set) var withdrawalPreflight: ManagedPlatformAddressWallet.WithdrawalPreflight? private var preflightTask: Task? - /// True once the L1 chain sync completed (`SyncingActivityMonitor` - /// `.syncDone`). Core-funded routes can't Continue before that — the - /// UTXO set may be stale (see `WalletSendService.ensureChainSynced`, - /// the boundary backstop behind this UI gate). + /// Drives the one-time restore gate reactively. A normal catch-up may set + /// this to false, but it only blocks while the recovery marker is active. @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone private var cancellables = Set() @@ -463,14 +461,14 @@ final class SendViewModel: ObservableObject { && dashDuffsUnsigned == platformWithdrawableDuffs } - /// True when the picked route spends Core UTXOs but the chain hasn't - /// finished syncing — Continue stays disabled and the screen explains - /// why (a stale UTXO set can't safely fund a send). + /// Only Core-funded routes during a restored wallet's first sync block. var isBlockedBySync: Bool { guard let route else { return false } switch route { case .coreToCore, .coreToShielded: - return !isChainSynced + return WalletSendService.isBlockedByInitialRestoreSync( + isResyncingWallet: DWGlobalOptions.sharedInstance().isResyncingWallet, + isChainSynced: isChainSynced) default: return false } diff --git a/DashWalletTests/PassiveWalletStateUITailTests.swift b/DashWalletTests/PassiveWalletStateUITailTests.swift index 74d17ea5d..6b07a87b3 100644 --- a/DashWalletTests/PassiveWalletStateUITailTests.swift +++ b/DashWalletTests/PassiveWalletStateUITailTests.swift @@ -13,6 +13,24 @@ import XCTest @MainActor final class PassiveWalletStateUITailTests: XCTestCase { + func testInitialRestoreSyncBlocksCoreSpendUntilSyncCompletes() { + XCTAssertTrue( + WalletSendService.isBlockedByInitialRestoreSync( + isResyncingWallet: true, + isChainSynced: false)) + XCTAssertFalse( + WalletSendService.isBlockedByInitialRestoreSync( + isResyncingWallet: true, + isChainSynced: true)) + } + + func testNormalCatchUpDoesNotBlockCoreSpend() { + XCTAssertFalse( + WalletSendService.isBlockedByInitialRestoreSync( + isResyncingWallet: false, + isChainSynced: false)) + } + func testAlreadyConsumedAssetLockMapsToUnconfirmedResume() { let error = PlatformWalletError.assetLockAlreadyConsumed("test outpoint")