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
41 changes: 24 additions & 17 deletions DashWallet/Sources/Models/Transactions/WalletSendService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}

Expand All @@ -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")
Expand All @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
Expand Down Expand Up @@ -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)
Comment on lines +624 to +630

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 '\bisResyncingWallet\b' \
  --glob '*.swift' --glob '*.m' --glob '*.mm' --glob '*.h'

rg -n -C 5 \
  -e 'objectWillChange' \
  -e 'NotificationCenter.*restore' \
  -e 'NotificationCenter.*resync' \
  -e 'publisher\(for:' \
  --glob '*.swift' --glob '*.m' --glob '*.mm' --glob '*.h'

Repository: dashpay/dashwallet-ios

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target files ---'
git ls-files '*InternalTransferViewModel.swift' '*SendViewModel.swift' '*DWGlobalOptions*'

printf '%s\n' '--- restore-marker references ---'
rg -n -C 8 'isResyncingWallet|DWGlobalOptions' \
  DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift \
  DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift \
  --glob '*.swift' --glob '*.m' --glob '*.mm' --glob '*.h' || true

printf '%s\n' '--- publication and observation in target files ---'
rg -n -C 5 'ObservableObject|objectWillChange|`@Published`|publisher\(for:|NotificationCenter|SyncingActivityMonitor|syncDone|init\(|deinit|sink\(' \
  DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift \
  DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift \
  --glob '*.swift' || true

printf '%s\n' '--- marker definition and mutation sites ---'
rg -n -C 10 'isResyncingWallet' . \
  --glob '*.swift' --glob '*.m' --glob '*.mm' --glob '*.h' || true

Repository: dashpay/dashwallet-ios

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- direct marker assignments ---'
rg -n -C 4 '(\bisResyncingWallet\b|\bresyncingWallet\b)\s*=' \
  --glob '*.swift' --glob '*.m' --glob '*.mm' --glob '*.h' . || true

printf '%s\n' '--- global-options implementation ---'
cat -n DashWallet/Sources/Models/DWGlobalOptions.h | sed -n '1,90p'
cat -n DashWallet/Sources/Models/DWGlobalOptions.m | sed -n '1,180p'

printf '%s\n' '--- monitor state transitions ---'
cat -n 'DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift' | sed -n '110,180p'

printf '%s\n' '--- restore/resync lifecycle references ---'
rg -n -C 6 'resync|restore|syncDone|syncStateChangedNotification' \
  --glob '*.swift' --glob '*.m' --glob '*.mm' --glob '*.h' \
  DashWallet/Sources | head -n 500

Repository: dashpay/dashwallet-ios

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- recovery command lifecycle ---'
cat -n DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverWalletCommand.m | sed -n '1,130p'

printf '%s\n' '--- restoreToDefaults callers ---'
rg -n -C 8 'DWGlobalOptions.*restoreToDefaults|restoreToDefaults\s*\(' \
  --glob '*.swift' --glob '*.m' --glob '*.mm' --glob '*.h' \
  DashWallet/Sources | head -n 300

printf '%s\n' '--- monitor initialization and state updates ---'
cat -n 'DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift' | sed -n '180,330p'
cat -n 'DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift' | sed -n '330,460p'

printf '%s\n' '--- payment view-model construction and lifetime anchors ---'
rg -n -C 5 'InternalTransferViewModel\(|SendViewModel\(' \
  --glob '*.swift' DashWallet/Sources/UI | head -n 400

Repository: dashpay/dashwallet-ios

Length of output: 24799


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- recovery command call sites ---'
rg -n -C 10 'DWRecoverWalletCommand|recoverWalletWithPhrase|recoverWallet' \
  --glob '*.swift' --glob '*.m' --glob '*.mm' --glob '*.h' \
  DashWallet/Sources | head -n 500

printf '%s\n' '--- exact view-model initialization paths ---'
cat -n DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift | sed -n '540,590p'
cat -n DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift | sed -n '90,155p'

printf '%s\n' '--- all writes to the sync monitor state ---'
rg -n -C 3 '\bstate\s*=' \
  'DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift' \
  --glob '*.swift'

Repository: dashpay/dashwallet-ios

Length of output: 44018


Publish restore-marker changes to both view models.

DWRecoverWalletCommand changes the marker without changing SyncingActivityMonitor.state. DWGlobalOptions.restoreToDefaults() also changes it without a monitor callback. Expose the marker through a typed publisher and update both InternalTransferViewModel and SendViewModel.

📍 Affects 2 files
  • DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift#L624-L630 (this comment)
  • DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift#L464-L471
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift`
around lines 624 - 630, The restore marker currently bypasses monitor callbacks,
so both view models can become stale. Expose the marker through a typed
publisher, then update InternalTransferViewModel.swift:624-630 and
SendViewModel.swift:464-471 to subscribe to it and recompute their sync-blocking
state when DWRecoverWalletCommand or DWGlobalOptions.restoreToDefaults() changes
the marker; retain existing SyncingActivityMonitor updates.

default:
return false
}
Expand Down
9 changes: 3 additions & 6 deletions DashWallet/Sources/UI/Payments/Pay/SendScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1131,19 +1131,16 @@ 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) {
Image(systemName: "clock.arrow.circlepath")
.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)
Expand Down
14 changes: 6 additions & 8 deletions DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,8 @@ final class SendViewModel: ObservableObject {
@Published private(set) var withdrawalPreflight: ManagedPlatformAddressWallet.WithdrawalPreflight?
private var preflightTask: Task<Void, Never>?

/// 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<AnyCancellable>()
Expand Down Expand Up @@ -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
}
Expand Down
18 changes: 18 additions & 0 deletions DashWalletTests/PassiveWalletStateUITailTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading