diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 801c6fb61..0fc16eb3e 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -1796,6 +1796,8 @@ CB9000022FE1000000000002 /* CoinbaseTransactionMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9000012FE1000000000001 /* CoinbaseTransactionMetadataTests.swift */; }; CB9100022FE2000000000002 /* CoinbaseTransferAmountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */; }; CB9200022FE3000000000002 /* PassiveWalletStateUITailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9200012FE3000000000001 /* PassiveWalletStateUITailTests.swift */; }; + CB9200042FE3000000000004 /* InitialRestoreSyncStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9200032FE3000000000003 /* InitialRestoreSyncStoreTests.swift */; }; + B17000022FE4000000000002 /* PaymentProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17000012FE4000000000001 /* PaymentProtocolTests.swift */; }; CC0000112DUMMYID001234567 /* PiggyCardsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000062DUMMYID001234567 /* PiggyCardsAPI.swift */; }; CC0000122DUMMYID001234567 /* PiggyCardsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000062DUMMYID001234567 /* PiggyCardsAPI.swift */; }; CC0000132DUMMYID001234567 /* PiggyCardsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000072DUMMYID001234567 /* PiggyCardsCache.swift */; }; @@ -3538,6 +3540,8 @@ CB9000012FE1000000000001 /* CoinbaseTransactionMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinbaseTransactionMetadataTests.swift; sourceTree = ""; }; CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinbaseTransferAmountTests.swift; sourceTree = ""; }; CB9200012FE3000000000001 /* PassiveWalletStateUITailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassiveWalletStateUITailTests.swift; sourceTree = ""; }; + CB9200032FE3000000000003 /* InitialRestoreSyncStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialRestoreSyncStoreTests.swift; sourceTree = ""; }; + B17000012FE4000000000001 /* PaymentProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProtocolTests.swift; sourceTree = ""; }; CC0000062DUMMYID001234567 /* PiggyCardsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiggyCardsAPI.swift; sourceTree = ""; }; CC0000072DUMMYID001234567 /* PiggyCardsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiggyCardsCache.swift; sourceTree = ""; }; CC0000082DUMMYID001234567 /* PiggyCardsEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiggyCardsEndpoint.swift; sourceTree = ""; }; @@ -7074,6 +7078,8 @@ CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */, 7A30000130A1000000000001 /* TransactionDirectionTests.swift */, CB9200012FE3000000000001 /* PassiveWalletStateUITailTests.swift */, + CB9200032FE3000000000003 /* InitialRestoreSyncStoreTests.swift */, + B17000012FE4000000000001 /* PaymentProtocolTests.swift */, AA0003032CA0F58E00A1B402 /* SwapAddressValidatorTests.swift */, AA00F1002FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift */, AA00100D2CA0B10001A0B10D /* SwapKitQuoteDecodingTests.swift */, @@ -10252,6 +10258,8 @@ CB9100022FE2000000000002 /* CoinbaseTransferAmountTests.swift in Sources */, 7A30000230A1000000000002 /* TransactionDirectionTests.swift in Sources */, CB9200022FE3000000000002 /* PassiveWalletStateUITailTests.swift in Sources */, + CB9200042FE3000000000004 /* InitialRestoreSyncStoreTests.swift in Sources */, + B17000022FE4000000000002 /* PaymentProtocolTests.swift in Sources */, AA0003042CA0F58E00A1B402 /* SwapAddressValidatorTests.swift in Sources */, AA00F1012FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift in Sources */, AA00100E2CA0B10001A0B10E /* SwapKitQuoteDecodingTests.swift in Sources */, diff --git a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift index 7eeea3521..8304017ec 100644 --- a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift +++ b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift @@ -143,10 +143,6 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { @objc public var state: State = .unknown { didSet { - if state == .syncDone { - DWGlobalOptions.sharedInstance().isResyncingWallet = false - } - guard oldValue != state else { return } @@ -289,7 +285,7 @@ extension SyncingActivityMonitor { // window (progress.rs — overall is Synced only while ALL managers // are simultaneously Synced). WaitForEvents is also the pre-start // default, so disambiguate on progress: fully caught up → done. - if sdkProgress >= 0.999 { + if sdkState.isEffectivelyComplete(progress: sdkProgress) { mapped = .syncDone } else { mapped = (state == .syncing) ? .syncing : .unknown diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift index e66ac939d..30e8663e2 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift @@ -68,6 +68,9 @@ struct AssetLockRecoveryService { /// 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)") + // All supported routes resume an existing asset lock and may enter the + // SDK's IS/CL proof wait. Fail before auth while quorum data is absent. + try AssetLockProofAvailability.shared.requireAllowed() switch fundingTypeRaw { case 1, 2: try await retryIdentityTopUp(txidWire: txidWire, vout: vout) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/BIP70PaymentService+App.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/BIP70PaymentService+App.swift index e1524ab70..cce1f670a 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/BIP70PaymentService+App.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/BIP70PaymentService+App.swift @@ -16,6 +16,13 @@ extension BIP70PaymentService { BIP70PaymentService( wallet: SwiftDashSDKWalletSending(), receiveAddress: SwiftDashSDKReceiveAddressProvider(), - auth: BIP70SendAuthorizer()) + auth: BIP70SendAuthorizer(), + coreSpendPreflight: { + do { + try await CoreSpendAvailability.shared.requireAllowed() + } catch CoreSpendAvailabilityError.initialRestoreSync { + throw BIP70Error.initialRestoreSync + } + }) } } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift index 2c020eb2d..3f3014e1a 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift @@ -484,10 +484,43 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { let recoveryLock = lookupRegistrationRecoveryLock( walletId: wallet.walletId, modelContainer: modelContainer) + let existingIdentityId = lookupExistingIdentityId( + walletId: wallet.walletId, + modelContainer: modelContainer) if let recoveryLock { Self.logger.info("🪪 IDENT-COORD :: recoverable Core registration found status=\(recoveryLock.statusRaw, privacy: .public)") } + let isContestedSubmission = DWContestedNameStatusService.isContestedLabel(username) + let requiredIdentityFundingDuffs = isContestedSubmission + ? DWDP_MIN_BALANCE_FOR_CONTESTED_USERNAME + : DWDP_MIN_BALANCE_TO_CREATE_USERNAME + let existingIdentityNeedsCoreTopUp: Bool + if let existingIdentityId, fundingSource == .core { + let requiredCredits = UInt64(requiredIdentityFundingDuffs) + * PlatformPaymentIdentityFundingPolicy.creditsPerDuff + existingIdentityNeedsCoreTopUp = + UsernameMarketplaceService.identityBalanceCredits( + identityId: existingIdentityId, + container: modelContainer) < requiredCredits + } else { + existingIdentityNeedsCoreTopUp = false + } + let resumesCoreAssetLock = recoveryLock != nil + let createsCoreAssetLock = recoveryLock == nil + && fundingSource == .core + && (existingIdentityId == nil || existingIdentityNeedsCoreTopUp) + + // A fresh Core-funded registration creates a new asset lock. A + // persisted recovery resumes its exact outpoint. Both require live + // quorum data, but only the fresh operation spends another Core UTXO. + if createsCoreAssetLock { + try CoreSpendAvailability.shared.requireAllowed() + } + if createsCoreAssetLock || resumesCoreAssetLock { + try AssetLockProofAvailability.shared.requireAllowed() + } + // Single-flight guard. The FFI calls we're about to make // (`registerIdentityWithFunding` / `registerIdentityFromAddresses` // / `registerDpnsName`) can't be cancelled — `resetState()` @@ -520,10 +553,6 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { currentFundingSource = recoveryLock == nil ? fundingSource : .core failedAtPhase = nil lastErrorMessage = nil - let isContestedSubmission = DWContestedNameStatusService.isContestedLabel(username) - let requiredIdentityFundingDuffs = isContestedSubmission - ? DWDP_MIN_BALANCE_FOR_CONTESTED_USERNAME - : DWDP_MIN_BALANCE_TO_CREATE_USERNAME Self.logger.info( "🪪 IDENT-COORD :: contested=\(isContestedSubmission, privacy: .public) identityFundingDuffs=\(requiredIdentityFundingDuffs, privacy: .public)") @@ -554,6 +583,15 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { throw CoordinatorError.authFailed } + // A wallet switch or reconnect can close readiness while the PIN sheet + // is visible. Stop before key preparation and before any asset-lock FFI. + if createsCoreAssetLock { + try CoreSpendAvailability.shared.requireAllowed() + } + if createsCoreAssetLock || resumesCoreAssetLock { + try AssetLockProofAvailability.shared.requireAllowed() + } + // Step 1: pre-derive identity public keys + persist privates // to Keychain. Synchronous on the FFI side; the resolver // callback reads the mnemonic via WalletStorage. @@ -604,10 +642,7 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { let identityId: Identifier var shouldTopUpRecoveredIdentity = false do { - if let existingId = lookupExistingIdentityId( - walletId: wallet.walletId, - modelContainer: modelContainer) - { + if let existingId = existingIdentityId { Self.logger.info("🪪 IDENT-COORD :: recovery — local identity exists at index \(Self.pinnedIdentityIndex, privacy: .public), skipping IdentityCreate") identityId = existingId reconcileConsumedRecoveryLock( @@ -926,6 +961,19 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { throw CoordinatorError.noModelContainer } + // Determine before PIN whether this purchase needs fresh Core + // funding. An already-funded identity buys with Platform credits and + // is not a Core spend. + let requiredCredits = Self.requiredCreditsForUsernamePurchase(priceCredits: priceCredits) + let requiresCoreFunding = purchaseRequiresCoreFunding( + requiredCredits: requiredCredits, + walletId: wallet.walletId, + modelContainer: modelContainer) + if requiresCoreFunding { + try CoreSpendAvailability.shared.requireAllowed() + try AssetLockProofAvailability.shared.requireAllowed() + } + // Single-flight — same rationale as `startCreateUsername`: the // funding FFI calls race to their terminal even if we stop // observing, and two funding attempts must never overlap. @@ -959,13 +1007,15 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { newController.enterFailed(lastErrorMessage ?? "") throw CoordinatorError.authFailed } + if requiresCoreFunding { + try CoreSpendAvailability.shared.requireAllowed() + try AssetLockProofAvailability.shared.requireAllowed() + } // Credits the buyer identity must hold: the sale price plus the // same 0.03-DASH headroom a fresh registration funds itself with, // covering the purchase transition fee (and Core-side asset-lock // conversion losses). - let headroomDuffs = DWDP_MIN_BALANCE_TO_CREATE_USERNAME - let requiredCredits = priceCredits + headroomDuffs * 1_000 let signer = KeychainSigner(modelContainer: modelContainer) let identityId: Identifier @@ -1441,6 +1491,39 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { return (try? context.fetch(descriptor))?.first?.identityId } + /// Whether buying a listed username needs a new transparent asset lock. + /// An existing identity that already holds the price plus fee headroom + /// spends Platform credits only and remains available during Core restore. + func purchaseRequiresCoreFunding(priceCredits: UInt64) -> Bool { + guard let walletId = SwiftDashSDKHost.shared.wallet?.walletId, + let modelContainer = SwiftDashSDKHost.shared.modelContainer else { + return true + } + return purchaseRequiresCoreFunding( + requiredCredits: Self.requiredCreditsForUsernamePurchase(priceCredits: priceCredits), + walletId: walletId, + modelContainer: modelContainer) + } + + private static func requiredCreditsForUsernamePurchase(priceCredits: UInt64) -> UInt64 { + priceCredits + DWDP_MIN_BALANCE_TO_CREATE_USERNAME * 1_000 + } + + private func purchaseRequiresCoreFunding( + requiredCredits: UInt64, + walletId: Data, + modelContainer: ModelContainer + ) -> Bool { + guard let identityId = lookupExistingIdentityId( + walletId: walletId, + modelContainer: modelContainer) else { + return true + } + return UsernameMarketplaceService.identityBalanceCredits( + identityId: identityId, + container: modelContainer) < requiredCredits + } + /// Bring a previously-created identity up to the amount this name /// requires before resuming at DPNS registration. /// @@ -1469,6 +1552,10 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { switch fundingSource { case .core: + // Resuming the original asset lock is allowed, but this is an + // additional transparent top-up and therefore a NEW Core spend. + try CoreSpendAvailability.shared.requireAllowed() + try AssetLockProofAvailability.shared.requireAllowed() let roundedShortfallDuffs = (missingCredits + PlatformPaymentIdentityFundingPolicy.creditsPerDuff - 1) / PlatformPaymentIdentityFundingPolicy.creditsPerDuff diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index 5917bbfa5..e3bbd8bf1 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -542,6 +542,10 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { /// Rust side carves from the single remainder recipient (the wallet's /// own next unused Platform address). public func fundFromCore(amountDuffs: UInt64) async throws { + // Defensive boundary for callers that bypass the transfer coordinator. + // A committed lock uses `resumeFundFromCore` and is exempt. + try CoreSpendAvailability.shared.requireAllowed() + try AssetLockProofAvailability.shared.requireAllowed() let (addressWallet, container, recipient, accountIndex) = try resolveFundEnvironment() let signer = KeychainSigner(modelContainer: container, network: runningNetwork!) @@ -566,6 +570,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { /// lock. See `ShieldedTransferCoordinator.resumeAssetLock` for the same /// pattern on the shielded route. public func resumeFundFromCore(outPointTxid: Data, outPointVout: UInt32) async throws { + try AssetLockProofAvailability.shared.requireAllowed() let (addressWallet, container, recipient, accountIndex) = try resolveFundEnvironment() let signer = KeychainSigner(modelContainer: container, network: runningNetwork!) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift index 343f0822d..ce13108ca 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift @@ -16,7 +16,7 @@ // - `start(network:)` is idempotent. Re-entering with the same network is // a no-op (preserves running SPV / BLAST state). A different network // tears down and rebuilds. -// - `createOrImportWallet(mnemonic:network:isImported:)` is the only path +// - `createOrImportWallet(mnemonic:network:origin:)` is the only path // that creates wallet rows and stores the mnemonic in WalletStorage. // - `stop()` releases the manager handle. Wipe-time persisted-row cleanup is // owned by `PlatformAddressSyncCoordinator` before BLAST stops. @@ -367,13 +367,13 @@ final class SwiftDashSDKHost { /// publishes it as bound. Onboarding's first wallet uses this. /// /// For adding a wallet ALONGSIDE existing ones without rebinding the - /// active wallet, use `addWallet(mnemonic:isImported:)` instead — this path replaces + /// active wallet, use `addWallet(mnemonic:origin:)` instead — this path replaces /// the running runtime and is not additive. @discardableResult func createOrImportWallet( mnemonic: String, network: Network, - isImported: Bool + origin: WalletMaterialOrigin ) async throws -> ManagedPlatformWallet { guard !mnemonic.isEmpty, Mnemonic.validate(mnemonic) else { throw HostError.invalidMnemonic @@ -394,7 +394,7 @@ final class SwiftDashSDKHost { // `importedWalletBirthHeight`). Freshly generated // mnemonics keep nil — nothing can predate them, so // the scan anchors at the tip. - birthHeight: isImported + birthHeight: origin.scansHistoricalRange ? Self.importedWalletBirthHeight(for: handles.network) : nil) } catch { @@ -405,17 +405,19 @@ final class SwiftDashSDKHost { throw error } + if origin.armsInitialRestoreSync { + InitialRestoreSyncStore.shared.markImportedIfNeeded(walletId: createdWallet.walletId) + } if let kind = registryNetworkKind(for: network) { WalletEnvironment.setActiveWalletId(createdWallet.walletId, for: kind) } publish(handles: handles, wallet: createdWallet) - let origin = isImported ? "imported" : "created" - Self.logger.info("🪺 HOST :: \(origin, privacy: .public) managed wallet for \(network.rawValue, privacy: .public)") + Self.logger.info("🪺 HOST :: \(String(describing: origin), privacy: .public) managed wallet for \(network.rawValue, privacy: .public)") return createdWallet } - /// Outcome of `addWallet(mnemonic:isImported:)`. + /// Outcome of `addWallet(mnemonic:origin:)`. enum AddWalletResult { /// The wallet was created and its mnemonic persisted; the running /// runtime is unchanged (the caller switches to it explicitly). @@ -441,7 +443,7 @@ final class SwiftDashSDKHost { /// `createOrImportWallet` (`createAndPersist`); differs only in that it /// uses the LIVE manager and does not publish or set-active. @discardableResult - func addWallet(mnemonic: String, isImported: Bool) async throws -> AddWalletResult { + func addWallet(mnemonic: String, origin: WalletMaterialOrigin) async throws -> AddWalletResult { guard !mnemonic.isEmpty, Mnemonic.validate(mnemonic) else { throw HostError.invalidMnemonic } @@ -468,10 +470,14 @@ final class SwiftDashSDKHost { // Same semantics as `createOrImportWallet`: imports scan // from the network's import floor, freshly generated // wallets from the tip. - birthHeight: isImported + birthHeight: origin.scansHistoricalRange ? Self.importedWalletBirthHeight(for: network) : nil) + if origin.armsInitialRestoreSync { + InitialRestoreSyncStore.shared.markImportedIfNeeded(walletId: createdWallet.walletId) + } + Self.logger.info("🪺 HOST :: added managed wallet for \(network.rawValue, privacy: .public) (additive)") return .added(walletId: createdWallet.walletId) } @@ -842,6 +848,10 @@ final class SwiftDashSDKHost { continue } do { + let derivedId = try Wallet( + mnemonic: entry.mnemonic, + network: handles.network).id + let wasMissingLocally = handles.manager.wallets[derivedId] == nil let created = try handles.manager.createWallet( mnemonic: entry.mnemonic, network: handles.network, @@ -859,6 +869,10 @@ final class SwiftDashSDKHost { birthHeight: Self.importedWalletBirthHeight(for: handles.network)) if created.walletId != entry.walletId { try? storage.storeMnemonic(entry.mnemonic, for: created.walletId) + InitialRestoreSyncStore.shared.remove(walletId: entry.walletId) + } + if wasMissingLocally { + InitialRestoreSyncStore.shared.markReconstructed(walletId: created.walletId) } } catch { Self.logger.error("🪺 HOST :: keychain wallet recovery failed for one entry: \(String(describing: error), privacy: .public)") @@ -876,6 +890,7 @@ final class SwiftDashSDKHost { wallet = resolvedWallet modelContainer = handles.modelContainer runningNetwork = handles.network + CoreSpendAvailability.shared.refresh() } // MARK: - ModelContainer diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift index d75e5ca7a..ed7f430a4 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift @@ -178,8 +178,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { } let sdkWalletId = try createWalletOnHost( mnemonic: mnemonic, - network: network, - isImported: true) + network: network) let prefix = sdkWalletId.prefix(4).map { String(format: "%02x", $0) }.joined() logger.info("🔑 KEYMIG :: migrated \(walletID, privacy: .public) → \(prefix, privacy: .public)… on \(String(describing: network), privacy: .public)") @@ -235,8 +234,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { private static func createWalletOnHost( mnemonic: String, - network: Network, - isImported: Bool + network: Network ) throws -> Data { guard !Thread.isMainThread else { throw MigrationError.hostCreateOnMainThread @@ -251,7 +249,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { result = .success(try await SwiftDashSDKHost.shared.createOrImportWallet( mnemonic: mnemonic, network: network, - isImported: isImported + origin: .legacyMigration ).walletId) } catch { result = .failure(error) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift index 8702934be..043c9f7e4 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift @@ -59,6 +59,13 @@ public enum SPVSyncState { case unknown public func isComplete() -> Bool { self == .synced } + + /// dash-spv's steady state drops from `.synced` to `.waitForEvents`. + /// Sharing this predicate keeps the UI monitor and the one-time restore + /// lifecycle from disagreeing when the transient `.synced` tick is missed. + public func isEffectivelyComplete(progress: Double) -> Bool { + self == .synced || (self == .waitForEvents && progress >= 0.999) + } } /// Local replacement for the SDK's removed `SPVSyncProgress`. @@ -103,6 +110,8 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { // MARK: - Singleton public static let shared = SwiftDashSDKSPVCoordinator() + static let proofReadinessDidChangeNotification = Notification.Name( + "DWSwiftDashSDKProofReadinessDidChange") // MARK: - Logging @@ -118,6 +127,10 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { @Published public private(set) var bestPeerHeight: UInt32 = 0 @Published public private(set) var lastError: String? = nil @Published public private(set) var syncProgress: SPVSyncProgress = .default() + /// Wallet whose subscription produced `syncProgress`. Kept separate from + /// the host's ambient wallet so a late tick from an outgoing subscription + /// can never certify the newly-bound wallet as proof-ready. + @Published public private(set) var syncProgressWalletId: Data? @Published public private(set) var isRestarting = false @Published public private(set) var isApplyingChainResync = false /// Peers the SPV client is currently connected to, classified against @@ -186,7 +199,7 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { func restartAsync() async throws { let host = SwiftDashSDKHost.shared guard let manager = host.manager, - host.wallet != nil, + let wallet = host.wallet, let network = host.runningNetwork else { let error = StartError.runtimeNotRunning lastError = error.localizedDescription @@ -200,7 +213,10 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { try self.performStop(lastError: nil, clearBalance: false) }, start: { - switch await self.performStart(manager: manager, for: network) { + switch await self.performStart( + manager: manager, + walletId: wallet.walletId, + for: network) { case .success: return case .failure(let error): @@ -262,12 +278,16 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { } #endif - return await performStart(manager: manager, for: network) + return await performStart( + manager: manager, + walletId: wallet.walletId, + for: network) } @MainActor private func performStart( manager: PlatformWalletManager, + walletId: Data, for network: Network ) async -> Result { // If SPV is already running on this network, treat it as a @@ -275,6 +295,8 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { // start-elision intent. if isAlreadyRunning(manager: manager, network: network) { Self.logger.info("🛰️ SPVCOORD :: already running on \(network.rawValue, privacy: .public)") + completeInitialRestoreIfNeeded( + walletId: walletId, state: state, progress: progress) return .success(()) } @@ -332,7 +354,7 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { runningNetwork = network lastError = nil - subscribeToManagerProgress(manager: manager) + subscribeToManagerProgress(manager: manager, walletId: walletId) refreshBalanceBridge() Self.logger.info("🛰️ SPVCOORD :: started on \(network.rawValue, privacy: .public)") @@ -362,6 +384,11 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { @MainActor func prepareForNetworkSwitch() { detachManagerSubscriptions() + syncProgressWalletId = nil + syncProgress = .default() + NotificationCenter.default.post( + name: Self.proofReadinessDidChangeNotification, + object: self) SwiftDashSDKWalletState.shared.clearAllState() } @@ -377,7 +404,9 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { Self.logger.info("🛰️ SPVCOORD :: stopped") } catch { Self.logger.error("🛰️ SPVCOORD :: stopSpv threw: \(String(describing: error), privacy: .public)") - subscribeToManagerProgress(manager: manager) + if let walletId = SwiftDashSDKHost.shared.wallet?.walletId { + subscribeToManagerProgress(manager: manager, walletId: walletId) + } self.lastError = error.localizedDescription state = .error throw StartError.stopSpv(error) @@ -514,7 +543,10 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { } @MainActor - private func subscribeToManagerProgress(manager: PlatformWalletManager) { + private func subscribeToManagerProgress( + manager: PlatformWalletManager, + walletId: Data + ) { progressCancellable = manager.$spvProgress .receive(on: RunLoop.main) .sink { [weak self] platformProgress in @@ -522,7 +554,7 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { // so we can hop into MainActor isolation synchronously to // touch the host's `@MainActor` wallet inside applyProgress. MainActor.assumeIsolated { - self?.applyProgress(platformProgress) + self?.applyProgress(platformProgress, walletId: walletId) } } peersCancellable = manager.$spvPeers @@ -551,7 +583,7 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { } @MainActor - private func applyProgress(_ p: PlatformSpvSyncProgress) { + private func applyProgress(_ p: PlatformSpvSyncProgress, walletId: Data) { let mappedState = mapState(p.overallState) let translated = SPVSyncProgress( state: mappedState, @@ -577,6 +609,7 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { // `combineLatest` consumers (`SyncingActivityMonitor`) see a // coherent snapshot. syncProgress = translated + syncProgressWalletId = walletId progress = p.overallPercentage state = mappedState tipHeight = headersCurrent @@ -584,18 +617,36 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { if mappedState != .error { lastError = nil } + NotificationCenter.default.post( + name: Self.proofReadinessDidChangeNotification, + object: self) // Piggyback on the deduped 1Hz progress tick to refresh the live // balance into `SwiftDashSDKWalletState.shared` for downstream // consumers (BalanceModel, SendAmountModel, etc.). refreshBalanceBridge() + completeInitialRestoreIfNeeded( + walletId: walletId, + state: mappedState, + progress: p.overallPercentage) + // Once a wide recovery scan has fully synced, revert to the fast gap if // there's nothing (left) to recover. Runs after the balance refresh so // `coinJoinBalanceDuffs` reflects the completed scan. maybeCompleteCoinJoinRecovery(state: mappedState) } + @MainActor + private func completeInitialRestoreIfNeeded( + walletId: Data, + state: SPVSyncState, + progress: Double + ) { + guard state.isEffectivelyComplete(progress: progress) else { return } + InitialRestoreSyncStore.shared.completeIfPending(walletId: walletId) + } + /// Pull the latest core-wallet balance via FFI and republish through /// `SwiftDashSDKWalletState.shared.applyBalance(_:)` so the home screen /// `BalanceModel` and friends keep working off the same `@Published` @@ -627,7 +678,11 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { tipHeight = 0 bestPeerHeight = 0 syncProgress = .default() + syncProgressWalletId = nil connectedPeers = [] + NotificationCenter.default.post( + name: Self.proofReadinessDidChangeNotification, + object: self) } // MARK: - Mapping diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift index d71521b98..72a139374 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift @@ -66,7 +66,7 @@ final class SwiftDashSDKWalletCreator: NSObject { mnemonic: mnemonic, pin: pin, network: network, - isImported: false, + origin: .fresh, label: "Created wallet") } } @@ -89,7 +89,7 @@ final class SwiftDashSDKWalletCreator: NSObject { mnemonic: mnemonic, pin: pin, network: network, - isImported: true, + origin: .userRestore, label: "Imported wallet") } } @@ -103,12 +103,12 @@ final class SwiftDashSDKWalletCreator: NSObject { /// /// Shared between `createWallet` (fresh-install) and `importWallet` /// (recover-from-recovery-phrase). The two callers differ only in the - /// `isImported` and `label` values they pass for logging. + /// material `origin` and label they pass for lifecycle and logging. private static func performCreate( mnemonic: String, pin: String, network: BridgeNetwork, - isImported: Bool, + origin: WalletMaterialOrigin, label: String ) { let appNetwork: Network = (network == .mainnet) ? .mainnet : .testnet @@ -137,7 +137,7 @@ final class SwiftDashSDKWalletCreator: NSObject { let walletId = try createWalletOnHost( mnemonic: mnemonic, network: appNetwork, - isImported: isImported) + origin: origin) let walletPrefix = walletId.prefix(4).map { String(format: "%02x", $0) }.joined() logger.info("\(label, privacy: .public) completed on \(appNetwork.rawValue, privacy: .public), wallet=\(walletPrefix, privacy: .public)…") @@ -152,7 +152,7 @@ final class SwiftDashSDKWalletCreator: NSObject { private static func createWalletOnHost( mnemonic: String, network: Network, - isImported: Bool + origin: WalletMaterialOrigin ) throws -> Data { guard !Thread.isMainThread else { throw CreateError.hostCreateOnMainThread @@ -167,7 +167,7 @@ final class SwiftDashSDKWalletCreator: NSObject { result = .success(try await SwiftDashSDKHost.shared.createOrImportWallet( mnemonic: mnemonic, network: network, - isImported: isImported + origin: origin ).walletId) } catch { result = .failure(error) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift index 555d38b1c..b03ccb431 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift @@ -19,6 +19,11 @@ import SwiftDashSDK final class SwiftDashSDKWalletSending: WalletSending { func buildSignedTransaction(recipients: [(address: String, amountDuffs: UInt64)]) async throws -> PreparedSend { + do { + try await CoreSpendAvailability.shared.requireAllowed() + } catch CoreSpendAvailabilityError.initialRestoreSync { + throw BIP70Error.initialRestoreSync + } let (tx, txHash) = try SwiftDashSDKTransactionSender.buildAndSign(recipients: recipients) return PreparedSend( txData: try tx.serializedData(), fee: tx.fee, txHashDisplay: txHash, sdkTransaction: tx) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift index ac66e1341..37289dd57 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift @@ -164,6 +164,7 @@ final class SwiftDashSDKWalletWiper: NSObject { DWGlobalOptions.sharedInstance().restoreToDefaults() DWAppGroupOptions.sharedInstance().restoreToDefaults() CrowdNode.shared.resetForWipe() + InitialRestoreSyncStore.shared.removeAll() } // These stores use UserDefaults + locks and are safe on this queue. @@ -342,6 +343,8 @@ final class SwiftDashSDKWalletWiper: NSObject { throw error } + InitialRestoreSyncStore.shared.remove(walletId: walletId) + // Clear this wallet's per-wallet app-side state that lives outside the // SDK/SwiftData/Keychain teardown above: CrowdNode account state and the // CoinJoin withdrawal tag set are UserDefaults, keyed by walletId hex. diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift index 62f4b17e3..3f98f3048 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift @@ -15,9 +15,343 @@ // limitations under the License. // +import Combine import Foundation import SwiftDashSDK +/// Why wallet material was registered with SwiftDashSDK. Imported material +/// scans from the historical floor; only restore/reconstruction origins arm +/// the one-time Core-spend gate. +enum WalletMaterialOrigin { + case fresh + case userRestore + case legacyMigration + case reconstructed + + var scansHistoricalRange: Bool { self != .fresh } + var armsInitialRestoreSync: Bool { + switch self { + case .userRestore, .legacyMigration, .reconstructed: return true + case .fresh: return false + } + } +} + +/// Durable lifecycle of the first Core scan for a restored wallet. +/// +/// `completed` is deliberately retained as a tombstone: retrying an import is +/// idempotent and must not re-arm the gate. A reconstruction after local +/// SwiftData loss explicitly overrides it back to `pending`. +@MainActor +final class InitialRestoreSyncStore { + enum State: String, Equatable { + case pending + case completed + } + + static let shared = InitialRestoreSyncStore() + static let didChangeNotification = Notification.Name( + "DWInitialRestoreSyncStoreDidChange") + + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter + private let statesKey = "coreSpend.initialRestoreSync.v2.states" + + init(defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default) { + self.defaults = defaults + self.notificationCenter = notificationCenter + } + + func state(walletId: Data) -> State? { + guard let raw = states()[walletId.hexEncodedString()] else { return nil } + return State(rawValue: raw) + } + + func isPending(walletId: Data) -> Bool { + state(walletId: walletId) == .pending + } + + func markImportedIfNeeded(walletId: Data) { + guard state(walletId: walletId) == nil else { return } + set(.pending, walletId: walletId) + } + + func markReconstructed(walletId: Data) { + set(.pending, walletId: walletId) + } + + func completeIfPending(walletId: Data) { + guard state(walletId: walletId) == .pending else { return } + set(.completed, walletId: walletId) + } + + func remove(walletId: Data) { + var values = states() + guard values.removeValue(forKey: walletId.hexEncodedString()) != nil else { return } + persist(values) + } + + func removeAll() { + guard defaults.object(forKey: statesKey) != nil else { return } + defaults.removeObject(forKey: statesKey) + notificationCenter.post(name: Self.didChangeNotification, object: self) + } + + private func states() -> [String: String] { + defaults.dictionary(forKey: statesKey) as? [String: String] ?? [:] + } + + private func set(_ state: State, walletId: Data) { + var values = states() + let key = walletId.hexEncodedString() + guard values[key] != state.rawValue else { return } + values[key] = state.rawValue + persist(values) + } + + private func persist(_ values: [String: String]) { + defaults.set(values, forKey: statesKey) + notificationCenter.post(name: Self.didChangeNotification, object: self) + } +} + +enum CoreSpendAvailabilityError: LocalizedError, Equatable { + case initialRestoreSync + + var errorDescription: String? { + NSLocalizedString( + "Your restored wallet is completing its initial sync. Sending from your Transparent balance will be available once it finishes.", + comment: "Core spend blocked during a restored wallet's first sync") + } +} + +enum AssetLockProofAvailabilityError: LocalizedError, Equatable { + case masternodeSync + + var errorDescription: String? { + NSLocalizedString( + "The masternode list is still syncing. Transfers that require InstantSend will be available once it finishes.", + comment: "Asset-lock operation blocked while the masternode list syncs") + } +} + +/// Runtime-only gate for operations that need an InstantSend/ChainLock asset +/// lock proof. Unlike `CoreSpendAvailability`, this is not a wallet lifecycle +/// marker: it follows the live masternode/quorum readiness and may close again +/// during a later catch-up or reconnect. +@MainActor +final class AssetLockProofAvailability: ObservableObject { + enum Decision: Equatable { + case allowed + case blockedMasternodeSync + + var isBlocked: Bool { self == .blockedMasternodeSync } + } + + static let shared = AssetLockProofAvailability() + static let didChangeNotification = Notification.Name( + "DWAssetLockProofAvailabilityDidChange") + + @Published private(set) var decision: Decision = .blockedMasternodeSync + + private let notificationCenter: NotificationCenter + private var observers: [NSObjectProtocol] = [] + + init(notificationCenter: NotificationCenter = .default, + observesRuntime: Bool = true) { + self.notificationCenter = notificationCenter + if observesRuntime { + for name in [ + SwiftDashSDKSPVCoordinator.proofReadinessDidChangeNotification, + SwiftDashSDKWalletState.activeWalletDidChangeNotification, + .DWCurrentNetworkDidChange, + ] { + observers.append(notificationCenter.addObserver( + forName: name, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in self?.refresh() } + }) + } + refresh() + } + } + + deinit { + observers.forEach(notificationCenter.removeObserver) + } + + var isBlocked: Bool { decision.isBlocked } + + var blockedPublisher: AnyPublisher { + $decision.map(\.isBlocked).removeDuplicates().eraseToAnyPublisher() + } + + func requireAllowed() throws { + guard !isBlocked else { throw AssetLockProofAvailabilityError.masternodeSync } + } + + func refresh() { + let coordinator = SwiftDashSDKSPVCoordinator.shared + apply(Self.decision( + boundWalletId: SwiftDashSDKHost.shared.wallet?.walletId, + progressWalletId: coordinator.syncProgressWalletId, + masternodes: coordinator.syncProgress.masternodes)) + } + + static func decision( + boundWalletId: Data?, + progressWalletId: Data?, + masternodes: MasternodesSubProgress? + ) -> Decision { + guard let boundWalletId, + boundWalletId == progressWalletId, + let masternodes, + masternodes.state == .synced, + masternodes.currentHeight > 0, + masternodes.targetHeight > 0, + masternodes.currentHeight >= masternodes.targetHeight + else { return .blockedMasternodeSync } + return .allowed + } + + /// Internal so policy publication can be verified without constructing an + /// SDK host in unit tests; production callers use `refresh()`. + func apply(_ newDecision: Decision) { + guard decision != newDecision else { return } + decision = newDecision + notificationCenter.post(name: Self.didChangeNotification, object: self) + } +} + +/// Thread-safe projection for legacy UIKit/ObjC call sites whose protocol +/// requirements are not actor-isolated. The policy owner writes it on the +/// main actor; readers never touch the wallet runtime directly. +private enum CoreSpendAvailabilitySnapshot { + static let lock = NSLock() + nonisolated(unsafe) static var blocked = false + + static func read() -> Bool { + lock.lock() + defer { lock.unlock() } + return blocked + } + + static func write(_ value: Bool) { + lock.lock() + blocked = value + lock.unlock() + } +} + +/// One observable policy shared by UI projections and hard transaction +/// boundaries. The decision is scoped to the wallet actually bound by the +/// host, never the registry target (which changes before a wallet switch has +/// finished rebuilding the runtime). +@objc(DWCoreSpendAvailability) +@MainActor +final class CoreSpendAvailability: NSObject, ObservableObject { + enum Decision: Equatable { + case allowed + case blockedInitialRestoreSync + + var isBlocked: Bool { self == .blockedInitialRestoreSync } + } + + static let shared = CoreSpendAvailability() + static let didChangeNotification = Notification.Name( + "DWCoreSpendAvailabilityDidChange") + + @Published private(set) var decision: Decision = .allowed + + private let store: InitialRestoreSyncStore + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter + private var observers: [NSObjectProtocol] = [] + private let legacyMigrationSentinel = "coreSpend.initialRestoreSync.v2.legacyMigrated" + + init(store: InitialRestoreSyncStore? = nil, + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default, + observesRuntime: Bool = true) { + self.store = store ?? .shared + self.defaults = defaults + self.notificationCenter = notificationCenter + super.init() + + if observesRuntime { + for name in [ + InitialRestoreSyncStore.didChangeNotification, + SwiftDashSDKWalletState.activeWalletDidChangeNotification, + .DWCurrentNetworkDidChange, + ] { + observers.append(notificationCenter.addObserver( + forName: name, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in self?.refresh() } + }) + } + refresh() + } + } + + deinit { + observers.forEach(notificationCenter.removeObserver) + } + + var isBlocked: Bool { decision.isBlocked } + + var blockedPublisher: AnyPublisher { + $decision + .map(\.isBlocked) + .removeDuplicates() + .eraseToAnyPublisher() + } + + func requireAllowed() throws { + guard !isBlocked else { throw CoreSpendAvailabilityError.initialRestoreSync } + } + + /// ObjC pre-auth seam used by interactive BIP70. + nonisolated static var blockedSnapshot: Bool { + CoreSpendAvailabilitySnapshot.read() + } + + @objc + nonisolated static func coreSpendBlockedError() -> NSError? { + guard blockedSnapshot else { return nil } + return CoreSpendAvailabilityError.initialRestoreSync as NSError + } + + func refresh() { + guard let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { + apply(.allowed) + return + } + + migrateLegacyFlagIfNeeded(to: walletId) + apply(store.isPending(walletId: walletId) + ? .blockedInitialRestoreSync + : .allowed) + } + + private func apply(_ newDecision: Decision) { + CoreSpendAvailabilitySnapshot.write(newDecision.isBlocked) + guard decision != newDecision else { return } + decision = newDecision + notificationCenter.post(name: Self.didChangeNotification, object: self) + } + + private func migrateLegacyFlagIfNeeded(to walletId: Data) { + guard !defaults.bool(forKey: legacyMigrationSentinel) else { return } + let options = DWGlobalOptions.sharedInstance() + if options.isResyncingWallet { + // Crash-safe order: durable scoped marker, sentinel, legacy clear. + store.markImportedIfNeeded(walletId: walletId) + } + defaults.set(true, forKey: legacyMigrationSentinel) + options.isResyncingWallet = false + } +} + /// DashSync-free network identity + wallet presence for the app. /// /// Owns the persisted network selection — the `CURRENT_CHAIN_TYPE_KEY` diff --git a/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift b/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift index 415f0fe3c..a65f22292 100644 --- a/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift +++ b/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift @@ -35,6 +35,8 @@ enum BIP70Error: Error, Equatable { case ackRejected /// The SwiftDashSDK wallet isn't bound yet (no funded wallet to build from). case walletNotReady + /// A restored wallet is still completing its first historical Core scan. + case initialRestoreSync /// The user cancelled the PIN/biometric prompt. case authCancelled /// A second send was attempted on a `Confirmation` that has already been (or is being) sent. @@ -56,6 +58,10 @@ extension BIP70Error: LocalizedError { case .missingPaymentURL: return "The payment request is missing a payment URL." case .ackRejected: return "The merchant did not acknowledge the payment." case .walletNotReady: return "The wallet isn't ready yet. Please try again in a moment." + case .initialRestoreSync: + return NSLocalizedString( + "Your restored wallet is completing its initial sync. Sending from your Transparent balance will be available once it finishes.", + comment: "BIP70 Core spend blocked during restored wallet sync") case .authCancelled: return "Authentication was cancelled." case .alreadySent: return "This payment is already being processed." } diff --git a/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift b/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift index 19c6bb45c..5102cef5e 100644 --- a/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift +++ b/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift @@ -163,6 +163,9 @@ final class BIP70PaymentService { private let wallet: WalletSending private let receiveAddress: ReceiveAddressProviding private let auth: SendAuthorizing + /// App-layer policy check. Kept injectable so this protocol core remains + /// independent of the wallet runtime and can prove pre-auth ordering. + private let coreSpendPreflight: () async throws -> Void /// When true, allow unsigned (`pki_type == "none"`) requests. Invalid SIGNED requests are /// always blocked regardless of this flag. private let allowUntrustedUnsigned: Bool @@ -172,12 +175,14 @@ final class BIP70PaymentService { wallet: WalletSending, receiveAddress: ReceiveAddressProviding, auth: SendAuthorizing, + coreSpendPreflight: @escaping () async throws -> Void, allowUntrustedUnsigned: Bool = true) { self.transport = transport self.verifier = verifier self.wallet = wallet self.receiveAddress = receiveAddress self.auth = auth + self.coreSpendPreflight = coreSpendPreflight self.allowUntrustedUnsigned = allowUntrustedUnsigned } @@ -263,6 +268,10 @@ final class BIP70PaymentService { /// independently, so a retry would rebuild a conflicting spend of the same inputs. func confirmAndSend(_ confirmation: Confirmation, now: Date = Date()) async throws -> SendResult { + // This is the hard service backstop. Interactive callers also check + // before PIN, but no caller may reach build/sign while blocked. + try await coreSpendPreflight() + // 1. Expiry re-check at send time (the user may have lingered on the confirm screen). try Self.assertNotExpired(confirmation.request.details.expires, now: now) @@ -323,12 +332,14 @@ final class BIP70PaymentService { callbackURL: callbackURL) } - /// One-shot entry for headless flows (CTX gift cards): authorize, then prepare + send. + /// One-shot entry for headless flows (CTX gift cards): policy, authorize, + /// then prepare + send. The policy must reject before any PIN prompt. func confirmAndSendHeadless(from requestURL: URL, scheme: String, network: PaymentNetwork, callbackScheme: String? = nil, now: Date = Date()) async throws -> SendResult { + try await coreSpendPreflight() try await auth.authorize() let confirmation = try await prepareForConfirmation(from: requestURL, scheme: scheme, network: network, callbackScheme: callbackScheme, now: now) diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index d1d5b0cbe..54d27fa5c 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -226,20 +226,17 @@ 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 { + /// Hard boundary for every NEW Core-funded operation. A normal foreground + /// catch-up is allowed; only a restored wallet's first historical scan is + /// blocked. Check before authentication and before any input reservation. + @MainActor + private static func ensureCoreSpendAllowed() throws { + do { + try CoreSpendAvailability.shared.requireAllowed() + } catch { throw Self.makeError( - code: .chainNotSynced, - 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")) + code: .initialRestoreSync, + description: error.localizedDescription) } } @@ -262,7 +259,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 await Self.ensureCoreSpendAllowed() 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 +275,7 @@ final class WalletSendService: NSObject { adjustAmountDownwards: Bool = false, sessionAuthSufficient: Bool = false ) async throws -> Data { - try Self.ensureChainSynced() + try await Self.ensureCoreSpendAllowed() // Also covers the selected-input path below, whose `buildAndSignFromAddress` // broadcasts internally and never reaches `PreparedStandardSend.broadcast()`. try Self.ensureOnline() @@ -325,7 +322,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 await Self.ensureCoreSpendAllowed() try Self.ensureOnline() try await sendAuthorizer.authorizeSend(spendAmount: amount) @@ -354,6 +351,7 @@ final class WalletSendService: NSObject { /// message; the on-chain amount delivered is this minus the network fee. @discardableResult func sweepCoinJoin() async throws -> UInt64 { + try await Self.ensureCoreSpendAllowed() let amount = await MainActor.run { SwiftDashSDKWalletState.shared.coinJoinBalanceDuffs } guard amount > 0 else { throw Self.makeError( @@ -433,7 +431,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 await Self.ensureCoreSpendAllowed() // 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. @@ -645,7 +643,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/DashPay/Profile/SDKIdentityProfileSheet.swift b/DashWallet/Sources/UI/DashPay/Profile/SDKIdentityProfileSheet.swift index c9f993caf..cfdd01e00 100644 --- a/DashWallet/Sources/UI/DashPay/Profile/SDKIdentityProfileSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Profile/SDKIdentityProfileSheet.swift @@ -462,12 +462,30 @@ final class IdentityTopUpViewModel: ObservableObject { isProcessing = false stepLabel = nil } + if source == .transparent { + do { + try CoreSpendAvailability.shared.requireAllowed() + try AssetLockProofAvailability.shared.requireAllowed() + } catch { + errorMessage = error.localizedDescription + return nil + } + } do { try await authorizer.authorize() } catch { // Backing out of the PIN prompt is not an error state. return nil } + if source == .transparent { + do { + try CoreSpendAvailability.shared.requireAllowed() + try AssetLockProofAvailability.shared.requireAllowed() + } catch { + errorMessage = error.localizedDescription + return nil + } + } do { switch source { case .transparent: @@ -584,6 +602,8 @@ struct IdentityTopUpSheet: View { @Environment(\.dismiss) private var dismiss @StateObject private var viewModel = IdentityTopUpViewModel() + @ObservedObject private var coreSpendAvailability = CoreSpendAvailability.shared + @ObservedObject private var assetLockProofAvailability = AssetLockProofAvailability.shared /// nil = the Custom chip is selected and `customText` carries the amount. @State private var selectedPresetDuffs: UInt64? = IdentityTopUpViewModel.presetsDuffs[0] @State private var customText = "" @@ -756,11 +776,17 @@ struct IdentityTopUpSheet: View { .cornerRadius(12) } } - .disabled(viewModel.isProcessing || effectiveDuffs == nil) - .opacity(effectiveDuffs == nil ? 0.55 : 1) + .disabled(viewModel.isProcessing || effectiveDuffs == nil || isTransparentCoreBlocked) + .opacity((effectiveDuffs == nil || isTransparentCoreBlocked) ? 0.55 : 1) .padding(.horizontal, 20) .padding(.top, 20) + if isTransparentCoreBlocked { + SyncGateNote(message: transparentCoreBlockedMessage) + .padding(.horizontal, 20) + .padding(.top, 10) + } + Button { dismiss() } label: { @@ -789,6 +815,20 @@ struct IdentityTopUpSheet: View { } } + private var isTransparentCoreBlocked: Bool { + source == .transparent + && (coreSpendAvailability.isBlocked || assetLockProofAvailability.isBlocked) + } + + private var transparentCoreBlockedMessage: String { + if coreSpendAvailability.isBlocked { + return NSLocalizedString( + "Your restored wallet is completing its initial sync. Transparent identity top-up will be available once it finishes.", + comment: "Transparent identity top-up blocked during initial restore sync") + } + return AssetLockProofAvailabilityError.masternodeSync.localizedDescription + } + /// Shared chip chrome for the preset and Custom amount buttons. private func amountChip( isSelected: Bool, diff --git a/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift b/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift index 98ff9c61c..4073c5c9e 100644 --- a/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift +++ b/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift @@ -92,6 +92,8 @@ class CreateUsernameViewController: UIViewController { struct CreateUsernameView: View { @StateObject private var viewModel = CreateUsernameViewModel() + @ObservedObject private var coreSpendAvailability = CoreSpendAvailability.shared + @ObservedObject private var assetLockProofAvailability = AssetLockProofAvailability.shared @FocusState private var isTextInputFocused: Bool @State private var inProgress: Bool = false @State private var screenLockedAfterAuth: Bool = false @@ -278,7 +280,8 @@ struct CreateUsernameView: View { DashButton( text: primaryButtonText, isEnabled: (viewModel.uiState.canContinue || viewModel.canPurchaseListedNameDirectly) - && !screenLockedAfterAuth, + && !screenLockedAfterAuth + && !isFreshCoreRegistrationBlocked, isLoading: inProgress ) { isTextInputFocused = false @@ -304,6 +307,11 @@ struct CreateUsernameView: View { } } .padding(.top, 20) + + if isFreshCoreRegistrationBlocked { + SyncGateNote(message: usernameCoreSpendBlockedMessage) + .padding(.top, 10) + } } .padding(.horizontal, 20) .padding(.bottom, 20) @@ -770,6 +778,40 @@ struct CreateUsernameView: View { } } + /// Fresh Core funding needs both gates; recovery is exempt from the + /// restore gate but still needs quorum data for the existing proof. + /// Invitation, Platform and Shielded funding need neither app-side gate. + private var isFreshCoreRegistrationBlocked: Bool { + if viewModel.canPurchaseListedNameDirectly { + return viewModel.directPurchaseRequiresCoreFunding + && (coreSpendAvailability.isBlocked || assetLockProofAvailability.isBlocked) + } + guard !viewModel.isInvitationMode else { return false } + if viewModel.hasPendingRegistrationRecovery { + return assetLockProofAvailability.isBlocked + } + return fundingSource == .core + && (coreSpendAvailability.isBlocked || assetLockProofAvailability.isBlocked) + } + + private var usernameCoreSpendBlockedMessage: String { + let isFreshCoreFunding = viewModel.canPurchaseListedNameDirectly + ? viewModel.directPurchaseRequiresCoreFunding + : !viewModel.hasPendingRegistrationRecovery && fundingSource == .core + if isFreshCoreFunding && coreSpendAvailability.isBlocked, + viewModel.canPurchaseListedNameDirectly { + return NSLocalizedString( + "Your restored wallet is completing its initial sync. A Core-funded username purchase will be available once it finishes.", + comment: "Core-funded username purchase blocked during initial restore sync") + } + if isFreshCoreFunding && coreSpendAvailability.isBlocked { + return NSLocalizedString( + "Your restored wallet is completing its initial sync. Core-funded identity registration will be available once it finishes.", + comment: "Core-funded identity registration blocked during initial restore sync") + } + return AssetLockProofAvailabilityError.masternodeSync.localizedDescription + } + /// Post-submit copy for a contested name. The deadline is a conservative /// submission-time estimate until Platform indexes the contest and /// `checkPendingContestResolution` swaps in `ContestVoteState.endTime`, diff --git a/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift b/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift index 7103b4f4b..7e864b438 100644 --- a/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift +++ b/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift @@ -157,7 +157,16 @@ class CreateUsernameViewModel: ObservableObject { !hasPendingRegistrationRecovery else { return false } let priceDuffs = credits / 1_000 guard priceDuffs < Self.directPurchaseMaxDuffs else { return false } - return coreSpendableDuffs >= priceDuffs + DWDP_MIN_BALANCE_TO_CREATE_USERNAME + return !directPurchaseRequiresCoreFunding + || coreSpendableDuffs >= priceDuffs + DWDP_MIN_BALANCE_TO_CREATE_USERNAME + } + + /// Direct purchases consume Core only when the current identity lacks the + /// listing price plus transition-fee headroom. + var directPurchaseRequiresCoreFunding: Bool { + guard let credits = takenNameSalePriceCredits else { return false } + return DWIdentityRegistrationCoordinator.shared + .purchaseRequiresCoreFunding(priceCredits: credits) } /// Listed at or above the direct-purchase ceiling — the form points diff --git a/DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/Details/Views/POIDetailsView.swift b/DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/Details/Views/POIDetailsView.swift index 45848d7e9..b2cfbc072 100644 --- a/DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/Details/Views/POIDetailsView.swift +++ b/DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/Details/Views/POIDetailsView.swift @@ -467,8 +467,7 @@ struct POIDetailsView: View { } return viewModel.merchantEnabled && - viewModel.networkStatus == .online && - viewModel.syncState == .syncDone + viewModel.networkStatus == .online } private var providerLoginText: String { diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift index ac6c812f1..5aeadf633 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift @@ -270,6 +270,10 @@ class DashSpendPayViewModel: NSObject, ObservableObject, NetworkReachabilityHand .receive(on: RunLoop.main) .sink { [weak self] _ in self?.refreshBalance() } .store(in: &cancellableBag) + + CoreSpendAvailability.shared.blockedPublisher + .sink { [weak self] _ in self?.checkAmountForErrors() } + .store(in: &cancellableBag) repository[provider]?.isUserSignedInPublisher .receive(on: DispatchQueue.main) @@ -409,9 +413,7 @@ class DashSpendPayViewModel: NSObject, ObservableObject, NetworkReachabilityHand return } - guard DWGlobalOptions.sharedInstance().isResyncingWallet == false || - SyncingActivityMonitor.shared.state == .syncDone - else { + guard !CoreSpendAvailability.shared.isBlocked else { error = SendAmountError.syncingChain return } diff --git a/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift b/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift index c446720a2..02e5f5425 100644 --- a/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift +++ b/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift @@ -174,6 +174,8 @@ final class CoinJoinMoveFundsViewModel: ObservableObject { struct CoinJoinMoveFundsSheet: View { @StateObject private var viewModel: CoinJoinMoveFundsViewModel + @ObservedObject private var coreSpendAvailability = CoreSpendAvailability.shared + @ObservedObject private var proofAvailability = AssetLockProofAvailability.shared var onDismiss: () -> Void init(amountDuffs: UInt64, onDismiss: @escaping () -> Void) { @@ -237,6 +239,7 @@ struct CoinJoinMoveFundsSheet: View { title: NSLocalizedString("Dash Wallet balance", comment: "CoinJoin"), subtitle: NSLocalizedString( "Move to your regular spendable balance.", comment: "CoinJoin"), + isEnabled: true, action: { Task { await viewModel.moveToWallet() } }) destinationCard( icon: "shield.fill", @@ -244,11 +247,18 @@ struct CoinJoinMoveFundsSheet: View { subtitle: NSLocalizedString( "Keep these coins private. Network and privacy fees apply.", comment: "CoinJoin"), + isEnabled: !isShieldedDestinationBlocked, action: { Task { await viewModel.moveToShielded() } }) } .padding(.horizontal, 16) .padding(.top, 20) + if isShieldedDestinationBlocked { + SyncGateNote(message: shieldedDestinationBlockedMessage) + .padding(.horizontal, 16) + .padding(.top, 10) + } + Spacer(minLength: 12) DashButton( @@ -262,7 +272,11 @@ struct CoinJoinMoveFundsSheet: View { } private func destinationCard( - icon: String, title: String, subtitle: String, action: @escaping () -> Void + icon: String, + title: String, + subtitle: String, + isEnabled: Bool, + action: @escaping () -> Void ) -> some View { Button(action: action) { HStack(spacing: 12) { @@ -297,6 +311,8 @@ struct CoinJoinMoveFundsSheet: View { .cornerRadius(12) } .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.55) } // MARK: In flight @@ -401,6 +417,7 @@ struct CoinJoinMoveFundsSheet: View { ButtonsGroup( orientation: .horizontal, size: .large, + positiveActionEnabled: destination != .shielded || !isShieldedRetryBlocked, positiveButtonText: NSLocalizedString("Try again", comment: ""), positiveButtonAction: { Task { @@ -419,6 +436,24 @@ struct CoinJoinMoveFundsSheet: View { } } + private var isShieldedDestinationBlocked: Bool { + coreSpendAvailability.isBlocked || proofAvailability.isBlocked + } + + private var isShieldedRetryBlocked: Bool { + if viewModel.coordinator.lastAssetLockOutPoint != nil { + return proofAvailability.isBlocked + } + return isShieldedDestinationBlocked + } + + private var shieldedDestinationBlockedMessage: String { + if coreSpendAvailability.isBlocked { + return CoreSpendAvailabilityError.initialRestoreSync.localizedDescription + } + return AssetLockProofAvailabilityError.masternodeSync.localizedDescription + } + // MARK: Pieces private var dragHandle: some View { diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift index a08403114..9d31310f6 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift @@ -629,7 +629,7 @@ private struct CreateWalletView: View { private func create() { guard let mnemonic else { return } Task { - let outcome = await viewModel.addWallet(mnemonic: mnemonic, isImported: false) + let outcome = await viewModel.addWallet(mnemonic: mnemonic, origin: .fresh) if outcome == .switched { onFinished() } // A brand-new phrase can't already exist, and on error the sheet // stays open (the ViewModel surfaces the message). @@ -710,7 +710,7 @@ private struct ImportWalletView: View { private func importWallet() { existingWalletId = nil Task { - let outcome = await viewModel.addWallet(mnemonic: phrase, isImported: true) + let outcome = await viewModel.addWallet(mnemonic: phrase, origin: .userRestore) switch outcome { case .switched: onFinished() diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift index ebdf2407f..39de45f26 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift @@ -181,8 +181,8 @@ final class WalletsViewModel: ObservableObject { Mnemonic.validate(Self.normalize(phrase)) } - /// Add a wallet from `mnemonic` and switch to it. `isImported` distinguishes - /// the two entry paths and sets the new wallet's `walletNeedsBackup` flag: + /// Add a wallet from `mnemonic` and switch to it. `origin` controls the + /// scan/restore lifecycle and the new wallet's `walletNeedsBackup` flag: /// a created wallet still needs a backup (its phrase was only shown, not /// verified — matches onboarding); an imported wallet does not (the user /// already holds the phrase — matches the recover flow). @@ -195,7 +195,7 @@ final class WalletsViewModel: ObservableObject { /// /// Returns the outcome; returns nil after surfacing an error (the sheet /// stays open on failure — never claims a success that didn't happen). - func addWallet(mnemonic: String, isImported: Bool) async -> AddOutcome? { + func addWallet(mnemonic: String, origin: WalletMaterialOrigin) async -> AddOutcome? { guard !addInProgress, !switchInProgress, !removeInProgress else { return nil } let normalized = Self.normalize(mnemonic) @@ -206,7 +206,7 @@ final class WalletsViewModel: ObservableObject { do { result = try await SwiftDashSDKHost.shared.addWallet( mnemonic: normalized, - isImported: isImported) + origin: origin) } catch { Self.logger.error("addWallet failed: \(String(describing: error), privacy: .public)") errorMessage = error.localizedDescription @@ -233,7 +233,11 @@ final class WalletsViewModel: ObservableObject { // The new wallet is now the active wallet, so this per-wallet flag // targets it (DWGlobalOptions scopes by the active walletId). - DWGlobalOptions.sharedInstance().walletNeedsBackup = !isImported + if case .fresh = origin { + DWGlobalOptions.sharedInstance().walletNeedsBackup = true + } else { + DWGlobalOptions.sharedInstance().walletNeedsBackup = false + } reload() return .switched diff --git a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift index 22b12d9e1..474675914 100644 --- a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift +++ b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift @@ -15,6 +15,7 @@ // limitations under the License. // +import Combine import Foundation // MARK: - SendAmountError @@ -27,8 +28,7 @@ enum SendAmountError: Error, ColorizedText, LocalizedError { var errorDescription: String? { switch self { case .insufficientFunds: return NSLocalizedString("Insufficient funds", comment: "Send screen") - case .syncingChain: return NSLocalizedString("Wait until wallet is synced to complete the transaction", - comment: "Send screen") + case .syncingChain: return CoreSpendAvailabilityError.initialRestoreSync.localizedDescription case .networkUnavailable: return NSLocalizedString("Network Unavailable", comment: "Network Unavailable") } } @@ -45,11 +45,12 @@ enum SendAmountError: Error, ColorizedText, LocalizedError { // MARK: - SendAmountModel class SendAmountModel: BaseAmountModel { + private var isCoreSpendBlocked = CoreSpendAvailability.blockedSnapshot + override var isAllowedToContinue: Bool { super.isAllowedToContinue && !canShowInsufficientFunds && - (DWGlobalOptions.sharedInstance().isResyncingWallet == false || - SyncingActivityMonitor.shared.state == .syncDone) + !isCoreSpendBlocked } var canShowInsufficientFunds: Bool { @@ -64,6 +65,14 @@ class SendAmountModel: BaseAmountModel { super.init() initializeSyncingActivityMonitor() + NotificationCenter.default.publisher( + for: CoreSpendAvailability.didChangeNotification) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.isCoreSpendBlocked = CoreSpendAvailability.blockedSnapshot + self?.checkAmountForErrors() + } + .store(in: &cancellableBag) checkAmountForErrors() } @@ -87,9 +96,7 @@ class SendAmountModel: BaseAmountModel { } override func checkAmountForErrors() { - guard DWGlobalOptions.sharedInstance().isResyncingWallet == false || - SyncingActivityMonitor.shared.state == .syncDone - else { + guard !isCoreSpendBlocked else { error = SendAmountError.syncingChain return } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift index 00a7eb9f4..a8ab118dc 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift @@ -48,6 +48,8 @@ struct InternalTransferConfirmSheet: View { var onPlatformShieldCapacityChanged: (UInt64?, Bool) -> Void @StateObject private var coordinator = ShieldedTransferCoordinator() + @ObservedObject private var coreSpendAvailability = CoreSpendAvailability.shared + @ObservedObject private var proofAvailability = AssetLockProofAvailability.shared @State private var handledPlatformShieldCapacityChange = false var body: some View { @@ -117,6 +119,13 @@ struct InternalTransferConfirmSheet: View { .padding(.top, 12) } + + if let message = assetLockGateMessage { + SyncGateNote(message: message) + .padding(.horizontal, 20) + .padding(.top, 12) + } + Spacer(minLength: 12) switch coordinator.phase { @@ -124,6 +133,7 @@ struct InternalTransferConfirmSheet: View { ButtonsGroup( orientation: .horizontal, size: .large, + positiveActionEnabled: assetLockGateMessage == nil, positiveButtonText: NSLocalizedString("Confirm", comment: ""), positiveButtonAction: confirm, negativeButtonText: NSLocalizedString("Cancel", comment: ""), @@ -135,6 +145,7 @@ struct InternalTransferConfirmSheet: View { ButtonsGroup( orientation: .horizontal, size: .large, + positiveActionEnabled: assetLockGateMessage == nil, positiveButtonText: NSLocalizedString("Try again", comment: ""), positiveButtonAction: tryAgain, negativeButtonText: NSLocalizedString("Close", comment: ""), @@ -154,6 +165,18 @@ struct InternalTransferConfirmSheet: View { } } + private var assetLockGateMessage: String? { + guard route == .coreToShielded || route == .coreToPlatform else { return nil } + let resumesCommittedLock = coordinator.lastAssetLockOutPoint != nil + if !resumesCommittedLock, coreSpendAvailability.isBlocked { + return CoreSpendAvailabilityError.initialRestoreSync.localizedDescription + } + if proofAvailability.isBlocked { + return AssetLockProofAvailabilityError.masternodeSync.localizedDescription + } + return nil + } + // MARK: - Success body private var successBody: some View { @@ -525,6 +548,7 @@ struct ShieldedRecoverySheet: View { var onDismiss: () -> Void @StateObject private var coordinator = ShieldedTransferCoordinator() + @ObservedObject private var proofAvailability = AssetLockProofAvailability.shared /// Set when "Finish now" finds the lock already consumed (a background sync /// landed the shield since the history row's snapshot was captured) — shows @@ -594,11 +618,20 @@ struct ShieldedRecoverySheet: View { .padding(.top, 12) } + if proofAvailability.isBlocked { + SyncGateNote(message: NSLocalizedString( + "Your Dash is safe. Finish will be available when the masternode list finishes syncing.", + comment: "Shielded asset-lock recovery waiting for masternode sync")) + .padding(.horizontal, 20) + .padding(.top, 12) + } + Spacer(minLength: 12) ButtonsGroup( orientation: .horizontal, size: .large, + positiveActionEnabled: !proofAvailability.isBlocked, positiveButtonText: NSLocalizedString("Finish now", comment: "InternalTransfer recovery"), positiveButtonAction: finish, negativeButtonText: NSLocalizedString("Close", comment: ""), @@ -711,6 +744,7 @@ struct ShieldedRecoverySheet: View { // MARK: - Action private func finish() { + guard !proofAvailability.isBlocked else { return } guard let op = transaction.shieldedOutPoint else { onDismiss() return diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift index 142f4cc0e..4e2c0313a 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift @@ -84,8 +84,8 @@ struct InternalTransferScreen: View { directionCards .padding(.horizontal, 20) - if viewModel.isBlockedBySync { - SyncGateNote() + if let message = viewModel.coreSpendGateMessage { + SyncGateNote(message: message) .padding(.horizontal, 20) } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift index d30cdd23b..0fbcff606 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -12,7 +12,6 @@ enum InternalTransferUnit: String { case dash case fiat } - /// Every balance-to-balance route the transfer engine can execute. The /// canonical read for validation, fees, and execution — derived from the /// current explicit (from, to) pair, whether the screen is standalone or @@ -543,26 +542,20 @@ 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. - @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone + /// One-time policy for a restored wallet's first historical Core scan. + @Published private(set) var isCoreSpendBlocked = CoreSpendAvailability.shared.isBlocked private var cancellables = Set() - deinit { - // The monitor holds observers strongly — remove or leak the VM. - SyncingActivityMonitor.shared.remove(observer: self) - } - init() { - SyncingActivityMonitor.shared.add(observer: self) coreBalanceDuffs = SwiftDashSDKWalletState.shared.balance?.total ?? 0 coreSpendableDuffs = SwiftDashSDKWalletState.shared.feeAwareMaxSendable() platformCredits = PlatformAddressSyncCoordinator.shared.platformBalance + CoreSpendAvailability.shared.blockedPublisher + .sink { [weak self] isBlocked in self?.isCoreSpendBlocked = isBlocked } + .store(in: &cancellables) + SwiftDashSDKWalletState.shared.$balance .receive(on: RunLoop.main) .sink { [weak self] balance in @@ -624,18 +617,26 @@ 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 NEW Core-funded asset locks are subject to the restore gate. var isBlockedBySync: Bool { switch route { case .coreToShielded, .coreToPlatform: - return !isChainSynced + return isCoreSpendBlocked default: return false } } + /// The runtime proof gate is intentionally absent here: users can prepare + /// the transfer and reach confirmation while the masternode list syncs. + var coreSpendGateMessage: String? { + guard route == .coreToShielded || route == .coreToPlatform else { return nil } + if isCoreSpendBlocked { + return CoreSpendAvailabilityError.initialRestoreSync.localizedDescription + } + return nil + } + var coreToShieldedMinimumAmountDuffs: UInt64? { guard route == .coreToShielded, let poolFeeCredits = CoreToShieldedAmountPolicy.poolFeeCredits @@ -1406,17 +1407,3 @@ final class InternalTransferViewModel: ObservableObject { return formatter.string(from: rounded) ?? "\(value)" } } - - -// MARK: - SyncingActivityMonitorObserver - -extension InternalTransferViewModel: SyncingActivityMonitorObserver { - nonisolated func syncingActivityMonitorProgressDidChange(_ progress: Double) {} - - nonisolated func syncingActivityMonitorStateDidChange(previousState: SyncingActivityMonitor.State, - state: SyncingActivityMonitor.State) { - Task { @MainActor in - self.isChainSynced = state == .syncDone - } - } -} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 3434fb45f..5fe9d7d87 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -168,6 +168,18 @@ enum ShieldedSweepAvailability: Equatable { @MainActor final class ShieldedTransferCoordinator: ObservableObject { + /// Classifies the funding side before any authorization or build work. + /// Resuming a committed outpoint is intentionally distinct from creating + /// another Core spend. + enum OperationKind: Equatable { + case newCoreSpend + case resumeCommittedCoreSpend + case nonCoreSpend + + var requiresCoreSpendAvailability: Bool { self == .newCoreSpend } + var requiresAssetLockProofAvailability: Bool { self != .nonCoreSpend } + } + enum Phase: Equatable { case idle case signing @@ -406,7 +418,7 @@ final class ShieldedTransferCoordinator: ObservableObject { /// stages, polling, and resume semantics for both funding sources (a /// stuck lock resumes by outpoint regardless of what funded it). func performAssetLock(funding: AssetLockFundingSource, recipientRaw43 recipientOverride: Data? = nil) async { - guard beginTransfer() else { return } + guard beginTransfer(.newCoreSpend) else { return } lastAssetLockOutPoint = nil Self.logger.info("🛡️ SHIELD-TX :: asset-lock route funding=\(String(describing: funding), privacy: .public) external=\(recipientOverride != nil)") @@ -441,6 +453,7 @@ final class ShieldedTransferCoordinator: ObservableObject { handleFailure(error) return } + guard revalidateAvailability(for: .newCoreSpend) else { return } phase = .locking let startTime = Date() @@ -500,7 +513,7 @@ final class ShieldedTransferCoordinator: ObservableObject { /// Used by both confirm sheets' "Try again" (in-session) and the /// home-screen recovery sheet (after relaunch). func resumeAssetLock(outPointTxidWire: Data, outPointVout: UInt32, recipientRaw43 recipientOverride: Data? = nil) async { - guard beginTransfer() else { return } + guard beginTransfer(.resumeCommittedCoreSpend) else { return } Self.logger.info("🛡️ SHIELD-TX :: resume asset-lock vout=\(outPointVout) external=\(recipientOverride != nil)") let env: Environment @@ -517,6 +530,7 @@ final class ShieldedTransferCoordinator: ObservableObject { handleFailure(error) return } + guard revalidateAvailability(for: .resumeCommittedCoreSpend) else { return } // The lock is already broadcast/locked — only the Orchard proof + the // shield ST remain, so jump straight to .proving (no .locking stage and @@ -625,7 +639,7 @@ final class ShieldedTransferCoordinator: ObservableObject { /// intermediate signals from the FFI — `.proving` covers the whole opaque /// ~30 s call; on return we jump to `.success`. func performShield(amountCredits: UInt64) async { - guard beginTransfer() else { return } + guard beginTransfer(.nonCoreSpend) else { return } Self.logger.info("🛡️ SHIELD-TX :: shield route amount=\(amountCredits) credits") let env: Environment @@ -716,7 +730,7 @@ final class ShieldedTransferCoordinator: ObservableObject { sweepAll: Bool = false, toCoreAddress destinationOverride: String? = nil ) async { - guard beginTransfer() else { return } + guard beginTransfer(.nonCoreSpend) else { return } Self.logger.info("🛡️ SHIELD-TX :: withdraw route amount=\(amountCredits) credits external=\(destinationOverride != nil)") let env: Environment @@ -821,7 +835,7 @@ final class ShieldedTransferCoordinator: ObservableObject { sweepAll: Bool = false, toPlatformAddress destinationOverride: String? = nil ) async { - guard beginTransfer() else { return } + guard beginTransfer(.nonCoreSpend) else { return } Self.logger.info("🛡️ SHIELD-TX :: unshield route amount=\(amountCredits) credits external=\(destinationOverride != nil)") let env: Environment @@ -913,7 +927,7 @@ final class ShieldedTransferCoordinator: ObservableObject { /// committed, `lastAssetLockOutPoint` is captured so "Try again" resumes /// that lock via `resumeFundPlatform` instead of stranding it. func performFundPlatform(amountDuffs: UInt64) async { - guard beginTransfer() else { return } + guard beginTransfer(.newCoreSpend) else { return } lastAssetLockOutPoint = nil Self.logger.info("🛡️ SHIELD-TX :: core→platform fund route amount=\(amountDuffs)") @@ -931,6 +945,7 @@ final class ShieldedTransferCoordinator: ObservableObject { handleFailure(error) return } + guard revalidateAvailability(for: .newCoreSpend) else { return } phase = .locking let startTime = Date() @@ -964,7 +979,7 @@ final class ShieldedTransferCoordinator: ObservableObject { /// funding ST never landed — drives the remaining stages on the SAME /// outpoint. Mirrors `resumeAssetLock` on the shielded route. func resumeFundPlatform(outPointTxidWire: Data, outPointVout: UInt32) async { - guard beginTransfer() else { return } + guard beginTransfer(.resumeCommittedCoreSpend) else { return } Self.logger.info("🛡️ SHIELD-TX :: resume core→platform fund vout=\(outPointVout)") do { @@ -980,6 +995,7 @@ final class ShieldedTransferCoordinator: ObservableObject { handleFailure(error) return } + guard revalidateAvailability(for: .resumeCommittedCoreSpend) else { return } // The lock is already on-chain; only the funding ST remains. phase = .broadcasting @@ -1016,7 +1032,7 @@ final class ShieldedTransferCoordinator: ObservableObject { feeHeadroomCredits: UInt64?, toCoreAddress destinationOverride: String? = nil ) async { - guard beginTransfer() else { return } + guard beginTransfer(.nonCoreSpend) else { return } Self.logger.info("🛡️ SHIELD-TX :: platform→core withdraw route full=\(fullBalance) amount=\(amountCredits) external=\(destinationOverride != nil)") // Destination Core (BIP44, Base58Check) address — for the internal @@ -1068,7 +1084,7 @@ final class ShieldedTransferCoordinator: ObservableObject { /// `.signing → .proving → .broadcasting → .success` — same opaque-call /// shape as `performWithdraw`/`performUnshield`. func performShieldedTransfer(amountCredits: UInt64, recipientRaw43: Data) async { - guard beginTransfer() else { return } + guard beginTransfer(.nonCoreSpend) else { return } Self.logger.info("🛡️ SHIELD-TX :: shielded→shielded send amount=\(amountCredits) credits") let env: BasicEnvironment @@ -1112,7 +1128,7 @@ final class ShieldedTransferCoordinator: ObservableObject { /// auto-selected largest-first). Stages: `.signing → .broadcasting → /// .success` — a single opaque transition, no proof and no asset lock. func performPlatformSend(destination: String, amountCredits: UInt64) async { - guard beginTransfer() else { return } + guard beginTransfer(.nonCoreSpend) else { return } Self.logger.info("🛡️ SHIELD-TX :: platform→platform send amount=\(amountCredits) credits") do { @@ -1213,13 +1229,47 @@ final class ShieldedTransferCoordinator: ObservableObject { /// coordinator is `@MainActor`, the `phase == .idle` check and the /// `.signing` write run with no suspension point between them — the /// first caller wins atomically and the second sees `.signing` + bails. - private func beginTransfer() -> Bool { + private func beginTransfer(_ operation: OperationKind) -> Bool { guard phase == .idle else { return false } lastFailure = nil + if operation.requiresCoreSpendAvailability { + do { + try CoreSpendAvailability.shared.requireAllowed() + } catch { + handleFailure(error) + return false + } + } + if operation.requiresAssetLockProofAvailability { + do { + try AssetLockProofAvailability.shared.requireAllowed() + } catch { + handleFailure(error) + return false + } + } phase = .signing return true } + /// Readiness can close while the PIN sheet is visible (wallet switch, + /// reconnect, or a new masternode catch-up). Re-check at the last safe + /// boundary before invoking the non-cancellable FFI call. + private func revalidateAvailability(for operation: OperationKind) -> Bool { + do { + if operation.requiresCoreSpendAvailability { + try CoreSpendAvailability.shared.requireAllowed() + } + if operation.requiresAssetLockProofAvailability { + try AssetLockProofAvailability.shared.requireAllowed() + } + return true + } catch { + handleFailure(error) + return false + } + } + /// PIN/biometric gate. `phase` is already `.signing` (set synchronously /// by `beginTransfer()`); this just awaits user authorization and maps /// the cancel/fail outcomes onto coordinator errors. diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift index 8e4dfc2a2..5310aea34 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift @@ -299,8 +299,8 @@ struct SendSourceScreen: View { sourceCards - if viewModel.isBlockedBySync { - SyncGateNote() + if let message = viewModel.coreSpendGateMessage { + SyncGateNote(message: message) .padding(.horizontal, 20) } } @@ -312,7 +312,7 @@ struct SendSourceScreen: View { text: NSLocalizedString("Continue", comment: ""), style: .filled, stretch: true, - isEnabled: viewModel.route != nil && !viewModel.isBlockedBySync, + isEnabled: viewModel.route != nil && viewModel.coreSpendGateMessage == nil, action: onContinue) .padding(.horizontal, 16) .padding(.bottom, 12) @@ -723,6 +723,8 @@ struct SendConfirmSheet: View { var onCompleted: () -> Void @StateObject private var coordinator = ShieldedTransferCoordinator() + @ObservedObject private var coreSpendAvailability = CoreSpendAvailability.shared + @ObservedObject private var proofAvailability = AssetLockProofAvailability.shared var body: some View { VStack(spacing: 0) { @@ -790,6 +792,13 @@ struct SendConfirmSheet: View { .padding(.top, 12) } + + if let message = assetLockGateMessage { + SyncGateNote(message: message) + .padding(.horizontal, 20) + .padding(.top, 12) + } + Spacer(minLength: 12) switch coordinator.phase { @@ -797,6 +806,7 @@ struct SendConfirmSheet: View { ButtonsGroup( orientation: .horizontal, size: .large, + positiveActionEnabled: assetLockGateMessage == nil, positiveButtonText: NSLocalizedString("Confirm", comment: ""), positiveButtonAction: confirm, negativeButtonText: NSLocalizedString("Cancel", comment: ""), @@ -808,6 +818,7 @@ struct SendConfirmSheet: View { ButtonsGroup( orientation: .horizontal, size: .large, + positiveActionEnabled: assetLockGateMessage == nil, positiveButtonText: NSLocalizedString("Try again", comment: ""), positiveButtonAction: tryAgain, negativeButtonText: NSLocalizedString("Close", comment: ""), @@ -826,6 +837,18 @@ struct SendConfirmSheet: View { } } + private var assetLockGateMessage: String? { + guard route == .coreToShielded else { return nil } + let resumesCommittedLock = coordinator.lastAssetLockOutPoint != nil + if !resumesCommittedLock, coreSpendAvailability.isBlocked { + return CoreSpendAvailabilityError.initialRestoreSync.localizedDescription + } + if proofAvailability.isBlocked { + return AssetLockProofAvailabilityError.masternodeSync.localizedDescription + } + return nil + } + // MARK: - Success body private var successBody: some View { @@ -1131,19 +1154,22 @@ 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. +/// Inline explanation for a restored wallet's one-time Core-spend gate. struct SyncGateNote: View { + let message: String + + init(message: String = NSLocalizedString( + "Your restored wallet is completing its initial sync. Sending from your Transparent balance will be available once it finishes.", + comment: "Core spend blocked during a restored wallet's first sync")) { + self.message = message + } + 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")) + Text(message) .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..12482e8ce 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift @@ -92,11 +92,9 @@ 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). - @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone + /// One-time policy for a restored wallet's first historical Core scan. + /// Ordinary foreground catch-up never changes this value. + @Published private(set) var isCoreSpendBlocked = CoreSpendAvailability.shared.isBlocked private var cancellables = Set() @@ -106,19 +104,16 @@ final class SendViewModel: ObservableObject { /// rather than silently re-picking the source. let pinnedSource: ChainNetwork? - deinit { - // The monitor holds observers strongly — without this the VM (and - // its Combine pipelines) outlive the screen. - SyncingActivityMonitor.shared.remove(observer: self) - } - init(pinnedSource: ChainNetwork? = nil) { self.pinnedSource = pinnedSource if let pinnedSource { source = pinnedSource } refreshClipboardSuggestion() - SyncingActivityMonitor.shared.add(observer: self) + + CoreSpendAvailability.shared.blockedPublisher + .sink { [weak self] isBlocked in self?.isCoreSpendBlocked = isBlocked } + .store(in: &cancellables) NotificationCenter.default.publisher(for: UIPasteboard.changedNotification) .receive(on: RunLoop.main) @@ -463,19 +458,27 @@ 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 NEW Core-funded routes are subject to the one-time restore gate. var isBlockedBySync: Bool { guard let route else { return false } switch route { case .coreToCore, .coreToShielded: - return !isChainSynced + return isCoreSpendBlocked default: return false } } + /// The runtime proof gate is intentionally absent here: users can prepare + /// the payment and reach confirmation while the masternode list syncs. + var coreSpendGateMessage: String? { + guard let route else { return nil } + if isCoreSpendBlocked, route == .coreToCore || route == .coreToShielded { + return CoreSpendAvailabilityError.initialRestoreSync.localizedDescription + } + return nil + } + /// Type-18's pool fee is carved out of the one-time asset-lock value. /// Reuse the internal-transfer policy so external sends to a shielded /// address cannot reach Confirm with an amount the SDK must reject. @@ -743,17 +746,3 @@ final class SendViewModel: ObservableObject { } } } - - -// MARK: - SyncingActivityMonitorObserver - -extension SendViewModel: SyncingActivityMonitorObserver { - nonisolated func syncingActivityMonitorProgressDidChange(_ progress: Double) {} - - nonisolated func syncingActivityMonitorStateDidChange(previousState: SyncingActivityMonitor.State, - state: SyncingActivityMonitor.State) { - Task { @MainActor in - self.isChainSynced = state == .syncDone - } - } -} diff --git a/DashWallet/Sources/UI/Payments/PaymentModels/DWPaymentProcessor.m b/DashWallet/Sources/UI/Payments/PaymentModels/DWPaymentProcessor.m index 1f3439ffa..48b51d937 100644 --- a/DashWallet/Sources/UI/Payments/PaymentModels/DWPaymentProcessor.m +++ b/DashWallet/Sources/UI/Payments/PaymentModels/DWPaymentProcessor.m @@ -233,6 +233,14 @@ - (void)confirmBIP70Output:(id)bip70Confirmation { /// Authenticate (PIN / biometric), then build + broadcast + POST via the Swift orchestrator. - (void)broadcastBIP70PaymentOutput:(DWPaymentOutput *)paymentOutput { + NSError *restoreSyncError = [DWCoreSpendAvailability coreSpendBlockedError]; + if (restoreSyncError != nil) { + [self failedWithError:restoreSyncError + title:NSLocalizedString(@"Couldn't make payment", nil) + message:restoreSyncError.localizedDescription]; + return; + } + BOOL skipAuth = [[DWGlobalOptions sharedInstance] spendingConfirmationDisabled] || paymentOutput.broadcastAuthorizationState == DWPaymentOutputBroadcastAuthorizationStateAlreadyAuthorized; diff --git a/DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverWalletCommand.m b/DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverWalletCommand.m index 0affe3b87..56103eea8 100644 --- a/DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverWalletCommand.m +++ b/DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverWalletCommand.m @@ -50,8 +50,6 @@ - (void)execute { - (void)recoverWalletWithPhrase:(NSString *)phrase { [self importWalletIntoSwiftDashSDK:phrase]; - [DWGlobalOptions sharedInstance].resyncingWallet = YES; - // SwiftDashSDK SPV is started by SwiftDashSDKWalletCreator after the // imported wallet record is committed to SwiftData (see // SwiftDashSDKWalletCreator.swift). No DashSync startSync needed — diff --git a/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift b/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift index 66bd33fb0..c98e5e3a8 100644 --- a/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift +++ b/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift @@ -109,6 +109,7 @@ class TXDetailViewController: BaseTxDetailsViewController { var currentSnapshot: NSDiffableDataSourceSnapshot! = nil /// Single-flight guard for the stuck-lock retry action. private var isRetryingAssetLock = false + private var proofAvailabilityObserver: NSObjectProtocol? enum Section: CaseIterable { case header @@ -202,6 +203,12 @@ class TXDetailViewController: BaseTxDetailsViewController { configureDataSource() reloadDataSource() + proofAvailabilityObserver = NotificationCenter.default.addObserver( + forName: AssetLockProofAvailability.didChangeNotification, + object: nil, + queue: .main) { [weak self] _ in + self?.reloadDataSource() + } // Dash DEX swap legs get an extra "View NEAR/Maya Explorer" action; the order lookup // is async (DAO reads), so resolve then rebuild the rows when it lands. @@ -209,6 +216,12 @@ class TXDetailViewController: BaseTxDetailsViewController { self?.reloadDataSource() } } + + deinit { + if let proofAvailabilityObserver { + NotificationCenter.default.removeObserver(proofAvailabilityObserver) + } + } } extension TXDetailViewController { @@ -413,11 +426,20 @@ extension TXDetailViewController { let cell = tableView.dequeueReusableCell(withIdentifier: TxDetailActionCell.reuseIdentifier, for: indexPath) as! TxDetailActionCell if case .rebroadcast(let title) = item { - cell.titleLabel.text = title - cell.titleLabel.textColor = .dw_label() + let blocked = AssetLockProofAvailability.shared.isBlocked + cell.titleLabel.text = blocked + ? NSLocalizedString( + "Funds safe — waiting for masternode sync", + comment: "Disabled asset-lock recovery action") + : title + cell.titleLabel.textColor = blocked ? .secondaryLabel : .dw_label() + cell.isUserInteractionEnabled = !blocked + cell.contentView.alpha = blocked ? 0.55 : 1 } else if item == .removeUnconfirmed { cell.titleLabel.text = NSLocalizedString("Remove if not on Blockchain", comment: "Delete a never-accepted transaction from local wallet state") cell.titleLabel.textColor = .systemRed + cell.isUserInteractionEnabled = true + cell.contentView.alpha = 1 } return cell @@ -542,6 +564,7 @@ extension TXDetailViewController { if dataSource.itemIdentifier(for: indexPath) == .removeUnconfirmed { confirmRemoveUnconfirmed() } else { + guard !AssetLockProofAvailability.shared.isBlocked else { return } retryStuckAssetLock() } case .rawTransaction: diff --git a/DashWalletTests/InitialRestoreSyncStoreTests.swift b/DashWalletTests/InitialRestoreSyncStoreTests.swift new file mode 100644 index 000000000..b6ef7987c --- /dev/null +++ b/DashWalletTests/InitialRestoreSyncStoreTests.swift @@ -0,0 +1,210 @@ +import Foundation +import XCTest +@testable import dashwallet + +@MainActor +final class InitialRestoreSyncStoreTests: XCTestCase { + private var suiteName: String? + private var defaultsUnderTest: UserDefaults? + private var storeUnderTest: InitialRestoreSyncStore? + + private var defaults: UserDefaults { + guard let defaultsUnderTest else { + preconditionFailure("Test defaults accessed outside setUp/tearDown") + } + return defaultsUnderTest + } + + private var store: InitialRestoreSyncStore { + guard let storeUnderTest else { + preconditionFailure("Test store accessed outside setUp/tearDown") + } + return storeUnderTest + } + + override func setUp() { + super.setUp() + let suiteName = "InitialRestoreSyncStoreTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + preconditionFailure("Unable to create isolated UserDefaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + self.suiteName = suiteName + defaultsUnderTest = defaults + storeUnderTest = InitialRestoreSyncStore( + defaults: defaults, + notificationCenter: NotificationCenter()) + } + + override func tearDown() { + if let suiteName { + defaultsUnderTest?.removePersistentDomain(forName: suiteName) + } + storeUnderTest = nil + defaultsUnderTest = nil + suiteName = nil + super.tearDown() + } + + func testFreshWalletHasNoMarkerAndOriginsHaveExpectedSemantics() { + let walletId = Data([0x01]) + XCTAssertNil(store.state(walletId: walletId)) + XCTAssertFalse(WalletMaterialOrigin.fresh.armsInitialRestoreSync) + XCTAssertTrue(WalletMaterialOrigin.userRestore.armsInitialRestoreSync) + XCTAssertTrue(WalletMaterialOrigin.legacyMigration.armsInitialRestoreSync) + XCTAssertTrue(WalletMaterialOrigin.reconstructed.armsInitialRestoreSync) + } + + func testImportedPendingCompletesOnceAndDoesNotRearm() { + let walletId = Data([0x02]) + store.markImportedIfNeeded(walletId: walletId) + XCTAssertEqual(store.state(walletId: walletId), .pending) + store.completeIfPending(walletId: walletId) + XCTAssertEqual(store.state(walletId: walletId), .completed) + store.markImportedIfNeeded(walletId: walletId) + XCTAssertEqual(store.state(walletId: walletId), .completed) + } + + func testReconstructionForcesCompletedWalletBackToPending() { + let walletId = Data([0x03]) + store.markImportedIfNeeded(walletId: walletId) + store.completeIfPending(walletId: walletId) + store.markReconstructed(walletId: walletId) + XCTAssertEqual(store.state(walletId: walletId), .pending) + } + + func testStatePersistsAndIsScopedPerWallet() { + let pending = Data([0x0a]) + let completed = Data([0x0b]) + store.markImportedIfNeeded(walletId: pending) + store.markImportedIfNeeded(walletId: completed) + store.completeIfPending(walletId: completed) + + let reloaded = InitialRestoreSyncStore( + defaults: defaults, + notificationCenter: NotificationCenter()) + XCTAssertEqual(reloaded.state(walletId: pending), .pending) + XCTAssertEqual(reloaded.state(walletId: completed), .completed) + } + + func testDeleteThenLateCompletionDoesNotRecreateMarker() { + let walletId = Data([0x04]) + store.markImportedIfNeeded(walletId: walletId) + store.remove(walletId: walletId) + store.completeIfPending(walletId: walletId) + XCTAssertNil(store.state(walletId: walletId)) + } + + func testRemoveAllOnlyAfterSuccessfulWipeCanClearEveryWallet() { + store.markImportedIfNeeded(walletId: Data([0x05])) + store.markImportedIfNeeded(walletId: Data([0x06])) + store.removeAll() + XCTAssertNil(store.state(walletId: Data([0x05]))) + XCTAssertNil(store.state(walletId: Data([0x06]))) + } + + func testEffectiveSyncCompletionPredicate() { + XCTAssertTrue(SPVSyncState.synced.isEffectivelyComplete(progress: 0)) + XCTAssertTrue(SPVSyncState.waitForEvents.isEffectivelyComplete(progress: 0.999)) + XCTAssertFalse(SPVSyncState.waitForEvents.isEffectivelyComplete(progress: 0.998)) + XCTAssertFalse(SPVSyncState.syncing.isEffectivelyComplete(progress: 1)) + XCTAssertFalse(SPVSyncState.unknown.isEffectivelyComplete(progress: 1)) + } + + func testOnlyNewCoreOperationRequiresRestoreAvailability() { + XCTAssertTrue( + ShieldedTransferCoordinator.OperationKind.newCoreSpend + .requiresCoreSpendAvailability) + XCTAssertFalse( + ShieldedTransferCoordinator.OperationKind.resumeCommittedCoreSpend + .requiresCoreSpendAvailability) + XCTAssertFalse( + ShieldedTransferCoordinator.OperationKind.nonCoreSpend + .requiresCoreSpendAvailability) + + XCTAssertTrue( + ShieldedTransferCoordinator.OperationKind.newCoreSpend + .requiresAssetLockProofAvailability) + XCTAssertTrue( + ShieldedTransferCoordinator.OperationKind.resumeCommittedCoreSpend + .requiresAssetLockProofAvailability) + XCTAssertFalse( + ShieldedTransferCoordinator.OperationKind.nonCoreSpend + .requiresAssetLockProofAvailability) + } + + func testAssetLockProofReadinessFailsClosed() { + let walletA = Data([0xa1]) + let walletB = Data([0xb1]) + let synced = MasternodesSubProgress( + state: .synced, + currentHeight: 100, + targetHeight: 100, + diffsProcessed: 0) + + XCTAssertEqual( + AssetLockProofAvailability.decision( + boundWalletId: nil, + progressWalletId: walletA, + masternodes: synced), + .blockedMasternodeSync) + XCTAssertEqual( + AssetLockProofAvailability.decision( + boundWalletId: walletA, + progressWalletId: walletB, + masternodes: synced), + .blockedMasternodeSync) + XCTAssertEqual( + AssetLockProofAvailability.decision( + boundWalletId: walletA, + progressWalletId: walletA, + masternodes: nil), + .blockedMasternodeSync) + } + + func testAssetLockProofReadinessRequiresSyncedNonzeroCaughtUpMasternodes() { + let walletId = Data([0xc1]) + let decision: (SPVSyncState, UInt32, UInt32) -> AssetLockProofAvailability.Decision = { + state, current, target in + AssetLockProofAvailability.decision( + boundWalletId: walletId, + progressWalletId: walletId, + masternodes: MasternodesSubProgress( + state: state, + currentHeight: current, + targetHeight: target, + diffsProcessed: 0)) + } + + XCTAssertEqual(decision(.syncing, 100, 100), .blockedMasternodeSync) + XCTAssertEqual(decision(.waitForEvents, 100, 100), .blockedMasternodeSync) + XCTAssertEqual(decision(.error, 100, 100), .blockedMasternodeSync) + XCTAssertEqual(decision(.synced, 0, 100), .blockedMasternodeSync) + XCTAssertEqual(decision(.synced, 100, 0), .blockedMasternodeSync) + XCTAssertEqual(decision(.synced, 99, 100), .blockedMasternodeSync) + XCTAssertEqual(decision(.synced, 100, 100), .allowed) + XCTAssertEqual(decision(.synced, 101, 100), .allowed) + } + + func testAssetLockProofAvailabilityPublishesOnlyDecisionChanges() { + let center = NotificationCenter() + let availability = AssetLockProofAvailability( + notificationCenter: center, + observesRuntime: false) + var notifications = 0 + let observer = center.addObserver( + forName: AssetLockProofAvailability.didChangeNotification, + object: nil, + queue: nil) { _ in notifications += 1 } + defer { center.removeObserver(observer) } + + availability.apply(.blockedMasternodeSync) + XCTAssertEqual(notifications, 0) + availability.apply(.allowed) + XCTAssertEqual(notifications, 1) + availability.apply(.allowed) + XCTAssertEqual(notifications, 1) + availability.apply(.blockedMasternodeSync) + XCTAssertEqual(notifications, 2) + } +} diff --git a/DashWalletTests/PaymentProtocolTests.swift b/DashWalletTests/PaymentProtocolTests.swift index f0e2e1058..98eaef570 100644 --- a/DashWalletTests/PaymentProtocolTests.swift +++ b/DashWalletTests/PaymentProtocolTests.swift @@ -548,7 +548,11 @@ private final class FakeReceive: ReceiveAddressProviding { private final class FakeAuth: SendAuthorizing { var error: Error? - func authorize() async throws { if let e = error { throw e } } + private(set) var calls = 0 + func authorize() async throws { + calls += 1 + if let e = error { throw e } + } } final class BIP70PaymentServiceTests: XCTestCase { @@ -570,9 +574,13 @@ final class BIP70PaymentServiceTests: XCTestCase { } private func service(_ t: FakeTransport, _ w: FakeWallet, receive: FakeReceive = FakeReceive(), - auth: FakeAuth = FakeAuth(), allowUntrusted: Bool = true) -> BIP70PaymentService { + auth: FakeAuth = FakeAuth(), + coreSpendPreflight: @escaping () async throws -> Void = {}, + allowUntrusted: Bool = true) -> BIP70PaymentService { BIP70PaymentService(transport: t, verifier: PaymentRequestVerifier(), wallet: w, - receiveAddress: receive, auth: auth, allowUntrustedUnsigned: allowUntrusted) + receiveAddress: receive, auth: auth, + coreSpendPreflight: coreSpendPreflight, + allowUntrustedUnsigned: allowUntrusted) } private func assertThrowsBIP70(_ expression: @autoclosure () async throws -> T, _ expected: BIP70Error, @@ -680,6 +688,34 @@ final class BIP70PaymentServiceTests: XCTestCase { XCTAssertTrue(w.calls.isEmpty) } + func testInitialRestoreGateStopsInteractiveSendBeforeBuild() async throws { + let wallet = FakeWallet() + let svc = service( + FakeTransport(unsigned()), wallet, + coreSpendPreflight: { throw BIP70Error.initialRestoreSync }) + let confirmation = try await svc.prepareForConfirmation( + from: url, scheme: "dash", network: .testnet) + + await assertThrowsBIP70( + try await svc.confirmAndSend(confirmation), .initialRestoreSync) + XCTAssertTrue(wallet.calls.isEmpty) + } + + func testInitialRestoreGateStopsHeadlessSendBeforeAuthAndBuild() async { + let wallet = FakeWallet() + let auth = FakeAuth() + let svc = service( + FakeTransport(unsigned()), wallet, auth: auth, + coreSpendPreflight: { throw BIP70Error.initialRestoreSync }) + + await assertThrowsBIP70( + try await svc.confirmAndSendHeadless( + from: url, scheme: "dash", network: .testnet), + .initialRestoreSync) + XCTAssertEqual(auth.calls, 0) + XCTAssertTrue(wallet.calls.isEmpty) + } + func testSoftPostFailureAfterBroadcast() async throws { let w = FakeWallet(); let t = FakeTransport(unsigned()); t.postShouldThrow = .unexpectedResponse(host: "h") let svc = service(t, w) @@ -955,3 +991,57 @@ final class BIP70PaymentServiceTests: XCTestCase { XCTAssertEqual(intent.amount, 42_000_000) } } + +// MARK: - URL loading stub + +/// File-private so these tests remain self-contained even when the phrase +/// repair test helper is not part of the active test target. +private final class MockURLProtocol: URLProtocol { + static var handler: ((URLRequest) -> (Int, Data))? + static var responseHandler: ((URLRequest) -> (Int, [String: String], Data))? + + static func bodyData(of request: URLRequest) -> Data? { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + guard read > 0 else { break } + data.append(buffer, count: read) + } + return data + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let statusCode: Int + let headers: [String: String]? + let data: Data + if let responseHandler = Self.responseHandler { + (statusCode, headers, data) = responseHandler(request) + } else if let handler = Self.handler { + (statusCode, data) = handler(request) + headers = nil + } else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) + return + } + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.invalid")!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/scripts/bip70_manual_test/main.swift b/scripts/bip70_manual_test/main.swift index 38d0b54f9..da479c9c6 100644 --- a/scripts/bip70_manual_test/main.swift +++ b/scripts/bip70_manual_test/main.swift @@ -215,9 +215,12 @@ func unsignedRequest(network: String? = "test", outputs: [PaymentOutput]? = nil, } func makeService(_ transport: FakeTransport, _ wallet: FakeWallet, receive: FakeReceive = FakeReceive(), auth: FakeAuth = FakeAuth(), + coreSpendPreflight: @escaping () async throws -> Void = {}, allowUntrusted: Bool = true) -> BIP70PaymentService { BIP70PaymentService(transport: transport, verifier: PaymentRequestVerifier(), wallet: wallet, - receiveAddress: receive, auth: auth, allowUntrustedUnsigned: allowUntrusted) + receiveAddress: receive, auth: auth, + coreSpendPreflight: coreSpendPreflight, + allowUntrustedUnsigned: allowUntrusted) } let anyURL = URL(string: "http://h/pr")! @@ -230,19 +233,38 @@ do { check("prepare builds/spends nothing (calls empty)", w.calls.isEmpty) } -// Call order build → broadcast → post. +// Call order preflight → build → post/ACK → broadcast. do { let w = FakeWallet(); let t = FakeTransport(unsignedRequest()); t.onPost = { w.calls.append("post") } - let svc = makeService(t, w) + let svc = makeService(t, w, coreSpendPreflight: { w.calls.append("preflight") }) let r = runSync { let c = try await svc.prepareForConfirmation(from: anyURL, scheme: "dash", network: .testnet) return try await svc.confirmAndSend(c) } - check("confirmAndSend order == [build, broadcast, post]", w.calls == ["build", "broadcast", "post"]) + check("confirmAndSend order == [preflight, build, post, broadcast]", + w.calls == ["preflight", "build", "post", "broadcast"]) check("POST carries the prepared signed bytes", t.postedPayment?.transactions == [w.prepared.txData]) check("ack memo surfaced", value(r)?.ackMemo == "thanks") } +// A blocked preflight stops before build/sign and broadcast. +do { + let w = FakeWallet() + let svc = makeService( + FakeTransport(unsignedRequest()), + w, + coreSpendPreflight: { + w.calls.append("preflight") + throw BIP70Error.initialRestoreSync + }) + let r = runSync { + let c = try await svc.prepareForConfirmation(from: anyURL, scheme: "dash", network: .testnet) + return try await svc.confirmAndSend(c) + } + check("blocked preflight → .initialRestoreSync; no build/broadcast", + threwBIP70(r, .initialRestoreSync) && w.calls == ["preflight"]) +} + // Idempotency: a second confirmAndSend on the SAME Confirmation is rejected (no second spend). do { let w = FakeWallet(); let t = FakeTransport(unsignedRequest()); t.onPost = { w.calls.append("post") } @@ -327,7 +349,7 @@ do { check("auth cancel → .authCancelled; no build", threwBIP70(r, .authCancelled) && w.calls.isEmpty) } -// Soft POST failure after broadcast → no throw, ackMemo nil, money moved. +// POST failure before broadcast → hard throw, money unmoved. do { let w = FakeWallet(); let t = FakeTransport(unsignedRequest()); t.postShouldThrow = .unexpectedResponse(host: "h") let svc = makeService(t, w) @@ -335,8 +357,8 @@ do { let c = try await svc.prepareForConfirmation(from: anyURL, scheme: "dash", network: .testnet) return try await svc.confirmAndSend(c) } - check("soft POST fail: succeeds, ackMemo nil, broadcast happened", - value(r) != nil && value(r)?.ackMemo == nil && w.calls.contains("broadcast")) + check("POST fail: throws before broadcast", + threwBIP70(r, .unexpectedResponse(host: "h")) && !w.calls.contains("broadcast")) } // Unsigned + no paymentURL → no missingPaymentURL gate (not secure); no POST.