From 659256adf3927aaae894acac769dfe049ef592ff Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:50:49 +0200 Subject: [PATCH 01/77] fix: prevent false on-chain send success --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Models/PaykitPaymentStateBackup.swift | 5 +- Bitkit/Services/CoreService.swift | 9 +- Bitkit/Services/LightningService.swift | 31 +- .../Services/OnchainSendAttemptService.swift | 354 +++++++++++++ .../Services/PaykitPaymentProofService.swift | 246 +++++---- Bitkit/Services/TransferService.swift | 4 + Bitkit/Utilities/Keychain.swift | 2 + Bitkit/ViewModels/TransferViewModel.swift | 53 +- Bitkit/ViewModels/WalletViewModel.swift | 38 +- Bitkit/Views/Offline/OfflineSheetScreen.swift | 7 +- .../Wallets/Send/SendConfirmationView.swift | 88 +++- .../Wallets/Send/SendPendingScreen.swift | 68 ++- Bitkit/Views/Wallets/Send/SendSheet.swift | 27 +- BitkitTests/ChannelPurchaseFlow.swift | 6 +- .../OnchainSendAttemptServiceTests.swift | 484 ++++++++++++++++++ .../PaykitPaymentProofServiceTests.swift | 424 ++++++++++----- .../PaykitPaymentStateBackupTests.swift | 5 +- .../TransferServiceActivityTests.swift | 119 +++++ BitkitTests/UtxoSelectionTests.swift | 6 +- changelog.d/next/717.fixed.md | 1 + journeys/send/onchain-accepted-result.xml | 26 + 23 files changed, 1728 insertions(+), 281 deletions(-) create mode 100644 Bitkit/Services/OnchainSendAttemptService.swift create mode 100644 BitkitTests/OnchainSendAttemptServiceTests.swift create mode 100644 changelog.d/next/717.fixed.md create mode 100644 journeys/send/onchain-accepted-result.xml diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 327f85c10..1296a26b4 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1202,7 +1202,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.66"; + version = "0.7.0-rc.67"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 81b2ef402..dc30f206b 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "190a17d966cf5a685444cfa17c3a419c290bf26c", - "version" : "0.7.0-rc.66" + "revision" : "2dac2c08f59d71af650c726eb766ba26cd49cbff", + "version" : "0.7.0-rc.67" } }, { diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index d2897bf50..9becc4412 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -72,6 +72,7 @@ struct PaykitPaymentStateBackup: Codable { let onchainAmountSats: UInt64? let onchainWalletId: String? let onchainMatchingTransactionIdsBeforeAttempt: Set + let onchainBroadcastAccepted: Bool? init(_ proof: PendingPaykitPaymentProof) { identity = proof.identity @@ -86,6 +87,7 @@ struct PaykitPaymentStateBackup: Codable { onchainAmountSats = proof.onchainAmountSats onchainWalletId = proof.onchainWalletId onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] + onchainBroadcastAccepted = proof.onchainBroadcastAccepted } func restored() throws -> PendingPaykitPaymentProof { @@ -107,7 +109,8 @@ struct PaykitPaymentStateBackup: Codable { onchainAddress: onchainAddress, onchainAmountSats: onchainAmountSats, onchainWalletId: onchainWalletId, - onchainMatchingTransactionIdsBeforeAttempt: onchainMatchingTransactionIdsBeforeAttempt + onchainMatchingTransactionIdsBeforeAttempt: onchainMatchingTransactionIdsBeforeAttempt, + onchainBroadcastAccepted: onchainBroadcastAccepted ) } } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 831b7ba9d..f586b518b 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1265,6 +1265,7 @@ class ActivityService { /// `walletId` scopes the row: a transfer funded from a watch-only hardware wallet is written /// under that wallet's id, so the merged activity list shows one hardware-owned transfer row /// rather than a main-wallet row plus a hardware duplicate. + @discardableResult func createSentOnchainActivityFromSendResult( txid: String, address: String, @@ -1274,10 +1275,10 @@ class ActivityService { isTransfer: Bool = false, contact: String? = nil, walletId: String = WalletScope.default - ) async { + ) async -> Bool { let normalizedContact = contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } do { - try await ServiceQueue.background(.core) { + return try await ServiceQueue.background(.core) { if let existing = try? BitkitCore.getActivityByTxId(walletId: walletId, txId: txid) { var updated = existing if isTransfer { @@ -1291,7 +1292,7 @@ class ActivityService { self.activitiesChangedSubject.send() } Logger.debug("Activity already exists for txid \(txid), skipping immediate creation", context: "ActivityService") - return + return true } let now = UInt64(Date().timeIntervalSince1970) let onchain = OnchainActivity( @@ -1321,9 +1322,11 @@ class ActivityService { self.updateBoostTxIdsCache(for: .onchain(onchain)) self.activitiesChangedSubject.send() Logger.info("Created sent onchain activity for txid \(txid) from send result", context: "ActivityService") + return true } } catch { Logger.error("Failed to create sent onchain activity for txid \(txid): \(error)", context: "ActivityService") + return false } } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index c87f88f3d..f86c8f663 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -9,6 +9,10 @@ class LightningService { private static let watchOnlyAccountHighestPreRevealedAddressIndex: UInt32 = 999 private var node: Node? + var onchainDispatchNode: AnyObject? { + node + } + var currentWalletIndex: Int = 0 private let syncStatusChangedSubject = PassthroughSubject() @@ -23,6 +27,7 @@ class LightningService { @MainActor private var cachedChannels: [ChannelDetails]? private var storedEventCallback: ((Event) -> Void)? + var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var syncStatusChangedPublisher: AnyPublisher { syncStatusChangedSubject.eraseToAnyPublisher() @@ -775,26 +780,31 @@ class LightningService { sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]? = nil, - isMaxAmount: Bool = false - ) async throws -> Txid { + isMaxAmount: Bool = false, + expectedWalletIndex: Int? = nil, + expectedNode: AnyObject? = nil + ) async throws -> OnchainSendResult { guard let node else { - throw AppError(serviceError: .nodeNotSetup) + throw NodeError.NotRunning(message: "Node not set up") } Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))") do { - return try await ServiceQueue.background(.ldk) { + return try await ServiceQueue.background(.ldk, wrapErrors: false) { + if let expectedWalletIndex { + guard self.currentWalletIndex == expectedWalletIndex, self.node === node, expectedNode === node else { + throw NodeError.NotRunning(message: "Wallet or node changed before on-chain dispatch") + } + } if isMaxAmount { - // For max amount sends, use sendAllToAddress to send all available funds - try node.onchainPayment().sendAllToAddress( + return try node.onchainPayment().sendAllToAddressWithBroadcastResult( address: address, - retainReserve: true, + retainReserves: true, feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte) ) } else { - // For normal sends, use sendToAddress with specific amount - try node.onchainPayment().sendToAddress( + return try node.onchainPayment().sendToAddressWithBroadcastResult( address: address, amountSats: sats, feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte), @@ -1468,6 +1478,9 @@ extension LightningService { case let .onchainTransactionConfirmed(txid, _, blockHeight, _, details): Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)") Task { + if let onchainTransactionConfirmed = self.onchainTransactionConfirmed { + await onchainTransactionConfirmed(txid) + } do { try await CoreService.shared.activity.handleOnchainTransactionConfirmed( txid: txid, diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift new file mode 100644 index 000000000..28bf6b27f --- /dev/null +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -0,0 +1,354 @@ +import BitkitCore +import Combine +import Foundation +import LDKNode + +protocol OnchainSending { + var currentWalletIndex: Int { get } + var onchainDispatchNode: AnyObject? { get } + func send(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, + expectedWalletIndex: Int?, expectedNode: AnyObject?) async throws + -> OnchainSendResult +} + +extension LightningService: OnchainSending {} + +enum OnchainSendAttemptError: LocalizedError { + case unresolved + case duplicate + case outcomeNotSaved + case localFollowupNotSaved + case preDispatch(Error) + + var errorDescription: String? { + switch self { + case .unresolved: + "An earlier on-chain send is unresolved. Check its transaction before trying another send." + case .duplicate: + "This request or order already has an on-chain payment. Do not pay it again." + case .outcomeNotSaved: + "The on-chain send result could not be saved. The payment may have been sent; do not retry it." + case .localFollowupNotSaved: + "This payment was sent, but its local details could not be restored. Do not send it again." + case let .preDispatch(error): + error.localizedDescription + } + } +} + +struct OnchainSendAttempt: Codable, Equatable { + enum Status: String, Codable { + case pending + case accepted + case rejected + case unknown + + var blocksNewSend: Bool { + self != .accepted + } + } + + let id: UUID + let walletId: String + let requestId: PaykitPaymentRequest.ID? + let orderId: String? + let address: String + let amountSats: UInt64 + let isMaxAmount: Bool + var status: Status + var txid: String? = nil + var rejectionReason: String? = nil + var localFollowupComplete = false + var followupContext: OnchainSendFollowupContext? = nil + + var blocksNewSend: Bool { + status.blocksNewSend || !localFollowupComplete + } +} + +struct OnchainSendFollowupContext: Codable, Equatable { + let feeSats: UInt64 + let feeRate: UInt32 + let tags: [String] + let contact: String? + let createdAt: UInt64 +} + +struct OnchainSendLocalResolution { + let attemptId: UUID + let walletId: String + let txid: String + let amountSats: UInt64 + let contact: String? +} + +protocol OnchainSendLocalFollowupHandling { + func save(_ attempt: OnchainSendAttempt) async throws -> OnchainActivity +} + +struct OnchainSendLocalFollowup: OnchainSendLocalFollowupHandling { + func save(_ attempt: OnchainSendAttempt) async throws -> OnchainActivity { + guard let txid = attempt.txid, attempt.status == .accepted else { throw OnchainSendAttemptError.localFollowupNotSaved } + let activity = CoreService.shared.activity + if let context = attempt.followupContext { + try await activity.upsertPreActivityMetadata([BitkitCore.PreActivityMetadata( + walletId: WalletScope.default, paymentId: txid, tags: context.tags, paymentHash: nil, + txId: txid, address: attempt.address, isReceive: false, feeRate: UInt64(context.feeRate), + isTransfer: false, channelId: nil, createdAt: context.createdAt + )]) + guard await activity.createSentOnchainActivityFromSendResult( + txid: txid, address: attempt.address, amount: attempt.amountSats, + fee: context.feeSats, feeRate: context.feeRate, contact: context.contact + ) else { throw OnchainSendAttemptError.localFollowupNotSaved } + } else { + guard let metadata = try await activity.getPreActivityMetadata(searchKey: txid), + metadata.paymentId.caseInsensitiveCompare(txid) == .orderedSame, + metadata.txId?.caseInsensitiveCompare(txid) == .orderedSame + else { throw OnchainSendAttemptError.localFollowupNotSaved } + } + guard let saved = try await activity.getOnchainActivityByTxId(txid: txid), + saved.txId.caseInsensitiveCompare(txid) == .orderedSame, saved.txType == .sent + else { throw OnchainSendAttemptError.localFollowupNotSaved } + return saved + } +} + +protocol OnchainSendAttemptStoring: Sendable { + func load() throws -> [OnchainSendAttempt] + func save(_ attempts: [OnchainSendAttempt]) throws +} + +struct OnchainSendAttemptStore: OnchainSendAttemptStoring { + func load() throws -> [OnchainSendAttempt] { + guard let data = try Keychain.load(key: .onchainSendAttempts) else { return [] } + return try JSONDecoder().decode([OnchainSendAttempt].self, from: data) + } + + func save(_ attempts: [OnchainSendAttempt]) throws { + try Keychain.upsert(key: .onchainSendAttempts, data: JSONEncoder().encode(attempts)) + } +} + +actor OnchainSendAttemptService { + static let shared = OnchainSendAttemptService() + + static func walletId(index: Int) -> String { + "node:\(Env.networkName):\(WalletScope.default):\(index)" + } + + private static let localResolutionSubject = PassthroughSubject() + + nonisolated static var localResolutionPublisher: AnyPublisher { + localResolutionSubject.receive(on: DispatchQueue.main).eraseToAnyPublisher() + } + + private let localFollowup: any OnchainSendLocalFollowupHandling + private let store: any OnchainSendAttemptStoring + private let hasPaidOrder: (String) throws -> Bool + private var knownAttempt: OnchainSendAttempt? + + init( + store: any OnchainSendAttemptStoring = OnchainSendAttemptStore(), + localFollowup: any OnchainSendLocalFollowupHandling = OnchainSendLocalFollowup(), + hasPaidOrder: @escaping (String) throws -> Bool = { orderId in + try TransferStorage.shared.getAll().contains(where: { $0.lspOrderId == orderId }) + } + ) { + self.localFollowup = localFollowup + self.store = store + self.hasPaidOrder = hasPaidOrder + } + + func send( + using lightningService: any OnchainSending, + address: String, + amountSats: UInt64, + satsPerVbyte: UInt32, + utxosToSpend: [SpendableUtxo]?, + isMaxAmount: Bool, + requestId: PaykitPaymentRequest.ID? = nil, + orderId: String? = nil, + followupContext: OnchainSendFollowupContext? = nil, + beforeBroadcastAttempt: () async throws -> Void = {} + ) async throws -> OnchainSendResult { + let walletIndex = lightningService.currentWalletIndex + let dispatchNode = lightningService.onchainDispatchNode + if let prior = try currentAttempt(), prior.status == .accepted, let txid = prior.txid, + prior.walletId == Self.walletId(index: walletIndex), + (requestId != nil && prior.requestId == requestId) || (orderId != nil && prior.orderId == orderId) + { + return .accepted(txid: txid) + } + if let orderId, try hasPaidOrder(orderId) { + throw OnchainSendAttemptError.duplicate + } + let attemptId = try admit( + walletId: Self.walletId(index: walletIndex), + requestId: requestId, + orderId: orderId, + address: address, + amountSats: amountSats, + isMaxAmount: isMaxAmount, + followupContext: followupContext + ) + do { + try await beforeBroadcastAttempt() + } catch { + throw OnchainSendAttemptError.unresolved + } + + let result: OnchainSendResult + do { + guard lightningService.currentWalletIndex == walletIndex, lightningService.onchainDispatchNode === dispatchNode else { + throw NodeError.NotRunning(message: "Wallet or node changed before on-chain dispatch") + } + result = try await lightningService.send( + address: address, + sats: amountSats, + satsPerVbyte: satsPerVbyte, + utxosToSpend: utxosToSpend, + isMaxAmount: isMaxAmount, + expectedWalletIndex: walletIndex, + expectedNode: dispatchNode + ) + } catch let error as NodeError { + do { + try clearBeforeDispatch(attemptId: attemptId) + } catch { + throw OnchainSendAttemptError.unresolved + } + throw OnchainSendAttemptError.preDispatch(error) + } catch { + throw OnchainSendAttemptError.unresolved + } + + do { + try record(result, attemptId: attemptId) + } catch { + Logger.warn("Could not persist the known on-chain outcome; the durable attempt still blocks another send", context: "OnchainSendAttempt") + } + return result + } + + func admit( + walletId: String, + requestId: PaykitPaymentRequest.ID?, + orderId: String?, + address: String, + amountSats: UInt64, + isMaxAmount: Bool, + followupContext: OnchainSendFollowupContext? = nil + ) throws -> UUID { + if let previous = try currentAttempt() { + guard !previous.blocksNewSend else { + throw OnchainSendAttemptError.unresolved + } + guard !(requestId != nil && previous.requestId == requestId), + !(orderId != nil && previous.orderId == orderId) + else { throw OnchainSendAttemptError.duplicate } + } + if let orderId, try hasPaidOrder(orderId) { + throw OnchainSendAttemptError.duplicate + } + let attempt = OnchainSendAttempt( + id: UUID(), + walletId: walletId, + requestId: requestId, + orderId: orderId, + address: address, + amountSats: amountSats, + isMaxAmount: isMaxAmount, + status: .pending, + followupContext: followupContext + ) + try store.save([attempt]) + knownAttempt = attempt + return attempt.id + } + + func record(_ result: OnchainSendResult, attemptId: UUID) throws { + guard var attempt = try knownAttempt ?? currentAttempt(), attempt.id == attemptId, attempt.status == .pending else { + throw OnchainSendAttemptError.outcomeNotSaved + } + switch result { + case let .accepted(txid): + attempt.status = .accepted + attempt.txid = txid + case let .rejected(txid, reason): + attempt.status = .rejected + attempt.txid = txid + attempt.rejectionReason = reason + case let .unknown(txid): + attempt.status = .unknown + attempt.txid = txid + } + knownAttempt = attempt + guard try store.load().first?.id == attemptId else { throw OnchainSendAttemptError.outcomeNotSaved } + try store.save([attempt]) + } + + private func currentAttempt() throws -> OnchainSendAttempt? { + let attempts = try store.load() + guard attempts.count <= 1 else { throw OnchainSendAttemptError.unresolved } + guard let saved = attempts.first else { return nil } + return knownAttempt?.id == saved.id ? knownAttempt : saved + } + + func acknowledgeLocalFollowup(txid: String) throws { + guard var attempt = try currentAttempt(), attempt.status == .accepted, + attempt.txid?.caseInsensitiveCompare(txid) == .orderedSame + else { return } + attempt.localFollowupComplete = true + try store.save([attempt]) + knownAttempt = attempt + } + + func resumeAcceptedOrdinarySend(walletId: String) async throws -> OnchainSendLocalResolution? { + guard let attempt = try currentAttempt(), attempt.walletId == walletId, + attempt.requestId == nil, attempt.orderId == nil, attempt.status == .accepted, let txid = attempt.txid + else { return nil } + let activity = try await localFollowup.save(attempt) + try acknowledgeLocalFollowup(txid: txid) + let resolution = OnchainSendLocalResolution( + attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: activity.value, contact: activity.contact + ) + Self.localResolutionSubject.send(resolution) + return resolution + } + + func clearBeforeDispatch(attemptId: UUID) throws { + var attempts = try store.load() + guard let index = attempts.firstIndex(where: { $0.id == attemptId && $0.status == .pending }) else { return } + guard knownAttempt?.id != attemptId || knownAttempt?.status == .pending else { return } + attempts.remove(at: index) + try store.save(attempts) + knownAttempt = nil + } + + func acceptedTransactionId(for requestId: PaykitPaymentRequest.ID) throws -> String? { + if let knownAttempt, knownAttempt.requestId == requestId, knownAttempt.status == .accepted { + return knownAttempt.txid + } + return try currentAttempt().flatMap { $0.requestId == requestId && $0.status == .accepted ? $0.txid : nil } + } + + func hasAttempt(for requestId: PaykitPaymentRequest.ID) throws -> Bool { + try currentAttempt()?.requestId == requestId + } + + func unresolvedAttempt(walletId: String) throws -> OnchainSendAttempt? { + try currentAttempt().flatMap { $0.walletId == walletId && $0.blocksNewSend ? $0 : nil } + } + + @discardableResult + func observeConfirmedTransaction(txid: String) throws -> Bool { + guard var attempt = try currentAttempt(), + attempt.txid?.caseInsensitiveCompare(txid) == .orderedSame, + attempt.blocksNewSend + else { return false } + attempt.status = .accepted + knownAttempt = attempt + try store.save([attempt]) + return true + } +} diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 9a42330b4..31cacd48b 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -33,6 +33,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var onchainAmountSats: UInt64? let onchainWalletId: String? var onchainMatchingTransactionIdsBeforeAttempt: Set? + var onchainBroadcastAccepted: Bool? var hasUnsupportedOnchainWallet: Bool { kind == .onchain && onchainWalletId != nil && onchainWalletId != WalletScope.default @@ -50,7 +51,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { onchainAddress: String? = nil, onchainAmountSats: UInt64? = nil, onchainWalletId: String? = nil, - onchainMatchingTransactionIdsBeforeAttempt: Set? = nil + onchainMatchingTransactionIdsBeforeAttempt: Set? = nil, + onchainBroadcastAccepted: Bool? = nil ) { self.identity = identity self.requestId = requestId @@ -64,6 +66,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { self.onchainAmountSats = onchainAmountSats self.onchainWalletId = onchainWalletId self.onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt + self.onchainBroadcastAccepted = onchainBroadcastAccepted } } @@ -139,39 +142,24 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { } } -protocol PaykitOnchainPaymentProofLookingUp: Sendable { - func existingTransactionIds(address: String, amountSats: UInt64) async throws -> Set - func transactionId(address: String, amountSats: UInt64, excluding transactionIds: Set) async throws -> String? -} - -struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp { - func existingTransactionIds(address: String, amountSats: UInt64) async throws -> Set { - try await Set(matchingTransactionIds(address: address, amountSats: amountSats).map { $0.lowercased() }) - } +private actor PaykitProofMutationLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] - func transactionId(address: String, amountSats: UInt64, excluding transactionIds: Set) async throws -> String? { - try await matchingTransactionIds(address: address, amountSats: amountSats) - .reversed() - .first { !transactionIds.contains($0.lowercased()) } - } - - private func matchingTransactionIds(address: String, amountSats: UInt64) async throws -> [String] { - guard let payments = await LightningService.shared.listPayments() else { - throw PaykitPaymentRequestError.requestUnavailable + func withLock(_ operation: () async throws -> T) async rethrows -> T { + if isLocked { + await withCheckedContinuation { waiters.append($0) } + } else { + isLocked = true } - var transactionIds: [String] = [] - for payment in payments { - guard payment.direction == .outbound, - payment.status != .failed, - case let .onchain(txid, _) = payment.kind, - let details = try? await CoreService.shared.activity.getTransactionDetails(txid: txid), - details.outputs.contains(where: { - $0.scriptpubkeyAddress == address && $0.value == amountSats - }) - else { continue } - transactionIds.append(txid) + defer { + if waiters.isEmpty { + isLocked = false + } else { + waiters.removeFirst().resume() + } } - return transactionIds + return try await operation() } } @@ -192,9 +180,10 @@ actor PaykitPaymentProofService { private let sdk: any PaykitPaymentProofSdkHandling private let store: any PaykitPaymentProofStoring private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp - private let onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp + private let attemptService: OnchainSendAttemptService private let logInfo: @Sendable (String) -> Void private let logWarning: @Sendable (String) -> Void + private let mutationLock = PaykitProofMutationLock() func backupSnapshot() async throws -> [PaykitPaymentStateBackup.Proof] { try await store.load().map(PaykitPaymentStateBackup.Proof.init) @@ -213,7 +202,7 @@ actor PaykitPaymentProofService { sdk: any PaykitPaymentProofSdkHandling = PaykitSdkService.shared, store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(), lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), - onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp = PaykitOnchainPaymentProofLookup(), + attemptService: OnchainSendAttemptService = .shared, logInfo: @escaping @Sendable (String) -> Void = { Logger.info($0, context: "PaykitPaymentProof") }, @@ -224,7 +213,7 @@ actor PaykitPaymentProofService { self.sdk = sdk self.store = store self.lightningPaymentLookup = lightningPaymentLookup - self.onchainPaymentLookup = onchainPaymentLookup + self.attemptService = attemptService self.logInfo = logInfo self.logWarning = logWarning } @@ -234,6 +223,22 @@ actor PaykitPaymentProofService { paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind ) async throws { + try await mutationLock.withLock { + try await prepareLocked(request: request, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind) + } + } + + private func prepareLocked( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind + ) async throws { + if try await attemptService.hasAttempt(for: request.id) { + if kind == .onchain, try await attemptService.acceptedTransactionId(for: request.id) != nil { + return + } + throw PaykitPaymentRequestError.operationInProgress + } let proof = try await pendingProof( request: request, paymentEndpointIdentifier: paymentEndpointIdentifier, @@ -275,6 +280,20 @@ actor PaykitPaymentProofService { throw PaykitPaymentRequestError.requestUnavailable } + let records = try await sdk.paymentRequests() + if records.contains(where: { record in + guard record.localRole == .payer, + record.paymentRequestId == request.paymentRequestId, + PubkyPublicKeyFormat.matches(record.counterparty, request.counterparty), + record.counterpartyReceiverPath == request.counterpartyReceiverPath + else { return false } + return (request.billingPeriod == nil && record.state == .proofSubmitted) || record.paymentProofs.contains { + Self.billingPeriod($0.billingPeriod, matches: request.billingPeriod) + } + }) { + throw PaykitPaymentRequestError.operationInProgress + } + return PendingPaykitPaymentProof( identity: identity, requestId: request.id, @@ -287,6 +306,15 @@ actor PaykitPaymentProofService { } func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + try await mutationLock.withLock { + try await associateLightningPaymentLocked(request, paymentHash: paymentHash) + } + } + + private func associateLightningPaymentLocked(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + if try await attemptService.hasAttempt(for: request.id) { + throw PaykitPaymentRequestError.operationInProgress + } guard Self.isHex(paymentHash, byteCount: 32) else { throw PaykitPaymentRequestError.requestUnavailable } @@ -309,11 +337,13 @@ actor PaykitPaymentProofService { } func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws { + try await mutationLock.withLock { + try await markOnchainPaymentStartedLocked(request, address: address) + } + } + + private func markOnchainPaymentStartedLocked(_ request: PaykitPaymentRequest, address: String) async throws { let identity = try await currentIdentity() - let existingTransactionIds = try await onchainPaymentLookup.existingTransactionIds( - address: address, - amountSats: request.amountSats - ) var pendingProofs = try await loadProofs() guard let index = pendingProofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && @@ -329,7 +359,6 @@ actor PaykitPaymentProofService { pendingProofs[index].paymentStarted = true pendingProofs[index].onchainAddress = address pendingProofs[index].onchainAmountSats = request.amountSats - pendingProofs[index].onchainMatchingTransactionIdsBeforeAttempt = existingTransactionIds try await persist(pendingProofs) } @@ -360,38 +389,50 @@ actor PaykitPaymentProofService { } } + @discardableResult func completeOnchainPayment( _ request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String - ) async { - guard let identity = try? await currentIdentity() else { return } - let fallbackProof = try? await pendingProof( - request: request, - paymentEndpointIdentifier: paymentEndpointIdentifier, - kind: .onchain - ) - await completeOnchainPayment( + ) async -> Bool { + guard let identity = try? await currentIdentity() else { return false } + return await completeOnchainPayment( requestId: request.id, identity: identity, - txid: txid, - fallbackProof: fallbackProof + txid: txid ) } private func completeOnchainPayment( requestId: PaykitPaymentRequest.ID, identity: String, - txid: String, - fallbackProof: PendingPaykitPaymentProof? = nil - ) async { + txid: String + ) async -> Bool { + return await mutationLock.withLock { + return await completeOnchainPaymentLocked(requestId: requestId, identity: identity, txid: txid) + } + } + + private func completeOnchainPaymentLocked( + requestId: PaykitPaymentRequest.ID, + identity: String, + txid: String + ) async -> Bool { guard Self.isHex(txid, byteCount: 32) else { logWarning("Ignored a Paykit on-chain proof with an invalid transaction id") - return + return false } do { var pendingProofs = try await loadProofs() + if let completed = pendingProofs.last(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.onchainBroadcastAccepted == true && + $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame + }) { + submitInBackground(completed) + return true + } guard let index = pendingProofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && @@ -400,40 +441,34 @@ actor PaykitPaymentProofService { $0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil - }) else { return } + }) else { + return try await sdk.paymentRequests().contains { record in + record.localRole == .payer && record.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(record.counterparty, requestId.counterparty) && + record.counterpartyReceiverPath == requestId.counterpartyReceiverPath && record.paymentProofs.contains { proof in + proof.billingPeriod.flatMap(PaykitBillingPeriod.init)?.startsAt == requestId.billingPeriodStartsAt && + Self.proofValues(proof.proof.exportText()) == [ + "type": PaykitPaymentProofKind.onchain.rawValue, + "data": txid.lowercased(), + ] + } + } + } pendingProofs[index].paymentIdentifier = txid.lowercased() pendingProofs[index].proofData = txid.lowercased() + pendingProofs[index].onchainBroadcastAccepted = true let completedProof = pendingProofs[index] - let didPersist: Bool - do { - try await persist(pendingProofs) - didPersist = true - } catch { - didPersist = false - logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") - } - if didPersist { - submitInBackground(completedProof) - } else { - persistAndSubmitInBackground(completedProof, allProofs: pendingProofs) - } + try await persist(pendingProofs) + submitInBackground(completedProof) Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( identity: completedProof.identity, requestId: requestId, transactionId: txid.lowercased() )) + return true } catch { - logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)") - guard var fallbackProof else { return } - fallbackProof.paymentStarted = true - fallbackProof.paymentIdentifier = txid.lowercased() - fallbackProof.proofData = txid.lowercased() - submitInBackground(fallbackProof) - Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( - identity: fallbackProof.identity, - requestId: requestId, - transactionId: txid.lowercased() - )) + logWarning("Failed to retain a completed Paykit on-chain proof for retry: \(error)") + return false } } @@ -498,20 +533,17 @@ actor PaykitPaymentProofService { do { guard !proof.hasUnsupportedOnchainWallet else { continue } if proof.proofData != nil { + if proof.kind == .onchain, proof.onchainBroadcastAccepted != true { + continue + } await submit(proof) continue } if proof.kind == .onchain, proof.paymentStarted, - let address = proof.onchainAddress, - let amountSats = proof.onchainAmountSats, - let txid = try await onchainPaymentLookup.transactionId( - address: address, - amountSats: amountSats, - excluding: proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] - ) + let txid = try await attemptService.acceptedTransactionId(for: proof.requestId) { - await completeOnchainPayment(requestId: proof.requestId, identity: proof.identity, txid: txid) + _ = await completeOnchainPayment(requestId: proof.requestId, identity: proof.identity, txid: txid) continue } guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue } @@ -597,7 +629,9 @@ actor PaykitPaymentProofService { func completedRequestProofKindsAwaitingSubmission(identity: String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] { do { return try await loadProofs().reduce(into: [:]) { result, proof in - guard PubkyPublicKeyFormat.matches(proof.identity, identity), proof.proofData != nil else { return } + guard PubkyPublicKeyFormat.matches(proof.identity, identity), proof.proofData != nil, + proof.kind != .onchain || proof.onchainBroadcastAccepted == true + else { return } result[proof.requestId] = proof.kind } } catch { @@ -660,6 +694,7 @@ actor PaykitPaymentProofService { @discardableResult private func submit(_ pendingProof: PendingPaykitPaymentProof) async -> Bool { guard !pendingProof.hasUnsupportedOnchainWallet, let proofData = pendingProof.proofData else { return false } + guard pendingProof.kind != .onchain || pendingProof.onchainBroadcastAccepted == true else { return false } do { guard let identityStatus = try await sdk.identityStatus(), identityStatus.liveSessionAvailable, @@ -771,6 +806,15 @@ actor PaykitPaymentProofService { private func removeRequestProofs( _ request: PaykitPaymentRequest, where shouldRemove: (PendingPaykitPaymentProof) -> Bool + ) async { + await mutationLock.withLock { + await removeRequestProofsLocked(request, where: shouldRemove) + } + } + + private func removeRequestProofsLocked( + _ request: PaykitPaymentRequest, + where shouldRemove: (PendingPaykitPaymentProof) -> Bool ) async { do { let pendingProofs = try await loadProofs() @@ -792,6 +836,12 @@ actor PaykitPaymentProofService { } private func removeProofs(where shouldRemove: (PendingPaykitPaymentProof) -> Bool) async { + await mutationLock.withLock { + await removeProofsLocked(where: shouldRemove) + } + } + + private func removeProofsLocked(where shouldRemove: (PendingPaykitPaymentProof) -> Bool) async { do { let pendingProofs = try await loadProofs() let remainingProofs = pendingProofs.filter { !shouldRemove($0) } @@ -802,28 +852,6 @@ actor PaykitPaymentProofService { } } - static func isDefiniteOnchainPreBroadcastFailure(_ error: Error) -> Bool { - let underlyingError = (error as? AppError)?.underlyingError ?? error - if let serviceError = underlyingError as? CustomServiceError { - switch serviceError { - case .nodeNotSetup, .nodeNotStarted: - return true - default: - return false - } - } - guard let nodeError = underlyingError as? NodeError else { return false } - - switch nodeError { - case .NotRunning, .OnchainTxCreationFailed, .OnchainWalletAccountNotRegistered, - .OnchainTxSigningFailed, .WalletOperationFailed, .PersistenceFailed, .InvalidAddress, .InvalidAmount, .InvalidNetwork, - .InvalidFeeRate, .InsufficientFunds, .CoinSelectionFailed, .NoSpendableOutputs: - return true - default: - return false - } - } - private static func endpoint(_ identifier: String, supports kind: PaykitPaymentProofKind) -> Bool { guard let methodId = PublicPaykitService.MethodId(rawValue: identifier) else { return false } switch kind { diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index 3f24ffbe5..53a0b8ee4 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -38,6 +38,10 @@ class TransferService { txTotalSats: UInt64? = nil, preTransferOnchainSats: UInt64? = nil ) async throws -> String { + if let lspOrderId, let existing = try storage.getAll().first(where: { $0.lspOrderId == lspOrderId }) { + guard existing.fundingTxId == fundingTxId else { throw OnchainSendAttemptError.duplicate } + return existing.id + } // When geoblocked, block transfers to spending that involve LSP (Blocktank) // toSpending with lspOrderId means it's a Blocktank LSP channel order let isGeoblocked = GeoService.shared.isGeoBlocked diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index a8ecffb3b..9b783505a 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -8,6 +8,7 @@ enum KeychainEntryType { case securityPin case paykitSession case paykitPendingPaymentProofs + case onchainSendAttempts case paykitPresentedPaymentRequests case paykitSubscriptionState case paykitReceiverNoiseSecretKey @@ -23,6 +24,7 @@ enum KeychainEntryType { case .securityPin: "security_pin" case .paykitSession: "paykit_session" case .paykitPendingPaymentProofs: "paykit_pending_payment_proofs" + case .onchainSendAttempts: "onchain_send_attempts" case .paykitPresentedPaymentRequests: "paykit_presented_payment_requests" case .paykitSubscriptionState: "paykit_subscription_state" case .paykitReceiverNoiseSecretKey: "paykit_receiver_noise_secret_key" diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 6e3ee6c5b..f10ab0773 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -434,13 +434,30 @@ class TransferViewModel: ObservableObject { // For sendAll (change would be dust), send entire balance // Otherwise, send exact order.feeSat amount - let txid = try await lightningService.send( + let result = try await OnchainSendAttemptService.shared.send( + using: lightningService, address: address, - sats: order.feeSat, + amountSats: order.feeSat, satsPerVbyte: satsPerVbyte, utxosToSpend: utxosToSpend, - isMaxAmount: isMaxAmount + isMaxAmount: isMaxAmount, + orderId: order.id ) + let txid: String + switch result { + case let .accepted(acceptedTxid): + txid = acceptedTxid + case let .rejected(rejectedTxid, reason): + throw AppError( + message: "The funding transaction was rejected. Do not try to fund this order again.", + debugMessage: "Broadcast rejected for \(rejectedTxid): \(reason)" + ) + case let .unknown(unknownTxid): + throw AppError( + message: "The funding transaction may have been sent. Do not try to fund this order again.", + debugMessage: "Broadcast outcome unknown for \(unknownTxid)" + ) + } let txTotalSats = SpendingConfirmTotal.leavingAmount( orderFeeSat: order.feeSat, @@ -464,16 +481,37 @@ class TransferViewModel: ObservableObject { channelId: nil, createdAt: currentTime ) - try? await coreService.activity.addPreActivityMetadata(preActivityMetadata) + let metadataSaved: Bool + do { + try await coreService.activity.addPreActivityMetadata(preActivityMetadata) + metadataSaved = true + } catch { + metadataSaved = false + Logger.warn("Accepted funding metadata could not be saved; retaining the attempt", context: "TransferViewModel") + } - await fundPaidOrder( + let trackingSaved = await fundPaidOrder( order: order, txId: txid, txTotalSats: txTotalSats, preTransferOnchainSats: preTransferOnchainSats ) + if trackingSaved, metadataSaved { + do { + try await OnchainSendAttemptService.shared.acknowledgeLocalFollowup(txid: txid) + } catch { + Logger.warn("Accepted funding follow-up could not be acknowledged; retaining the attempt", context: "TransferViewModel") + } + } + if !trackingSaved { + throw AppError( + message: "Funding was sent, but local order tracking could not be saved. Do not fund this order again.", + debugMessage: "Accepted funding transaction \(txid) for order \(order.id)" + ) + } } + @discardableResult private func fundPaidOrder( order: IBtOrder, txId: String, @@ -483,8 +521,9 @@ class TransferViewModel: ObservableObject { txTotalSats: UInt64? = nil, preTransferOnchainSats: UInt64? = nil, activityWalletId: String = WalletScope.default - ) async { + ) async -> Bool { fundedOrderId = order.id + var trackingSaved = false do { let transferId = try await transferService.createTransfer( type: .toSpending, @@ -495,6 +534,7 @@ class TransferViewModel: ObservableObject { preTransferOnchainSats: preTransferOnchainSats ) Logger.info("Created transfer tracking record: \(transferId)", context: "TransferViewModel") + trackingSaved = true } catch { Logger.error("Failed to create transfer tracking record", context: error.localizedDescription) } @@ -512,6 +552,7 @@ class TransferViewModel: ObservableObject { lightningSetupStep = 0 await onBalanceRefresh?() watchOrder(orderId: order.id) + return trackingSaved } func startWatchingOrderFromRestart(_ order: IBtOrder) async { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1b7b1c4d3..96e56143b 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -115,6 +115,18 @@ class WalletViewModel: ObservableObject { transferService: transferService, coreService: coreService ) + lightningService.onchainTransactionConfirmed = { txid in + do { + if try await OnchainSendAttemptService.shared.observeConfirmedTransaction(txid: txid) { + _ = try await OnchainSendAttemptService.shared.resumeAcceptedOrdinarySend( + walletId: OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex) + ) + await PaykitPaymentProofService.shared.reconcile() + } + } catch { + Logger.error("Failed to retain confirmed transaction evidence for \(txid): \(error)", context: "WalletViewModel") + } + } } /// Convenience initializer for previews and testing @@ -597,14 +609,16 @@ class WalletViewModel: ObservableObject { /// - address: The bitcoin address to send to /// - sats: The amount in satoshis to send /// - isMaxAmount: Whether this is a max amount send (uses sendAllToAddress) - /// - Returns: The transaction ID (txid) of the sent transaction + /// - Returns: The backend's broadcast result and the attempted transaction ID /// - Throws: An error if the transaction fails or if fee rates cannot be retrieved func send( address: String, sats: UInt64, isMaxAmount: Bool = false, + requestId: PaykitPaymentRequest.ID? = nil, + followupContext: OnchainSendFollowupContext? = nil, beforeBroadcastAttempt: () async throws -> Void = {} - ) async throws -> Txid { + ) async throws -> OnchainSendResult { guard let selectedFeeRateSatsPerVByte else { throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.") } @@ -615,21 +629,25 @@ class WalletViewModel: ObservableObject { Logger.warn("No UTXO selected, using default selection algorithm.") } - try await beforeBroadcastAttempt() - let txid = try await lightningService.send( + let result = try await OnchainSendAttemptService.shared.send( + using: lightningService, address: address, - sats: sats, + amountSats: sats, satsPerVbyte: selectedFeeRateSatsPerVByte, utxosToSpend: selectedUtxos, - isMaxAmount: isMaxAmount + isMaxAmount: isMaxAmount, + requestId: requestId, + followupContext: followupContext, + beforeBroadcastAttempt: beforeBroadcastAttempt ) - Task { - // Best to auto sync on chain so we have latest state - try await sync() + if case .accepted = result { + Task { + try await sync() + } } - return txid + return result } /// Sets the fee rate for the send flow diff --git a/Bitkit/Views/Offline/OfflineSheetScreen.swift b/Bitkit/Views/Offline/OfflineSheetScreen.swift index 6531f52e6..093145d8f 100644 --- a/Bitkit/Views/Offline/OfflineSheetScreen.swift +++ b/Bitkit/Views/Offline/OfflineSheetScreen.swift @@ -26,9 +26,10 @@ private struct OfflineSheetOverlayModifier: ViewModifier { let title: String var forceShow = false + var isEnabled = true private var isShowing: Bool { - !network.isConnected || forceShow + isEnabled && (!network.isConnected || forceShow) } func body(content: Content) -> some View { @@ -54,7 +55,7 @@ extension View { /// Overlays a `OfflineSheetScreen` when the device is offline, or whenever `forceShow` is true /// (e.g. connection issues beyond device connectivity, like an unreachable Lightning peer). /// The underlying content remains mounted so navigation state and inputs are preserved. - func offlineSheetOverlay(title: String, forceShow: Bool = false) -> some View { - modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow)) + func offlineSheetOverlay(title: String, forceShow: Bool = false, isEnabled: Bool = true) -> some View { + modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow, isEnabled: isEnabled)) } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 8f774e2e5..5929dc117 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -879,10 +879,16 @@ struct SendConfirmationView: View { } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount) - let txid = try await wallet.send( + let result = try await wallet.send( address: invoice.address, sats: amount, - isMaxAmount: useMaxAmount + isMaxAmount: useMaxAmount, + requestId: incomingPaymentRequest?.id, + followupContext: OnchainSendFollowupContext( + feeSats: UInt64(transactionFee), feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1, + tags: tagManager.selectedTagsArray, contact: contactPublicKey, + createdAt: UInt64(Date().timeIntervalSince1970) + ) ) { if let incomingPaymentRequest { try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( @@ -893,8 +899,38 @@ struct SendConfirmationView: View { } } shouldCancelPaymentProof = false + let txid: String + switch result { + case let .accepted(acceptedTxid): + txid = acceptedTxid + case let .rejected(rejectedTxid, reason): + Logger.warn("On-chain broadcast rejected for \(rejectedTxid): \(reason)", context: "SendConfirmation") + app.toast( + type: .warning, + title: "Broadcast rejected", + description: "This payment may still have reached the network. Do not send it again. \(reason)" + ) + navigationPath.append(.pending( + paymentHash: nil, + retryRoute: .confirm, + paymentRequest: nil, + paykitPaymentRequestId: incomingPaymentRequest?.id + )) + return + case let .unknown(unknownTxid): + Logger.warn("On-chain broadcast outcome unknown for \(unknownTxid)", context: "SendConfirmation") + app.toast(type: .warning, title: "Broadcast unconfirmed", description: "The payment may have been sent. Do not send it again.") + navigationPath.append(.pending( + paymentHash: nil, + retryRoute: .confirm, + paymentRequest: nil, + paykitPaymentRequestId: incomingPaymentRequest?.id + )) + return + } + var proofSaved = true if let incomingPaymentRequest, let preparedPaymentProof { - await PaykitPaymentProofService.shared.completeOnchainPayment( + proofSaved = await PaykitPaymentProofService.shared.completeOnchainPayment( incomingPaymentRequest, txid: txid, paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier @@ -902,10 +938,15 @@ struct SendConfirmationView: View { } // Create pre-activity metadata for tags and activity address - await createPreActivityMetadata(paymentId: txid, address: invoice.address, txId: txid, feeRate: wallet.selectedFeeRateSatsPerVByte) + let metadataSaved = await createPreActivityMetadata( + paymentId: txid, + address: invoice.address, + txId: txid, + feeRate: wallet.selectedFeeRateSatsPerVByte + ) // Create sent onchain activity immediately so it appears before LDK event (which can be delayed) - await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + let activitySaved = await CoreService.shared.activity.createSentOnchainActivityFromSendResult( txid: txid, address: invoice.address, amount: amount, @@ -914,6 +955,14 @@ struct SendConfirmationView: View { contact: contactPublicKey ) + if proofSaved, metadataSaved, activitySaved { + do { + try await OnchainSendAttemptService.shared.acknowledgeLocalFollowup(txid: txid) + } catch { + Logger.warn("Accepted payment follow-up could not be acknowledged; retaining the attempt", context: "SendConfirmation") + } + } + // Set the amount for the success screen wallet.sendAmountSats = amount @@ -932,7 +981,7 @@ struct SendConfirmationView: View { return } catch { if onchainPaymentStarted, let incomingPaymentRequest { - if PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error) { + if let attemptError = error as? OnchainSendAttemptError, case .preDispatch = attemptError { await PaykitPaymentProofService.shared.failOnchainPayment(incomingPaymentRequest) onchainPaymentStarted = false } else { @@ -948,6 +997,22 @@ struct SendConfirmationView: View { return } } + if let attemptError = error as? OnchainSendAttemptError { + switch attemptError { + case .unresolved, .duplicate, .outcomeNotSaved, .localFollowupNotSaved: + shouldCancelPaymentProof = false + app.toast(attemptError) + navigationPath.append(.pending( + paymentHash: nil, + retryRoute: .confirm, + paymentRequest: nil, + paykitPaymentRequestId: incomingPaymentRequest?.id + )) + return + case .preDispatch: + break + } + } if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } @@ -1124,13 +1189,14 @@ struct SendConfirmationView: View { } } + @discardableResult private func createPreActivityMetadata( paymentId: String, paymentHash: String? = nil, address: String? = nil, txId: String? = nil, feeRate: UInt32? = nil - ) async { + ) async -> Bool { let currentTime = UInt64(Date().timeIntervalSince1970) let preActivityMetadata = BitkitCore.PreActivityMetadata( walletId: WalletScope.default, @@ -1145,7 +1211,13 @@ struct SendConfirmationView: View { channelId: nil, createdAt: currentTime ) - try? await CoreService.shared.activity.addPreActivityMetadata(preActivityMetadata) + do { + try await CoreService.shared.activity.addPreActivityMetadata(preActivityMetadata) + return true + } catch { + Logger.warn("Payment metadata could not be saved", context: "SendConfirmation") + return false + } } private func navigateToManual(with value: String) { diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 26025b59a..eb00436ba 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -39,6 +39,10 @@ struct SendPendingScreen: View { @EnvironmentObject private var wallet: WalletViewModel @State private var foundActivity: Activity? + @State private var onchainAttempt: OnchainSendAttempt? + @State private var onchainStateUnavailable = false + @State private var ordinarySendResolved = false + @State private var localFollowupUnavailable = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -49,7 +53,15 @@ struct SendPendingScreen: View { .padding(.bottom, 32) } - BodyMText(t("wallet__send_pending_note")) + if paymentHash == nil { + BodyMText(onchainPendingMessage) + if let txid = onchainAttempt?.txid { + BodySSBText("Transaction ID: \(txid)") + .textSelection(.enabled) + } + } else { + BodyMText(t("wallet__send_pending_note")) + } Spacer() @@ -80,12 +92,35 @@ struct SendPendingScreen: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .task { + if paymentHash == nil { + do { + onchainAttempt = try await OnchainSendAttemptService.shared.unresolvedAttempt( + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) + ) + } catch { + onchainStateUnavailable = true + } + if paykitPaymentRequestId == nil { + do { + if let resolution = try await OnchainSendAttemptService.shared.resumeAcceptedOrdinarySend( + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) + ) { + applyOrdinarySendResolution(resolution) + } + } catch { + localFollowupUnavailable = true + } + } + } applyPendingResolutionIfNeeded(app.sendSheetPendingResolution) await searchForActivity() } .onChange(of: app.sendSheetPendingResolution) { _, resolution in applyPendingResolutionIfNeeded(resolution) } + .onReceive(OnchainSendAttemptService.localResolutionPublisher) { resolution in + applyOrdinarySendResolution(resolution) + } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in guard resolution.requestId == paykitPaymentRequestId, let identity = pubkyProfile.publicKey, @@ -102,6 +137,37 @@ struct SendPendingScreen: View { } } + private var onchainPendingMessage: String { + if onchainStateUnavailable { + return "The on-chain payment state could not be read. Do not send another payment until it is checked." + } + switch onchainAttempt?.status { + case .rejected: + return "The backend rejected this transaction. It may still have reached the network. Do not send it again. \(onchainAttempt?.rejectionReason ?? "")" + case .unknown, .pending: + return "This transaction may have been sent. Its outcome is unknown. Do not send it again." + case .accepted: + if localFollowupUnavailable { + return "This payment was sent, but its local details could not be restored. Do not send this payment again." + } + return "This payment was sent. Local follow-up is still pending. Do not send this payment again." + case .none: + return t("wallet__send_pending_note") + } + } + + private func applyOrdinarySendResolution(_ resolution: OnchainSendLocalResolution) { + guard paymentHash == nil, paykitPaymentRequestId == nil, !ordinarySendResolved, + resolution.walletId == OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex), + onchainAttempt == nil || onchainAttempt?.id == resolution.attemptId + else { return } + ordinarySendResolved = true + wallet.sendAmountSats = resolution.amountSats + app.selectedWalletToPayFrom = .onchain + app.contactPaymentContext = resolution.contact.map { ContactPaymentContext(publicKey: $0) } + navigationPath.append(.success(paymentId: resolution.txid)) + } + private func applyPendingResolutionIfNeeded(_ resolution: SendSheetPendingResolution?) { guard let paymentHash, let resolution, resolution.paymentHash == paymentHash else { return } app.consumeSendSheetPendingResolution(paymentHash: paymentHash) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 33ba38291..9dffc92d1 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -121,6 +121,7 @@ struct SendSheet: View { @State private var pendingEmbeddedRetryRoute: SendRoute? @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false + @State private var isResumingAcceptedOrdinarySend = false @State private var pinCheckContinuations: [CheckedContinuation] = [] @State private var hwSend: HwSendCoordinator @State private var setupTask: Task? @@ -144,7 +145,7 @@ struct SendSheet: View { /// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue. /// For onchain: only need node running. private var shouldShowSyncOverlay: Bool { - if hwSend.isActive { + if hwSend.isActive || isResumingAcceptedOrdinarySend { return false } @@ -201,7 +202,9 @@ struct SendSheet: View { .sheet(isPresented: reconnectPairingBinding) { HardwarePairingSheet(config: HardwarePairingSheetItem()) } - .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut) + .offlineSheetOverlay( + title: t("wallet__send_bitcoin"), forceShow: syncTimedOut, isEnabled: !isResumingAcceptedOrdinarySend + ) .onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } @@ -249,6 +252,24 @@ struct SendSheet: View { setupTask?.cancel() setupTask = Task { + if incomingPaymentRequest == nil, config.hardwareWalletId == nil, + currentRoot == .options || app.selectedWalletToPayFrom == .onchain + { + do { + if let attempt = try await OnchainSendAttemptService.shared.unresolvedAttempt( + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) + ), attempt.status == .accepted, attempt.requestId == nil, attempt.orderId == nil { + isResumingAcceptedOrdinarySend = true + hasValidatedAfterSync = true + app.selectedWalletToPayFrom = .onchain + wallet.sendAmountSats = attempt.amountSats + replaceRootRoute(with: .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil)) + return + } + } catch { + Logger.warn("Could not inspect retained on-chain follow-up", context: "SendSheet") + } + } do { try await wallet.setFeeRate(speed: settings.defaultTransactionSpeed) } catch is CancellationError { @@ -444,6 +465,7 @@ struct SendSheet: View { /// For onchain: validates balance and shows error if insufficient /// Pass `ignoreChannelWait: true` to validate even while channels are unusable (sync timeout). private func validatePaymentAfterSync(ignoreChannelWait: Bool = false) { + guard !isResumingAcceptedOrdinarySend else { return } let result = performPaymentValidationAfterSync(ignoreChannelWait: ignoreChannelWait) guard let route = pendingEmbeddedRetryRoute else { return } @@ -822,7 +844,6 @@ struct SendSheet: View { private func cancelHardwareContactPayment() async { guard let request = app.contactPaymentContext?.incomingPaymentRequest else { return } - await PaykitPaymentProofService.shared.failOnchainPayment(request) await PaykitPaymentProofService.shared.cancelPreparation(request) } diff --git a/BitkitTests/ChannelPurchaseFlow.swift b/BitkitTests/ChannelPurchaseFlow.swift index 75eb18bbc..a2667f87a 100644 --- a/BitkitTests/ChannelPurchaseFlow.swift +++ b/BitkitTests/ChannelPurchaseFlow.swift @@ -178,7 +178,11 @@ final class PaymentFlowTests: XCTestCase { // Pay the order Logger.test("Paying order with fee \(order.feeSat) sats to address \(order.payment?.onchain?.address ?? "")", context: "PaymentFlowTests") - let paymentTxId = try await lightning.send(address: order.payment?.onchain?.address ?? "", sats: order.feeSat, satsPerVbyte: 1) + let paymentResult = try await lightning.send(address: order.payment?.onchain?.address ?? "", sats: order.feeSat, satsPerVbyte: 1) + guard case let .accepted(paymentTxId) = paymentResult else { + XCTFail("Order funding broadcast was not accepted: \(paymentResult)") + return + } XCTAssertFalse(paymentTxId.isEmpty, "Payment transaction ID should not be empty") Logger.test("Payment sent with transaction ID: \(paymentTxId)", context: "PaymentFlowTests") diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift new file mode 100644 index 000000000..e18b4b140 --- /dev/null +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -0,0 +1,484 @@ +@testable import Bitkit +import LDKNode +import XCTest + +final class OnchainSendAttemptServiceTests: XCTestCase { + private let walletId = "node-0" + private let txid = String(repeating: "ab", count: 32) + + func testConcurrentAdmissionPersistsOnlyOnePendingAttempt() async { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let walletId = walletId + + let admitted = await withTaskGroup(of: UUID?.self) { group in + for _ in 0 ..< 20 { + group.addTask { + try? await service.admit( + walletId: walletId, + requestId: nil, + orderId: nil, + address: "bcrt1qexample", + amountSats: 1000, + isMaxAmount: false + ) + } + } + var ids: [UUID] = [] + for await id in group { + if let id { + ids.append(id) + } + } + return ids + } + + XCTAssertEqual(admitted.count, 1) + XCTAssertEqual(store.snapshot().count, 1) + XCTAssertEqual(store.snapshot().first?.status, .pending) + } + + func testReadAndWriteFailuresPreventAdmission() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + store.failLoad = true + do { + _ = try await admit(service) + XCTFail("Read failure admitted a send") + } catch {} + + store.failLoad = false + store.failSave = true + do { + _ = try await admit(service) + XCTFail("Write failure admitted a send") + } catch {} + XCTAssertTrue(store.snapshot().isEmpty) + } + + func testPendingWithoutTxidStillBlocksAfterServiceRestart() async throws { + let store = MemoryAttemptStore() + _ = try await admit(OnchainSendAttemptService(store: store)) + let restarted = OnchainSendAttemptService(store: store) + + do { + _ = try await admit(restarted) + XCTFail("A crash gap without a txid admitted another send") + } catch let error as OnchainSendAttemptError { + guard case .unresolved = error else { return XCTFail("Expected an unresolved-attempt guard") } + } + XCTAssertNil(store.snapshot().first?.txid) + } + + func testRejectedAndUnknownRetainTxidAndBlockAnotherSend() async throws { + for result in [OnchainSendResult.rejected(txid: txid, reason: "non-final"), .unknown(txid: txid)] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let id = try await admit(service) + try await service.record(result, attemptId: id) + XCTAssertEqual(store.snapshot().first?.txid, txid) + do { + _ = try await admit(service) + XCTFail("Unresolved outcome admitted a second send") + } catch {} + } + } + + func testAcceptedIsDurableAndAllowsDistinctNewSend() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let firstId = try await admit(service) + try await service.record(.accepted(txid: txid), attemptId: firstId) + try await service.acknowledgeLocalFollowup(txid: txid) + let nextId = try await admit(service) + + XCTAssertNotEqual(firstId, nextId) + XCTAssertEqual(store.snapshot().count, 1) + XCTAssertEqual(store.snapshot().first?.status, .pending) + } + + func testAcceptedOrderCannotBeFundedAgain() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let id = try await service.admit( + walletId: walletId, requestId: nil, orderId: "order-1", + address: "bcrt1qexample", amountSats: 1000, isMaxAmount: false + ) + try await service.record(.accepted(txid: txid), attemptId: id) + try await service.acknowledgeLocalFollowup(txid: txid) + + do { + _ = try await service.admit( + walletId: walletId, requestId: nil, orderId: "order-1", + address: "bcrt1qexample", amountSats: 1000, isMaxAmount: false + ) + XCTFail("Accepted order admitted a second funding payment") + } catch let error as OnchainSendAttemptError { + guard case .duplicate = error else { return XCTFail("Expected a duplicate-payment guard") } + } + } + + func testOutcomeWriteFailureRetainsPendingGuard() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let id = try await admit(service) + store.failSave = true + do { + try await service.record(.accepted(txid: txid), attemptId: id) + XCTFail("Outcome write unexpectedly succeeded") + } catch {} + XCTAssertEqual(store.snapshot().first?.status, .pending) + } + + func testOnlyMatchingPreDispatchGuardCanBeCleared() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let id = try await admit(service) + + try await service.clearBeforeDispatch(attemptId: UUID()) + XCTAssertEqual(store.snapshot().first?.id, id) + + try await service.clearBeforeDispatch(attemptId: id) + XCTAssertTrue(store.snapshot().isEmpty) + + let acceptedId = try await admit(service) + try await service.record(.accepted(txid: txid), attemptId: acceptedId) + try await service.clearBeforeDispatch(attemptId: acceptedId) + XCTAssertEqual(store.snapshot().first?.status, .accepted) + } + + func testOnlyExactConfirmedTxidResolvesUnknownAttempt() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let id = try await admit(service) + try await service.record(.unknown(txid: txid), attemptId: id) + + let unrelatedObserved = try await service.observeConfirmedTransaction(txid: String(repeating: "cd", count: 32)) + XCTAssertFalse(unrelatedObserved) + XCTAssertEqual(store.snapshot().first?.status, .unknown) + let exactObserved = try await service.observeConfirmedTransaction(txid: txid) + XCTAssertTrue(exactObserved) + XCTAssertEqual(store.snapshot().first?.status, .accepted) + } + + func testSendPreservesAcceptedWhenOutcomeStorageFailsAndRestartBlocksDispatch() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + do { + let result = try await service.send( + using: node, address: "bcrt1qexample", amountSats: 1000, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false + ) { + store.failSave = true + } + guard case let .accepted(returnedTxid) = result else { return XCTFail("Lost known acceptance") } + XCTAssertEqual(returnedTxid, txid) + } catch { + XCTFail("Known Accepted result became a generic error: \(error)") + } + XCTAssertEqual(node.calls, 1) + XCTAssertEqual(store.snapshot().first?.status, .pending) + XCTAssertNil(store.snapshot().first?.txid) + store.failSave = false + let restarted = OnchainSendAttemptService(store: store) + do { + _ = try await restarted.send( + using: node, address: "bcrt1qother", amountSats: 2000, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: true + ) + XCTFail("Restart bypassed pending guard") + } catch {} + XCTAssertEqual(node.calls, 1) + } + + func testCompletedOrdinarySendsDoNotAccumulateHistory() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + for _ in 0 ..< 50 { + let id = try await admit(service) + try await service.record(.accepted(txid: txid), attemptId: id) + try await service.acknowledgeLocalFollowup(txid: txid) + XCTAssertEqual(store.snapshot().count, 1, "Attempt guard grew into ordinary send history") + } + _ = try await admit(service) + XCTAssertEqual(store.snapshot().count, 1) + } + + func testCompletedOrdinaryGuardCanBeReplacedAcrossWallets() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let id = try await admit(service) + try await service.record(.accepted(txid: txid), attemptId: id) + do { + _ = try await service.admit( + walletId: "node-1", requestId: nil, orderId: nil, + address: "bcrt1qother", amountSats: 2000, isMaxAmount: false + ) + XCTFail("Incomplete accepted follow-up stopped guarding other wallets") + } catch {} + try await service.acknowledgeLocalFollowup(txid: txid) + _ = try await service.admit( + walletId: "node-1", requestId: nil, orderId: nil, + address: "bcrt1qother", amountSats: 2000, isMaxAmount: false + ) + XCTAssertEqual(store.snapshot().count, 1) + XCTAssertEqual(store.snapshot().first?.walletId, "node-1") + XCTAssertEqual(store.snapshot().first?.status, .pending) + } + + func testWalletOrNodeChangeDuringCallbackDoesNotDispatch() async throws { + for changeWalletIndex in [true, false] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + do { + _ = try await service.send( + using: node, address: "bcrt1qexample", amountSats: 1000, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false + ) { + if changeWalletIndex { + node.currentWalletIndex = 1 + } else { + node.dispatchNode = NSObject() + } + } + XCTFail("Dispatch used a different wallet or node after admission") + } catch let error as OnchainSendAttemptError { + guard case .preDispatch = error else { return XCTFail("Expected known pre-dispatch failure") } + } + XCTAssertEqual(node.calls, 0) + XCTAssertTrue(store.snapshot().isEmpty, "Known pre-dispatch failure retained an attempt") + } + } + + func testAcceptedBlocksNewSendUntilMatchingDurableFollowup() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let id = try await admit(service) + try await service.record(.accepted(txid: txid), attemptId: id) + try await service.acknowledgeLocalFollowup(txid: String(repeating: "cd", count: 32)) + do { _ = try await admit(service); XCTFail("Accepted without follow-up admitted new send") } catch {} + store.failSave = true + do { try await service.acknowledgeLocalFollowup(txid: txid); XCTFail("Follow-up save should fail") } catch {} + store.failSave = false + let restarted = OnchainSendAttemptService(store: store) + do { _ = try await admit(restarted); XCTFail("Restart lost incomplete follow-up") } catch {} + XCTAssertEqual(store.snapshot().first?.status, .accepted) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) + try await restarted.acknowledgeLocalFollowup(txid: txid) + _ = try await admit(restarted) + XCTAssertEqual(store.snapshot().count, 1) + } + + func testAcceptedOrderResumesWithoutAnotherNodeCallAfterRestart() async throws { + let store = MemoryAttemptStore() + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let service = OnchainSendAttemptService(store: store) + _ = try await send(service, node: node, orderId: "order-1") + let restarted = OnchainSendAttemptService(store: store) + let result = try await send(restarted, node: node, orderId: "order-1", isMaxAmount: true) + guard case let .accepted(savedTxid) = result else { return XCTFail("Lost accepted prior result") } + XCTAssertEqual(savedTxid, txid) + XCTAssertEqual(node.calls, 1) + } + + func testPaidOrderStoreProtectsOlderOrderAfterCurrentAttemptIsReplaced() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store, hasPaidOrder: { $0 == "paid-order" }) + let id = try await admit(service) + try await service.record(.accepted(txid: txid), attemptId: id) + try await service.acknowledgeLocalFollowup(txid: txid) + _ = try await admit(service) + let node = AttemptNodeMock(result: .unknown(txid: txid)) + do { + _ = try await send(service, node: node, orderId: "paid-order") + XCTFail("Older paid order was dispatched again") + } catch let error as OnchainSendAttemptError { + guard case .duplicate = error else { return XCTFail("Expected paid-order guard") } + } + XCTAssertEqual(node.calls, 0) + XCTAssertEqual(store.snapshot().first?.status, .pending) + } + + func testActualSendSerializesFixedMaxAndTransferBeforeNodeDispatch() async { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: .unknown(txid: txid)) + node.onSend = { + XCTAssertEqual(store.snapshot().first?.status, .pending) + await Task.yield() + } + let results = await withTaskGroup(of: Bool.self) { group in + for index in 0 ..< 20 { + group.addTask { + do { + _ = try await service.send( + using: node, address: "bcrt1qexample", amountSats: 1000, satsPerVbyte: 1, + utxosToSpend: nil, isMaxAmount: index % 2 == 0, orderId: index % 3 == 0 ? "order" : nil + ) + return true + } catch { return false } + } + } + var count = 0 + for await sent in group { + if sent { + count += 1 + } + } + return count + } + XCTAssertEqual(results, 1) + XCTAssertEqual(node.calls, 1) + XCTAssertEqual(store.snapshot().count, 1) + } + + func testCallbackNodeErrorAndCancellationRetainGuardWithoutDispatch() async throws { + for error in [NodeError.NotRunning(message: "callback failure") as Error, CancellationError()] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + do { + _ = try await service.send( + using: node, address: "bcrt1qexample", amountSats: 1000, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false + ) { throw error } + XCTFail("Callback failure succeeded") + } catch let error as OnchainSendAttemptError { + guard case .unresolved = error else { return XCTFail("Callback error cleared preguard") } + } + XCTAssertEqual(node.calls, 0) + XCTAssertEqual(store.snapshot().first?.status, .pending) + } + } + + func testOnlyNodePreDispatchErrorReleasesGuardAndSaveFailureRetainsIt() async throws { + for failRelease in [false, true] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + node.error = NodeError.NotRunning(message: "not running") + node.onSend = { store.failSave = failRelease } + do { _ = try await send(service, node: node); XCTFail("Node error succeeded") } catch let error as OnchainSendAttemptError { + if failRelease { + guard case .unresolved = error else { return XCTFail("Release write failure lost guard") } + } else { + guard case .preDispatch = error else { return XCTFail("Proven pre-dispatch error not released") } + } + } + XCTAssertEqual(store.snapshot().isEmpty, !failRelease) + } + } + + func testGenericNodeWorkflowErrorsRetainGuard() async throws { + for error in [MemoryAttemptStoreError.failed as Error, CancellationError()] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + node.error = error + do { _ = try await send(service, node: node); XCTFail("Node workflow error succeeded") } catch {} + XCTAssertEqual(store.snapshot().first?.status, .pending) + } + } + + func testRejectedAndUnknownArePreservedWhenSavingTheirOutcomesFails() async throws { + for result in [OnchainSendResult.rejected(txid: txid, reason: "non-final"), .unknown(txid: txid)] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: result) + node.onSend = { store.failSave = true } + let returned = try await send(service, node: node, isMaxAmount: true) + XCTAssertEqual(returned, result) + store.failSave = false + let current = try await service.unresolvedAttempt(walletId: OnchainSendAttemptService.walletId(index: 0)) + XCTAssertEqual(current?.txid, txid) + XCTAssertEqual(store.snapshot().first?.status, .pending) + do { _ = try await send(service, node: node); XCTFail("In-memory outcome allowed new send") } catch {} + XCTAssertEqual(node.calls, 1) + } + } + + private func send( + _ service: OnchainSendAttemptService, node: AttemptNodeMock, orderId: String? = nil, isMaxAmount: Bool = false + ) async throws -> OnchainSendResult { + try await service.send( + using: node, address: "bcrt1qexample", amountSats: 1000, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: isMaxAmount, orderId: orderId + ) + } + + private func admit(_ service: OnchainSendAttemptService) async throws -> UUID { + try await service.admit( + walletId: walletId, + requestId: nil, + orderId: nil, + address: "bcrt1qexample", + amountSats: 1000, + isMaxAmount: false + ) + } +} + +final class MemoryAttemptStore: OnchainSendAttemptStoring, @unchecked Sendable { + private let lock = NSLock() + private var attempts: [OnchainSendAttempt] = [] + var failLoad = false + var failSave = false + + func load() throws -> [OnchainSendAttempt] { + lock.lock() + defer { lock.unlock() } + if failLoad { + throw MemoryAttemptStoreError.failed + } + return attempts + } + + func save(_ attempts: [OnchainSendAttempt]) throws { + lock.lock() + defer { lock.unlock() } + if failSave { + throw MemoryAttemptStoreError.failed + } + self.attempts = attempts + } + + func snapshot() -> [OnchainSendAttempt] { + lock.lock() + defer { lock.unlock() } + return attempts + } +} + +private enum MemoryAttemptStoreError: Error { + case failed +} + +final class AttemptNodeMock: OnchainSending { + var currentWalletIndex = 0 + var dispatchNode: AnyObject = NSObject() + var onchainDispatchNode: AnyObject? { + dispatchNode + } + + var calls = 0 + var result: OnchainSendResult + var error: Error? + var onSend: (() async throws -> Void)? + + init(result: OnchainSendResult) { + self.result = result + } + + func send(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]?, + isMaxAmount: Bool, expectedWalletIndex: Int?, expectedNode: AnyObject?) async throws -> OnchainSendResult + { + calls += 1 + try await onSend?() + if let error { + throw error + } + return result + } +} diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 6d30e47a4..1ebfcead1 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -131,13 +131,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) - try await service.prepare( - request: request, + await store.seed([PendingPaykitPaymentProof( + identity: identity, requestId: request.id, paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, - kind: .lightning - ) - try await service.associateLightningPayment(request, paymentHash: paymentHash) - await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + kind: .lightning, paymentStarted: true, paymentIdentifier: paymentHash, proofData: preimage + )]) + await service.reconcile() let submissionCount = await sdk.submissionCount() let remainingProofs = await store.snapshot() @@ -411,6 +410,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let persistedProof = await store.snapshot().first XCTAssertEqual(persistedProof?.paymentIdentifier, txid) XCTAssertEqual(persistedProof?.proofData, txid) + XCTAssertEqual(persistedProof?.onchainBroadcastAccepted, true) await sdk.resumeSubmission() await completionTask.value @@ -432,74 +432,317 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(proof.paymentStarted) } - func testUncertainOnchainPaymentReconcilesFromPrivateDestination() async throws { + func testUncertainOnchainPaymentDoesNotReconcileByDestinationAndAmount() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord( endpoints: [endpoint], paymentRequestId: "550e8400-e29b-41d4-a716-446655440099" ) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) - let proofRemoved = expectation(description: "On-chain proof removed after submission") - let store = PaymentProofMemoryStore { proofs in - if proofs.isEmpty { - proofRemoved.fulfill() - } - } + let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.reconcile() + let submissionCount = await sdk.submissionCount() + let remainingProofs = await store.snapshot() + XCTAssertEqual(submissionCount, 0) + XCTAssertEqual(remainingProofs.first?.paymentStarted, true) + XCTAssertNil(remainingProofs.first?.proofData) + } + + func testUncertainOnchainPaymentDoesNotReuseTransactionFromBeforeAttempt() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.reconcile() + + let submissionCount = await sdk.submissionCount() + let storedProofs = await store.snapshot() + let storedProof = try XCTUnwrap(storedProofs.first) + XCTAssertEqual(submissionCount, 0) + XCTAssertNil(storedProof.proofData) + XCTAssertNil(storedProof.onchainMatchingTransactionIdsBeforeAttempt) + } + + func testAcceptedAttemptReconcilesExactTransactionId() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let attempts = OnchainSendAttemptService(store: MemoryAttemptStore()) + let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) let txid = String(repeating: "ab", count: 32) - let service = paymentProofService(sdk: sdk, store: store, onchainTxids: [txid]) - let resolutionExpectation = expectation(description: "On-chain payment resolution published") - let resolution = PaykitPaymentProofService.onchainPaymentResolutionPublisher - .filter { $0.requestId == request.id } - .first() - .sink { - XCTAssertEqual($0.identity, self.identity) - XCTAssertEqual($0.transactionId, txid) - resolutionExpectation.fulfill() - } try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) + let attemptId = try await attempts.admit( + walletId: "node-0", requestId: request.id, orderId: nil, + address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false + ) + try await attempts.record(.accepted(txid: txid), attemptId: attemptId) await service.reconcile() await sdk.waitForSubmissionStart() - await fulfillment(of: [resolutionExpectation, proofRemoved], timeout: 1) - withExtendedLifetime(resolution) {} let submittedProof = await sdk.lastSubmission() let submission = try XCTUnwrap(submittedProof) - XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) XCTAssertEqual( try proofValues(submission.proof.exportText()), ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] ) - let remainingProofs = await store.snapshot() - XCTAssertTrue(remainingProofs.isEmpty) } - func testUncertainOnchainPaymentDoesNotReuseTransactionFromBeforeAttempt() async throws { + func testAcceptedResultStorageFailureStillCompletesProofWithExactTxid() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let attemptStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptStore) + let proofStore = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: proofStore, attemptService: attempts) + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + _ = try await attempts.send( + using: node, address: onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, requestId: request.id + ) { + try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) + attemptStore.failSave = true + } + await service.reconcile() + await sdk.waitForSubmissionStart() + let submitted = await sdk.lastSubmission() + XCTAssertEqual(try proofValues(XCTUnwrap(submitted).proof.exportText()), ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue]) + XCTAssertEqual(node.calls, 1) + XCTAssertEqual(attemptStore.snapshot().first?.status, .pending) + XCTAssertNil(attemptStore.snapshot().first?.txid) + attemptStore.failSave = false + let restarted = OnchainSendAttemptService(store: attemptStore) + let knownAfterRestart = try await restarted.acceptedTransactionId(for: request.id) + XCTAssertNil(knownAfterRestart) + do { + _ = try await restarted.send( + using: node, address: onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, requestId: request.id + ) + XCTFail("Restart repaid accepted request after result save failure") + } catch {} + XCTAssertEqual(node.calls, 1) + } + + func testFailedProofPersistenceRetainsAcceptedPriorResultForLocalRetry() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let attemptStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptStore) let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) - let oldTransactionId = String(repeating: "ab", count: 32) + let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + _ = try await attempts.send( + using: node, address: onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: true, requestId: request.id + ) { try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) } + await store.failNextSave() + let saved = await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + XCTAssertFalse(saved) + XCTAssertEqual(attemptStore.snapshot().first?.status, .accepted) + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + let resumed = try await attempts.send( + using: node, address: onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: true, requestId: request.id + ) + XCTAssertEqual(resumed, .accepted(txid: txid)) + let retrySaved = await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + XCTAssertTrue(retrySaved) + XCTAssertEqual(node.calls, 1) + } + + func testOlderPaidRequestCannotSwitchMethodAfterGuardIsReplaced() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let txid = String(repeating: "ab", count: 32) + let remoteProof = try paymentProofRecord(endpoint: endpoint, kind: .onchain, data: txid) + let paidRecord = try paymentRequestRecord( + endpoints: [endpoint, PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], + paymentProofs: [remoteProof], + state: .proofSubmitted + ) + let request = try XCTUnwrap(PaykitPaymentRequest( + record: paymentRequestRecord(endpoints: [endpoint, PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue]), + now: Date() + )) + let attemptStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptStore) + let id = try await attempts.admit( + walletId: "node-0", + requestId: request.id, + orderId: nil, + address: onchainAddress, + amountSats: request.amountSats, + isMaxAmount: false + ) + try await attempts.record(.accepted(txid: txid), attemptId: id) + try await attempts.acknowledgeLocalFollowup(txid: txid) + _ = try await attempts.admit(walletId: "node-0", requestId: nil, orderId: nil, address: onchainAddress, amountSats: 1000, isMaxAmount: false) let service = paymentProofService( - sdk: sdk, - store: store, - onchainTxids: [oldTransactionId], - existingOnchainTxids: [oldTransactionId] + sdk: PaymentProofSdkMock(identity: identity, records: [paidRecord]), + store: PaymentProofMemoryStore(), + attemptService: attempts ) + do { + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + XCTFail("Older paid request bypassed existing SDK record") + } catch let error as PaykitPaymentRequestError { + XCTAssertEqual(error, .operationInProgress) + } + XCTAssertEqual(attemptStore.snapshot().count, 1) + } + + func testUndecodableProofStoreIsPreservedAndBlocksDispatch() async throws { + guard Env.isUnitTest else { throw XCTSkip("Requires isolated unit-test Keychain namespace") } + let key = KeychainEntryType.paykitPendingPaymentProofs + let original = try Keychain.load(key: key) + defer { + if let original { + try? Keychain.upsert(key: key, data: original) + } else { + try? Keychain.delete(key: key) + } + } + let corrupt = Data("{broken proof state".utf8) + try Keychain.upsert(key: key, data: corrupt) + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let attempts = OnchainSendAttemptService(store: MemoryAttemptStore()) + let service = PaykitPaymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), + store: PaykitPaymentProofStore(), + attemptService: attempts, + logInfo: { _ in }, + logWarning: { _ in } + ) + do { _ = try await PaykitPaymentProofStore().load(); XCTFail("Corrupt proofs decoded as empty") } catch {} + do { + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + XCTFail("Corrupt proof state allowed preparation") + } catch {} + let node = AttemptNodeMock(result: .accepted(txid: String(repeating: "ab", count: 32))) + do { + _ = try await attempts.send( + using: node, + address: onchainAddress, + amountSats: 1000, + satsPerVbyte: 1, + utxosToSpend: nil, + isMaxAmount: false, + requestId: request.id + ) { + try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) + } + XCTFail("Corrupt proof state allowed dispatch") + } catch {} + XCTAssertEqual(node.calls, 0) + XCTAssertEqual(try Keychain.load(key: key), corrupt) + } + + func testLegacyOnchainTxidWithoutAcceptanceEvidenceIsNotSubmitted() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let txid = String(repeating: "ab", count: 32) + let legacyProof = PendingPaykitPaymentProof( + identity: identity, + requestId: request.id, + paymentEndpointIdentifier: endpoint, + kind: .onchain, + paymentStarted: true, + paymentIdentifier: txid, + proofData: txid, + onchainAddress: onchainAddress, + onchainAmountSats: request.amountSats, + onchainMatchingTransactionIdsBeforeAttempt: [] + ) + let store = PaymentProofMemoryStore() + await store.seed([legacyProof]) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) - try await service.markOnchainPaymentStarted(request, address: onchainAddress) await service.reconcile() let submissionCount = await sdk.submissionCount() let storedProofs = await store.snapshot() - let storedProof = try XCTUnwrap(storedProofs.first) XCTAssertEqual(submissionCount, 0) - XCTAssertNil(storedProof.proofData) - XCTAssertEqual(storedProof.onchainMatchingTransactionIdsBeforeAttempt, [oldTransactionId]) + XCTAssertEqual(storedProofs, [legacyProof]) + let completedKinds = await service.completedRequestProofKindsAwaitingSubmission(identity: identity) + XCTAssertTrue(completedKinds.isEmpty) + } + + func testPendingOnchainAttemptBlocksLightningMethodSwitch() async throws { + let onchainEndpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let lightningEndpoint = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + let record = try paymentRequestRecord(endpoints: [onchainEndpoint, lightningEndpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let attempts = OnchainSendAttemptService(store: MemoryAttemptStore()) + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), + store: PaymentProofMemoryStore(), + attemptService: attempts + ) + + try await service.prepare(request: request, paymentEndpointIdentifier: onchainEndpoint, kind: .onchain) + _ = try await attempts.admit( + walletId: "node-0", requestId: request.id, orderId: nil, + address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false + ) + do { + try await service.prepare(request: request, paymentEndpointIdentifier: lightningEndpoint, kind: .lightning) + XCTFail("Lightning method switch bypassed the on-chain attempt") + } catch let error as PaykitPaymentRequestError { + XCTAssertEqual(error, .operationInProgress) + } + } + + func testAttemptReadFailureBlocksShopPreparation() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let attemptStore = MemoryAttemptStore() + attemptStore.failLoad = true + let proofStore = PaymentProofMemoryStore() + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), + store: proofStore, + attemptService: OnchainSendAttemptService(store: attemptStore) + ) + + do { + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + XCTFail("Shop preparation continued after an attempt-store read failure") + } catch {} + let proofs = await proofStore.snapshot() + XCTAssertTrue(proofs.isEmpty) } func testForeignWalletOnchainProofStaysInertAndBlocksDuplicatePayment() async throws { @@ -522,11 +765,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let store = PaymentProofMemoryStore() await store.seed([proof]) let sdk = PaymentProofSdkMock(identity: identity, records: [record]) - let service = paymentProofService( - sdk: sdk, - store: store, - onchainTxids: [String(repeating: "ab", count: 32)] - ) + let service = paymentProofService(sdk: sdk, store: store) await service.reconcile() await service.completeOnchainPayment( @@ -578,7 +817,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let store = PaymentProofMemoryStore() await store.seed([proof]) let sdk = PaymentProofSdkMock(identity: identity, records: [settledRecord]) - let service = paymentProofService(sdk: sdk, store: store, onchainLookupFails: true) + let service = paymentProofService(sdk: sdk, store: store) await service.reconcile() @@ -757,7 +996,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let completedProofKinds = await service.completedRequestProofKindsAwaitingSubmission(identity: identity) XCTAssertEqual(submissionCount, 0) XCTAssertEqual(storedProofs, [proof]) - XCTAssertEqual(completedProofKinds, [request.id: .onchain]) + XCTAssertTrue(completedProofKinds.isEmpty) } func testForeignWalletUnstartedProofSurvivesCancellationCleanup() async throws { @@ -880,8 +1119,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService( sdk: sdk, store: store, - lightningStatus: .succeeded(preimage: preimage), - onchainLookupFails: true + lightningStatus: .succeeded(preimage: preimage) ) await service.reconcile() @@ -895,15 +1133,6 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } func testDefiniteOnchainFailureClearsStartedProof() async throws { - let errors: [Error] = [ - NodeError.WalletOperationFailed(message: "wallet"), - NodeError.PersistenceFailed(message: "io"), - Bitkit.AppError(error: NodeError.PersistenceFailed(message: "io")), - ] - for error in errors { - XCTAssertTrue(PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error)) - } - XCTAssertFalse(PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(NSError(domain: "unknown", code: 1))) let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -1088,16 +1317,11 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(remainingProofs.first?.requestId.paymentRequestId, secondRequestId) } - func testOnchainPaymentSubmitsWhenCompletedProofCannotBePersisted() async throws { + func testOnchainPaymentKeepsStartedProofWhenCompletionCannotBePersisted() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) - let proofRemoved = expectation(description: "On-chain proof removed after submission") - let store = PaymentProofMemoryStore { proofs in - if proofs.isEmpty { - proofRemoved.fulfill() - } - } + let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) @@ -1109,16 +1333,14 @@ final class PaykitPaymentProofServiceTests: XCTestCase { txid: String(repeating: "ab", count: 32), paymentEndpointIdentifier: endpoint ) - await sdk.waitForSubmissionStart() - await fulfillment(of: [proofRemoved], timeout: 1) - let submissionCount = await sdk.submissionCount() let remainingProofs = await store.snapshot() - XCTAssertEqual(submissionCount, 1) - XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertEqual(submissionCount, 0) + XCTAssertEqual(remainingProofs.first?.paymentStarted, true) + XCTAssertNil(remainingProofs.first?.proofData) } - func testCompletedOnchainProofRemainsDurableWhenPersistenceAndSubmissionInitiallyFail() async throws { + func testFailedOnchainProofPersistenceKeepsStartedMarker() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -1130,27 +1352,19 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextSave() - await sdk.setSubmissionFailure(true) await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) - await sdk.waitForSubmissionStart() - let storedProofs = await store.snapshot() let proof = try XCTUnwrap(storedProofs.first) - XCTAssertEqual(proof.proofData, txid) + XCTAssertNil(proof.proofData) let submissionCount = await sdk.submissionCount() - XCTAssertEqual(submissionCount, 1) + XCTAssertEqual(submissionCount, 0) } - func testOnchainPaymentSubmitsWhenPreparedProofCannotBeLoaded() async throws { + func testOnchainPaymentKeepsStartedProofWhenPreparedProofCannotBeLoaded() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) - let proofRemoved = expectation(description: "On-chain proof removed after submission") - let store = PaymentProofMemoryStore { proofs in - if proofs.isEmpty { - proofRemoved.fulfill() - } - } + let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) let txid = String(repeating: "ab", count: 32) @@ -1159,18 +1373,11 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextLoad() await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) - await sdk.waitForSubmissionStart() - await fulfillment(of: [proofRemoved], timeout: 1) - - let submittedProof = await sdk.lastSubmission() - let submission = try XCTUnwrap(submittedProof) - XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) - XCTAssertEqual( - try proofValues(submission.proof.exportText()), - ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] - ) + let submissionCount = await sdk.submissionCount() let remainingProofs = await store.snapshot() - XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertEqual(submissionCount, 0) + XCTAssertEqual(remainingProofs.first?.paymentStarted, true) + XCTAssertNil(remainingProofs.first?.proofData) } func testReconcileWithoutPendingProofsSkipsSdk() async { @@ -1188,19 +1395,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, lightningStatus: PaykitLightningPaymentProofStatus = .unknown, - onchainTxids: [String] = [], - existingOnchainTxids: Set = [], - onchainLookupFails: Bool = false + attemptService: OnchainSendAttemptService = OnchainSendAttemptService(store: MemoryAttemptStore()) ) -> PaykitPaymentProofService { PaykitPaymentProofService( sdk: sdk, store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), - onchainPaymentLookup: PaymentProofOnchainLookup( - transactionIds: onchainTxids, - existingTransactionIds: existingOnchainTxids, - transactionLookupFails: onchainLookupFails - ), + attemptService: attemptService, logInfo: { _ in }, logWarning: { _ in } ) @@ -1327,23 +1528,6 @@ private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp } } -private struct PaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { - let transactionIds: [String] - let existingTransactionIds: Set - let transactionLookupFails: Bool - - func existingTransactionIds(address _: String, amountSats _: UInt64) async throws -> Set { - existingTransactionIds - } - - func transactionId(address _: String, amountSats _: UInt64, excluding transactionIds: Set) async throws -> String? { - if transactionLookupFails { - throw PaymentProofLookupMockError.transactionLookup - } - return self.transactionIds.first { !transactionIds.contains($0) } - } -} - private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { private let identity: String private var records: [PaymentRequestRecord] @@ -1495,7 +1679,3 @@ private enum PaymentProofStoreMockError: Error { case load case save } - -private enum PaymentProofLookupMockError: Error { - case transactionLookup -} diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 584761f45..af63fd717 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -54,7 +54,8 @@ final class PaykitPaymentStateBackupTests: XCTestCase { onchainAddress: "test-address", onchainAmountSats: 1000, onchainWalletId: "trezor:android", - onchainMatchingTransactionIdsBeforeAttempt: ["previous-transaction"] + onchainMatchingTransactionIdsBeforeAttempt: ["previous-transaction"], + onchainBroadcastAccepted: true ) let backup = PaykitPaymentStateBackup( subscriptions: [identity: .init(subscriptions)], @@ -63,6 +64,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let data = try JSONEncoder().encode(backup) let decoded = try JSONDecoder().decode(PaykitPaymentStateBackup.self, from: data) XCTAssertEqual(decoded.pendingProofs.first?.requestId.billingPeriodStartsAt, "2026-09-24T10:00:00.100Z") + XCTAssertEqual(decoded.pendingProofs.first?.onchainBroadcastAccepted, true) try PaykitSubscriptionStateStore().restoreBackup(decoded.subscriptions) try await PaykitPaymentProofService.shared.restoreBackup(decoded.pendingProofs) @@ -254,6 +256,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let proof = try XCTUnwrap(backup.pendingProofs.first).restored() XCTAssertTrue(proof.paymentStarted) + XCTAssertNil(proof.onchainBroadcastAccepted) XCTAssertEqual(proof.requestId.billingPeriodStartsAt, proof.billingPeriod?.startsAt) XCTAssertEqual(proof.onchainWalletId, "trezor:android") XCTAssertEqual(PaykitPaymentStateBackup.Proof(proof).onchainWalletId, "trezor:android") diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index a0c810782..067e3e90d 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import LDKNode import XCTest /// Regression coverage for the hardware-wallet pending transfer activity. `OnchainActivity.channelId` @@ -43,6 +44,124 @@ final class TransferServiceActivityTests: XCTestCase { ) } + func testAcceptedOrdinaryRestartResumesStoredContextAndDurablyAcknowledgesActivity() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let original = OnchainSendAttemptService(store: store) + let context = OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: ["saved-tag"], contact: nil, createdAt: 100) + _ = try await original.send( + using: node, address: "bcrt1qoriginal", amountSats: 4321, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: true, followupContext: context + ) + let walletId = OnchainSendAttemptService.walletId(index: 0) + let restarted = OnchainSendAttemptService(store: store) + let unrelatedTxid = String(repeating: "cd", count: 32) + let unrelatedSaved = await activity.createSentOnchainActivityFromSendResult( + txid: unrelatedTxid, address: "bcrt1qoriginal", amount: 4321, fee: 999, feeRate: 99 + ) + XCTAssertTrue(unrelatedSaved) + store.failSave = true + do { + _ = try await restarted.resumeAcceptedOrdinarySend(walletId: walletId) + XCTFail("Failed acknowledgement released the accepted guard") + } catch {} + XCTAssertEqual(store.snapshot().first?.status, .accepted) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) + let exactSaved = try await activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual(exactSaved?.value, 4321) + XCTAssertEqual(exactSaved?.address, "bcrt1qoriginal") + XCTAssertEqual(exactSaved?.fee, 123) + store.failSave = false + let restartedAgain = OnchainSendAttemptService(store: store) + do { + _ = try await restartedAgain.send( + using: node, + address: "bcrt1qfresh-ui", + amountSats: 9999, + satsPerVbyte: 99, + utxosToSpend: nil, + isMaxAmount: false + ) + XCTFail("Fresh UI dispatched before the original local follow-up was acknowledged") + } catch {} + let firstResolution = try await restartedAgain.resumeAcceptedOrdinarySend(walletId: walletId) + let repeatedResolution = try await restartedAgain.resumeAcceptedOrdinarySend(walletId: walletId) + XCTAssertEqual(firstResolution?.txid, txid) + XCTAssertEqual(firstResolution?.amountSats, 4321) + XCTAssertEqual(repeatedResolution?.txid, txid) + XCTAssertEqual(node.calls, 1, "Local resume invoked node dispatch") + let metadata = try await activity.getPreActivityMetadata(searchKey: txid) + XCTAssertEqual(metadata?.address, "bcrt1qoriginal") + XCTAssertEqual(metadata?.tags, ["saved-tag"]) + XCTAssertEqual(metadata?.feeRate, 2) + let activities = try await activity.get(filter: .onchain, limit: 50, sortDirection: .desc) + XCTAssertEqual(activities.count, 2, "Repeated follow-up duplicated a durable activity") + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + _ = try await restartedAgain.send(using: node, address: "bcrt1qnew", amountSats: 1000, satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + XCTAssertEqual(node.calls, 2) + XCTAssertEqual(store.snapshot().count, 1) + } + + func testExactConfirmationCanFinishAcceptedIncompleteOrdinaryFollowup() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let txid = String(repeating: "ab", count: 32) + let walletId = OnchainSendAttemptService.walletId(index: 0) + let id = try await service.admit( + walletId: walletId, requestId: nil, orderId: nil, address: "bcrt1qoriginal", amountSats: 1000, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 100, feeRate: 1, tags: [], contact: nil, createdAt: 100) + ) + try await service.record(.accepted(txid: txid), attemptId: id) + let unrelated = try await service.observeConfirmedTransaction(txid: String(repeating: "cd", count: 32)) + XCTAssertFalse(unrelated) + let observed = try await service.observeConfirmedTransaction(txid: txid) + XCTAssertTrue(observed, "Exact confirmation ignored the accepted incomplete guard") + let resolution = try await service.resumeAcceptedOrdinarySend(walletId: walletId) + XCTAssertEqual(resolution?.txid, txid) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + } + + func testAcceptedOrdinaryWithoutContextOrExactDurableDetailsStaysGuarded() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let walletId = OnchainSendAttemptService.walletId(index: 0) + let id = try await service.admit( + walletId: walletId, + requestId: nil, + orderId: nil, + address: "bcrt1qoriginal", + amountSats: 1000, + isMaxAmount: false + ) + try await service.record(.accepted(txid: String(repeating: "ab", count: 32)), attemptId: id) + do { + _ = try await OnchainSendAttemptService(store: store).resumeAcceptedOrdinarySend(walletId: walletId) + XCTFail("Missing local context was reconstructed from a fresh UI") + } catch {} + XCTAssertEqual(store.snapshot().first?.status, .accepted) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) + } + + func testPaidOrderFollowupIsIdempotentAndRejectsDifferentFundingTxid() async throws { + let service = makeService() + let txid = String(repeating: "ab", count: 32) + let first = try await service.createTransfer(type: .toSpending, amountSats: 1000, fundingTxId: txid, lspOrderId: "paid-order") + let resumed = try await service.createTransfer(type: .toSpending, amountSats: 1000, fundingTxId: txid, lspOrderId: "paid-order") + XCTAssertEqual(first, resumed) + XCTAssertEqual(try Bitkit.TransferStorage(defaults: transferDefaults).getAll().count, 1) + do { + _ = try await service.createTransfer( + type: .toSpending, + amountSats: 1000, + fundingTxId: String(repeating: "cd", count: 32), + lspOrderId: "paid-order" + ) + XCTFail("Paid order was associated with another funding transaction") + } catch {} + XCTAssertEqual(try Bitkit.TransferStorage(defaults: transferDefaults).getAll().first?.fundingTxId, txid) + } + func testPendingToSpendingActivityDoesNotStoreShortChannelId() async throws { var channel = IBtChannel.mock() channel.shortChannelId = "820100x5x0" diff --git a/BitkitTests/UtxoSelectionTests.swift b/BitkitTests/UtxoSelectionTests.swift index 76128e20e..f05058b6f 100644 --- a/BitkitTests/UtxoSelectionTests.swift +++ b/BitkitTests/UtxoSelectionTests.swift @@ -165,12 +165,16 @@ final class UtxoSelectionTests: XCTestCase { let feeRate: UInt32 = 1 // 1 sat/vbyte Logger.test("Sending \(sendAmount) sats to \(destinationAddress) using specific UTXOs", context: "UtxoSelectionTests") - let txId = try await lightning.send( + let sendResult = try await lightning.send( address: destinationAddress, sats: sendAmount, satsPerVbyte: feeRate, utxosToSpend: utxosToSpend ) + guard case let .accepted(txId) = sendResult else { + XCTFail("Selected UTXO broadcast was not accepted: \(sendResult)") + return + } XCTAssertFalse(txId.isEmpty, "Transaction ID should not be empty") Logger.test("Transaction sent successfully with txid: \(txId)", context: "UtxoSelectionTests") diff --git a/changelog.d/next/717.fixed.md b/changelog.d/next/717.fixed.md new file mode 100644 index 000000000..9f9c2567c --- /dev/null +++ b/changelog.d/next/717.fixed.md @@ -0,0 +1 @@ +On-chain sends now keep uncertain broadcast attempts pending and show the transaction ID, preventing another payment while the outcome is unresolved. diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml new file mode 100644 index 000000000..52604f75f --- /dev/null +++ b/journeys/send/onchain-accepted-result.xml @@ -0,0 +1,26 @@ + + + Verifies that fixed-amount and send-all sends show success after the configured backend + acknowledges the transaction, and that an accepted send finishes its local activity before a + new send is admitted. Precondition: an onboarded E2E wallet on regtest with confirmed Savings + funds sufficient for both sends and fees, a synced running node, and a second test wallet's + regtest receiving address. The wallet, recipient and backend must be test fixtures. + Rejected or missing-result cases need controlled broadcast-refusal/response-loss fixtures, + which the Capabilities table does not provide, and belong in Manual Tests. + + + Hand the receiving test wallet address to the app with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>?amount=0.00001" + If iOS asks whether to open the payment link in Bitkit, choose Open + Verify the Send Amount screen (id "SendAmount") is visible with 1 000 sats and Savings selected + Tap Continue (id "ContinueAmount") and verify the Confirm screen shows the receiving test wallet address (id "ReviewUri") + Swipe the send handle (id "GRAB") fully to the right + Wait for the sent screen (id "SendSuccess") and verify it shows the accepted payment amount + Tap Details (id "Details"), tap Transaction Details (id "ActivityTxDetails"), and record the exact transaction ID (id "TXID") without selecting a different transaction by address or amount + Navigate back from Transaction Details, then back from the activity to the wallet home screen + Hand the receiving test wallet address to the app again with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>" + If iOS asks whether to open the payment link in Bitkit, choose Open + Tap the available balance (id "AvailableAmount") on Send Amount to choose Max, tap Continue (id "ContinueAmount"), and verify the Confirm screen shows the receiving test wallet address + Swipe the send handle (id "GRAB") fully to the right + Wait for the sent screen (id "SendSuccess"), tap Details (id "Details"), tap Transaction Details (id "ActivityTxDetails"), and verify this send-all transaction has its own exact transaction ID (id "TXID") + + From e925ca9e1117b58c1b96fca86821ce848138e767 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:59:08 +0200 Subject: [PATCH 02/77] chore: link release notes to the pull request --- changelog.d/next/{717.fixed.md => 844.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{717.fixed.md => 844.fixed.md} (100%) diff --git a/changelog.d/next/717.fixed.md b/changelog.d/next/844.fixed.md similarity index 100% rename from changelog.d/next/717.fixed.md rename to changelog.d/next/844.fixed.md From bdccb67ff239d83981ccdcfe64ba091611602b61 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 03:34:12 +0200 Subject: [PATCH 03/77] docs: clarify accepted on-chain send journeys --- journeys/send/onchain-accepted-result.xml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 52604f75f..4a8e0635d 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -4,7 +4,12 @@ acknowledges the transaction, and that an accepted send finishes its local activity before a new send is admitted. Precondition: an onboarded E2E wallet on regtest with confirmed Savings funds sufficient for both sends and fees, a synced running node, and a second test wallet's - regtest receiving address. The wallet, recipient and backend must be test fixtures. + regtest receiving address. Start with Autopilot coin selection. The wallet, recipient and + backend must be test fixtures. The Max leg uses the existing Manual coin-selection route: + an amount-less payment link followed by Max currently fails in Autopilot's Branch and Bound + selection with CoinSelectionFailed before Confirm or native dispatch, even with confirmed + funds. This journey verifies actual send-all acceptance through Manual; it does not claim + that the Autopilot limitation is fixed or that fixture confirmation proves acceptance. Rejected or missing-result cases need controlled broadcast-refusal/response-loss fixtures, which the Capabilities table does not provide, and belong in Manual Tests. @@ -12,15 +17,23 @@ Hand the receiving test wallet address to the app with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>?amount=0.00001" If iOS asks whether to open the payment link in Bitkit, choose Open Verify the Send Amount screen (id "SendAmount") is visible with 1 000 sats and Savings selected - Tap Continue (id "ContinueAmount") and verify the Confirm screen shows the receiving test wallet address (id "ReviewUri") + Tap Continue (id "ContinueAmount"), tap Show Details (id "SendConfirmToggleDetails"), and verify the Confirm screen shows the receiving test wallet address (id "ReviewUri") Swipe the send handle (id "GRAB") fully to the right Wait for the sent screen (id "SendSuccess") and verify it shows the accepted payment amount - Tap Details (id "Details"), tap Transaction Details (id "ActivityTxDetails"), and record the exact transaction ID (id "TXID") without selecting a different transaction by address or amount + Tap Details (id "Details"), tap Explore (id "ActivityTxDetails"), and record the exact transaction ID (id "TXID") without selecting a different transaction by address or amount + Use the configured regtest backend to query that exact recorded txid and verify the acknowledged transaction pays 1 000 sats to the receiving test wallet Navigate back from Transaction Details, then back from the activity to the wallet home screen + Mine one block with the configured regtest fixture backend and wait for the fixed transaction's exact recorded txid to confirm before sending the change output + Open the menu (id "HeaderMenu"), Settings (id "DrawerSettings"), Advanced (id "Tab-advanced"), Coin Selection (id "CoinSelectPreference"), and choose Manual; return to Wallet from the menu (id "DrawerWallet") Hand the receiving test wallet address to the app again with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>" If iOS asks whether to open the payment link in Bitkit, choose Open - Tap the available balance (id "AvailableAmount") on Send Amount to choose Max, tap Continue (id "ContinueAmount"), and verify the Confirm screen shows the receiving test wallet address + Tap the available balance (id "AvailableAmount") on Send Amount to choose Max, then tap Continue (id "ContinueAmount") + On Coin Selection, select all the fixture's spendable outputs with their switches, verify Total Selected covers Total Required, and tap Continue + Tap Show Details (id "SendConfirmToggleDetails") and verify the Confirm screen shows the receiving test wallet address and the drain amount and fee Swipe the send handle (id "GRAB") fully to the right - Wait for the sent screen (id "SendSuccess"), tap Details (id "Details"), tap Transaction Details (id "ActivityTxDetails"), and verify this send-all transaction has its own exact transaction ID (id "TXID") + If the Are You Sure? alert warns that the payment exceeds half the fixture balance, choose Yes, Send + Wait for the sent screen (id "SendSuccess"), tap Details (id "Details"), tap Explore (id "ActivityTxDetails"), and verify this send-all transaction has its own exact transaction ID (id "TXID") + Query this exact txid on the configured regtest backend and verify it spends the selected fixture outputs and pays the remaining balance minus the actual fee to the recipient + Return to Settings, Advanced, Coin Selection and restore Autopilot; stop recording and shut down only the task-owned simulator From e447862bafab1d98199a175d2d885e5f1f83cd67 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 04:13:59 +0200 Subject: [PATCH 04/77] fix: resume original accepted payment follow-up --- Bitkit/Models/PaykitPaymentStateBackup.swift | 6 +- Bitkit/Services/LightningService.swift | 2 + .../Services/OnchainSendAttemptService.swift | 87 ++++++- .../Services/PaykitPaymentProofService.swift | 92 ++++++-- Bitkit/ViewModels/TransferViewModel.swift | 86 +++---- Bitkit/ViewModels/WalletViewModel.swift | 28 ++- .../Wallets/Send/SendConfirmationView.swift | 39 ++-- .../Wallets/Send/SendPendingScreen.swift | 25 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 1 - .../PaykitPaymentProofServiceTests.swift | 191 ++++++++++++++- .../PaykitPaymentStateBackupTests.swift | 9 +- .../TransferServiceActivityTests.swift | 221 ++++++++++++++++++ 12 files changed, 666 insertions(+), 121 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 9becc4412..8f68fe998 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -72,7 +72,7 @@ struct PaykitPaymentStateBackup: Codable { let onchainAmountSats: UInt64? let onchainWalletId: String? let onchainMatchingTransactionIdsBeforeAttempt: Set - let onchainBroadcastAccepted: Bool? + let onchainAcceptanceVerified: Bool? init(_ proof: PendingPaykitPaymentProof) { identity = proof.identity @@ -87,7 +87,7 @@ struct PaykitPaymentStateBackup: Codable { onchainAmountSats = proof.onchainAmountSats onchainWalletId = proof.onchainWalletId onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] - onchainBroadcastAccepted = proof.onchainBroadcastAccepted + onchainAcceptanceVerified = proof.onchainAcceptanceVerified } func restored() throws -> PendingPaykitPaymentProof { @@ -110,7 +110,7 @@ struct PaykitPaymentStateBackup: Codable { onchainAmountSats: onchainAmountSats, onchainWalletId: onchainWalletId, onchainMatchingTransactionIdsBeforeAttempt: onchainMatchingTransactionIdsBeforeAttempt, - onchainBroadcastAccepted: onchainBroadcastAccepted + onchainAcceptanceVerified: onchainAcceptanceVerified ) } } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index f86c8f663..d9e6e837b 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -28,6 +28,7 @@ class LightningService { private var storedEventCallback: ((Event) -> Void)? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? + var onchainTransactionReceived: (@Sendable (String) async -> Void)? var syncStatusChangedPublisher: AnyPublisher { syncStatusChangedSubject.eraseToAnyPublisher() @@ -1469,6 +1470,7 @@ extension LightningService { case let .onchainTransactionReceived(txid, details): Logger.info("📥 Onchain transaction received: txid=\(txid) amountSats=\(details.amountSats)") Task { + await self.onchainTransactionReceived?(txid) do { try await CoreService.shared.activity.handleOnchainTransactionReceived(txid: txid, details: details) } catch { diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 28bf6b27f..9bb102ed1 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -60,6 +60,7 @@ struct OnchainSendAttempt: Codable, Equatable { var rejectionReason: String? = nil var localFollowupComplete = false var followupContext: OnchainSendFollowupContext? = nil + var transferContext: OnchainSendTransferContext? = nil var blocksNewSend: Bool { status.blocksNewSend || !localFollowupComplete @@ -74,12 +75,19 @@ struct OnchainSendFollowupContext: Codable, Equatable { let createdAt: UInt64 } +struct OnchainSendTransferContext: Codable, Equatable { + let clientBalanceSats: UInt64 + let txTotalSats: UInt64 + let preTransferOnchainSats: UInt64 +} + struct OnchainSendLocalResolution { let attemptId: UUID let walletId: String let txid: String let amountSats: UInt64 let contact: String? + let activity: OnchainActivity } protocol OnchainSendLocalFollowupHandling { @@ -169,6 +177,7 @@ actor OnchainSendAttemptService { requestId: PaykitPaymentRequest.ID? = nil, orderId: String? = nil, followupContext: OnchainSendFollowupContext? = nil, + transferContext: OnchainSendTransferContext? = nil, beforeBroadcastAttempt: () async throws -> Void = {} ) async throws -> OnchainSendResult { let walletIndex = lightningService.currentWalletIndex @@ -189,7 +198,8 @@ actor OnchainSendAttemptService { address: address, amountSats: amountSats, isMaxAmount: isMaxAmount, - followupContext: followupContext + followupContext: followupContext, + transferContext: transferContext ) do { try await beforeBroadcastAttempt() @@ -237,7 +247,8 @@ actor OnchainSendAttemptService { address: String, amountSats: UInt64, isMaxAmount: Bool, - followupContext: OnchainSendFollowupContext? = nil + followupContext: OnchainSendFollowupContext? = nil, + transferContext: OnchainSendTransferContext? = nil ) throws -> UUID { if let previous = try currentAttempt() { guard !previous.blocksNewSend else { @@ -259,7 +270,8 @@ actor OnchainSendAttemptService { amountSats: amountSats, isMaxAmount: isMaxAmount, status: .pending, - followupContext: followupContext + followupContext: followupContext, + transferContext: transferContext ) try store.save([attempt]) knownAttempt = attempt @@ -303,19 +315,80 @@ actor OnchainSendAttemptService { knownAttempt = attempt } - func resumeAcceptedOrdinarySend(walletId: String) async throws -> OnchainSendLocalResolution? { - guard let attempt = try currentAttempt(), attempt.walletId == walletId, - attempt.requestId == nil, attempt.orderId == nil, attempt.status == .accepted, let txid = attempt.txid + func resumeAcceptedOrdinarySend(walletId: String, observedTxid: String? = nil) async throws -> OnchainSendLocalResolution? { + guard var attempt = try currentAttempt(), attempt.walletId == walletId, + attempt.requestId == nil, attempt.orderId == nil, let txid = attempt.txid else { return nil } + if let observedTxid { + guard txid.caseInsensitiveCompare(observedTxid) == .orderedSame else { return nil } + if attempt.status != .accepted { + attempt.status = .accepted + knownAttempt = attempt + try store.save([attempt]) + } + } + guard attempt.status == .accepted else { return nil } let activity = try await localFollowup.save(attempt) try acknowledgeLocalFollowup(txid: txid) let resolution = OnchainSendLocalResolution( - attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: activity.value, contact: activity.contact + attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: activity.value, contact: activity.contact, activity: activity ) Self.localResolutionSubject.send(resolution) return resolution } + func acceptedRequestAttempt() throws -> OnchainSendAttempt? { + try currentAttempt().flatMap { $0.requestId != nil && $0.status == .accepted && !$0.localFollowupComplete ? $0 : nil } + } + + @discardableResult + func resumeAcceptedRequestSend(requestId: PaykitPaymentRequest.ID, txid: String) async throws -> Bool { + guard let attempt = try currentAttempt(), attempt.requestId == requestId, attempt.orderId == nil, + attempt.status == .accepted, attempt.txid?.caseInsensitiveCompare(txid) == .orderedSame + else { return false } + if !attempt.localFollowupComplete { + _ = try await localFollowup.save(attempt) + try acknowledgeLocalFollowup(txid: txid) + } + return true + } + + @discardableResult + func resumeAcceptedTransfer(walletId: String, using transferService: TransferService) async throws -> Bool { + guard let attempt = try currentAttempt(), attempt.walletId == walletId, + attempt.status == .accepted, attempt.requestId == nil, attempt.orderId != nil, attempt.txid != nil + else { return false } + return try await restoreAcceptedTransfer(attempt, using: transferService) + } + + @discardableResult + func resumeAcceptedTransfer(orderId: String, txid: String, using transferService: TransferService) async throws -> Bool { + guard let attempt = try currentAttempt(), attempt.orderId == orderId, + attempt.status == .accepted, attempt.requestId == nil, + attempt.txid?.caseInsensitiveCompare(txid) == .orderedSame + else { return false } + return try await restoreAcceptedTransfer(attempt, using: transferService) + } + + private func restoreAcceptedTransfer(_ attempt: OnchainSendAttempt, using transferService: TransferService) async throws -> Bool { + guard let savedOrderId = attempt.orderId, let txid = attempt.txid else { return false } + guard !attempt.localFollowupComplete else { return true } + guard let context = attempt.followupContext, let transfer = attempt.transferContext else { + throw OnchainSendAttemptError.localFollowupNotSaved + } + try await CoreService.shared.activity.upsertPreActivityMetadata([BitkitCore.PreActivityMetadata( + walletId: WalletScope.default, paymentId: txid, tags: context.tags, paymentHash: nil, + txId: txid, address: attempt.address, isReceive: false, feeRate: UInt64(context.feeRate), + isTransfer: true, channelId: nil, createdAt: context.createdAt + )]) + _ = try await transferService.createTransfer( + type: .toSpending, amountSats: transfer.clientBalanceSats, fundingTxId: txid, + lspOrderId: savedOrderId, txTotalSats: transfer.txTotalSats, preTransferOnchainSats: transfer.preTransferOnchainSats + ) + try acknowledgeLocalFollowup(txid: txid) + return true + } + func clearBeforeDispatch(attemptId: UUID) throws { var attempts = try store.load() guard let index = attempts.firstIndex(where: { $0.id == attemptId && $0.status == .pending }) else { return } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 31cacd48b..52c9cbb7f 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -33,7 +33,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var onchainAmountSats: UInt64? let onchainWalletId: String? var onchainMatchingTransactionIdsBeforeAttempt: Set? - var onchainBroadcastAccepted: Bool? + var onchainAcceptanceVerified: Bool? var hasUnsupportedOnchainWallet: Bool { kind == .onchain && onchainWalletId != nil && onchainWalletId != WalletScope.default @@ -52,7 +52,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { onchainAmountSats: UInt64? = nil, onchainWalletId: String? = nil, onchainMatchingTransactionIdsBeforeAttempt: Set? = nil, - onchainBroadcastAccepted: Bool? = nil + onchainAcceptanceVerified: Bool? = false ) { self.identity = identity self.requestId = requestId @@ -66,7 +66,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { self.onchainAmountSats = onchainAmountSats self.onchainWalletId = onchainWalletId self.onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt - self.onchainBroadcastAccepted = onchainBroadcastAccepted + self.onchainAcceptanceVerified = onchainAcceptanceVerified } } @@ -427,38 +427,41 @@ actor PaykitPaymentProofService { var pendingProofs = try await loadProofs() if let completed = pendingProofs.last(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && - $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.onchainBroadcastAccepted == true && + $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.onchainAcceptanceVerified == true && + $0.paymentStarted && $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame }) { + await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) submitInBackground(completed) return true } + // A queued-era txid is not positive evidence. Only the original typed Accepted + // result or an exact native observation of its attempt may authorize this writer. + guard try await attemptService.acceptedTransactionId(for: requestId)?.caseInsensitiveCompare(txid) == .orderedSame else { + return false + } guard let index = pendingProofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.paymentStarted && - $0.paymentIdentifier == nil && - $0.proofData == nil + (($0.paymentIdentifier == nil && $0.proofData == nil) || + ($0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && + $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame)) }) else { - return try await sdk.paymentRequests().contains { record in - record.localRole == .payer && record.paymentRequestId == requestId.paymentRequestId && - PubkyPublicKeyFormat.matches(record.counterparty, requestId.counterparty) && - record.counterpartyReceiverPath == requestId.counterpartyReceiverPath && record.paymentProofs.contains { proof in - proof.billingPeriod.flatMap(PaykitBillingPeriod.init)?.startsAt == requestId.billingPeriodStartsAt && - Self.proofValues(proof.proof.exportText()) == [ - "type": PaykitPaymentProofKind.onchain.rawValue, - "data": txid.lowercased(), - ] - } - } + guard try await sdk.paymentRequests().contains(where: { + Self.hasExactOnchainProof(requestId: requestId, txid: txid, in: $0) + }) else { return false } + await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) + return true } pendingProofs[index].paymentIdentifier = txid.lowercased() pendingProofs[index].proofData = txid.lowercased() - pendingProofs[index].onchainBroadcastAccepted = true + pendingProofs[index].onchainAcceptanceVerified = true let completedProof = pendingProofs[index] try await persist(pendingProofs) + await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) submitInBackground(completedProof) Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( identity: completedProof.identity, @@ -518,13 +521,31 @@ actor PaykitPaymentProofService { func reconcile() async { do { var pendingProofs = try await loadProofs() - guard !pendingProofs.isEmpty else { return } + let acceptedRequest = try await attemptService.acceptedRequestAttempt() + guard !pendingProofs.isEmpty || acceptedRequest != nil else { return } guard let identityStatus = try await sdk.identityStatus(), identityStatus.liveSessionAvailable, let publicKey = identityStatus.publicKey, let identity = PubkyPublicKeyFormat.normalized(publicKey) else { return } + if let acceptedRequest, let requestId = acceptedRequest.requestId, let txid = acceptedRequest.txid { + let hasSavedProof = pendingProofs.contains { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.onchainAcceptanceVerified == true && + $0.paymentStarted && $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && + $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame + } + var hasDurableProof = hasSavedProof + if !hasDurableProof { + hasDurableProof = try await sdk.paymentRequests().contains(where: { + Self.hasExactOnchainProof(requestId: requestId, txid: txid, in: $0) + }) + } + if hasDurableProof { + await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) + } + } pendingProofs = await removingSettledUnsupportedWalletProofs(from: pendingProofs, identity: identity) let identityProofs = pendingProofs.filter { PubkyPublicKeyFormat.matches($0.identity, identity) @@ -533,7 +554,14 @@ actor PaykitPaymentProofService { do { guard !proof.hasUnsupportedOnchainWallet else { continue } if proof.proofData != nil { - if proof.kind == .onchain, proof.onchainBroadcastAccepted != true { + if proof.kind == .onchain, proof.onchainAcceptanceVerified != true { + if proof.paymentStarted, + let txid = try await attemptService.acceptedTransactionId(for: proof.requestId), + proof.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame, + proof.proofData?.caseInsensitiveCompare(txid) == .orderedSame + { + _ = await completeOnchainPayment(requestId: proof.requestId, identity: proof.identity, txid: txid) + } continue } await submit(proof) @@ -564,6 +592,26 @@ actor PaykitPaymentProofService { } } + private func resumeAcceptedRequestFollowup(requestId: PaykitPaymentRequest.ID, txid: String) async { + do { + _ = try await attemptService.resumeAcceptedRequestSend(requestId: requestId, txid: txid) + } catch { + logWarning("Accepted Paykit payment local follow-up remains guarded: \(error)") + } + } + + private static func hasExactOnchainProof(requestId: PaykitPaymentRequest.ID, txid: String, in record: Paykit.PaymentRequestRecord) -> Bool { + record.localRole == .payer && record.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(record.counterparty, requestId.counterparty) && + record.counterpartyReceiverPath == requestId.counterpartyReceiverPath && record.paymentProofs.contains { proof in + proof.billingPeriod.flatMap(PaykitBillingPeriod.init)?.startsAt == requestId.billingPeriodStartsAt && + Self.proofValues(proof.proof.exportText()) == [ + "type": PaykitPaymentProofKind.onchain.rawValue, + "data": txid.lowercased(), + ] + } + } + private func removingSettledUnsupportedWalletProofs( from pendingProofs: [PendingPaykitPaymentProof], identity: String @@ -630,7 +678,7 @@ actor PaykitPaymentProofService { do { return try await loadProofs().reduce(into: [:]) { result, proof in guard PubkyPublicKeyFormat.matches(proof.identity, identity), proof.proofData != nil, - proof.kind != .onchain || proof.onchainBroadcastAccepted == true + proof.kind != .onchain || proof.onchainAcceptanceVerified == true else { return } result[proof.requestId] = proof.kind } @@ -694,7 +742,7 @@ actor PaykitPaymentProofService { @discardableResult private func submit(_ pendingProof: PendingPaykitPaymentProof) async -> Bool { guard !pendingProof.hasUnsupportedOnchainWallet, let proofData = pendingProof.proofData else { return false } - guard pendingProof.kind != .onchain || pendingProof.onchainBroadcastAccepted == true else { return false } + guard pendingProof.kind != .onchain || pendingProof.onchainAcceptanceVerified == true else { return false } do { guard let identityStatus = try await sdk.identityStatus(), identityStatus.liveSessionAvailable, diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index f10ab0773..2a3a96584 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -152,6 +152,9 @@ class TransferViewModel: ObservableObject { private let coreService: CoreService private let lightningService: LightningService + private let onchainAttemptService: OnchainSendAttemptService + private let onchainSender: any OnchainSending + private let onchainBalanceProvider: () -> UInt64 private let currencyService: CurrencyService private let transferService: TransferService private let sheetViewModel: SheetViewModel @@ -201,10 +204,16 @@ class TransferViewModel: ObservableObject { hwFeeRateProvider: (() async -> UInt64?)? = nil, hwAddressProvider: (() async throws -> String)? = nil, hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120), - onBalanceRefresh: (() async -> Void)? = nil + onBalanceRefresh: (() async -> Void)? = nil, + onchainAttemptService: OnchainSendAttemptService = .shared, + onchainSender: (any OnchainSending)? = nil, + onchainBalanceProvider: (() -> UInt64)? = nil ) { self.coreService = coreService self.lightningService = lightningService + self.onchainAttemptService = onchainAttemptService + self.onchainSender = onchainSender ?? lightningService + self.onchainBalanceProvider = onchainBalanceProvider ?? { lightningService.balances?.totalOnchainBalanceSats ?? 0 } self.currencyService = currencyService self.transferService = transferService self.sheetViewModel = sheetViewModel @@ -422,7 +431,7 @@ class TransferViewModel: ObservableObject { throw AppError(message: "Order payment onchain address is nil", debugMessage: nil) } - let preTransferOnchainSats = lightningService.balances?.totalOnchainBalanceSats ?? 0 + let preTransferOnchainSats = onchainBalanceProvider() // Verify we can afford the transfer when using sendAll if isMaxAmount, let maxSendable = maxSendableAmount, maxSendable < order.feeSat { @@ -432,16 +441,32 @@ class TransferViewModel: ObservableObject { ) } + let txTotalSats = SpendingConfirmTotal.leavingAmount( + orderFeeSat: order.feeSat, + networkFeeSat: txFee, + shouldUseSendAll: isMaxAmount, + maxSendable: maxSendableAmount + ) + // For sendAll (change would be dust), send entire balance // Otherwise, send exact order.feeSat amount - let result = try await OnchainSendAttemptService.shared.send( - using: lightningService, + let result = try await onchainAttemptService.send( + using: onchainSender, address: address, amountSats: order.feeSat, satsPerVbyte: satsPerVbyte, utxosToSpend: utxosToSpend, isMaxAmount: isMaxAmount, - orderId: order.id + orderId: order.id, + followupContext: OnchainSendFollowupContext( + feeSats: txFee, feeRate: satsPerVbyte, tags: [], contact: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ), + transferContext: OnchainSendTransferContext( + clientBalanceSats: order.clientBalanceSat, + txTotalSats: txTotalSats, + preTransferOnchainSats: preTransferOnchainSats + ) ) let txid: String switch result { @@ -459,56 +484,21 @@ class TransferViewModel: ObservableObject { ) } - let txTotalSats = SpendingConfirmTotal.leavingAmount( - orderFeeSat: order.feeSat, - networkFeeSat: txFee, - shouldUseSendAll: isMaxAmount, - maxSendable: maxSendableAmount - ) - - // Pre-activity metadata lets the LDK activity sync recognize this send as a transfer. - let currentTime = UInt64(Date().timeIntervalSince1970) - let preActivityMetadata = BitkitCore.PreActivityMetadata( - walletId: WalletScope.default, - paymentId: txid, - tags: [], - paymentHash: nil, - txId: txid, - address: address, - isReceive: false, - feeRate: UInt64(satsPerVbyte), - isTransfer: true, - channelId: nil, - createdAt: currentTime - ) - let metadataSaved: Bool do { - try await coreService.activity.addPreActivityMetadata(preActivityMetadata) - metadataSaved = true + guard try await onchainAttemptService.resumeAcceptedTransfer( + orderId: order.id, txid: txid, using: transferService + ) else { throw OnchainSendAttemptError.localFollowupNotSaved } } catch { - metadataSaved = false - Logger.warn("Accepted funding metadata could not be saved; retaining the attempt", context: "TransferViewModel") - } - - let trackingSaved = await fundPaidOrder( - order: order, - txId: txid, - txTotalSats: txTotalSats, - preTransferOnchainSats: preTransferOnchainSats - ) - if trackingSaved, metadataSaved { - do { - try await OnchainSendAttemptService.shared.acknowledgeLocalFollowup(txid: txid) - } catch { - Logger.warn("Accepted funding follow-up could not be acknowledged; retaining the attempt", context: "TransferViewModel") - } - } - if !trackingSaved { + Logger.warn("Accepted funding local follow-up remains guarded: \(error)", context: "TransferViewModel") throw AppError( message: "Funding was sent, but local order tracking could not be saved. Do not fund this order again.", debugMessage: "Accepted funding transaction \(txid) for order \(order.id)" ) } + fundedOrderId = order.id + lightningSetupStep = 0 + await onBalanceRefresh?() + watchOrder(orderId: order.id) } @discardableResult diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 96e56143b..05b3d3850 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -101,7 +101,8 @@ class WalletViewModel: ObservableObject { rgsConfigService: RgsConfigService = RgsConfigService(), transferService: TransferService, sheetViewModel: SheetViewModel, - feeEstimatesManager: FeeEstimatesManager + feeEstimatesManager: FeeEstimatesManager, + onchainAttemptService: OnchainSendAttemptService = .shared ) { self.lightningService = lightningService self.coreService = coreService @@ -115,12 +116,33 @@ class WalletViewModel: ObservableObject { transferService: transferService, coreService: coreService ) + Task { + do { + _ = try await onchainAttemptService.resumeAcceptedTransfer( + walletId: OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex), using: transferService + ) + } catch { + Logger.warn("Accepted order local follow-up remains guarded: \(error)", context: "WalletViewModel") + } + } + lightningService.onchainTransactionReceived = { txid in + do { + _ = try await onchainAttemptService.resumeAcceptedOrdinarySend( + walletId: OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex), observedTxid: txid + ) + } catch { + Logger.warn("Observed ordinary payment local follow-up remains guarded: \(error)", context: "WalletViewModel") + } + } lightningService.onchainTransactionConfirmed = { txid in do { - if try await OnchainSendAttemptService.shared.observeConfirmedTransaction(txid: txid) { - _ = try await OnchainSendAttemptService.shared.resumeAcceptedOrdinarySend( + if try await onchainAttemptService.observeConfirmedTransaction(txid: txid) { + _ = try await onchainAttemptService.resumeAcceptedOrdinarySend( walletId: OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex) ) + _ = try await onchainAttemptService.resumeAcceptedTransfer( + walletId: OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex), using: transferService + ) await PaykitPaymentProofService.shared.reconcile() } } catch { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 5929dc117..8ae457f3a 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -937,34 +937,27 @@ struct SendConfirmationView: View { ) } - // Create pre-activity metadata for tags and activity address - let metadataSaved = await createPreActivityMetadata( - paymentId: txid, - address: invoice.address, - txId: txid, - feeRate: wallet.selectedFeeRateSatsPerVByte - ) - - // Create sent onchain activity immediately so it appears before LDK event (which can be delayed) - let activitySaved = await CoreService.shared.activity.createSentOnchainActivityFromSendResult( - txid: txid, - address: invoice.address, - amount: amount, - fee: UInt64(transactionFee), - feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1, - contact: contactPublicKey - ) - - if proofSaved, metadataSaved, activitySaved { + var savedActivity: OnchainActivity? + if proofSaved { do { - try await OnchainSendAttemptService.shared.acknowledgeLocalFollowup(txid: txid) + if let incomingPaymentRequest { + if try await OnchainSendAttemptService.shared + .resumeAcceptedRequestSend(requestId: incomingPaymentRequest.id, txid: txid) + { + savedActivity = try await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid) + } + } else { + savedActivity = try await OnchainSendAttemptService.shared.resumeAcceptedOrdinarySend( + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) + )?.activity + } } catch { - Logger.warn("Accepted payment follow-up could not be acknowledged; retaining the attempt", context: "SendConfirmation") + Logger.warn("Accepted payment local follow-up remains guarded: \(error)", context: "SendConfirmation") } } - // Set the amount for the success screen - wallet.sendAmountSats = amount + // The accepted attempt owns the original amount and metadata during local resume. + wallet.sendAmountSats = savedActivity?.value ?? incomingPaymentRequest?.amountSats ?? amount Logger.info("Onchain send result txid: \(txid)") diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index eb00436ba..f00a96756 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -29,6 +29,7 @@ struct SendPendingScreen: View { let paymentRequest: String? let paykitPaymentRequestId: PaykitPaymentRequest.ID? let routingCacheResetAttempted: Bool + var attemptService: OnchainSendAttemptService = .shared @Binding var navigationPath: [SendRoute] @EnvironmentObject private var activityList: ActivityListViewModel @@ -48,7 +49,11 @@ struct SendPendingScreen: View { VStack(alignment: .leading, spacing: 0) { SheetHeader(title: t("wallet__send_pending"), showBackButton: false) - if let sendAmountSats = wallet.sendAmountSats { + if let sendAmountSats = onchainAttempt?.amountSats ?? wallet.sendAmountSats { + if onchainAttempt?.requestId == nil, onchainAttempt != nil { + BodySSBText("Earlier on-chain payment") + .accessibilityIdentifier("EarlierOnchainPayment") + } MoneyStack(sats: Int(sendAmountSats), showSymbol: true) .padding(.bottom, 32) } @@ -94,15 +99,15 @@ struct SendPendingScreen: View { .task { if paymentHash == nil { do { - onchainAttempt = try await OnchainSendAttemptService.shared.unresolvedAttempt( + onchainAttempt = try await attemptService.unresolvedAttempt( walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) ) } catch { onchainStateUnavailable = true } - if paykitPaymentRequestId == nil { + if paykitPaymentRequestId == nil, onchainAttempt != nil { do { - if let resolution = try await OnchainSendAttemptService.shared.resumeAcceptedOrdinarySend( + if let resolution = try await attemptService.resumeAcceptedOrdinarySend( walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) ) { applyOrdinarySendResolution(resolution) @@ -147,6 +152,9 @@ struct SendPendingScreen: View { case .unknown, .pending: return "This transaction may have been sent. Its outcome is unknown. Do not send it again." case .accepted: + if ordinarySendResolved { + return "The earlier payment's local details were restored. No new payment was sent." + } if localFollowupUnavailable { return "This payment was sent, but its local details could not be restored. Do not send this payment again." } @@ -159,13 +167,12 @@ struct SendPendingScreen: View { private func applyOrdinarySendResolution(_ resolution: OnchainSendLocalResolution) { guard paymentHash == nil, paykitPaymentRequestId == nil, !ordinarySendResolved, resolution.walletId == OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex), - onchainAttempt == nil || onchainAttempt?.id == resolution.attemptId + onchainAttempt?.id == resolution.attemptId else { return } ordinarySendResolved = true - wallet.sendAmountSats = resolution.amountSats - app.selectedWalletToPayFrom = .onchain - app.contactPaymentContext = resolution.contact.map { ContactPaymentContext(publicKey: $0) } - navigationPath.append(.success(paymentId: resolution.txid)) + foundActivity = .onchain(resolution.activity) + onchainAttempt?.status = .accepted + onchainAttempt?.localFollowupComplete = true } private func applyPendingResolutionIfNeeded(_ resolution: SendSheetPendingResolution?) { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 9dffc92d1..8e59f0e4e 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -262,7 +262,6 @@ struct SendSheet: View { isResumingAcceptedOrdinarySend = true hasValidatedAfterSync = true app.selectedWalletToPayFrom = .onchain - wallet.sendAmountSats = attempt.amountSats replaceRootRoute(with: .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil)) return } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 1ebfcead1..a6f3e2ba9 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import LDKNode import Paykit import XCTest @@ -371,6 +372,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.markOnchainPaymentStarted(request, address: onchainAddress) let inFlightRequestIds = await service.inFlightRequestIds(identity: identity) XCTAssertEqual(inFlightRequestIds, [request.id]) + try await recordAcceptedAttempt(service: service, request: request, txid: String(repeating: "ab", count: 32)) await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) await sdk.waitForSubmissionStart() await fulfillment(of: [proofRemoved], timeout: 1) @@ -398,6 +400,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await recordAcceptedAttempt(service: service, request: request, txid: String(repeating: "ab", count: 32)) await sdk.suspendSubmission() let completionTask = Task { await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) @@ -410,7 +413,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let persistedProof = await store.snapshot().first XCTAssertEqual(persistedProof?.paymentIdentifier, txid) XCTAssertEqual(persistedProof?.proofData, txid) - XCTAssertEqual(persistedProof?.onchainBroadcastAccepted, true) + XCTAssertEqual(persistedProof?.onchainAcceptanceVerified, true) await sdk.resumeSubmission() await completionTask.value @@ -500,6 +503,167 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ) } + func testSavedAcceptedShopProofSubmitsAfterCurrentGuardIsReplaced() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let attemptStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptStore) + let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) + let txid = String(repeating: "ab", count: 32) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + let id = try await attempts.admit( + walletId: "node-0", requestId: request.id, orderId: nil, + address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false + ) + try await attempts.record(.accepted(txid: txid), attemptId: id) + await sdk.setSubmissionFailure(true) + let completed = await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + XCTAssertTrue(completed) + await sdk.waitForSubmissionStart() + await sdk.setIdentityAvailable(false) + try await attempts.acknowledgeLocalFollowup(txid: txid) + _ = try await attempts.admit( + walletId: "node-0", requestId: nil, orderId: nil, + address: "bcrt1qnew", amountSats: 9000, isMaxAmount: false + ) + let countBefore = await sdk.submissionCount() + await sdk.setIdentityAvailable(true) + await sdk.setSubmissionFailure(false) + let restarted = paymentProofService( + sdk: sdk, store: store, attemptService: OnchainSendAttemptService(store: attemptStore) + ) + await restarted.reconcile() + let countAfter = await sdk.submissionCount() + let submitted = await sdk.lastSubmission() + XCTAssertGreaterThan(countAfter, countBefore) + XCTAssertEqual(try proofValues(XCTUnwrap(submitted).proof.exportText()), ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue]) + XCTAssertEqual(attemptStore.snapshot().first?.status, .pending, "Earlier paid proof mutated the later send guard") + } + + func testOnchainProofWriterRequiresExactPositiveAttemptEvidence() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let txid = String(repeating: "ab", count: 32) + for result in [OnchainSendResult.unknown(txid: txid), .rejected(txid: txid, reason: "fixture refusal"), + .accepted(txid: String(repeating: "cd", count: 32))] + { + let store = PaymentProofMemoryStore() + let attempts = OnchainSendAttemptService(store: MemoryAttemptStore()) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + let id = try await attempts.admit( + walletId: "node-0", requestId: request.id, orderId: nil, + address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false + ) + try await attempts.record(result, attemptId: id) + let completed = await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + XCTAssertFalse(completed, "A bare txid became acceptance evidence") + let proof = await store.snapshot().first + XCTAssertNotEqual(proof?.onchainAcceptanceVerified, true) + XCTAssertNil(proof?.proofData) + let count = await sdk.submissionCount() + XCTAssertEqual(count, 0) + } + } + + func testUnmarkedOnchainProofUpgradesOnlyFromExactCurrentPositiveAttempt() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let txid = String(repeating: "ab", count: 32) + for observed in [false, true] { + let store = PaymentProofMemoryStore() + await store.seed([PendingPaykitPaymentProof( + identity: identity, requestId: request.id, paymentEndpointIdentifier: endpoint, + kind: .onchain, paymentStarted: true, paymentIdentifier: txid, proofData: txid, + onchainAcceptanceVerified: nil + )]) + let attempts = OnchainSendAttemptService(store: MemoryAttemptStore()) + let id = try await attempts.admit( + walletId: "node-0", requestId: request.id, orderId: nil, + address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false + ) + try await attempts.record(observed ? .unknown(txid: txid) : .accepted(txid: txid), attemptId: id) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) + if observed { + await service.reconcile() + let unmarked = await store.snapshot().first + XCTAssertNotEqual(unmarked?.onchainAcceptanceVerified, true) + let count = await sdk.submissionCount() + XCTAssertEqual(count, 0) + _ = try await attempts.observeConfirmedTransaction(txid: txid.uppercased()) + } + await service.reconcile() + let upgraded = await store.snapshot().first + XCTAssertEqual(upgraded?.onchainAcceptanceVerified, true) + XCTAssertEqual(upgraded?.paymentIdentifier, txid) + let kinds = await service.completedRequestProofKindsAwaitingSubmission(identity: identity) + XCTAssertEqual(kinds[request.id], .onchain) + } + } + + func testAcceptedShopProofFailureRestartsOriginalActivityAndDurableAck() async throws { + let dbPath = FileManager.default.temporaryDirectory.appendingPathComponent("ShopAcceptedResume-\(UUID())") + await drainCoreServiceQueue() + try FileManager.default.createDirectory(at: dbPath, withIntermediateDirectories: true) + _ = try initDb(basePath: dbPath.path) + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let proofDelivered = expectation(description: "Original positive proof moved to durable SDK request store") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofDelivered.fulfill() + } + } + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let attemptStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptStore) + let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + _ = try await attempts.send( + using: node, address: onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, requestId: request.id, + followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: ["original"], contact: nil, createdAt: 100) + ) { try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) } + await store.failNextSave() + let completed = await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + XCTAssertFalse(completed) + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false) + let failedActivityRestart = paymentProofService( + sdk: sdk, store: store, + attemptService: OnchainSendAttemptService(store: attemptStore, localFollowup: FailingShopActivityFollowup()) + ) + await failedActivityRestart.reconcile() + await fulfillment(of: [proofDelivered], timeout: 2) + let deliveredProofs = await store.snapshot() + XCTAssertTrue(deliveredProofs.isEmpty) + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false) + let restarted = paymentProofService( + sdk: sdk, store: store, attemptService: OnchainSendAttemptService(store: attemptStore) + ) + await restarted.reconcile() + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, true) + let saved = try await Bitkit.CoreService.shared.activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual(saved?.value, request.amountSats) + XCTAssertEqual(saved?.address, onchainAddress) + XCTAssertEqual(saved?.fee, 123) + XCTAssertEqual(node.calls, 1) + await repointCoreToAppStorage() + try? FileManager.default.removeItem(at: dbPath) + } + func testAcceptedResultStorageFailureStillCompletesProofWithExactTxid() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -1067,6 +1231,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await recordAcceptedAttempt(service: service, request: request, txid: String(repeating: "ab", count: 32)) await service.completeOnchainPayment( request, txid: String(repeating: "ab", count: 32), @@ -1328,6 +1493,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextSave() + try await recordAcceptedAttempt(service: service, request: request, txid: String(repeating: "ab", count: 32)) await service.completeOnchainPayment( request, txid: String(repeating: "ab", count: 32), @@ -1352,6 +1518,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextSave() + try await recordAcceptedAttempt(service: service, request: request, txid: String(repeating: "ab", count: 32)) await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) let storedProofs = await store.snapshot() let proof = try XCTUnwrap(storedProofs.first) @@ -1372,6 +1539,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextLoad() + try await recordAcceptedAttempt(service: service, request: request, txid: String(repeating: "ab", count: 32)) await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) let submissionCount = await sdk.submissionCount() let remainingProofs = await store.snapshot() @@ -1391,13 +1559,24 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(identityStatusCallCount, 0) } + private var serviceAttempts: [ObjectIdentifier: OnchainSendAttemptService] = [:] + + private func recordAcceptedAttempt(service: PaykitPaymentProofService, request: PaykitPaymentRequest, txid: String) async throws { + let attempts = try XCTUnwrap(serviceAttempts[ObjectIdentifier(service)]) + let id = try await attempts.admit( + walletId: "node-0", requestId: request.id, orderId: nil, + address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false + ) + try await attempts.record(.accepted(txid: txid), attemptId: id) + } + private func paymentProofService( sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, lightningStatus: PaykitLightningPaymentProofStatus = .unknown, attemptService: OnchainSendAttemptService = OnchainSendAttemptService(store: MemoryAttemptStore()) ) -> PaykitPaymentProofService { - PaykitPaymentProofService( + let service = PaykitPaymentProofService( sdk: sdk, store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), @@ -1405,6 +1584,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { logInfo: { _ in }, logWarning: { _ in } ) + serviceAttempts[ObjectIdentifier(service)] = attemptService + return service } private func paymentRequestRecord( @@ -1472,6 +1653,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } +private struct FailingShopActivityFollowup: OnchainSendLocalFollowupHandling { + func save(_: OnchainSendAttempt) async throws -> OnchainActivity { + throw OnchainSendAttemptError.localFollowupNotSaved + } +} + private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { private var proofs: [PendingPaykitPaymentProof] = [] private var shouldFailNextLoad = false diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index af63fd717..9d8ca28a2 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -55,16 +55,19 @@ final class PaykitPaymentStateBackupTests: XCTestCase { onchainAmountSats: 1000, onchainWalletId: "trezor:android", onchainMatchingTransactionIdsBeforeAttempt: ["previous-transaction"], - onchainBroadcastAccepted: true + onchainAcceptanceVerified: true ) let backup = PaykitPaymentStateBackup( subscriptions: [identity: .init(subscriptions)], pendingProofs: [.init(proof)] ) let data = try JSONEncoder().encode(backup) + let json = String(decoding: data, as: UTF8.self) + XCTAssertTrue(json.contains("\"onchainAcceptanceVerified\":true")) + XCTAssertFalse(json.contains("onchainBroadcastAccepted")) let decoded = try JSONDecoder().decode(PaykitPaymentStateBackup.self, from: data) XCTAssertEqual(decoded.pendingProofs.first?.requestId.billingPeriodStartsAt, "2026-09-24T10:00:00.100Z") - XCTAssertEqual(decoded.pendingProofs.first?.onchainBroadcastAccepted, true) + XCTAssertEqual(decoded.pendingProofs.first?.onchainAcceptanceVerified, true) try PaykitSubscriptionStateStore().restoreBackup(decoded.subscriptions) try await PaykitPaymentProofService.shared.restoreBackup(decoded.pendingProofs) @@ -256,7 +259,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let proof = try XCTUnwrap(backup.pendingProofs.first).restored() XCTAssertTrue(proof.paymentStarted) - XCTAssertNil(proof.onchainBroadcastAccepted) + XCTAssertNil(proof.onchainAcceptanceVerified) XCTAssertEqual(proof.requestId.billingPeriodStartsAt, proof.billingPeriod?.startsAt) XCTAssertEqual(proof.onchainWalletId, "trezor:android") XCTAssertEqual(PaykitPaymentStateBackup.Proof(proof).onchainWalletId, "trezor:android") diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 067e3e90d..2b37acdb9 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -1,6 +1,7 @@ @testable import Bitkit import BitkitCore import LDKNode +import SwiftUI import XCTest /// Regression coverage for the hardware-wallet pending transfer activity. `OnchainActivity.channelId` @@ -44,6 +45,226 @@ final class TransferServiceActivityTests: XCTestCase { ) } + @MainActor + private func makeWallet(attempts: OnchainSendAttemptService) -> WalletViewModel { + WalletViewModel( + transferService: makeService(), sheetViewModel: SheetViewModel(), + feeEstimatesManager: FeeEstimatesManager(), onchainAttemptService: attempts + ) + } + + @MainActor + func testPendingResumeShowsEarlierActivityWithoutSuccessForNewUnsentAmount() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let original = OnchainSendAttemptService(store: store) + _ = try await original.send( + using: node, address: "bcrt1qoriginal", amountSats: 4321, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100) + ) + let restarted = OnchainSendAttemptService(store: store) + do { + _ = try await restarted.send( + using: node, address: "bcrt1qnew-unsent", amountSats: 9999, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false + ) + XCTFail("New ordinary payment reused old acceptance") + } catch {} + let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed + let receivedCallback = Bitkit.LightningService.shared.onchainTransactionReceived + defer { + Bitkit.LightningService.shared.onchainTransactionConfirmed = confirmedCallback + Bitkit.LightningService.shared.onchainTransactionReceived = receivedCallback + } + let wallet = makeWallet(attempts: restarted) + wallet.sendAmountSats = 9999 + var path: [SendRoute] = [] + let resolutionReady = expectation(description: "Actual Pending task durably finishes original activity") + let subscription = OnchainSendAttemptService.localResolutionPublisher.sink { resolution in + if resolution.txid == txid { + resolutionReady.fulfill() + } + } + defer { subscription.cancel() } + let view = SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: nil, + routingCacheResetAttempted: false, attemptService: restarted, + navigationPath: Binding(get: { path }, set: { path = $0 }) + ) + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(ActivityListViewModel()) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) + .environmentObject(SheetViewModel()) + .environmentObject(wallet) + let controller = UIHostingController(rootView: view) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = controller + window.isHidden = false + defer { + window.isHidden = true + window.rootViewController = nil + } + await fulfillment(of: [resolutionReady], timeout: 5) + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertTrue(path.isEmpty, "Original local resume must not navigate to success for the new unsent payment") + XCTAssertEqual(wallet.sendAmountSats, 9999, "Original resume overwrote the new payment's amount") + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + XCTAssertEqual(node.calls, 1) + let saved = try await activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual(saved?.value, 4321) + } + + @MainActor + func testNativeExactObservationFinishesUnknownAndRejectedOrdinaryActivityAndAck() async throws { + let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed + let receivedCallback = Bitkit.LightningService.shared.onchainTransactionReceived + defer { + Bitkit.LightningService.shared.onchainTransactionConfirmed = confirmedCallback + Bitkit.LightningService.shared.onchainTransactionReceived = receivedCallback + } + for (index, status) in [OnchainSendResult.unknown(txid: String(repeating: "ab", count: 32)), + .rejected(txid: String(repeating: "cd", count: 32), reason: "fixture refusal")].enumerated() + { + let store = MemoryAttemptStore() + let txid = index == 0 ? String(repeating: "ab", count: 32) : String(repeating: "cd", count: 32) + let node = AttemptNodeMock(result: status) + _ = try await OnchainSendAttemptService(store: store).send( + using: node, address: "bcrt1qoriginal", amountSats: 4321, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100) + ) + let restarted = OnchainSendAttemptService(store: store) + let wallet = makeWallet(attempts: restarted) + XCTAssertNotNil(Bitkit.LightningService.shared.onchainTransactionReceived, "App observation callback must be installed") + await Bitkit.LightningService.shared.onchainTransactionReceived?(String(repeating: "ef", count: 32)) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) + await Bitkit.LightningService.shared.onchainTransactionReceived?(txid.uppercased()) + XCTAssertEqual(store.snapshot().first?.status, .accepted) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + let saved = try await activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual(saved?.value, 4321) + XCTAssertEqual(saved?.fee, 123) + _ = try await restarted.send( + using: node, address: "bcrt1qnew", amountSats: 9999, satsPerVbyte: 1, + utxosToSpend: nil, isMaxAmount: false + ) + XCTAssertEqual(node.calls, 2, "Exact durable follow-up did not release the guard") + withExtendedLifetime(wallet) {} + } + } + + @MainActor + func testTransferResumeRetainsOriginalBalanceMetadataAfterTrackingFailure() async throws { + for isMax in [false, true] { + transferDefaults.removeObject(forKey: "transfers") + let store = MemoryAttemptStore() + let txid = String(repeating: isMax ? "cd" : "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let attempts = OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }) + let order = IBtOrder.mock() + let initialTotal = isMax ? UInt64(20000) : order.feeSat + 123 + let vm = TransferViewModel( + transferService: makeService(), sheetViewModel: SheetViewModel(), + onchainAttemptService: attempts, onchainSender: node, onchainBalanceProvider: { 50000 } + ) + transferDefaults.set(Data("broken-test-transfer-store".utf8), forKey: "transfers") + do { + try await vm.payOrder( + order: order, speed: .normal, txFee: 123, satsPerVbyte: 2, + isMaxAmount: isMax, maxSendableAmount: isMax ? initialTotal - 123 : nil + ) + XCTFail("Tracking failure was not surfaced") + } catch {} + XCTAssertEqual(node.calls, 1) + transferDefaults.removeObject(forKey: "transfers") + let restarted = TransferViewModel( + transferService: makeService(), sheetViewModel: SheetViewModel(), + onchainAttemptService: OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }), + onchainSender: node, onchainBalanceProvider: { 4000 } + ) + try await restarted.payOrder( + order: order, speed: .normal, txFee: 999, satsPerVbyte: 9, + isMaxAmount: false, maxSendableAmount: nil + ) + let tracking = try XCTUnwrap(Bitkit.TransferStorage(defaults: transferDefaults).getAll().first) + XCTAssertEqual(tracking.txTotalSats, initialTotal) + XCTAssertEqual(tracking.preTransferOnchainSats, 50000) + XCTAssertEqual(tracking.fundingTxId, txid) + XCTAssertEqual(node.calls, 1, "Transfer local resume dispatched funding twice") + } + } + + @MainActor + func testAcceptedOrderFollowupKeepsAdmissionContextWhenWalletChangesAfterDispatch() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let order = IBtOrder.mock() + node.onSend = { + XCTAssertEqual(store.snapshot().first?.walletId, OnchainSendAttemptService.walletId(index: 0)) + node.currentWalletIndex = 1 + node.dispatchNode = NSObject() + } + let vm = TransferViewModel( + transferService: makeService(), sheetViewModel: SheetViewModel(), + onchainAttemptService: OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }), + onchainSender: node, onchainBalanceProvider: { node.currentWalletIndex == 0 ? 50000 : 4000 } + ) + do { + try await vm.payOrder(order: order, speed: .normal, txFee: 123, satsPerVbyte: 2) + } catch { + XCTFail("Accepted original order used the selected wallet after dispatch: \(error)") + } + let original = store.snapshot().first + XCTAssertEqual(original?.walletId, OnchainSendAttemptService.walletId(index: 0)) + XCTAssertEqual(original?.txid, txid) + XCTAssertEqual(original?.localFollowupComplete, true) + let tracking = try Bitkit.TransferStorage(defaults: transferDefaults).getAll().first + XCTAssertEqual(tracking?.fundingTxId, txid) + XCTAssertEqual(tracking?.txTotalSats, order.feeSat + 123) + XCTAssertEqual(tracking?.preTransferOnchainSats, 50000) + XCTAssertEqual(node.calls, 1) + } + + @MainActor + func testConfirmedAcceptedOrderRestoresTrackingAndAckWithoutRepayment() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let order = IBtOrder.mock() + let vm = TransferViewModel( + transferService: makeService(), sheetViewModel: SheetViewModel(), + onchainAttemptService: OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }), + onchainSender: node, onchainBalanceProvider: { 50000 } + ) + transferDefaults.set(Data("broken-test-transfer-store".utf8), forKey: "transfers") + do { + try await vm.payOrder(order: order, speed: .normal, txFee: 123, satsPerVbyte: 2) + XCTFail("Tracking failure was not surfaced") + } catch {} + transferDefaults.removeObject(forKey: "transfers") + let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed + let receivedCallback = Bitkit.LightningService.shared.onchainTransactionReceived + defer { + Bitkit.LightningService.shared.onchainTransactionConfirmed = confirmedCallback + Bitkit.LightningService.shared.onchainTransactionReceived = receivedCallback + } + let wallet = makeWallet(attempts: OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false })) + await Bitkit.LightningService.shared.onchainTransactionConfirmed?(txid) + let tracking = try Bitkit.TransferStorage(defaults: transferDefaults).getAll().first + XCTAssertEqual(tracking?.fundingTxId, txid) + XCTAssertEqual(tracking?.txTotalSats, order.feeSat + 123) + XCTAssertEqual(tracking?.preTransferOnchainSats, 50000) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + XCTAssertEqual(node.calls, 1) + withExtendedLifetime(wallet) {} + } + func testAcceptedOrdinaryRestartResumesStoredContextAndDurablyAcknowledgesActivity() async throws { let store = MemoryAttemptStore() let txid = String(repeating: "ab", count: 32) From 9c84067ccffd7ee0343906bc4568d0027a4c2cd5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 04:55:44 +0200 Subject: [PATCH 05/77] fix: require observed hardware shop payments --- Bitkit/Managers/HwWalletManager.swift | 8 + .../Services/PaykitPaymentProofService.swift | 148 ++++++++++- Bitkit/ViewModels/HwFundingSigner.swift | 14 ++ .../Views/Wallets/Send/HwSendSignView.swift | 41 ++- .../Wallets/Send/SendPendingScreen.swift | 83 ++++-- Bitkit/Views/Wallets/Send/SendSheet.swift | 82 ++++-- BitkitTests/HwFundingSignerTests.swift | 43 ++++ BitkitTests/HwWalletManagerTests.swift | 30 +++ .../PaykitPaymentProofServiceTests.swift | 237 +++++++++++++++++- .../TransferServiceActivityTests.swift | 160 ++++++++++++ 10 files changed, 788 insertions(+), 58 deletions(-) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index f2419e415..fe17bb9b4 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -959,6 +959,14 @@ final class HwWalletManager { ) } + nonisolated static func persistedFundingAccount(walletId: String) throws -> HwFundingAccount { + let entries = TrezorKnownDeviceStorage.loadAll(walletId: walletId) + guard let xpub = entries.compactMap({ $0.xpubs[hwFundingDefaultAddressType.stringValue] }).first else { + throw PaykitPaymentRequestError.requestUnavailable + } + return HwFundingAccount(xpub: xpub, addressType: hwFundingDefaultAddressType, balanceSats: 0) + } + func watcherReceiveAddress( walletId: String, addressType: AddressScriptType = hwFundingDefaultAddressType diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 52c9cbb7f..a6c84fbe3 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1,3 +1,4 @@ +import BitkitCore import Combine import CryptoKit import Foundation @@ -18,6 +19,28 @@ struct PaykitOnchainPaymentResolution: Equatable { let identity: String let requestId: PaykitPaymentRequest.ID let transactionId: String + var walletId: String = WalletScope.default +} + +protocol PaykitHardwareTransactionLookingUp: Sendable { + func hasWallet(walletId: String) -> Bool + func transactionDetail(walletId: String, txid: String) async throws -> TransactionDetail +} + +struct PaykitHardwareTransactionLookup: PaykitHardwareTransactionLookingUp { + func hasWallet(walletId: String) -> Bool { + (try? HwWalletManager.persistedFundingAccount(walletId: walletId)) != nil + } + + func transactionDetail(walletId: String, txid: String) async throws -> TransactionDetail { + let account = try HwWalletManager.persistedFundingAccount(walletId: walletId) + let electrumUrl = OnChainHwService.getElectrumUrl() + let network = OnChainHwService.appDefaultCoinType + return try await OnChainHwService.shared.getTransactionDetail( + extendedKey: account.xpub, electrumUrl: electrumUrl, txid: txid, + network: network, scriptType: account.accountType + ) + } } struct PendingPaykitPaymentProof: Codable, Equatable { @@ -31,7 +54,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var proofData: String? var onchainAddress: String? var onchainAmountSats: UInt64? - let onchainWalletId: String? + var onchainWalletId: String? var onchainMatchingTransactionIdsBeforeAttempt: Set? var onchainAcceptanceVerified: Bool? @@ -180,6 +203,7 @@ actor PaykitPaymentProofService { private let sdk: any PaykitPaymentProofSdkHandling private let store: any PaykitPaymentProofStoring private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp + private let hardwareTransactionLookup: any PaykitHardwareTransactionLookingUp private let attemptService: OnchainSendAttemptService private let logInfo: @Sendable (String) -> Void private let logWarning: @Sendable (String) -> Void @@ -191,7 +215,9 @@ actor PaykitPaymentProofService { func restoreBackup(_ proofs: [PaykitPaymentStateBackup.Proof]) async throws { let restoredProofs = try proofs.map { try $0.restored() } - let unsupportedWalletProofCount = restoredProofs.filter(\.hasUnsupportedOnchainWallet).count + let unsupportedWalletProofCount = restoredProofs.filter { + $0.hasUnsupportedOnchainWallet && $0.onchainWalletId.map { hardwareTransactionLookup.hasWallet(walletId: $0) } != true + }.count if unsupportedWalletProofCount > 0 { logWarning("Retained \(unsupportedWalletProofCount) pending Paykit payment proof(s) for another wallet") } @@ -202,6 +228,7 @@ actor PaykitPaymentProofService { sdk: any PaykitPaymentProofSdkHandling = PaykitSdkService.shared, store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(), lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), + hardwareTransactionLookup: any PaykitHardwareTransactionLookingUp = PaykitHardwareTransactionLookup(), attemptService: OnchainSendAttemptService = .shared, logInfo: @escaping @Sendable (String) -> Void = { Logger.info($0, context: "PaykitPaymentProof") @@ -213,6 +240,7 @@ actor PaykitPaymentProofService { self.sdk = sdk self.store = store self.lightningPaymentLookup = lightningPaymentLookup + self.hardwareTransactionLookup = hardwareTransactionLookup self.attemptService = attemptService self.logInfo = logInfo self.logWarning = logWarning @@ -336,14 +364,31 @@ actor PaykitPaymentProofService { try await persist(pendingProofs) } - func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws { + func markOnchainPaymentStarted( + _ request: PaykitPaymentRequest, + address: String, + hardwareWalletId: String? = nil, + paymentIdentity: String? = nil + ) async throws { try await mutationLock.withLock { - try await markOnchainPaymentStartedLocked(request, address: address) + try await markOnchainPaymentStartedLocked(request, address: address, hardwareWalletId: hardwareWalletId, paymentIdentity: paymentIdentity) } } - private func markOnchainPaymentStartedLocked(_ request: PaykitPaymentRequest, address: String) async throws { + private func markOnchainPaymentStartedLocked( + _ request: PaykitPaymentRequest, + address: String, + hardwareWalletId: String?, + paymentIdentity: String? + ) async throws { + if let hardwareWalletId { + guard hardwareWalletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: hardwareWalletId) + else { throw PaykitPaymentRequestError.requestUnavailable } + } let identity = try await currentIdentity() + if hardwareWalletId != nil { + guard PubkyPublicKeyFormat.matches(identity, paymentIdentity) else { throw PaykitPaymentRequestError.requestUnavailable } + } var pendingProofs = try await loadProofs() guard let index = pendingProofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && @@ -359,9 +404,70 @@ actor PaykitPaymentProofService { pendingProofs[index].paymentStarted = true pendingProofs[index].onchainAddress = address pendingProofs[index].onchainAmountSats = request.amountSats + pendingProofs[index].onchainWalletId = hardwareWalletId try await persist(pendingProofs) } + @discardableResult + func completeHardwareOnchainPayment(_ request: PaykitPaymentRequest, paymentIdentity: String, walletId: String, txid: String) async -> Bool { + guard let identity = PubkyPublicKeyFormat.normalized(paymentIdentity) else { return false } + return await completeHardwareOnchainPayment(requestId: request.id, identity: identity, walletId: walletId, txid: txid) + } + + private func completeHardwareOnchainPayment( + requestId: PaykitPaymentRequest.ID, + identity: String, + walletId: String, + txid: String + ) async -> Bool { + await mutationLock.withLock { + guard walletId != WalletScope.default, Self.isHex(txid, byteCount: 32) else { return false } + do { + var proofs = try await loadProofs() + guard let index = proofs.lastIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && $0.paymentStarted && $0.onchainWalletId == walletId && + ($0.paymentIdentifier == nil || $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame) && + ($0.proofData == nil || $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame) + }) else { return false } + if proofs[index].onchainAcceptanceVerified == true, proofs[index].proofData != nil { + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: proofs[index].identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId + )) + submitInBackground(proofs[index]) + return true + } + + // Core's broadcast return is a candidate, not acceptance. Retain it for an + // exact lookup of this original account; paymentStarted still prevents repayment. + if proofs[index].paymentIdentifier == nil { + proofs[index].paymentIdentifier = txid.lowercased() + try await persist(proofs) + } + let original = proofs[index] + let detail = try await hardwareTransactionLookup.transactionDetail(walletId: walletId, txid: txid) + guard detail.txid.caseInsensitiveCompare(txid) == .orderedSame, detail.sent > 0 else { return false } + + // The lookup suspends. Update only the same retained proof, and keep other + // request records that may have changed during that observation. + proofs = try await loadProofs() + guard let currentIndex = proofs.firstIndex(of: original) else { return false } + proofs[currentIndex].proofData = txid.lowercased() + proofs[currentIndex].onchainAcceptanceVerified = true + let completed = proofs[currentIndex] + try await persist(proofs) + submitInBackground(completed) + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: completed.identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId + )) + return true + } catch { + logWarning("Hardware Paykit payment remains pending exact transaction observation: \(error)") + return false + } + } + } + func completeLightningPayment(paymentHash: String, preimage: String?) async { guard let preimage, Self.preimage(preimage, matchesPaymentHash: paymentHash) @@ -552,6 +658,23 @@ actor PaykitPaymentProofService { } for proof in identityProofs { do { + if proof.kind == .onchain, proof.paymentStarted, + let walletId = proof.onchainWalletId, walletId != WalletScope.default, + let txid = proof.paymentIdentifier + { + guard hardwareTransactionLookup.hasWallet(walletId: walletId) else { continue } + if proof.onchainAcceptanceVerified == true, proof.proofData != nil { + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: proof.identity, requestId: proof.requestId, transactionId: txid, walletId: walletId + )) + await submit(proof) + } else { + _ = await completeHardwareOnchainPayment( + requestId: proof.requestId, identity: proof.identity, walletId: walletId, txid: txid + ) + } + continue + } guard !proof.hasUnsupportedOnchainWallet else { continue } if proof.proofData != nil { if proof.kind == .onchain, proof.onchainAcceptanceVerified != true { @@ -617,7 +740,8 @@ actor PaykitPaymentProofService { identity: String ) async -> [PendingPaykitPaymentProof] { let candidates = pendingProofs.filter { - $0.hasUnsupportedOnchainWallet && PubkyPublicKeyFormat.matches($0.identity, identity) + $0.hasUnsupportedOnchainWallet && PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.onchainWalletId.map { hardwareTransactionLookup.hasWallet(walletId: $0) } != true } guard !candidates.isEmpty else { return pendingProofs } @@ -700,6 +824,12 @@ actor PaykitPaymentProofService { } } + func pendingOnchainPayment(requestId: PaykitPaymentRequest.ID, identity: String) async throws -> PendingPaykitPaymentProof? { + try await loadProofs().last { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && $0.paymentStarted + } + } + func protectedRequestIdsForSubscriptionCancellation( identity: String, subscriptionId: PaykitSubscription.ID @@ -741,7 +871,9 @@ actor PaykitPaymentProofService { @discardableResult private func submit(_ pendingProof: PendingPaykitPaymentProof) async -> Bool { - guard !pendingProof.hasUnsupportedOnchainWallet, let proofData = pendingProof.proofData else { return false } + guard !pendingProof.hasUnsupportedOnchainWallet || + pendingProof.onchainWalletId.map({ hardwareTransactionLookup.hasWallet(walletId: $0) }) == true, + let proofData = pendingProof.proofData else { return false } guard pendingProof.kind != .onchain || pendingProof.onchainAcceptanceVerified == true else { return false } do { guard let identityStatus = try await sdk.identityStatus(), @@ -847,7 +979,7 @@ actor PaykitPaymentProofService { await removeProofs { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == proof.requestId && - !$0.hasUnsupportedOnchainWallet + (!$0.hasUnsupportedOnchainWallet || $0 == proof) } } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index d941469d4..f2b0501e5 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -533,6 +533,20 @@ final class HwSendCoordinator { isPassphraseRequired = false } + func completionRoute( + result: HwFundingBroadcastResult, + walletId: String, + requestId: PaykitPaymentRequest.ID?, + paymentIdentity: String?, + completeContactPayment: (String) async -> Bool + ) async -> SendRoute { + let verified = await completeContactPayment(result.txId) + if let requestId, !verified { + return .hardwarePending(requestId: requestId, walletId: walletId, transactionId: result.txId, paymentIdentity: paymentIdentity) + } + return .success(paymentId: result.txId, walletId: walletId) + } + func completeBroadcast() { pendingPayment = nil isBroadcastUnresolved = false diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 47d13c8e2..2f3d8dd52 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -5,12 +5,15 @@ struct HwSendSignView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var tagManager: TagManager @EnvironmentObject private var wallet: WalletViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] let hwSend: HwSendCoordinator + let contactPaymentRequestId: PaykitPaymentRequest.ID? + let contactPaymentIdentity: String? let prepareContactPayment: () async throws -> Void - let completeContactPayment: (String) async -> Void + let completeContactPayment: (String) async -> Bool let cancelContactPayment: () async -> Void @State private var signingTask: Task? @State private var passphraseTask: Task? @@ -108,8 +111,11 @@ struct HwSendSignView: View { return } let contactPublicKey = app.contactPaymentContext?.publicKey + let requestId = contactPaymentRequestId + let tags = tagManager.selectedTagsArray do { + var proofVerified = requestId == nil let result = try await hwSend.signAndBroadcast( manager: hwWalletManager, address: invoice.address, @@ -117,18 +123,27 @@ struct HwSendSignView: View { satsPerVByte: UInt64(feeRate), beforeBroadcast: prepareContactPayment, afterBroadcast: { result in - await completeContactPayment(result.txId) + proofVerified = await completeContactPayment(result.txId) } ) - await recordSentPayment( + await Self.recordPaymentResult( result, walletId: walletId, address: invoice.address, amount: amount, - contactPublicKey: contactPublicKey + contactPublicKey: contactPublicKey, + tags: tags, + requestId: requestId, + proofVerified: proofVerified ) hwSend.completeBroadcast() - navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) + let completionRoute = await hwSend.completionRoute( + result: result, walletId: walletId, requestId: requestId, paymentIdentity: contactPaymentIdentity, + completeContactPayment: { _ in + proofVerified && (requestId == nil || PubkyPublicKeyFormat.matches(pubkyProfile.publicKey, contactPaymentIdentity)) + } + ) + navigationPath.append(completionRoute) } catch is CancellationError { await cancelContactPaymentIfBroadcastIsRetryable() return @@ -188,17 +203,20 @@ struct HwSendSignView: View { } } - private func recordSentPayment( + static func recordPaymentResult( _ result: HwFundingBroadcastResult, walletId: String, address: String, amount: UInt64, - contactPublicKey: String? + contactPublicKey: String?, + tags: [String], + requestId: PaykitPaymentRequest.ID?, + proofVerified: Bool ) async { let metadata = PreActivityMetadata( walletId: walletId, paymentId: result.txId, - tags: tagManager.selectedTagsArray, + tags: tags, paymentHash: nil, txId: result.txId, address: address, @@ -210,6 +228,9 @@ struct HwSendSignView: View { ) try? await CoreService.shared.activity.addPreActivityMetadata(metadata) + // Retain original metadata for an eventual exact observation. A Shop payment's + // local Core txid alone must not create a Sent row. + guard requestId == nil || proofVerified else { return } await CoreService.shared.activity.createSentOnchainActivityFromSendResult( txid: result.txId, address: address, @@ -219,10 +240,10 @@ struct HwSendSignView: View { contact: contactPublicKey, walletId: walletId ) - if !tagManager.selectedTagsArray.isEmpty { + if !tags.isEmpty { try? await CoreService.shared.activity.appendTags( toActivity: result.txId, - tagManager.selectedTagsArray, + tags, walletId: walletId ) } diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index f00a96756..ecb820cfa 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -30,6 +30,10 @@ struct SendPendingScreen: View { let paykitPaymentRequestId: PaykitPaymentRequest.ID? let routingCacheResetAttempted: Bool var attemptService: OnchainSendAttemptService = .shared + var hardwareWalletId: String? + var hardwareTransactionId: String? + var hardwarePaymentIdentity: String? + var proofService: PaykitPaymentProofService = .shared @Binding var navigationPath: [SendRoute] @EnvironmentObject private var activityList: ActivityListViewModel @@ -44,12 +48,25 @@ struct SendPendingScreen: View { @State private var onchainStateUnavailable = false @State private var ordinarySendResolved = false @State private var localFollowupUnavailable = false + @State private var pendingOnchainProof: PendingPaykitPaymentProof? + + private var pendingHardwareWalletId: String? { + hardwareWalletId ?? pendingOnchainProof?.onchainWalletId.flatMap { $0 == WalletScope.default ? nil : $0 } + } + + private var pendingAmountSats: UInt64? { + pendingHardwareWalletId == nil ? onchainAttempt?.amountSats ?? wallet.sendAmountSats : pendingOnchainProof?.onchainAmountSats + } + + private var pendingTransactionId: String? { + pendingHardwareWalletId == nil ? onchainAttempt?.txid : pendingOnchainProof?.paymentIdentifier ?? hardwareTransactionId + } var body: some View { VStack(alignment: .leading, spacing: 0) { SheetHeader(title: t("wallet__send_pending"), showBackButton: false) - if let sendAmountSats = onchainAttempt?.amountSats ?? wallet.sendAmountSats { + if let sendAmountSats = pendingAmountSats { if onchainAttempt?.requestId == nil, onchainAttempt != nil { BodySSBText("Earlier on-chain payment") .accessibilityIdentifier("EarlierOnchainPayment") @@ -60,7 +77,7 @@ struct SendPendingScreen: View { if paymentHash == nil { BodyMText(onchainPendingMessage) - if let txid = onchainAttempt?.txid { + if let txid = pendingTransactionId { BodySSBText("Transaction ID: \(txid)") .textSelection(.enabled) } @@ -98,22 +115,43 @@ struct SendPendingScreen: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .task { if paymentHash == nil { - do { - onchainAttempt = try await attemptService.unresolvedAttempt( - walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) - ) - } catch { - onchainStateUnavailable = true + if let requestId = paykitPaymentRequestId { + if let identity = hardwarePaymentIdentity ?? pubkyProfile.publicKey { + do { + pendingOnchainProof = try await proofService.pendingOnchainPayment(requestId: requestId, identity: identity) + } catch { onchainStateUnavailable = true } + } else { + onchainStateUnavailable = true + } } - if paykitPaymentRequestId == nil, onchainAttempt != nil { + if let walletId = pendingHardwareWalletId { + if let proofWalletId = pendingOnchainProof?.onchainWalletId, proofWalletId != walletId { + onchainStateUnavailable = true + } + if let expected = hardwareTransactionId, let stored = pendingOnchainProof?.paymentIdentifier, + expected.caseInsensitiveCompare(stored) != .orderedSame + { + onchainStateUnavailable = true + } + if !onchainStateUnavailable, let txid = pendingTransactionId, + let activity = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid, walletId: walletId) + { + foundActivity = .onchain(activity) + } + } else if !onchainStateUnavailable { do { - if let resolution = try await attemptService.resumeAcceptedOrdinarySend( + onchainAttempt = try await attemptService.unresolvedAttempt( walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) - ) { - applyOrdinarySendResolution(resolution) - } - } catch { - localFollowupUnavailable = true + ) + } catch { onchainStateUnavailable = true } + if paykitPaymentRequestId == nil, onchainAttempt != nil { + do { + if let resolution = try await attemptService.resumeAcceptedOrdinarySend( + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) + ) { + applyOrdinarySendResolution(resolution) + } + } catch { localFollowupUnavailable = true } } } } @@ -131,13 +169,21 @@ struct SendPendingScreen: View { let identity = pubkyProfile.publicKey, PubkyPublicKeyFormat.matches(resolution.identity, identity) else { return } + if let walletId = pendingHardwareWalletId { + guard resolution.walletId == walletId, + let txid = pendingTransactionId, + txid.caseInsensitiveCompare(resolution.transactionId) == .orderedSame, + PubkyPublicKeyFormat.matches(resolution.identity, hardwarePaymentIdentity ?? pendingOnchainProof?.identity) + else { return } + } app.addPendingContactPaymentContext( resolution.transactionId, context: ContactPaymentContext(publicKey: resolution.requestId.counterparty) ) Task { - await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) - navigationPath.append(.success(paymentId: resolution.transactionId)) + await proofService.consumeOnchainPaymentResolution(resolution) + guard PubkyPublicKeyFormat.matches(resolution.identity, pubkyProfile.publicKey) else { return } + navigationPath.append(.success(paymentId: resolution.transactionId, walletId: resolution.walletId)) } } } @@ -146,6 +192,9 @@ struct SendPendingScreen: View { if onchainStateUnavailable { return "The on-chain payment state could not be read. Do not send another payment until it is checked." } + if pendingHardwareWalletId != nil { + return "This hardware-wallet transaction is awaiting verified payment follow-up. Do not send this payment again." + } switch onchainAttempt?.status { case .rejected: return "The backend rejected this transaction. It may still have reached the network. Do not send it again. \(onchainAttempt?.rejectionReason ?? "")" diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 8e59f0e4e..b3983fc9d 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -66,6 +66,7 @@ enum SendRoute: Hashable { paymentRequest: String?, paykitPaymentRequestId: PaykitPaymentRequest.ID? = nil ) + case hardwarePending(requestId: PaykitPaymentRequest.ID, walletId: String, transactionId: String, paymentIdentity: String?) case success(paymentId: String, walletId: String = WalletScope.default) case failure(SendFailureContext) case lnurlPayAmount @@ -104,6 +105,7 @@ struct SendSheet: View { @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var tagManager: TagManager @EnvironmentObject private var wallet: WalletViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager @Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager @Environment(HwWalletManager.self) private var hwWalletManager @Environment(TrezorManager.self) private var trezorManager @@ -712,12 +714,29 @@ struct SendSheet: View { routingCacheResetAttempted: routingCacheResetAttempted ) case .hardwareSign: + let contactContext = app.contactPaymentContext + let address = app.scannedOnchainInvoice?.address + let walletId = hwSend.walletId + let paymentIdentity = pubkyProfile.publicKey HwSendSignView( navigationPath: $navigationPath, hwSend: hwSend, - prepareContactPayment: prepareHardwareContactPayment, - completeContactPayment: completeHardwareContactPayment, - cancelContactPayment: cancelHardwareContactPayment + contactPaymentRequestId: contactContext?.incomingPaymentRequest?.id, + contactPaymentIdentity: paymentIdentity, + prepareContactPayment: { + try await prepareHardwareContactPayment( + context: contactContext, + address: address, + walletId: walletId, + paymentIdentity: paymentIdentity + ) + }, + completeContactPayment: { txid in + await completeHardwareContactPayment(context: contactContext, walletId: walletId, paymentIdentity: paymentIdentity, txid: txid) + }, + cancelContactPayment: { + await cancelHardwareContactPayment(context: contactContext) + } ) case .feeRate: SendFeeRate(navigationPath: $navigationPath, hwSend: hwSend) @@ -743,6 +762,14 @@ struct SendSheet: View { routingCacheResetAttempted: routingCacheResetAttempted, navigationPath: $navigationPath ) + case let .hardwarePending(requestId, walletId, transactionId, paymentIdentity): + SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId, + routingCacheResetAttempted: routingCacheResetAttempted, + hardwareWalletId: walletId, hardwareTransactionId: transactionId, + hardwarePaymentIdentity: paymentIdentity, + navigationPath: $navigationPath + ) case let .success(paymentId, walletId): SendSuccess( paymentId: paymentId, @@ -792,7 +819,11 @@ struct SendSheet: View { } private func prepareIncomingPaymentRequest() async throws { - guard let context = app.contactPaymentContext, + try await prepareIncomingPaymentRequest(context: app.contactPaymentContext) + } + + private func prepareIncomingPaymentRequest(context: ContactPaymentContext?) async throws { + guard let context, let request = context.incomingPaymentRequest else { return } guard !paykitPaymentRequestManager.isApprovedForPayment(request) else { return } @@ -806,13 +837,20 @@ struct SendSheet: View { } } - private func prepareHardwareContactPayment() async throws { - guard let request = app.contactPaymentContext?.incomingPaymentRequest, - let address = app.scannedOnchainInvoice?.address - else { - try await prepareIncomingPaymentRequest() + private func prepareHardwareContactPayment( + context: ContactPaymentContext?, + address: String?, + walletId: String?, + paymentIdentity: String? + ) async throws { + guard let request = context?.incomingPaymentRequest else { + try await prepareIncomingPaymentRequest(context: context) return } + guard let address, let walletId, let paymentIdentity, + PubkyPublicKeyFormat.matches(pubkyProfile.publicKey, paymentIdentity) + else { throw PaykitPaymentRequestError.requestUnavailable } + _ = try hwWalletManager.getFundingAccount(walletId: walletId) let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue try await PaykitPaymentProofService.shared.prepare( @@ -821,28 +859,32 @@ struct SendSheet: View { kind: .onchain ) do { - try await prepareIncomingPaymentRequest() - try await PaykitPaymentProofService.shared.markOnchainPaymentStarted(request, address: address) + try await prepareIncomingPaymentRequest(context: context) + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( + request, address: address, hardwareWalletId: walletId, paymentIdentity: paymentIdentity + ) } catch { await PaykitPaymentProofService.shared.cancelPreparation(request) throw error } } - private func completeHardwareContactPayment(txid: String) async { - guard let request = app.contactPaymentContext?.incomingPaymentRequest, - let address = app.scannedOnchainInvoice?.address - else { return } + private func completeHardwareContactPayment(context: ContactPaymentContext?, walletId: String?, paymentIdentity: String?, + txid: String) async -> Bool + { + guard let request = context?.incomingPaymentRequest else { return true } + guard let walletId, let paymentIdentity else { return false } - await PaykitPaymentProofService.shared.completeOnchainPayment( + return await PaykitPaymentProofService.shared.completeHardwareOnchainPayment( request, - txid: txid, - paymentEndpointIdentifier: PublicPaykitService.onchainMethodId(for: address).rawValue + paymentIdentity: paymentIdentity, + walletId: walletId, + txid: txid ) } - private func cancelHardwareContactPayment() async { - guard let request = app.contactPaymentContext?.incomingPaymentRequest else { return } + private func cancelHardwareContactPayment(context: ContactPaymentContext?) async { + guard let request = context?.incomingPaymentRequest else { return } await PaykitPaymentProofService.shared.cancelPreparation(request) } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 8ed3139a8..fe8adc43b 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -187,6 +187,49 @@ final class HwFundingSignerTests: XCTestCase { try await assertCoordinatorRetryReusesSignedPayment(error: HwTransferError.broadcastUncertain) } + func testCoordinatorRoutesOnlyUnverifiedShopBroadcastToPending() async throws { + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: UUID().uuidString, counterparty: "merchant", counterpartyReceiverPath: "bitkit/server", billingPeriodStartsAt: nil + ) + for (request, verified) in [(Optional(requestId), false), (Optional(requestId), true), (nil, false)] { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let coordinator = HwSendCoordinator(walletId: "trezor:original-ios-wallet", signerFactory: { [self] _, address, rate in + makeSigner(funding: funding, connecting: connecting, feeRate: rate, address: address) + }) + var route: SendRoute? + var completionTxids: [String] = [] + let result = try await coordinator.signAndBroadcast( + manager: HwWalletManager(), address: "bc1qoriginal", sats: 42000, satsPerVByte: 2, + afterBroadcast: { result in + route = await coordinator.completionRoute( + result: result, walletId: "trezor:original-ios-wallet", requestId: request, paymentIdentity: "original-payer", + completeContactPayment: { txid in + completionTxids.append(txid) + return verified + } + ) + } + ) + if let request, !verified { + XCTAssertEqual( + route, + .hardwarePending( + requestId: request, + walletId: "trezor:original-ios-wallet", + transactionId: result.txId, + paymentIdentity: "original-payer" + ) + ) + } else { + XCTAssertEqual(route, .success(paymentId: result.txId, walletId: "trezor:original-ios-wallet")) + } + XCTAssertEqual(completionTxids, [result.txId]) + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertEqual(funding.signCalls, 1) + } + } + func testCoordinatorRetryReusesSignedPaymentAfterConnectivityFailure() async throws { try await assertCoordinatorRetryReusesSignedPayment( error: BroadcastError.ElectrumError(errorDetails: "offline") diff --git a/BitkitTests/HwWalletManagerTests.swift b/BitkitTests/HwWalletManagerTests.swift index f81c597ef..7a0fee867 100644 --- a/BitkitTests/HwWalletManagerTests.swift +++ b/BitkitTests/HwWalletManagerTests.swift @@ -115,6 +115,36 @@ final class HwWalletManagerTests: XCTestCase { // MARK: - Factories + func testPersistedFundingAccountResolvesOriginalWalletAndFailsWhenUnpaired() throws { + let key = "trezor.knownDevices" + let previous = UserDefaults.standard.data(forKey: key) + defer { + if let previous { + UserDefaults.standard.set(previous, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + let original = TrezorKnownDevice( + id: "original-device", name: "original", path: "ble:original", transportType: "bluetooth", + lastConnectedAt: Date(timeIntervalSince1970: 1), xpubs: ["nativeSegwit": "original-account"], + walletId: "trezor:original-ios-wallet" + ) + let later = TrezorKnownDevice( + id: "later-device", name: "later", path: "ble:later", transportType: "bluetooth", + lastConnectedAt: Date(timeIntervalSince1970: 2), xpubs: ["nativeSegwit": "later-account"], + walletId: "trezor:later-ios-wallet" + ) + try UserDefaults.standard.set(JSONEncoder().encode([later, original]), forKey: key) + let account = try HwWalletManager.persistedFundingAccount(walletId: "trezor:original-ios-wallet") + XCTAssertEqual(account.xpub, "original-account") + XCTAssertEqual(account.accountType, .nativeSegwit) + + try UserDefaults.standard.set(JSONEncoder().encode([later]), forKey: key) + XCTAssertThrowsError(try HwWalletManager.persistedFundingAccount(walletId: "trezor:original-ios-wallet")) + XCTAssertEqual(account.xpub, "original-account", "An account captured before suspension cannot follow the new wallet") + } + private func makeViewModel( watcherService: OnChainWatcherServicing = MockWatcherService(), monitored: Set = ["legacy", "nestedSegwit", "nativeSegwit", "taproot"], diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index a6f3e2ba9..148e3a063 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -12,6 +12,204 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private let preimage = String(repeating: "00", count: 32) private let onchainAddress = "bcrt1qpaymentproof" + private let hardwareWalletId = "trezor:original-ios-wallet" + + func testHardwareCompletionRetainsCapturedPayerAfterProfileSwitch() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint], paymentRequestId: UUID().uuidString) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let txid = String(repeating: "ab", count: 32) + let otherIdentity = "pubky" + String(repeating: "x", count: 52) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + let lookup = PaymentProofHardwareLookup(result: .success(hardwareTransaction(txid: txid))) + let service = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) + func pending(_ payer: String) -> PendingPaykitPaymentProof { + PendingPaykitPaymentProof( + identity: payer, requestId: request.id, paymentEndpointIdentifier: endpoint, kind: .onchain, + paymentStarted: true, paymentIdentifier: nil, proofData: nil, onchainAddress: onchainAddress, + onchainAmountSats: 1000, onchainWalletId: hardwareWalletId + ) + } + await store.seed([pending(identity), pending(otherIdentity)]) + await sdk.setIdentity(otherIdentity) + var resolutions: [PaykitOnchainPaymentResolution] = [] + let subscription = PaykitPaymentProofService.onchainPaymentResolutionPublisher.sink { resolution in + if resolution.requestId == request.id { + resolutions.append(resolution) + } + } + defer { subscription.cancel() } + + _ = await service.completeHardwareOnchainPayment(request, paymentIdentity: identity, walletId: hardwareWalletId, txid: txid) + + let proofs = await store.snapshot() + let original = proofs.first { PubkyPublicKeyFormat.matches($0.identity, identity) } + let other = proofs.first { PubkyPublicKeyFormat.matches($0.identity, otherIdentity) } + XCTAssertEqual(original?.paymentIdentifier, txid) + XCTAssertEqual(original?.proofData, txid) + XCTAssertEqual(original?.onchainAcceptanceVerified, true) + XCTAssertNil(other?.paymentIdentifier) + XCTAssertNil(other?.proofData) + XCTAssertEqual(other?.onchainAcceptanceVerified, false) + let originalResolution = PaykitOnchainPaymentResolution( + identity: identity, requestId: request.id, transactionId: txid, walletId: hardwareWalletId + ) + XCTAssertEqual(resolutions, [originalResolution]) + await service.consumeOnchainPaymentResolution(originalResolution) + await sdk.setIdentity(identity) + await service.reconcile() + XCTAssertEqual( + resolutions, + [originalResolution, originalResolution], + "Returning to the original payer must resume its already verified local result" + ) + let lookups = await lookup.calls() + XCTAssertEqual(lookups.count, 1, "Already verified original follow-up must not repeat payment or observation") + } + + func testHardwareShopProofRequiresExactFreshOriginalWalletObservation() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let txid = String(repeating: "ab", count: 32) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + let lookup = PaymentProofHardwareLookup(result: .success(hardwareTransaction(txid: txid))) + let service = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, hardwareWalletId: hardwareWalletId, paymentIdentity: identity) + + let completed = await service.completeHardwareOnchainPayment(request, paymentIdentity: identity, walletId: hardwareWalletId, txid: txid) + + XCTAssertTrue(completed) + let proof = await store.snapshot().first + XCTAssertEqual(proof?.paymentIdentifier, txid) + XCTAssertEqual(proof?.proofData, txid) + XCTAssertEqual(proof?.onchainAcceptanceVerified, true) + XCTAssertEqual(proof?.onchainWalletId, hardwareWalletId) + let observed = await lookup.calls() + XCTAssertEqual(observed, [.init(walletId: hardwareWalletId, txid: txid)]) + let nativeAttempt = try await serviceAttempts[ObjectIdentifier(service)]?.unresolvedAttempt(walletId: WalletScope.default) + XCTAssertNil(nativeAttempt) + if completed { + await sdk.waitForSubmissionStart() + } + let submissionCount = await sdk.submissionCount() + XCTAssertEqual(submissionCount, 1) + + await sdk.setSubmissionFailure(false) + let restarted = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) + await restarted.reconcile() + let remainingProofs = await store.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + let submitted = await sdk.lastSubmission() + XCTAssertEqual(try submitted.map { try proofValues($0.proof.exportText()) }, ["type": PaykitPaymentProofKind.onchain.rawValue, "data": txid]) + let observationsAfterDelivery = await lookup.calls() + XCTAssertEqual(observationsAfterDelivery.count, 1, "Verified proof delivery needs no second lookup or payment") + } + + func testHardwareMissingMismatchedOrInboundObservationStaysPendingAndRestartUsesOriginalContext() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let txid = String(repeating: "ab", count: 32) + let failures: [Result] = [ + .failure(AccountInfoError.TransactionNotFound(errorDetails: "not observed")), + .failure(PaymentProofStoreMockError.load), + .success(hardwareTransaction(txid: String(repeating: "cd", count: 32))), + .success(hardwareTransaction(txid: txid, sent: 0)), + ] + for result in failures { + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + let lookup = PaymentProofHardwareLookup(result: result) + let service = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted( + request, + address: onchainAddress, + hardwareWalletId: hardwareWalletId, + paymentIdentity: identity + ) + + let completed = await service.completeHardwareOnchainPayment(request, paymentIdentity: identity, walletId: hardwareWalletId, txid: txid) + XCTAssertFalse(completed) + let pending = await store.snapshot() + XCTAssertEqual(pending.first?.paymentIdentifier, txid) + XCTAssertNil(pending.first?.proofData) + XCTAssertEqual(pending.first?.onchainAcceptanceVerified, false) + XCTAssertEqual(pending.first?.paymentStarted, true) + let submissions = await sdk.submissionCount() + XCTAssertEqual(submissions, 0) + do { + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + XCTFail("Original hardware payment must prevent a second payment") + } catch { XCTAssertEqual(error as? PaykitPaymentRequestError, .operationInProgress) } + + let backup = try JSONDecoder().decode( + [PaykitPaymentStateBackup.Proof].self, + from: JSONEncoder().encode(pending.map(PaykitPaymentStateBackup.Proof.init)) + ) + let restartedLookup = PaymentProofHardwareLookup(result: .success(hardwareTransaction(txid: txid))) + let restarted = paymentProofService(sdk: sdk, store: store, hardwareLookup: restartedLookup) + try await restarted.restoreBackup(backup) + await restarted.reconcile() + let recovered = await store.snapshot().first + XCTAssertEqual(recovered?.proofData, txid) + XCTAssertEqual(recovered?.onchainAcceptanceVerified, true) + let observed = await restartedLookup.calls() + XCTAssertEqual(observed, [.init(walletId: hardwareWalletId, txid: txid)]) + let nativeAttempt = try await serviceAttempts[ObjectIdentifier(restarted)]?.unresolvedAttempt(walletId: WalletScope.default) + XCTAssertNil(nativeAttempt) + } + } + + func testHardwareCandidateCannotBeReplacedByDifferentWalletRequestOrTxid() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let otherRecord = try paymentRequestRecord(endpoints: [endpoint], paymentRequestId: UUID().uuidString) + let otherRequest = try XCTUnwrap(PaykitPaymentRequest(record: otherRecord, now: Date())) + let txid = String(repeating: "ab", count: 32) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record, otherRecord]) + let lookup = PaymentProofHardwareLookup(result: .failure(PaymentProofStoreMockError.load)) + let service = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, hardwareWalletId: hardwareWalletId, paymentIdentity: identity) + _ = await service.completeHardwareOnchainPayment(request, paymentIdentity: identity, walletId: hardwareWalletId, txid: txid) + + let wrongWallet = await service.completeHardwareOnchainPayment(request, paymentIdentity: identity, walletId: "trezor:new-wallet", txid: txid) + let wrongRequest = await service.completeHardwareOnchainPayment( + otherRequest, + paymentIdentity: identity, + walletId: hardwareWalletId, + txid: txid + ) + let wrongTxid = await service.completeHardwareOnchainPayment(request, paymentIdentity: identity, walletId: hardwareWalletId, + txid: String(repeating: "cd", count: 32)) + XCTAssertFalse(wrongWallet) + XCTAssertFalse(wrongRequest) + XCTAssertFalse(wrongTxid) + let proof = await store.snapshot().first + XCTAssertEqual(proof?.paymentIdentifier, txid) + XCTAssertEqual(proof?.onchainWalletId, hardwareWalletId) + let observed = await lookup.calls() + XCTAssertEqual(observed.count, 1) + } + + private func hardwareTransaction(txid: String, sent: UInt64 = 1200) -> TransactionDetail { + TransactionDetail( + txid: txid, received: 100, sent: sent, net: -1100, amount: 1000, fee: 100, + direction: sent > 0 ? .sent : .received, blockHeight: nil, timestamp: nil, confirmations: 0, + inputs: [], outputs: [], size: 112, vsize: 112, weight: 448, feeRate: 1 + ) + } + func testSubscriptionHistoryKeepsPaymentProofKind() throws { let recurrence = PaymentRequestRecurrence( every: 1, @@ -1574,12 +1772,14 @@ final class PaykitPaymentProofServiceTests: XCTestCase { sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, lightningStatus: PaykitLightningPaymentProofStatus = .unknown, + hardwareLookup: any PaykitHardwareTransactionLookingUp = PaymentProofHardwareLookup(result: .failure(PaymentProofStoreMockError.load)), attemptService: OnchainSendAttemptService = OnchainSendAttemptService(store: MemoryAttemptStore()) ) -> PaykitPaymentProofService { let service = PaykitPaymentProofService( sdk: sdk, store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), + hardwareTransactionLookup: hardwareLookup, attemptService: attemptService, logInfo: { _ in }, logWarning: { _ in } @@ -1659,7 +1859,7 @@ private struct FailingShopActivityFollowup: OnchainSendLocalFollowupHandling { } } -private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { +actor PaymentProofMemoryStore: PaykitPaymentProofStoring { private var proofs: [PendingPaykitPaymentProof] = [] private var shouldFailNextLoad = false private var shouldFailNextSave = false @@ -1715,8 +1915,35 @@ private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp } } -private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { - private let identity: String +actor PaymentProofHardwareLookup: PaykitHardwareTransactionLookingUp { + struct Call: Equatable { + let walletId: String + let txid: String + } + + let result: Result + private var observed: [Call] = [] + + init(result: Result) { + self.result = result + } + + nonisolated func hasWallet(walletId: String) -> Bool { + walletId == "trezor:original-ios-wallet" + } + + func transactionDetail(walletId: String, txid: String) throws -> TransactionDetail { + observed.append(Call(walletId: walletId, txid: txid)) + return try result.get() + } + + func calls() -> [Call] { + observed + } +} + +actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { + private var identity: String private var records: [PaymentRequestRecord] private var submissions: [PaymentProofSubmission] = [] private var shouldFailSubmission = false @@ -1742,6 +1969,10 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { return IdentityStatus(publicKey: identity, liveSessionAvailable: true) } + func setIdentity(_ identity: String) { + self.identity = identity + } + func setIdentityAvailable(_ isAvailable: Bool) { isIdentityAvailable = isAvailable } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 2b37acdb9..f085ff1d2 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -119,6 +119,152 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(saved?.value, 4321) } + @MainActor + func testHardwareShopCandidateDoesNotCreateSentActivityUntilVerified() async throws { + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: UUID().uuidString, counterparty: "pubky" + String(repeating: "y", count: 52), + counterpartyReceiverPath: "bitkit/server", billingPeriodStartsAt: nil + ) + let walletId = "trezor:original-ios-wallet" + for (index, request, verified) in [(0, Optional(requestId), false), (1, Optional(requestId), true), (2, nil, false)] { + let txid = String(repeating: ["ab", "cd", "ef"][index], count: 32) + let result = HwFundingBroadcastResult(txId: txid, miningFeeSats: 100, feeRate: 2, totalSpent: 1334) + await HwSendSignView.recordPaymentResult( + result, walletId: walletId, address: "bcrt1qoriginal", amount: 1234, + contactPublicKey: requestId.counterparty, tags: ["original tag"], requestId: request, proofVerified: verified + ) + let sent = try await activity.getOnchainActivityByTxId(txid: txid, walletId: walletId) + if request != nil, !verified { + XCTAssertNil(sent, "The local hardware txid must not become Sent activity without positive observation") + let metadata = try await ServiceQueue.background(.core) { + try BitkitCore.getPreActivityMetadata(walletId: walletId, searchKey: txid, searchByAddress: false) + } + XCTAssertEqual(metadata?.walletId, walletId) + XCTAssertEqual(metadata?.txId, txid) + XCTAssertEqual(metadata?.address, "bcrt1qoriginal") + XCTAssertEqual(metadata?.tags, ["original tag"]) + // A later positive observation resumes this exact result and existing metadata. + await HwSendSignView.recordPaymentResult( + result, walletId: walletId, address: "bcrt1qoriginal", amount: 1234, + contactPublicKey: requestId.counterparty, tags: ["original tag"], requestId: request, proofVerified: true + ) + } + let completed = try await activity.getOnchainActivityByTxId(txid: txid, walletId: walletId) + XCTAssertEqual(completed?.walletId, walletId) + XCTAssertEqual(completed?.txId, txid) + XCTAssertEqual(completed?.value, 1234) + XCTAssertEqual(completed?.fee, 100) + } + } + + @MainActor + func testHardwarePendingSkipsSavingsGuardAndResolvesOnlyOriginalWalletAndTxid() async throws { + let identity = "pubky" + String(repeating: "z", count: 52) + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: UUID().uuidString, counterparty: "pubky" + String(repeating: "y", count: 52), + counterpartyReceiverPath: "bitkit/server", billingPeriodStartsAt: nil + ) + let walletId = "trezor:original-ios-wallet" + let txid = String(repeating: "ab", count: 32) + let proof = PendingPaykitPaymentProof( + identity: identity, requestId: requestId, paymentEndpointIdentifier: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue, + kind: .onchain, paymentStarted: true, paymentIdentifier: txid, proofData: nil, + onchainAddress: "bcrt1qoriginal", onchainAmountSats: 1234, onchainWalletId: walletId + ) + let store = PaymentProofMemoryStore() + await store.seed([proof]) + let sdk = PaymentProofSdkMock(identity: identity, records: []) + func lookup(_ txid: String) -> PaymentProofHardwareLookup { + PaymentProofHardwareLookup(result: .success(TransactionDetail( + txid: txid, received: 0, sent: 1334, net: -1334, amount: 1234, fee: 100, direction: .sent, + blockHeight: nil, timestamp: nil, confirmations: 0, inputs: [], outputs: [], size: 112, vsize: 112, weight: 448, feeRate: 1 + ))) + } + let service = PaykitPaymentProofService( + sdk: sdk, store: store, hardwareTransactionLookup: lookup(txid), + attemptService: OnchainSendAttemptService(store: MemoryAttemptStore()), logInfo: { _ in }, logWarning: { _ in } + ) + let savingsStore = HardwarePendingSavingsSpy() + let attempts = OnchainSendAttemptService(store: savingsStore) + let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed + let receivedCallback = Bitkit.LightningService.shared.onchainTransactionReceived + defer { + Bitkit.LightningService.shared.onchainTransactionConfirmed = confirmedCallback + Bitkit.LightningService.shared.onchainTransactionReceived = receivedCallback + } + // The spy belongs to Pending, so wallet startup's independent transfer recovery + // cannot be mistaken for a lookup made by the screen. + let wallet = makeWallet(attempts: OnchainSendAttemptService(store: MemoryAttemptStore())) + wallet.sendAmountSats = 9999 + let profile = PubkyProfileManager() + profile.publicKey = identity + var path: [SendRoute] = [] + let view = SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId, + routingCacheResetAttempted: false, attemptService: attempts, + hardwareWalletId: walletId, hardwareTransactionId: txid, hardwarePaymentIdentity: identity, proofService: service, + navigationPath: Binding(get: { path }, set: { path = $0 }) + ) + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(ActivityListViewModel()) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(profile) + .environmentObject(SheetViewModel()) + .environmentObject(wallet) + let controller = UIHostingController(rootView: view) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = controller + window.isHidden = false + defer { + window.isHidden = true + window.rootViewController = nil + } + try await Task.sleep(nanoseconds: 300_000_000) + XCTAssertEqual(savingsStore.loadCount, 0, "Hardware Pending must not inspect an unrelated selected node guard") + XCTAssertTrue(path.isEmpty, "The candidate txid alone must not open Bitcoin Sent") + let original = try await service.pendingOnchainPayment(requestId: requestId, identity: identity) + XCTAssertEqual(original?.onchainAmountSats, 1234) + XCTAssertEqual(original?.onchainWalletId, walletId) + + let wrongTxid = String(repeating: "cd", count: 32) + var wrongProof = proof + wrongProof.paymentIdentifier = wrongTxid + await store.seed([wrongProof]) + let wrongService = PaykitPaymentProofService( + sdk: sdk, store: store, hardwareTransactionLookup: lookup(wrongTxid), + attemptService: OnchainSendAttemptService(store: MemoryAttemptStore()), logInfo: { _ in }, logWarning: { _ in } + ) + await wrongService.reconcile() + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertTrue(path.isEmpty, "Another txid for the same request must not resolve the original hardware wait") + + let otherIdentity = "pubky" + String(repeating: "x", count: 52) + let otherProof = PendingPaykitPaymentProof( + identity: otherIdentity, requestId: requestId, paymentEndpointIdentifier: proof.paymentEndpointIdentifier, + kind: .onchain, paymentStarted: true, paymentIdentifier: txid, proofData: nil, + onchainAddress: proof.onchainAddress, onchainAmountSats: 7777, onchainWalletId: walletId + ) + profile.publicKey = otherIdentity + await sdk.setIdentity(otherIdentity) + await store.seed([otherProof]) + await service.reconcile() + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertTrue(path.isEmpty, "A profile switch must not resolve another identity's same request/wallet/txid") + + profile.publicKey = identity + await sdk.setIdentity(identity) + await store.seed([proof]) + await service.reconcile() + for _ in 0 ..< 20 where path.isEmpty { + try await Task.sleep(nanoseconds: 50_000_000) + } + XCTAssertEqual(path, [.success(paymentId: txid, walletId: walletId)]) + XCTAssertEqual(wallet.sendAmountSats, 9999) + XCTAssertEqual(savingsStore.loadCount, 0) + } + @MainActor func testNativeExactObservationFinishesUnknownAndRejectedOrdinaryActivityAndAck() async throws { let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed @@ -435,3 +581,17 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(onchain.fee, 500, "fee must be preserved") } } + +private final class HardwarePendingSavingsSpy: OnchainSendAttemptStoring, @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var loadCount: Int { + lock.withLock { count } + } + + func load() -> [OnchainSendAttempt] { + lock.withLock { count += 1 }; return [] + } + + func save(_: [OnchainSendAttempt]) {} +} From f16736db9fcda267e4558924874ad76435f995d2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 05:21:12 +0200 Subject: [PATCH 06/77] chore: use ldk node rc.68 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +-- .../hardware-wallet/shop-onchain-proof.xml | 27 +++++++++++++++++++ journeys/send/onchain-accepted-result.xml | 1 + 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 journeys/hardware-wallet/shop-onchain-proof.xml diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 1296a26b4..5e03fd9b0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1202,7 +1202,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.67"; + version = "0.7.0-rc.68"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index dc30f206b..56a1292e8 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "2dac2c08f59d71af650c726eb766ba26cd49cbff", - "version" : "0.7.0-rc.67" + "revision" : "773792d83ff1371b9c6c9886bffcd61cd48bdf6f", + "version" : "0.7.0-rc.68" } }, { diff --git a/journeys/hardware-wallet/shop-onchain-proof.xml b/journeys/hardware-wallet/shop-onchain-proof.xml new file mode 100644 index 000000000..eb470f25e --- /dev/null +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -0,0 +1,27 @@ + + + Verifies a hardware-funded Shop Payment Request against the original payer, request and + wallet, using fresh exact transaction observation before proof, Sent activity or Success. + Requires the paired, funded regtest Bridge Trezor emulator from hardware-wallet/README.md + and the saved, linked issuer from payment-requests/README.md, with Paykit UI enabled. + Record the original payer public key and hardware wallet identity before signing. Use + the canonical lowercase btc and btc-regtest-p2wpkh issuer shape on bitkit/server; a Bitkit + issuer uses its negotiated bitkit/wallet path. If real observation initially lags, inspect + Pending without another payment and let existing reconciliation finish. Do not manufacture + a refusal or missing response: controlled native faults and physical transports are manual + tests. This spec has not been run as part of the rc68 software-wallet fixture validation. + + + Have the fixture issuer publish its current regtest receive address as btc-regtest-p2wpkh with JSON payload {"value":"<current address>"}, then send proposed one-time request "71300000-0000-4000-8000-000000000009" for asset "btc" and amount "0.001" + Open that original request through the Payment Requests bell (id "PaymentRequestsBell") and Pay (id "PaymentRequestPay-71300000-0000-4000-8000-000000000009"), or use its automatic confirmation + Verify the Payment Request confirmation (id "PaymentRequestConfirm") shows 100,000 sats; tap Show Details (id "SendConfirmToggleDetails") and verify the saved issuer contact (id "ReviewContactRecipient") + If necessary, cycle the funding source (id "SendConfirmAssetButton") until it shows the original funded hardware wallet, then verify that wallet and the original request amount before signing + Swipe the send handle (id "GRAB") fully right and verify the hardware signing screen (id "HardwareSendSign") shows the original request amount and issuer address + Tap Open Trezor Connect (id "HardwareSendOpenTrezorConnect") and approve the transaction prompts on the deterministic emulator as described in hardware-wallet/README.md + If fresh observation initially lags and Pending appears, verify it retains 100,000 sats and the original candidate txid, offers no repayment, and has not shown Bitcoin Sent or created Sent activity solely from the bare broadcast return; an existing watcher may independently show a transaction it has actually observed. Record the candidate without initiating another payment + Wait for exact positive observation and the sent screen (id "SendSuccess"); on the issuer, verify the onchain proof for the original payer and request contains that exact observed txid, rather than accepting a bare broadcast-returned txid as proof + Tap Details (id "Details"), then Explore (id "ActivityTxDetails"); record the exact transaction ID (id "TXID") and verify Details belongs to the original hardware wallet and request amount + Query the exact recorded txid on the configured regtest backend and verify the original hardware wallet inputs pay 100,000 sats to the issuer; if Pending supplied a candidate, verify the final proof and Details use that same txid + Return to the Payment Requests list and verify the original paid request cannot initiate a second payment; stop recording and shut down only the task-owned simulator + + diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 4a8e0635d..13682ef1a 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -23,6 +23,7 @@ Tap Details (id "Details"), tap Explore (id "ActivityTxDetails"), and record the exact transaction ID (id "TXID") without selecting a different transaction by address or amount Use the configured regtest backend to query that exact recorded txid and verify the acknowledged transaction pays 1 000 sats to the receiving test wallet Navigate back from Transaction Details, then back from the activity to the wallet home screen + If the Wallet Backup introduction appears, choose Later (id "BackupIntroViewCancel") before continuing with the fixture send Mine one block with the configured regtest fixture backend and wait for the fixed transaction's exact recorded txid to confirm before sending the change output Open the menu (id "HeaderMenu"), Settings (id "DrawerSettings"), Advanced (id "Tab-advanced"), Coin Selection (id "CoinSelectPreference"), and choose Manual; return to Wallet from the menu (id "DrawerWallet") Hand the receiving test wallet address to the app again with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>" From 2039d399e021d555c782538eb7927a0c1429a56e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 14:48:29 +0200 Subject: [PATCH 07/77] fix: finish verified payments without another broadcast --- .../Services/OnchainSendAttemptService.swift | 5 +- .../Services/PaykitPaymentProofService.swift | 430 ++++++++++-------- Bitkit/Services/TransferService.swift | 7 +- .../Views/Wallets/Send/HwSendSignView.swift | 9 + .../Wallets/Send/SendPendingScreen.swift | 56 ++- .../OnchainSendAttemptServiceTests.swift | 33 +- .../PaykitPaymentProofServiceTests.swift | 74 +++ .../TransferServiceActivityTests.swift | 75 ++- .../hardware-wallet/shop-onchain-proof.xml | 1 + 9 files changed, 462 insertions(+), 228 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 9bb102ed1..6dca7a000 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -204,7 +204,10 @@ actor OnchainSendAttemptService { do { try await beforeBroadcastAttempt() } catch { - throw OnchainSendAttemptError.unresolved + let callbackError = error + do { try clearBeforeDispatch(attemptId: attemptId) } + catch { throw OnchainSendAttemptError.unresolved } + throw OnchainSendAttemptError.preDispatch(callbackError) } let result: OnchainSendResult diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index a6c84fbe3..61191f284 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -204,6 +204,7 @@ actor PaykitPaymentProofService { private let store: any PaykitPaymentProofStoring private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp private let hardwareTransactionLookup: any PaykitHardwareTransactionLookingUp + private let hardwareFollowup: @Sendable (PendingPaykitPaymentProof, TransactionDetail?) async throws -> Void private let attemptService: OnchainSendAttemptService private let logInfo: @Sendable (String) -> Void private let logWarning: @Sendable (String) -> Void @@ -221,7 +222,7 @@ actor PaykitPaymentProofService { if unsupportedWalletProofCount > 0 { logWarning("Retained \(unsupportedWalletProofCount) pending Paykit payment proof(s) for another wallet") } - try await persist(restoredProofs) + try await mutationLock.withLock { try await persist(restoredProofs) } } init( @@ -229,6 +230,8 @@ actor PaykitPaymentProofService { store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(), lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), hardwareTransactionLookup: any PaykitHardwareTransactionLookingUp = PaykitHardwareTransactionLookup(), + hardwareFollowup: @escaping @Sendable (PendingPaykitPaymentProof, TransactionDetail?) async throws -> Void = PaykitPaymentProofService + .restoreHardwareActivity, attemptService: OnchainSendAttemptService = .shared, logInfo: @escaping @Sendable (String) -> Void = { Logger.info($0, context: "PaykitPaymentProof") @@ -241,6 +244,7 @@ actor PaykitPaymentProofService { self.store = store self.lightningPaymentLookup = lightningPaymentLookup self.hardwareTransactionLookup = hardwareTransactionLookup + self.hardwareFollowup = hardwareFollowup self.attemptService = attemptService self.logInfo = logInfo self.logWarning = logWarning @@ -251,28 +255,23 @@ actor PaykitPaymentProofService { paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind ) async throws { - try await mutationLock.withLock { - try await prepareLocked(request: request, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind) + if try await attemptService.hasAttempt(for: request.id) { + if kind == .onchain, try await attemptService.acceptedTransactionId(for: request.id) != nil { + return + } + throw PaykitPaymentRequestError.operationInProgress } + let proof = try await pendingProof(request: request, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind) + try await mutationLock.withLock { try await prepareLocked(request: request, proof: proof) } } - private func prepareLocked( - request: PaykitPaymentRequest, - paymentEndpointIdentifier: String, - kind: PaykitPaymentProofKind - ) async throws { + private func prepareLocked(request: PaykitPaymentRequest, proof: PendingPaykitPaymentProof) async throws { if try await attemptService.hasAttempt(for: request.id) { - if kind == .onchain, try await attemptService.acceptedTransactionId(for: request.id) != nil { + if proof.kind == .onchain, try await attemptService.acceptedTransactionId(for: request.id) != nil { return } throw PaykitPaymentRequestError.operationInProgress } - let proof = try await pendingProof( - request: request, - paymentEndpointIdentifier: paymentEndpointIdentifier, - kind: kind - ) - var pendingProofs = try await loadProofs() guard !pendingProofs.contains(where: { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && @@ -334,12 +333,13 @@ actor PaykitPaymentProofService { } func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + let identity = try await currentIdentity() try await mutationLock.withLock { - try await associateLightningPaymentLocked(request, paymentHash: paymentHash) + try await associateLightningPaymentLocked(request, paymentHash: paymentHash, identity: identity) } } - private func associateLightningPaymentLocked(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + private func associateLightningPaymentLocked(_ request: PaykitPaymentRequest, paymentHash: String, identity: String) async throws { if try await attemptService.hasAttempt(for: request.id) { throw PaykitPaymentRequestError.operationInProgress } @@ -347,7 +347,6 @@ actor PaykitPaymentProofService { throw PaykitPaymentRequestError.requestUnavailable } - let identity = try await currentIdentity() var pendingProofs = try await loadProofs() guard let index = pendingProofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && @@ -370,8 +369,15 @@ actor PaykitPaymentProofService { hardwareWalletId: String? = nil, paymentIdentity: String? = nil ) async throws { + let identity = try await currentIdentity() try await mutationLock.withLock { - try await markOnchainPaymentStartedLocked(request, address: address, hardwareWalletId: hardwareWalletId, paymentIdentity: paymentIdentity) + try await markOnchainPaymentStartedLocked( + request, + address: address, + hardwareWalletId: hardwareWalletId, + paymentIdentity: paymentIdentity, + identity: identity + ) } } @@ -379,13 +385,13 @@ actor PaykitPaymentProofService { _ request: PaykitPaymentRequest, address: String, hardwareWalletId: String?, - paymentIdentity: String? + paymentIdentity: String?, + identity: String ) async throws { if let hardwareWalletId { guard hardwareWalletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: hardwareWalletId) else { throw PaykitPaymentRequestError.requestUnavailable } } - let identity = try await currentIdentity() if hardwareWalletId != nil { guard PubkyPublicKeyFormat.matches(identity, paymentIdentity) else { throw PaykitPaymentRequestError.requestUnavailable } } @@ -418,54 +424,117 @@ actor PaykitPaymentProofService { requestId: PaykitPaymentRequest.ID, identity: String, walletId: String, - txid: String + txid: String, + deliverInBackground: Bool = true ) async -> Bool { - await mutationLock.withLock { - guard walletId != WalletScope.default, Self.isHex(txid, byteCount: 32) else { return false } - do { + guard walletId != WalletScope.default, Self.isHex(txid, byteCount: 32) else { return false } + do { + let original: PendingPaykitPaymentProof? = try await mutationLock.withLock { var proofs = try await loadProofs() guard let index = proofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && $0.paymentStarted && $0.onchainWalletId == walletId && ($0.paymentIdentifier == nil || $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame) && ($0.proofData == nil || $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame) - }) else { return false } - if proofs[index].onchainAcceptanceVerified == true, proofs[index].proofData != nil { - Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( - identity: proofs[index].identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId - )) - submitInBackground(proofs[index]) - return true - } - - // Core's broadcast return is a candidate, not acceptance. Retain it for an - // exact lookup of this original account; paymentStarted still prevents repayment. + }) else { return nil } if proofs[index].paymentIdentifier == nil { proofs[index].paymentIdentifier = txid.lowercased() try await persist(proofs) } - let original = proofs[index] - let detail = try await hardwareTransactionLookup.transactionDetail(walletId: walletId, txid: txid) - guard detail.txid.caseInsensitiveCompare(txid) == .orderedSame, detail.sent > 0 else { return false } - - // The lookup suspends. Update only the same retained proof, and keep other - // request records that may have changed during that observation. - proofs = try await loadProofs() - guard let currentIndex = proofs.firstIndex(of: original) else { return false } - proofs[currentIndex].proofData = txid.lowercased() - proofs[currentIndex].onchainAcceptanceVerified = true - let completed = proofs[currentIndex] - try await persist(proofs) + return proofs[index] + } + guard let original else { return false } + var detail: TransactionDetail? + var completed = original + if original.onchainAcceptanceVerified != true || original.proofData == nil { + let observed = try await hardwareTransactionLookup.transactionDetail(walletId: walletId, txid: txid) + guard observed.txid.caseInsensitiveCompare(txid) == .orderedSame, observed.sent > 0 else { return false } + detail = observed + let saved: PendingPaykitPaymentProof? = try await mutationLock.withLock { + var proofs = try await loadProofs() + guard let index = proofs.firstIndex(of: original) else { return nil } + proofs[index].proofData = txid.lowercased() + proofs[index].onchainAcceptanceVerified = true + try await persist(proofs) + return proofs[index] + } + guard let saved else { return false } + completed = saved + } + try await hardwareFollowup(completed, detail) + if deliverInBackground { submitInBackground(completed) - Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( - identity: completed.identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId - )) - return true - } catch { - logWarning("Hardware Paykit payment remains pending exact transaction observation: \(error)") - return false + } else { + _ = await submit(completed) } + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: completed.identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId + )) + return true + } catch { + logWarning("Hardware Paykit payment remains pending exact transaction/local follow-up: \(error)") + return false + } + } + + nonisolated static func restoreHardwareActivity(_ proof: PendingPaykitPaymentProof, detail: TransactionDetail?) async throws { + guard proof.onchainAcceptanceVerified == true, let walletId = proof.onchainWalletId, walletId != WalletScope.default, + let txid = proof.paymentIdentifier, proof.proofData?.caseInsensitiveCompare(txid) == .orderedSame, + let address = proof.onchainAddress, let amount = proof.onchainAmountSats + else { throw PaykitPaymentRequestError.requestUnavailable } + let metadata = try await ServiceQueue.background(.core) { + try BitkitCore.getPreActivityMetadata(walletId: walletId, searchKey: txid, searchByAddress: false) + } + let existing = try await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid, walletId: walletId) + var observed = detail + if existing == nil, observed == nil { + observed = try await PaykitHardwareTransactionLookup().transactionDetail(walletId: walletId, txid: txid) + guard observed?.txid.caseInsensitiveCompare(txid) == .orderedSame, (observed?.sent ?? 0) > 0 + else { throw OnchainSendAttemptError.localFollowupNotSaved } + } + guard let fee = observed?.fee ?? existing?.fee else { throw OnchainSendAttemptError.localFollowupNotSaved } + let feeRate = metadata?.feeRate ?? existing?.feeRate ?? UInt64(observed?.feeRate ?? 0) + guard await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: txid, address: address, amount: amount, fee: fee, feeRate: UInt32(clamping: feeRate), + contact: proof.requestId.counterparty, walletId: walletId + ) else { throw OnchainSendAttemptError.localFollowupNotSaved } + if let tags = metadata?.tags, !tags.isEmpty { + try await CoreService.shared.activity.appendTags(toActivity: txid, tags, walletId: walletId) } + guard let saved = try await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid, walletId: walletId), + saved.txType == .sent, saved.txId.caseInsensitiveCompare(txid) == .orderedSame + else { throw OnchainSendAttemptError.localFollowupNotSaved } + } + + func resolvedHardwarePayment(requestId: PaykitPaymentRequest.ID, identity: String, walletId: String, + txid: String) async -> PaykitOnchainPaymentResolution? + { + guard let active = try? await currentIdentity(), PubkyPublicKeyFormat.matches(active, identity) else { return nil } + let pending: PendingPaykitPaymentProof? + do { pending = try await pendingOnchainPayment(requestId: requestId, identity: identity) } + catch { return nil } + if let proof = pending, + proof.onchainWalletId == walletId, proof.onchainAcceptanceVerified == true, + proof.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame, + proof.proofData?.caseInsensitiveCompare(txid) == .orderedSame + { + guard await completeHardwareOnchainPayment(requestId: requestId, identity: identity, walletId: walletId, txid: txid) else { return nil } + } else { + // Delivery can remove the local proof before Pending reopens. Require both + // exact remote proof and fresh original-wallet observation, never a local row alone. + guard hardwareTransactionLookup.hasWallet(walletId: walletId), + let records = try? await sdk.paymentRequests(), records.contains(where: { Self.hasExactOnchainProof( + requestId: requestId, + txid: txid, + in: $0 + ) }), + let detail = try? await hardwareTransactionLookup.transactionDetail(walletId: walletId, txid: txid), + detail.txid.caseInsensitiveCompare(txid) == .orderedSame, detail.sent > 0, + let activity = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid, walletId: walletId), + activity.txType == .sent + else { return nil } + } + return PaykitOnchainPaymentResolution(identity: identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId) } func completeLightningPayment(paymentHash: String, preimage: String?) async { @@ -479,20 +548,32 @@ actor PaykitPaymentProofService { } do { - var pendingProofs = try await loadProofs() - let indexes = pendingProofs.indices.filter { - pendingProofs[$0].kind == .lightning && - pendingProofs[$0].paymentIdentifier?.caseInsensitiveCompare(paymentHash) == .orderedSame + let completed: [PendingPaykitPaymentProof] = try await mutationLock.withLock { + var proofs = try await loadProofs() + let indexes = proofs.indices + .filter { proofs[$0].kind == .lightning && proofs[$0].paymentIdentifier?.caseInsensitiveCompare(paymentHash) == .orderedSame } + for index in indexes { + proofs[index].proofData = preimage.lowercased() + } + let completed = indexes.map { proofs[$0] } + if !completed.isEmpty { + do { try await persist(proofs) } + catch { logWarning("Failed to persist completed Lightning proof; attempting immediate delivery: \(error)") } + } + return completed } - guard !indexes.isEmpty else { return } - for index in indexes { - pendingProofs[index].proofData = preimage.lowercased() + for proof in completed where await !submit(proof) { + try await mutationLock.withLock { + var proofs = try await loadProofs() + guard let index = proofs.firstIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == proof.requestId && + $0.kind == .lightning && $0.paymentIdentifier == proof.paymentIdentifier + }) else { return } + proofs[index].proofData = proof.proofData + try await persist(proofs) + } } - let completedProofs = indexes.map { pendingProofs[$0] } - await persistAndSubmit(completedProofs, allProofs: pendingProofs) - } catch { - logWarning("Failed to complete a Paykit Lightning payment proof: \(error)") - } + } catch { logWarning("Failed to complete a Paykit Lightning payment proof: \(error)") } } @discardableResult @@ -514,66 +595,53 @@ actor PaykitPaymentProofService { identity: String, txid: String ) async -> Bool { - return await mutationLock.withLock { - return await completeOnchainPaymentLocked(requestId: requestId, identity: identity, txid: txid) - } - } - - private func completeOnchainPaymentLocked( - requestId: PaykitPaymentRequest.ID, - identity: String, - txid: String - ) async -> Bool { - guard Self.isHex(txid, byteCount: 32) else { - logWarning("Ignored a Paykit on-chain proof with an invalid transaction id") - return false - } - + guard Self.isHex(txid, byteCount: 32) else { return false } do { - var pendingProofs = try await loadProofs() - if let completed = pendingProofs.last(where: { + // SDK operations can suspend on network. Obtain remote evidence before the + // storage critical section, then re-read all mutable proof/attempt state there. + let snapshot = try await loadProofs() + let hasStartedProof = snapshot.contains { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && - $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.onchainAcceptanceVerified == true && - $0.paymentStarted && $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && - $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame - }) { - await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) - submitInBackground(completed) - return true - } - // A queued-era txid is not positive evidence. Only the original typed Accepted - // result or an exact native observation of its attempt may authorize this writer. - guard try await attemptService.acceptedTransactionId(for: requestId)?.caseInsensitiveCompare(txid) == .orderedSame else { - return false + $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.paymentStarted } - guard let index = pendingProofs.lastIndex(where: { - PubkyPublicKeyFormat.matches($0.identity, identity) && - $0.requestId == requestId && - $0.kind == .onchain && - !$0.hasUnsupportedOnchainWallet && - $0.paymentStarted && - (($0.paymentIdentifier == nil && $0.proofData == nil) || - ($0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && - $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame)) - }) else { - guard try await sdk.paymentRequests().contains(where: { - Self.hasExactOnchainProof(requestId: requestId, txid: txid, in: $0) - }) else { return false } - await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) - return true + let hasRemoteProof = hasStartedProof ? false : try await sdk.paymentRequests().contains(where: { + Self.hasExactOnchainProof(requestId: requestId, txid: txid, in: $0) + }) + let result: (Bool, PendingPaykitPaymentProof?) = try await mutationLock.withLock { + var proofs = try await loadProofs() + if let completed = proofs.last(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.onchainAcceptanceVerified == true && + $0.paymentStarted && $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && + $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame + }) { + return (true, completed) + } + guard try await attemptService.acceptedTransactionId(for: requestId)?.caseInsensitiveCompare(txid) == .orderedSame else { return ( + false, + nil + ) } + guard let index = proofs.lastIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.paymentStarted && + (($0.paymentIdentifier == nil && $0.proofData == nil) || + ($0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && + $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame)) + }) else { return (hasRemoteProof, nil) } + proofs[index].paymentIdentifier = txid.lowercased() + proofs[index].proofData = txid.lowercased() + proofs[index].onchainAcceptanceVerified = true + try await persist(proofs) + return (true, proofs[index]) } - pendingProofs[index].paymentIdentifier = txid.lowercased() - pendingProofs[index].proofData = txid.lowercased() - pendingProofs[index].onchainAcceptanceVerified = true - let completedProof = pendingProofs[index] - try await persist(pendingProofs) + guard result.0 else { return false } await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) - submitInBackground(completedProof) - Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( - identity: completedProof.identity, - requestId: requestId, - transactionId: txid.lowercased() - )) + if let completed = result.1 { + submitInBackground(completed) + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: completed.identity, requestId: requestId, transactionId: txid.lowercased() + )) + } return true } catch { logWarning("Failed to retain a completed Paykit on-chain proof for retry: \(error)") @@ -663,16 +731,10 @@ actor PaykitPaymentProofService { let txid = proof.paymentIdentifier { guard hardwareTransactionLookup.hasWallet(walletId: walletId) else { continue } - if proof.onchainAcceptanceVerified == true, proof.proofData != nil { - Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( - identity: proof.identity, requestId: proof.requestId, transactionId: txid, walletId: walletId - )) - await submit(proof) - } else { - _ = await completeHardwareOnchainPayment( - requestId: proof.requestId, identity: proof.identity, walletId: walletId, txid: txid - ) - } + _ = await completeHardwareOnchainPayment( + requestId: proof.requestId, identity: proof.identity, walletId: walletId, txid: txid, + deliverInBackground: false + ) continue } guard !proof.hasUnsupportedOnchainWallet else { continue } @@ -752,13 +814,16 @@ actor PaykitPaymentProofService { PubkyPublicKeyFormat.matches(identityStatus.publicKey, identity) else { return pendingProofs } - let currentProofs = try await loadProofs() - let remainingProofs = currentProofs.filter { proof in - !candidates.contains(proof) || !Self.hasSubmittedRemoteProof(matching: proof, in: records) + return try await mutationLock.withLock { + let currentProofs = try await loadProofs() + let remainingProofs = currentProofs.filter { proof in + !candidates.contains(proof) || !Self.hasSubmittedRemoteProof(matching: proof, in: records) + } + if remainingProofs != currentProofs { + try await persist(remainingProofs) + } + return remainingProofs } - guard remainingProofs != currentProofs else { return currentProofs } - try await persist(remainingProofs) - return remainingProofs } catch { logWarning("Failed to reconcile Paykit payment proofs for another wallet: \(error)") return await (try? loadProofs()) ?? pendingProofs @@ -834,26 +899,29 @@ actor PaykitPaymentProofService { identity: String, subscriptionId: PaykitSubscription.ID ) async throws -> Set { - let proofs = try await loadProofs() - let belongsToSubscription: (PendingPaykitPaymentProof) -> Bool = { - PubkyPublicKeyFormat.matches($0.identity, identity) && - $0.requestId.billingPeriodStartsAt != nil && - $0.requestId.paymentRequestId == subscriptionId.paymentRequestId && - $0.requestId.counterparty == subscriptionId.counterparty && - $0.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath - } - let protectedRequestIds: Set = Set(proofs.compactMap { proof in - guard belongsToSubscription(proof) else { return nil } - guard proof.paymentStarted || proof.paymentIdentifier != nil || proof.proofData != nil else { return nil } - return proof.requestId - }) - let remainingProofs = proofs.filter { - !belongsToSubscription($0) || $0.hasUnsupportedOnchainWallet || $0.paymentStarted || $0.paymentIdentifier != nil || $0.proofData != nil - } - if remainingProofs != proofs { - try await persist(remainingProofs) + return try await mutationLock.withLock { + let proofs = try await loadProofs() + let belongsToSubscription: (PendingPaykitPaymentProof) -> Bool = { + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.requestId.billingPeriodStartsAt != nil && + $0.requestId.paymentRequestId == subscriptionId.paymentRequestId && + $0.requestId.counterparty == subscriptionId.counterparty && + $0.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath + } + let protectedRequestIds: Set = Set(proofs.compactMap { proof in + guard belongsToSubscription(proof) else { return nil } + guard proof.paymentStarted || proof.paymentIdentifier != nil || proof.proofData != nil else { return nil } + return proof.requestId + }) + let remainingProofs = proofs.filter { + !belongsToSubscription($0) || $0.hasUnsupportedOnchainWallet || $0.paymentStarted || $0.paymentIdentifier != nil || $0 + .proofData != nil + } + if remainingProofs != proofs { + try await persist(remainingProofs) + } + return protectedRequestIds } - return protectedRequestIds } func consumeOnchainPaymentResolution(_ resolution: PaykitOnchainPaymentResolution) { @@ -930,49 +998,8 @@ actor PaykitPaymentProofService { Self.proofStateChangedSubject.send() } - private func persistAndSubmit( - _ completedProofs: [PendingPaykitPaymentProof], - allProofs: [PendingPaykitPaymentProof] - ) async { - let didPersist: Bool - do { - try await persist(allProofs) - didPersist = true - } catch { - didPersist = false - logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") - } - var hasUndeliveredProof = false - for proof in completedProofs { - if await !submit(proof) { - hasUndeliveredProof = true - } - } - if !didPersist, hasUndeliveredProof { - do { - try await persist(allProofs) - } catch { - logWarning("Failed to retain a completed Paykit payment proof for retry: \(error)") - } - } - if !completedProofs.isEmpty { - Self.proofStateChangedSubject.send() - } - } - private func submitInBackground(_ proof: PendingPaykitPaymentProof) { - Task { [weak self] in - await self?.submit(proof) - } - } - - private func persistAndSubmitInBackground( - _ proof: PendingPaykitPaymentProof, - allProofs: [PendingPaykitPaymentProof] - ) { - Task { [weak self] in - await self?.persistAndSubmit([proof], allProofs: allProofs) - } + Task { [weak self] in await self?.submit(proof) } } private func removeRequestProofs(_ proof: PendingPaykitPaymentProof) async { @@ -987,20 +1014,21 @@ actor PaykitPaymentProofService { _ request: PaykitPaymentRequest, where shouldRemove: (PendingPaykitPaymentProof) -> Bool ) async { + let identity = try? await currentIdentity() await mutationLock.withLock { - await removeRequestProofsLocked(request, where: shouldRemove) + await removeRequestProofsLocked(request, identity: identity, where: shouldRemove) } } private func removeRequestProofsLocked( _ request: PaykitPaymentRequest, + identity: String?, where shouldRemove: (PendingPaykitPaymentProof) -> Bool ) async { do { let pendingProofs = try await loadProofs() let candidates = pendingProofs.filter { $0.requestId == request.id && shouldRemove($0) } let candidateIdentities = Set(candidates.compactMap { PubkyPublicKeyFormat.normalized($0.identity) }) - let identity = try? await currentIdentity() guard let targetIdentity = identity ?? (candidateIdentities.count == 1 ? candidateIdentities.first : nil) else { return } let remainingProofs = pendingProofs.filter { diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index 53a0b8ee4..6bd985064 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -7,14 +7,17 @@ class TransferService { private let storage: TransferStorage private let lightningService: LightningService private let blocktankService: BlocktankService + private let isGeoBlocked: () -> Bool private let coreService: CoreService init( storage: TransferStorage = TransferStorage.shared, lightningService: LightningService, blocktankService: BlocktankService, - coreService: CoreService = .shared + coreService: CoreService = .shared, + isGeoBlocked: @escaping () -> Bool = { GeoService.shared.isGeoBlocked } ) { + self.isGeoBlocked = isGeoBlocked self.storage = storage self.lightningService = lightningService self.blocktankService = blocktankService @@ -44,7 +47,7 @@ class TransferService { } // When geoblocked, block transfers to spending that involve LSP (Blocktank) // toSpending with lspOrderId means it's a Blocktank LSP channel order - let isGeoblocked = GeoService.shared.isGeoBlocked + let isGeoblocked = isGeoBlocked() if isGeoblocked && type.isToSpending() && lspOrderId != nil { Logger.error("Cannot create LSP transfer when geoblocked", context: "TransferService") throw AppError( diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 2f3d8dd52..2a26b3459 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -123,6 +123,15 @@ struct HwSendSignView: View { satsPerVByte: UInt64(feeRate), beforeBroadcast: prepareContactPayment, afterBroadcast: { result in + if requestId != nil { + // Save original tags before proof reconciliation can complete. + // This retains metadata only; a bare Core txid is not Sent. + await Self.recordPaymentResult( + result, walletId: walletId, address: invoice.address, amount: amount, + contactPublicKey: contactPublicKey, tags: tags, requestId: requestId, + proofVerified: false + ) + } proofVerified = await completeContactPayment(result.txId) } ) diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index ecb820cfa..cce3b2646 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -138,6 +138,18 @@ struct SendPendingScreen: View { { foundActivity = .onchain(activity) } + if !onchainStateUnavailable, let requestId = paykitPaymentRequestId, + let identity = hardwarePaymentIdentity ?? pendingOnchainProof?.identity, + let txid = pendingTransactionId, + let resolution = await proofService.resolvedHardwarePayment( + requestId: requestId, + identity: identity, + walletId: walletId, + txid: txid + ) + { + applyOnchainPaymentResolution(resolution) + } } else if !onchainStateUnavailable { do { onchainAttempt = try await attemptService.unresolvedAttempt( @@ -165,26 +177,32 @@ struct SendPendingScreen: View { applyOrdinarySendResolution(resolution) } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in - guard resolution.requestId == paykitPaymentRequestId, - let identity = pubkyProfile.publicKey, - PubkyPublicKeyFormat.matches(resolution.identity, identity) + applyOnchainPaymentResolution(resolution) + } + } + + private func applyOnchainPaymentResolution(_ resolution: PaykitOnchainPaymentResolution) { + guard !ordinarySendResolved else { return } + guard resolution.requestId == paykitPaymentRequestId, + let identity = pubkyProfile.publicKey, + PubkyPublicKeyFormat.matches(resolution.identity, identity) + else { return } + if let walletId = pendingHardwareWalletId { + guard resolution.walletId == walletId, + let txid = pendingTransactionId, + txid.caseInsensitiveCompare(resolution.transactionId) == .orderedSame, + PubkyPublicKeyFormat.matches(resolution.identity, hardwarePaymentIdentity ?? pendingOnchainProof?.identity) else { return } - if let walletId = pendingHardwareWalletId { - guard resolution.walletId == walletId, - let txid = pendingTransactionId, - txid.caseInsensitiveCompare(resolution.transactionId) == .orderedSame, - PubkyPublicKeyFormat.matches(resolution.identity, hardwarePaymentIdentity ?? pendingOnchainProof?.identity) - else { return } - } - app.addPendingContactPaymentContext( - resolution.transactionId, - context: ContactPaymentContext(publicKey: resolution.requestId.counterparty) - ) - Task { - await proofService.consumeOnchainPaymentResolution(resolution) - guard PubkyPublicKeyFormat.matches(resolution.identity, pubkyProfile.publicKey) else { return } - navigationPath.append(.success(paymentId: resolution.transactionId, walletId: resolution.walletId)) - } + } + ordinarySendResolved = true + app.addPendingContactPaymentContext( + resolution.transactionId, + context: ContactPaymentContext(publicKey: resolution.requestId.counterparty) + ) + Task { + await proofService.consumeOnchainPaymentResolution(resolution) + guard PubkyPublicKeyFormat.matches(resolution.identity, pubkyProfile.publicKey) else { return } + navigationPath.append(.success(paymentId: resolution.transactionId, walletId: resolution.walletId)) } } diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index e18b4b140..b8fe076fe 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -334,8 +334,8 @@ final class OnchainSendAttemptServiceTests: XCTestCase { XCTAssertEqual(store.snapshot().count, 1) } - func testCallbackNodeErrorAndCancellationRetainGuardWithoutDispatch() async throws { - for error in [NodeError.NotRunning(message: "callback failure") as Error, CancellationError()] { + func testCallbackFailuresReleaseGuardWithoutDispatch() async throws { + for error in [NodeError.NotRunning(message: "callback failure") as Error, CancellationError(), PaykitPaymentRequestError.requestUnavailable] { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) let node = AttemptNodeMock(result: .accepted(txid: txid)) @@ -346,11 +346,36 @@ final class OnchainSendAttemptServiceTests: XCTestCase { ) { throw error } XCTFail("Callback failure succeeded") } catch let error as OnchainSendAttemptError { - guard case .unresolved = error else { return XCTFail("Callback error cleared preguard") } + guard case .preDispatch = error else { return XCTFail("Proven callback failure was not classified before dispatch") } } XCTAssertEqual(node.calls, 0) - XCTAssertEqual(store.snapshot().first?.status, .pending) + XCTAssertTrue(store.snapshot().isEmpty) + _ = try await send(service, node: node) + XCTAssertEqual(node.calls, 1, "Safe callback release must admit a later payment") + } + } + + func testCallbackReleaseWriteFailureRetainsGuardWithoutDispatch() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + do { + _ = try await service.send( + using: node, address: "bcrt1qexample", amountSats: 1000, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false + ) { + store.failSave = true + throw CancellationError() + } + XCTFail("Callback failure succeeded") + } catch let error as OnchainSendAttemptError { + guard case .unresolved = error else { return XCTFail("Release storage failure lost the guard") } } + XCTAssertEqual(node.calls, 0) + XCTAssertEqual(store.snapshot().first?.status, .pending) + store.failSave = false + do { _ = try await send(service, node: node); XCTFail("Uncleared guard allowed dispatch") } catch {} + XCTAssertEqual(node.calls, 0) } func testOnlyNodePreDispatchErrorReleasesGuardAndSaveFailureRetainsIt() async throws { diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 148e3a063..75c30cb22 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -202,6 +202,42 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(observed.count, 1) } + func testLightningCompletionCannotOverwriteConcurrentOnchainStart() async throws { + let onchainEndpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [onchainEndpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let started = PendingPaykitPaymentProof( + identity: identity, requestId: request.id, paymentEndpointIdentifier: onchainEndpoint, + kind: .onchain, paymentIdentifier: nil, proofData: nil + ) + let lightningId = PaykitPaymentRequest.ID( + paymentRequestId: UUID().uuidString, counterparty: counterparty, + counterpartyReceiverPath: PaykitReceiverPath.wallet, billingPeriodStartsAt: nil + ) + let lightning = PendingPaykitPaymentProof( + identity: identity, requestId: lightningId, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, paymentStarted: true, paymentIdentifier: paymentHash, proofData: nil + ) + let store = SuspendedProofSaveStore(proofs: [started, lightning]) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + let service = PaykitPaymentProofService( + sdk: sdk, store: store, attemptService: OnchainSendAttemptService(store: MemoryAttemptStore()), + logInfo: { _ in }, logWarning: { _ in } + ) + let completion = Task { await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) } + await store.waitForSuspendedSave() + let marking = Task { try await service.markOnchainPaymentStarted(request, address: onchainAddress) } + try await Task.sleep(nanoseconds: 100_000_000) + await store.resumeSave() + await completion.value + try await marking.value + let proofs = try await store.load() + XCTAssertEqual(proofs.first(where: { $0.requestId == request.id })?.paymentStarted, true) + XCTAssertEqual(proofs.first(where: { $0.requestId == lightningId })?.proofData, preimage) + } + private func hardwareTransaction(txid: String, sent: UInt64 = 1200) -> TransactionDetail { TransactionDetail( txid: txid, received: 100, sent: sent, net: -1100, amount: 1000, fee: 100, @@ -1780,6 +1816,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), hardwareTransactionLookup: hardwareLookup, + hardwareFollowup: { _, _ in }, attemptService: attemptService, logInfo: { _ in }, logWarning: { _ in } @@ -2097,3 +2134,40 @@ private enum PaymentProofStoreMockError: Error { case load case save } + +private actor SuspendedProofSaveStore: PaykitPaymentProofStoring { + private var proofs: [PendingPaykitPaymentProof] + private var suspendNext = true + private var saveWaiter: CheckedContinuation? + private var startedWaiter: CheckedContinuation? + private var suspended = false + + init(proofs: [PendingPaykitPaymentProof]) { + self.proofs = proofs + } + + func load() -> [PendingPaykitPaymentProof] { + proofs + } + + func save(_ proofs: [PendingPaykitPaymentProof]) async { + if suspendNext { + suspendNext = false + suspended = true + startedWaiter?.resume() + startedWaiter = nil + await withCheckedContinuation { saveWaiter = $0 } + } + self.proofs = proofs + } + + func waitForSuspendedSave() async { + if !suspended { + await withCheckedContinuation { startedWaiter = $0 } + } + } + + func resumeSave() { + saveWaiter?.resume(); saveWaiter = nil + } +} diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index f085ff1d2..4d127e40b 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -41,7 +41,8 @@ final class TransferServiceActivityTests: XCTestCase { Bitkit.TransferService( storage: Bitkit.TransferStorage(defaults: transferDefaults), lightningService: .shared, - blocktankService: Bitkit.CoreService.shared.blocktank + blocktankService: Bitkit.CoreService.shared.blocktank, + isGeoBlocked: { false } ) } @@ -157,6 +158,78 @@ final class TransferServiceActivityTests: XCTestCase { } } + @MainActor + func testDelayedHardwareProofRestoresOriginalSentActivityTagsAndReopenedPending() async throws { + let identity = "pubky" + String(repeating: "z", count: 52) + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: UUID().uuidString, counterparty: "pubky" + String(repeating: "y", count: 52), + counterpartyReceiverPath: "bitkit/server", billingPeriodStartsAt: nil + ) + let walletId = "trezor:original-ios-wallet" + let txid = String(repeating: "ab", count: 32) + let result = HwFundingBroadcastResult(txId: txid, miningFeeSats: 100, feeRate: 2, totalSpent: 1334) + await HwSendSignView.recordPaymentResult( + result, walletId: walletId, address: "bcrt1qoriginal", amount: 1234, + contactPublicKey: requestId.counterparty, tags: ["original tag"], requestId: requestId, proofVerified: false + ) + let proof = PendingPaykitPaymentProof( + identity: identity, requestId: requestId, paymentEndpointIdentifier: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue, + kind: .onchain, paymentStarted: true, paymentIdentifier: txid, proofData: nil, + onchainAddress: "bcrt1qoriginal", onchainAmountSats: 1234, onchainWalletId: walletId + ) + let store = PaymentProofMemoryStore() + await store.seed([proof]) + let sdk = PaymentProofSdkMock(identity: identity, records: []) + let lookup = PaymentProofHardwareLookup(result: .success(TransactionDetail( + txid: txid, received: 0, sent: 1334, net: -1334, amount: 1234, fee: 100, direction: .sent, + blockHeight: nil, timestamp: nil, confirmations: 0, inputs: [], outputs: [], size: 112, vsize: 112, weight: 448, feeRate: 2 + ))) + let service = PaykitPaymentProofService( + sdk: sdk, store: store, hardwareTransactionLookup: lookup, + attemptService: OnchainSendAttemptService(store: MemoryAttemptStore()), logInfo: { _ in }, logWarning: { _ in } + ) + await service.reconcile() + let sent = try await activity.getOnchainActivityByTxId(txid: txid, walletId: walletId) + XCTAssertEqual(sent?.txId, txid) + XCTAssertEqual(sent?.walletId, walletId) + XCTAssertEqual(sent?.value, 1234) + XCTAssertEqual(sent?.fee, 100) + XCTAssertEqual(sent?.contact, PubkyPublicKeyFormat.normalized(requestId.counterparty)) + let tags = try await activity.tags(forActivity: txid, walletId: walletId) + XCTAssertEqual(tags, ["original tag"]) + await service.consumeOnchainPaymentResolution(.init(identity: identity, requestId: requestId, transactionId: txid, walletId: walletId)) + let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed + let receivedCallback = Bitkit.LightningService.shared.onchainTransactionReceived + defer { + Bitkit.LightningService.shared.onchainTransactionConfirmed = confirmedCallback + Bitkit.LightningService.shared.onchainTransactionReceived = receivedCallback + } + let wallet = makeWallet(attempts: OnchainSendAttemptService(store: MemoryAttemptStore())) + wallet.sendAmountSats = 9999 + let profile = PubkyProfileManager() + profile.publicKey = identity + var path: [SendRoute] = [] + let view = SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId, + routingCacheResetAttempted: false, hardwareWalletId: walletId, hardwareTransactionId: txid, + hardwarePaymentIdentity: identity, proofService: service, + navigationPath: Binding(get: { path }, set: { path = $0 }) + ) + .environmentObject(CurrencyViewModel()).environmentObject(SettingsViewModel.shared) + .environmentObject(ActivityListViewModel()).environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()).environmentObject(profile) + .environmentObject(SheetViewModel()).environmentObject(wallet) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIHostingController(rootView: view) + window.isHidden = false + defer { window.isHidden = true; window.rootViewController = nil } + for _ in 0 ..< 30 where path.isEmpty { + try await Task.sleep(nanoseconds: 50_000_000) + } + XCTAssertEqual(path, [.success(paymentId: txid, walletId: walletId)], "Consumed publisher must not strand durable verified Pending") + XCTAssertEqual(wallet.sendAmountSats, 9999) + } + @MainActor func testHardwarePendingSkipsSavingsGuardAndResolvesOnlyOriginalWalletAndTxid() async throws { let identity = "pubky" + String(repeating: "z", count: 52) diff --git a/journeys/hardware-wallet/shop-onchain-proof.xml b/journeys/hardware-wallet/shop-onchain-proof.xml index eb470f25e..b9354f20c 100644 --- a/journeys/hardware-wallet/shop-onchain-proof.xml +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -19,6 +19,7 @@ Swipe the send handle (id "GRAB") fully right and verify the hardware signing screen (id "HardwareSendSign") shows the original request amount and issuer address Tap Open Trezor Connect (id "HardwareSendOpenTrezorConnect") and approve the transaction prompts on the deterministic emulator as described in hardware-wallet/README.md If fresh observation initially lags and Pending appears, verify it retains 100,000 sats and the original candidate txid, offers no repayment, and has not shown Bitcoin Sent or created Sent activity solely from the bare broadcast return; an existing watcher may independently show a transaction it has actually observed. Record the candidate without initiating another payment + If Pending appears, close and reopen that same original request after verification has been consumed by the app listener; verify reopening resolves the durable exact original payment without another send, and Sent activity retains the original contact and any original tags Wait for exact positive observation and the sent screen (id "SendSuccess"); on the issuer, verify the onchain proof for the original payer and request contains that exact observed txid, rather than accepting a bare broadcast-returned txid as proof Tap Details (id "Details"), then Explore (id "ActivityTxDetails"); record the exact transaction ID (id "TXID") and verify Details belongs to the original hardware wallet and request amount Query the exact recorded txid on the configured regtest backend and verify the original hardware wallet inputs pay 100,000 sats to the issuer; if Pending supplied a candidate, verify the final proof and Details use that same txid From 64dea0bc2f683390a2eda12303d8db375f1f0429 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 23:53:07 +0200 Subject: [PATCH 08/77] fix: preserve on-chain proof and contact state --- .../Services/OnchainSendAttemptService.swift | 10 ++ .../Services/PaykitPaymentProofService.swift | 44 +++++- Bitkit/ViewModels/HwFundingSigner.swift | 2 + .../Views/Wallets/Send/HwSendSignView.swift | 2 + Bitkit/Views/Wallets/Send/SendSheet.swift | 6 + BitkitTests/HwFundingSignerTests.swift | 119 +++++++++++++-- .../PaykitPaymentProofServiceTests.swift | 42 ++++++ .../TransferServiceActivityTests.swift | 136 ++++++++++++++++++ 8 files changed, 350 insertions(+), 11 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 6dca7a000..88da67274 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -331,6 +331,16 @@ actor OnchainSendAttemptService { } } guard attempt.status == .accepted else { return nil } + if attempt.localFollowupComplete { + // A delayed native event must not replay metadata/contact writes or publish a new resolution. + guard observedTxid == nil else { return nil } + guard let saved = try await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid), + saved.txId.caseInsensitiveCompare(txid) == .orderedSame, saved.txType == .sent + else { throw OnchainSendAttemptError.localFollowupNotSaved } + return OnchainSendLocalResolution( + attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: saved.value, contact: saved.contact, activity: saved + ) + } let activity = try await localFollowup.save(attempt) try acknowledgeLocalFollowup(txid: txid) let resolution = OnchainSendLocalResolution( diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 6fe2e904a..cefd63a06 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -57,6 +57,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var onchainWalletId: String? var onchainMatchingTransactionIdsBeforeAttempt: Set? var onchainAcceptanceVerified: Bool? + /// Device-local acknowledgement: backup restore reruns local activity proof. + var onchainLocalFollowupComplete: Bool? var hasUnsupportedOnchainWallet: Bool { kind == .onchain && onchainWalletId != nil && onchainWalletId != WalletScope.default @@ -75,7 +77,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { onchainAmountSats: UInt64? = nil, onchainWalletId: String? = nil, onchainMatchingTransactionIdsBeforeAttempt: Set? = nil, - onchainAcceptanceVerified: Bool? = false + onchainAcceptanceVerified: Bool? = false, + onchainLocalFollowupComplete: Bool? = false ) { self.identity = identity self.requestId = requestId @@ -90,6 +93,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { self.onchainWalletId = onchainWalletId self.onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt self.onchainAcceptanceVerified = onchainAcceptanceVerified + self.onchainLocalFollowupComplete = onchainLocalFollowupComplete } } @@ -461,7 +465,27 @@ actor PaykitPaymentProofService { guard let saved else { return false } completed = saved } - try await hardwareFollowup(completed, detail) + if completed.onchainLocalFollowupComplete != true { + try await hardwareFollowup(completed, detail) + let followedUp: PendingPaykitPaymentProof? = try await mutationLock.withLock { + var proofs = try await loadProofs() + if let index = proofs.firstIndex(of: completed) { + proofs[index].onchainLocalFollowupComplete = true + try await persist(proofs) + return proofs[index] + } + // Another reconciliation may have acknowledged this exact original proof. + return proofs.first { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.onchainWalletId == walletId && $0.kind == .onchain && $0.paymentStarted && + $0.onchainAcceptanceVerified == true && $0.onchainLocalFollowupComplete == true && + $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && + $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame + } + } + guard let followedUp else { return false } + completed = followedUp + } if deliverInBackground { submitInBackground(completed) } else { @@ -496,7 +520,9 @@ actor PaykitPaymentProofService { let feeRate = metadata?.feeRate ?? existing?.feeRate ?? UInt64(observed?.feeRate ?? 0) guard await CoreService.shared.activity.createSentOnchainActivityFromSendResult( txid: txid, address: address, amount: amount, fee: fee, feeRate: UInt32(clamping: feeRate), - contact: proof.requestId.counterparty, walletId: walletId + // A saved Sent row may already have a later user contact edit, including deletion. + // Preserve it even if the proof acknowledgement write needs to be retried. + contact: existing?.txType == .sent ? existing?.contact : proof.requestId.counterparty, walletId: walletId ) else { throw OnchainSendAttemptError.localFollowupNotSaved } if let tags = metadata?.tags, !tags.isEmpty { try await CoreService.shared.activity.appendTags(toActivity: txid, tags, walletId: walletId) @@ -683,6 +709,18 @@ actor PaykitPaymentProofService { } } + func cancelHardwarePaymentBeforeDispatch(_ request: PaykitPaymentRequest, paymentIdentity: String, walletId: String) async { + guard let identity = PubkyPublicKeyFormat.normalized(paymentIdentity), walletId != WalletScope.default, + hardwareTransactionLookup.hasWallet(walletId: walletId) else { return } + // Called only by the coordinator after authorization fails before its first native dispatch. + // A profile switch does not change ownership of the original prepared operation. + await removeProofs { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == request.id && + $0.kind == .onchain && $0.onchainWalletId == walletId && $0.paymentStarted && + $0.paymentIdentifier == nil && $0.proofData == nil && $0.onchainAcceptanceVerified != true + } + } + func cancelPreparation(_ request: PaykitPaymentRequest) async { await removeRequestProofs(request) { !$0.hasUnsupportedOnchainWallet && diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index abd32a19f..502a94dc0 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -456,6 +456,7 @@ final class HwSendCoordinator { satsPerVByte: UInt64, beforeFirstBroadcast: @escaping () async throws -> Void = {}, beforeBroadcastAttempt: @escaping () async throws -> Void = {}, + onFirstBroadcastAuthorizationFailure: @escaping () async -> Void = {}, afterBroadcast: @escaping (HwFundingBroadcastResult) async -> Void = { _ in } ) async throws -> HwFundingBroadcastResult { guard let walletId else { @@ -495,6 +496,7 @@ final class HwSendCoordinator { try await beforeBroadcastAttempt() } catch { if pendingPayment?.hasBroadcastAttempted != true { + await onFirstBroadcastAuthorizationFailure() pendingPayment = nil } throw error diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 9f0f6f948..542fd83c4 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -16,6 +16,7 @@ struct HwSendSignView: View { let authorizeContactPayment: () async throws -> Void let completeContactPayment: (String) async -> Bool let cancelContactPayment: () async -> Void + var releaseContactPaymentBeforeDispatch: () async -> Void = {} @State private var signingTask: Task? @State private var passphraseTask: Task? @@ -124,6 +125,7 @@ struct HwSendSignView: View { satsPerVByte: UInt64(feeRate), beforeFirstBroadcast: prepareContactPayment, beforeBroadcastAttempt: authorizeContactPayment, + onFirstBroadcastAuthorizationFailure: releaseContactPaymentBeforeDispatch, afterBroadcast: { result in if requestId != nil { // Save original tags before proof reconciliation can complete. diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index b9aee0caa..365e62ca4 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -739,6 +739,12 @@ struct SendSheet: View { }, cancelContactPayment: { await cancelHardwareContactPayment(context: contactContext) + }, + releaseContactPaymentBeforeDispatch: { + guard let request = contactContext?.incomingPaymentRequest, let walletId, let paymentIdentity else { return } + await PaykitPaymentProofService.shared.cancelHardwarePaymentBeforeDispatch( + request, paymentIdentity: paymentIdentity, walletId: walletId + ) } ) case .feeRate: diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index c83cab100..cf946c151 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -1,11 +1,55 @@ @testable import Bitkit import BitkitCore +import Paykit import XCTest /// Device-signing orchestration coverage for `HwFundingSigner`, exercised in isolation from /// `TransferViewModel` via the `HwTransferFunding` / `HwTransferConnecting` mocks. @MainActor final class HwFundingSignerTests: XCTestCase { + private func paymentRequestRecord( + endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], + paymentProofs: [PaymentProofRecord] = [], + paymentRequestId: String = "550e8400-e29b-41d4-a716-446655440000", + state: PaymentRequestLifecycleState = .proposed, + recurrence: PaymentRequestRecurrence? = nil + ) throws -> PaymentRequestRecord { + try PaymentRequestRecord( + counterparty: "pubky" + String(repeating: "y", count: 52), + counterpartyReceiverPath: PaykitReceiverPath.wallet, + paymentRequestId: paymentRequestId, + localRole: .payer, + state: state, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), + paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: recurrence, + acceptedPaymentEndpointIdentifiers: endpoints, + conversion: nil, + paymentDeadline: nil, + metadata: PrivateJsonObject(text: "{}") + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + conversionQuotes: [], + paymentProofs: paymentProofs, + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: "2027-01-15T08:00:00Z", + invalidReason: nil + ) + } + private func makeSigner( funding: MockHwFunding, connecting: MockHwConnecting, @@ -247,7 +291,7 @@ final class HwFundingSignerTests: XCTestCase { let connecting = MockHwConnecting() let manager = HwWalletManager() let coordinator = HwSendCoordinator( - walletId: "trezor:wallet", + walletId: "trezor:original-ios-wallet", signerFactory: { [self] _, address, satsPerVByte in makeSigner( funding: funding, @@ -257,10 +301,29 @@ final class HwFundingSignerTests: XCTestCase { ) } ) + let identity = "pubky" + String(repeating: "z", count: 52) + let request = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord( + endpoints: [PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue] + ), now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: []) + let proofService = PaykitPaymentProofService(sdk: sdk, store: store, logInfo: { _ in }, logWarning: { _ in }) + var releases = 0 + let releaseBeforeDispatch: () async -> Void = { + releases += 1 + await proofService.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: "trezor:original-ios-wallet") + } var preparationCalls = 0 var authorizationCalls = 0 var isPaymentAllowed = true - let preparePayment: () async throws -> Void = { preparationCalls += 1 } + let preparePayment: () async throws -> Void = { + preparationCalls += 1 + await store.seed([PendingPaykitPaymentProof( + identity: identity, requestId: request.id, + paymentEndpointIdentifier: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue, + kind: .onchain, paymentStarted: true, paymentIdentifier: nil, proofData: nil, onchainWalletId: "trezor:original-ios-wallet" + )]) + } let authorizePayment: () async throws -> Void = { authorizationCalls += 1 if !isPaymentAllowed { @@ -276,7 +339,8 @@ final class HwFundingSignerTests: XCTestCase { sats: 42000, satsPerVByte: 2, beforeFirstBroadcast: preparePayment, - beforeBroadcastAttempt: authorizePayment + beforeBroadcastAttempt: authorizePayment, + onFirstBroadcastAuthorizationFailure: releaseBeforeDispatch ) } @@ -289,13 +353,18 @@ final class HwFundingSignerTests: XCTestCase { sats: 42000, satsPerVByte: 2, beforeFirstBroadcast: preparePayment, - beforeBroadcastAttempt: authorizePayment + beforeBroadcastAttempt: authorizePayment, + onFirstBroadcastAuthorizationFailure: releaseBeforeDispatch ) } XCTAssertTrue(coordinator.hasPendingBroadcast) XCTAssertEqual(funding.broadcastCalls, 1) + let durableProofs = await store.snapshot() + XCTAssertEqual(durableProofs.count, 1) + XCTAssertEqual(durableProofs.first?.paymentStarted, true, "Denied attempted retry must retain its durable original proof") + XCTAssertEqual(releases, 0, "Attempted retry incorrectly invoked pre-dispatch proof release") isPaymentAllowed = true _ = try await coordinator.signAndBroadcast( manager: manager, @@ -303,7 +372,8 @@ final class HwFundingSignerTests: XCTestCase { sats: 42000, satsPerVByte: 2, beforeFirstBroadcast: preparePayment, - beforeBroadcastAttempt: authorizePayment + beforeBroadcastAttempt: authorizePayment, + onFirstBroadcastAuthorizationFailure: releaseBeforeDispatch ) XCTAssertEqual(preparationCalls, 1) @@ -314,11 +384,11 @@ final class HwFundingSignerTests: XCTestCase { } } - func testCoordinatorDeniedFirstAttemptDropsPreparedPayment() async { + func testCoordinatorDeniedFirstAttemptDropsPreparedPayment() async throws { let funding = MockHwFunding() let manager = HwWalletManager() let coordinator = HwSendCoordinator( - walletId: "trezor:wallet", + walletId: "trezor:original-ios-wallet", signerFactory: { [self] _, address, satsPerVByte in makeSigner( funding: funding, @@ -329,19 +399,52 @@ final class HwFundingSignerTests: XCTestCase { } ) + let identity = "pubky" + String(repeating: "z", count: 52) + let request = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord( + endpoints: [PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue] + ), now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: []) + let proofService = PaykitPaymentProofService(sdk: sdk, store: store, + hardwareTransactionLookup: PaymentProofHardwareLookup(result: .failure(MockHwFunding + .TestError())), logInfo: { _ in }, logWarning: { _ in }) + let originalProof = PendingPaykitPaymentProof( + identity: identity, requestId: request.id, + paymentEndpointIdentifier: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue, + kind: .onchain, paymentStarted: true, paymentIdentifier: nil, proofData: nil, + onchainAddress: "bc1qtest", onchainAmountSats: 42000, onchainWalletId: "trezor:original-ios-wallet" + ) await assertThrowsAsync { _ = try await coordinator.signAndBroadcast( manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeBroadcastAttempt: { throw MockHwFunding.TestError() } + beforeFirstBroadcast: { + try await proofService.prepare( + request: request, + paymentEndpointIdentifier: originalProof.paymentEndpointIdentifier, + kind: .onchain + ) + try await proofService.markOnchainPaymentStarted( + request, + address: "bc1qtest", + hardwareWalletId: "trezor:original-ios-wallet", + paymentIdentity: identity + ) + }, + beforeBroadcastAttempt: { throw MockHwFunding.TestError() }, + onFirstBroadcastAuthorizationFailure: { + await proofService.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: "trezor:original-ios-wallet") + } ) } XCTAssertEqual(funding.signCalls, 1) XCTAssertEqual(funding.broadcastCalls, 0) XCTAssertFalse(coordinator.hasPendingBroadcast) + let durableProofs = await store.snapshot() + XCTAssertTrue(durableProofs.isEmpty, "Denied first attempt retained its started durable hardware proof despite zero dispatch") } func testCoordinatorCancelDropsSignedPaymentAfterFailedBroadcast() async throws { diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 67a170e27..f93001318 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -14,6 +14,48 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private let hardwareWalletId = "trezor:original-ios-wallet" + func testHardwarePredispatchReleaseMatchesOnlyOriginalProofAndFailsClosedOnSave() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let otherIdentity = "pubky" + String(repeating: "x", count: 52) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: otherIdentity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + func proof(payer: String, wallet: String, txid: String? = nil, verified: Bool = false, + requestId: PaykitPaymentRequest.ID? = nil) -> PendingPaykitPaymentProof + { + PendingPaykitPaymentProof(identity: payer, requestId: requestId ?? request.id, paymentEndpointIdentifier: endpoint, + kind: .onchain, paymentStarted: true, paymentIdentifier: txid, proofData: verified ? txid : nil, + onchainWalletId: wallet, onchainAcceptanceVerified: verified) + } + let otherRequest = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord( + endpoints: [endpoint], paymentRequestId: UUID().uuidString + ), now: Date())) + let original = proof(payer: identity, wallet: hardwareWalletId) + let retained = [proof(payer: otherIdentity, wallet: hardwareWalletId), + proof(payer: identity, wallet: "trezor:other-wallet"), + proof(payer: identity, wallet: hardwareWalletId, requestId: otherRequest.id), + proof(payer: identity, wallet: hardwareWalletId, verified: true), + proof(payer: identity, wallet: hardwareWalletId, txid: String(repeating: "ab", count: 32)), + proof(payer: identity, wallet: hardwareWalletId, txid: String(repeating: "cd", count: 32), verified: true)] + await store.seed([original] + retained) + await service.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: "missing-wallet") + let unchanged = await store.snapshot() + XCTAssertEqual(unchanged, [original] + retained) + await store.failNextSave() + await service.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: hardwareWalletId) + let failedClear = await store.snapshot() + XCTAssertEqual(failedClear, [original] + retained, "Failed durable clear must preserve every original guard") + await service.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: hardwareWalletId) + let afterClear = await store.snapshot() + XCTAssertEqual( + afterClear, + retained, + "Current profile must not replace the captured payer; candidate/verified/other wallet proofs stay guarded" + ) + } + func testHardwareCompletionRetainsCapturedPayerAfterProfileSwitch() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint], paymentRequestId: UUID().uuidString) diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 4d127e40b..9d2c2474d 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -1,6 +1,7 @@ @testable import Bitkit import BitkitCore import LDKNode +import Paykit import SwiftUI import XCTest @@ -37,6 +38,49 @@ final class TransferServiceActivityTests: XCTestCase { try? FileManager.default.removeItem(atPath: testDbPath) } + private func paymentRequestRecord( + endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], + paymentProofs: [PaymentProofRecord] = [], + paymentRequestId: String = "550e8400-e29b-41d4-a716-446655440000", + state: PaymentRequestLifecycleState = .proposed, + recurrence: PaymentRequestRecurrence? = nil + ) throws -> PaymentRequestRecord { + try PaymentRequestRecord( + counterparty: "pubky" + String(repeating: "y", count: 52), + counterpartyReceiverPath: "bitkit/server", + paymentRequestId: paymentRequestId, + localRole: .payer, + state: state, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), + paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: recurrence, + acceptedPaymentEndpointIdentifiers: endpoints, + conversion: nil, + paymentDeadline: nil, + metadata: PrivateJsonObject(text: "{}") + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + conversionQuotes: [], + paymentProofs: paymentProofs, + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: "2027-01-15T08:00:00Z", + invalidReason: nil + ) + } + private func makeService() -> Bitkit.TransferService { Bitkit.TransferService( storage: Bitkit.TransferStorage(defaults: transferDefaults), @@ -230,6 +274,98 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(wallet.sendAmountSats, 9999) } + @MainActor + func testHardwareProofSubmissionRetryPreservesContactEdits() async throws { + let identity = "pubky" + String(repeating: "z", count: 52) + let original = "pubky" + String(repeating: "y", count: 52) + let reassigned = "pubky" + String(repeating: "x", count: 52) + for (index, editedContact) in [String?.none, Optional(reassigned)].enumerated() { + let requestId = PaykitPaymentRequest.ID(paymentRequestId: UUID().uuidString, counterparty: original, + counterpartyReceiverPath: "bitkit/server", billingPeriodStartsAt: nil) + let walletId = "trezor:original-ios-wallet" + let txid = String(repeating: index == 0 ? "ab" : "cd", count: 32) + let store = PaymentProofMemoryStore() + await store.seed([PendingPaykitPaymentProof(identity: identity, requestId: requestId, + paymentEndpointIdentifier: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue, + kind: .onchain, + paymentStarted: true, paymentIdentifier: txid, proofData: nil, + onchainAddress: "bcrt1qoriginal", + onchainAmountSats: 1234, onchainWalletId: walletId)]) + let sdk = try PaymentProofSdkMock(identity: identity, records: [paymentRequestRecord( + endpoints: [PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue], paymentRequestId: requestId.paymentRequestId + )]) + await sdk.setSubmissionFailure(true) + let lookup = PaymentProofHardwareLookup(result: .success(TransactionDetail( + txid: txid, received: 0, sent: 1334, net: -1334, amount: 1234, fee: 100, direction: .sent, + blockHeight: nil, timestamp: nil, confirmations: 0, inputs: [], outputs: [], size: 112, vsize: 112, weight: 448, feeRate: 2 + ))) + func service() -> PaykitPaymentProofService { + PaykitPaymentProofService(sdk: sdk, store: store, hardwareTransactionLookup: lookup, + attemptService: OnchainSendAttemptService(store: MemoryAttemptStore()), logInfo: { _ in }, + logWarning: { _ in }) + } + await service().reconcile() + let sent = try await activity.getOnchainActivityByTxId(txid: txid, walletId: walletId) + XCTAssertEqual(sent?.contact, PubkyPublicKeyFormat.normalized(original)) + let completedProofs = await store.snapshot() + XCTAssertEqual(completedProofs.first?.onchainLocalFollowupComplete, true) + let failedSubmissions = await sdk.submissionCount() + XCTAssertEqual(failedSubmissions, 1, "Fixture must exercise actual failed SDK submission after local follow-up") + try await activity.setContact(editedContact, forActivity: txid, walletId: walletId) + await service().reconcile() // restart after SDK failure; original durable proof retained + let saved = try await activity.getOnchainActivityByTxId(txid: txid, walletId: walletId) + XCTAssertEqual( + saved?.contact, + editedContact.flatMap(PubkyPublicKeyFormat.normalized), + "Proof delivery retry overwrote a later Details contact edit" + ) + let retrySubmissions = await sdk.submissionCount() + XCTAssertEqual(retrySubmissions, 2) + let lookups = await lookup.calls() + XCTAssertEqual(lookups.count, 1, "Verified proof delivery retry must not repeat transaction observation or payment") + } + } + + @MainActor + func testReceivedEventPreservesCompletedOrdinaryContactEdits() async throws { + let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed + let receivedCallback = Bitkit.LightningService.shared.onchainTransactionReceived + defer { + Bitkit.LightningService.shared.onchainTransactionConfirmed = confirmedCallback + Bitkit.LightningService.shared.onchainTransactionReceived = receivedCallback + } + let original = "pubky" + String(repeating: "y", count: 52) + let reassigned = "pubky" + String(repeating: "x", count: 52) + for (index, editedContact) in [String?.none, Optional(reassigned)].enumerated() { + let store = MemoryAttemptStore() + let txid = String(repeating: index == 0 ? "ab" : "cd", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let attempts = OnchainSendAttemptService(store: store) + _ = try await attempts.send(using: node, address: "bcrt1qoriginal", amountSats: 4321, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: false, + followupContext: OnchainSendFollowupContext( + feeSats: 123, + feeRate: 2, + tags: [], + contact: original, + createdAt: 100 + )) + _ = try await attempts.resumeAcceptedOrdinarySend(walletId: OnchainSendAttemptService.walletId(index: node.currentWalletIndex)) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + try await activity.setContact(editedContact, forActivity: txid) + let wallet = makeWallet(attempts: OnchainSendAttemptService(store: store)) + await Bitkit.LightningService.shared.onchainTransactionReceived?(txid.uppercased()) + let saved = try await activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual( + saved?.contact, + editedContact.flatMap(PubkyPublicKeyFormat.normalized), + "Delayed native event overwrote a later Details contact edit" + ) + XCTAssertEqual(node.calls, 1, "Completed follow-up must not dispatch another payment") + withExtendedLifetime(wallet) {} + } + } + @MainActor func testHardwarePendingSkipsSavingsGuardAndResolvesOnlyOriginalWalletAndTxid() async throws { let identity = "pubky" + String(repeating: "z", count: 52) From bfa96467ef29fdf1a6c1cafdd83f25b2c68c12ff Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Oct 2026 14:08:57 +0200 Subject: [PATCH 09/77] fix: restore original pending payment details --- Bitkit/AppScene.swift | 15 +- .../Localization/en.lproj/Localizable.strings | 22 +++ .../Services/OnchainSendAttemptService.swift | 40 ++++- Bitkit/Services/TransferService.swift | 13 ++ Bitkit/ViewModels/TransferViewModel.swift | 6 +- .../Wallets/Send/SendConfirmationView.swift | 41 +++-- .../Wallets/Send/SendPendingScreen.swift | 64 +++++--- Bitkit/Views/Wallets/Send/SendSheet.swift | 7 + .../TransferServiceActivityTests.swift | 147 ++++++++++++++++++ 9 files changed, 303 insertions(+), 52 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 155939fdd..23e148572 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1120,7 +1120,19 @@ struct AppScene: View { } private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async { - if let identity = pubkyProfile.publicKey, + await Self.associateResolvedPaykitOnchainPayment(resolution, activeIdentity: pubkyProfile.publicKey, activity: activity) + await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) + } + + static func associateResolvedPaykitOnchainPayment( + _ resolution: PaykitOnchainPaymentResolution, + activeIdentity: String?, + activity: ActivityListViewModel + ) async { + // Hardware proof reconciliation already saved contact/tags in the original wallet before publishing. + // Replaying the default-wallet association would overwrite unrelated Savings rows and later edits. + guard resolution.walletId == WalletScope.default else { return } + if let identity = activeIdentity, PubkyPublicKeyFormat.matches(resolution.identity, identity) { do { @@ -1144,7 +1156,6 @@ struct AppScene: View { ) } } - await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) } private func pollIncomingPaykitPaymentRequests() async { diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index af0f52e7d..9c4a68d52 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1754,3 +1754,25 @@ "settings__adv__pp_both" = "Both"; "settings__adv__pp_lightning_short" = "Lightning"; "settings__adv__pp_onchain_short" = "On-chain"; + +// On-chain acceptance and original-payment follow-up +"wallet__onchain_send_unresolved" = "An earlier on-chain send is unresolved. Check its transaction before trying another send."; +"wallet__onchain_send_duplicate" = "This request or order already has an on-chain payment. Do not pay it again."; +"wallet__onchain_outcome_save_failed" = "The on-chain send result could not be saved. The payment may have been sent; do not retry it."; +"wallet__onchain_followup_failed" = "This payment was sent, but its local details could not be restored. Do not send it again."; +"wallet__onchain_earlier_payment" = "Earlier on-chain payment"; +"wallet__onchain_transaction_id" = "Transaction ID: {txid}"; +"wallet__onchain_state_unavailable" = "The on-chain payment state could not be read. Do not send another payment until it is checked."; +"wallet__onchain_hardware_pending" = "This hardware-wallet transaction is awaiting verified payment follow-up. Do not send this payment again."; +"wallet__onchain_rejected_pending" = "The backend rejected this transaction. It may still have reached the network. Do not send it again. {reason}"; +"wallet__onchain_unknown_pending" = "This transaction may have been sent. Its outcome is unknown. Do not send it again."; +"wallet__onchain_earlier_restored" = "The earlier payment's local details were restored. No new payment was sent."; +"wallet__onchain_pending_followup_failed" = "This payment was sent, but its local details could not be restored. Do not send this payment again."; +"wallet__onchain_pending_followup" = "This payment was sent. Local follow-up is still pending. Do not send this payment again."; +"wallet__onchain_broadcast_rejected" = "Broadcast rejected"; +"wallet__onchain_broadcast_rejected_note" = "This payment may still have reached the network. Do not send it again. {reason}"; +"wallet__onchain_broadcast_unconfirmed" = "Broadcast unconfirmed"; +"wallet__onchain_broadcast_unconfirmed_note" = "The payment may have been sent. Do not send it again."; +"wallet__onchain_funding_rejected" = "The funding transaction was rejected. Do not try to fund this order again."; +"wallet__onchain_funding_unknown" = "The funding transaction may have been sent. Do not try to fund this order again."; +"wallet__onchain_funding_followup_failed" = "Funding was sent, but local order tracking could not be saved. Do not fund this order again."; diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 88da67274..9c685a89d 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -23,13 +23,13 @@ enum OnchainSendAttemptError: LocalizedError { var errorDescription: String? { switch self { case .unresolved: - "An earlier on-chain send is unresolved. Check its transaction before trying another send." + t("wallet__onchain_send_unresolved") case .duplicate: - "This request or order already has an on-chain payment. Do not pay it again." + t("wallet__onchain_send_duplicate") case .outcomeNotSaved: - "The on-chain send result could not be saved. The payment may have been sent; do not retry it." + t("wallet__onchain_outcome_save_failed") case .localFollowupNotSaved: - "This payment was sent, but its local details could not be restored. Do not send it again." + t("wallet__onchain_followup_failed") case let .preDispatch(error): error.localizedDescription } @@ -137,6 +137,12 @@ struct OnchainSendAttemptStore: OnchainSendAttemptStoring { } } +struct OnchainSendPendingContext: Hashable { + let attemptId: UUID + let walletId: String + let txid: String? +} + actor OnchainSendAttemptService { static let shared = OnchainSendAttemptService() @@ -318,10 +324,16 @@ actor OnchainSendAttemptService { knownAttempt = attempt } - func resumeAcceptedOrdinarySend(walletId: String, observedTxid: String? = nil) async throws -> OnchainSendLocalResolution? { + func resumeAcceptedOrdinarySend(walletId: String, observedTxid: String? = nil, + pendingContext: OnchainSendPendingContext? = nil) async throws -> OnchainSendLocalResolution? + { guard var attempt = try currentAttempt(), attempt.walletId == walletId, attempt.requestId == nil, attempt.orderId == nil, let txid = attempt.txid else { return nil } + if let pendingContext { + guard attempt.id == pendingContext.attemptId, attempt.walletId == pendingContext.walletId, + attempt.txid == pendingContext.txid else { return nil } + } if let observedTxid { guard txid.caseInsensitiveCompare(observedTxid) == .orderedSame else { return nil } if attempt.status != .accepted { @@ -422,6 +434,24 @@ actor OnchainSendAttemptService { try currentAttempt()?.requestId == requestId } + func ordinaryPendingContext(txid: String? = nil) throws -> OnchainSendPendingContext? { + guard let attempt = try currentAttempt(), attempt.requestId == nil, attempt.orderId == nil else { return nil } + if let txid { + guard attempt.txid?.caseInsensitiveCompare(txid) == .orderedSame else { return nil } + } else { + // A new unsent operation cannot acquire a previous completed result. + guard attempt.blocksNewSend else { return nil } + } + return OnchainSendPendingContext(attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid) + } + + func ordinaryPendingAttempt(context: OnchainSendPendingContext) throws -> OnchainSendAttempt? { + try currentAttempt().flatMap { + $0.id == context.attemptId && $0.walletId == context.walletId && $0.txid == context.txid && + $0.requestId == nil && $0.orderId == nil ? $0 : nil + } + } + func unresolvedAttempt(walletId: String) throws -> OnchainSendAttempt? { try currentAttempt().flatMap { $0.walletId == walletId && $0.blocksNewSend ? $0 : nil } } diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index 6bd985064..b51c36aa2 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -4,6 +4,7 @@ import LDKNode /// Service for managing transfer operations class TransferService { + private static let creationLock = NSLock() private let storage: TransferStorage private let lightningService: LightningService private let blocktankService: BlocktankService @@ -41,6 +42,18 @@ class TransferService { txTotalSats: UInt64? = nil, preTransferOnchainSats: UInt64? = nil ) async throws -> String { + try createTransferRecord(type: type, amountSats: amountSats, channelId: channelId, + fundingTxId: fundingTxId, lspOrderId: lspOrderId, claimableAtHeight: claimableAtHeight, + txTotalSats: txTotalSats, preTransferOnchainSats: preTransferOnchainSats) + } + + private func createTransferRecord( + type: TransferType, amountSats: UInt64, channelId: String?, fundingTxId: String?, + lspOrderId: String?, claimableAtHeight: UInt32?, txTotalSats: UInt64?, preTransferOnchainSats: UInt64? + ) throws -> String { + // Lookup and insert belong to one original paid-order operation, including across service instances. + Self.creationLock.lock() + defer { Self.creationLock.unlock() } if let lspOrderId, let existing = try storage.getAll().first(where: { $0.lspOrderId == lspOrderId }) { guard existing.fundingTxId == fundingTxId else { throw OnchainSendAttemptError.duplicate } return existing.id diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 2a3a96584..bdb52ab23 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -474,12 +474,12 @@ class TransferViewModel: ObservableObject { txid = acceptedTxid case let .rejected(rejectedTxid, reason): throw AppError( - message: "The funding transaction was rejected. Do not try to fund this order again.", + message: t("wallet__onchain_funding_rejected"), debugMessage: "Broadcast rejected for \(rejectedTxid): \(reason)" ) case let .unknown(unknownTxid): throw AppError( - message: "The funding transaction may have been sent. Do not try to fund this order again.", + message: t("wallet__onchain_funding_unknown"), debugMessage: "Broadcast outcome unknown for \(unknownTxid)" ) } @@ -491,7 +491,7 @@ class TransferViewModel: ObservableObject { } catch { Logger.warn("Accepted funding local follow-up remains guarded: \(error)", context: "TransferViewModel") throw AppError( - message: "Funding was sent, but local order tracking could not be saved. Do not fund this order again.", + message: t("wallet__onchain_funding_followup_failed"), debugMessage: "Accepted funding transaction \(txid) for order \(order.id)" ) } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index e6f7f807d..f8bc10807 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -160,6 +160,16 @@ struct SendConfirmationView: View { !hwSend.isActive && !requiresPaymentConfirmation } + static func onchainPendingRoute( + txid: String? = nil, requestId: PaykitPaymentRequest.ID?, + using attempts: OnchainSendAttemptService = .shared + ) async -> SendRoute { + if requestId == nil, let context = try? await attempts.ordinaryPendingContext(txid: txid) { + return .onchainPending(context) + } + return .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId) + } + var body: some View { ZStack { confirmationContent @@ -1003,26 +1013,20 @@ struct SendConfirmationView: View { Logger.warn("On-chain broadcast rejected for \(rejectedTxid): \(reason)", context: "SendConfirmation") app.toast( type: .warning, - title: "Broadcast rejected", - description: "This payment may still have reached the network. Do not send it again. \(reason)" + title: t("wallet__onchain_broadcast_rejected"), + description: t("wallet__onchain_broadcast_rejected_note", variables: ["reason": reason]) ) - navigationPath.append(.pending( - paymentHash: nil, - retryRoute: .confirm, - paymentRequest: nil, - paykitPaymentRequestId: incomingPaymentRequest?.id - )) + await navigationPath.append(Self.onchainPendingRoute(txid: rejectedTxid, requestId: incomingPaymentRequest?.id)) return case let .unknown(unknownTxid): await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) Logger.warn("On-chain broadcast outcome unknown for \(unknownTxid)", context: "SendConfirmation") - app.toast(type: .warning, title: "Broadcast unconfirmed", description: "The payment may have been sent. Do not send it again.") - navigationPath.append(.pending( - paymentHash: nil, - retryRoute: .confirm, - paymentRequest: nil, - paykitPaymentRequestId: incomingPaymentRequest?.id - )) + app.toast( + type: .warning, + title: t("wallet__onchain_broadcast_unconfirmed"), + description: t("wallet__onchain_broadcast_unconfirmed_note") + ) + await navigationPath.append(Self.onchainPendingRoute(txid: unknownTxid, requestId: incomingPaymentRequest?.id)) return } privatePaymentListOutcome = .succeeded @@ -1101,12 +1105,7 @@ struct SendConfirmationView: View { await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) shouldCancelPaymentProof = false app.toast(attemptError) - navigationPath.append(.pending( - paymentHash: nil, - retryRoute: .confirm, - paymentRequest: nil, - paykitPaymentRequestId: incomingPaymentRequest?.id - )) + await navigationPath.append(Self.onchainPendingRoute(requestId: incomingPaymentRequest?.id)) return case .preDispatch: break diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index cce3b2646..2a457bbd5 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -30,6 +30,7 @@ struct SendPendingScreen: View { let paykitPaymentRequestId: PaykitPaymentRequest.ID? let routingCacheResetAttempted: Bool var attemptService: OnchainSendAttemptService = .shared + var ordinaryPendingContext: OnchainSendPendingContext? var hardwareWalletId: String? var hardwareTransactionId: String? var hardwarePaymentIdentity: String? @@ -68,7 +69,7 @@ struct SendPendingScreen: View { if let sendAmountSats = pendingAmountSats { if onchainAttempt?.requestId == nil, onchainAttempt != nil { - BodySSBText("Earlier on-chain payment") + BodySSBText(t("wallet__onchain_earlier_payment")) .accessibilityIdentifier("EarlierOnchainPayment") } MoneyStack(sats: Int(sendAmountSats), showSymbol: true) @@ -78,7 +79,7 @@ struct SendPendingScreen: View { if paymentHash == nil { BodyMText(onchainPendingMessage) if let txid = pendingTransactionId { - BodySSBText("Transaction ID: \(txid)") + BodySSBText(t("wallet__onchain_transaction_id", variables: ["txid": txid])) .textSelection(.enabled) } } else { @@ -152,19 +153,17 @@ struct SendPendingScreen: View { } } else if !onchainStateUnavailable { do { - onchainAttempt = try await attemptService.unresolvedAttempt( - walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) + let loaded = try await Self.loadOrdinaryPending( + using: attemptService, context: ordinaryPendingContext, + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex), + isOrdinary: paykitPaymentRequestId == nil ) + onchainAttempt = loaded.attempt + localFollowupUnavailable = loaded.followupUnavailable + if let resolution = loaded.resolution { + applyOrdinarySendResolution(resolution) + } } catch { onchainStateUnavailable = true } - if paykitPaymentRequestId == nil, onchainAttempt != nil { - do { - if let resolution = try await attemptService.resumeAcceptedOrdinarySend( - walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) - ) { - applyOrdinarySendResolution(resolution) - } - } catch { localFollowupUnavailable = true } - } } } applyPendingResolutionIfNeeded(app.sendSheetPendingResolution) @@ -208,32 +207,55 @@ struct SendPendingScreen: View { private var onchainPendingMessage: String { if onchainStateUnavailable { - return "The on-chain payment state could not be read. Do not send another payment until it is checked." + return t("wallet__onchain_state_unavailable") } if pendingHardwareWalletId != nil { - return "This hardware-wallet transaction is awaiting verified payment follow-up. Do not send this payment again." + return t("wallet__onchain_hardware_pending") } switch onchainAttempt?.status { case .rejected: - return "The backend rejected this transaction. It may still have reached the network. Do not send it again. \(onchainAttempt?.rejectionReason ?? "")" + return t("wallet__onchain_rejected_pending", variables: ["reason": onchainAttempt?.rejectionReason ?? ""]) case .unknown, .pending: - return "This transaction may have been sent. Its outcome is unknown. Do not send it again." + return t("wallet__onchain_unknown_pending") case .accepted: if ordinarySendResolved { - return "The earlier payment's local details were restored. No new payment was sent." + return t("wallet__onchain_earlier_restored") } if localFollowupUnavailable { - return "This payment was sent, but its local details could not be restored. Do not send this payment again." + return t("wallet__onchain_pending_followup_failed") } - return "This payment was sent. Local follow-up is still pending. Do not send this payment again." + return t("wallet__onchain_pending_followup") case .none: return t("wallet__send_pending_note") } } + static func loadOrdinaryPending( + using service: OnchainSendAttemptService, context: OnchainSendPendingContext?, + walletId: String, isOrdinary: Bool = true + ) async throws -> (attempt: OnchainSendAttempt?, resolution: OnchainSendLocalResolution?, followupUnavailable: Bool) { + let attempt: OnchainSendAttempt? = if isOrdinary, let context { + try await service.ordinaryPendingAttempt(context: context) + } else { + try await service.unresolvedAttempt(walletId: walletId) + } + if isOrdinary, let attempt { + do { + let resolution = try await service.resumeAcceptedOrdinarySend(walletId: attempt.walletId, pendingContext: context) + return (attempt, resolution, false) + } catch { + // Retain original txid/amount/status even if durable local follow-up is unavailable. + return (attempt, nil, true) + } + } + return (attempt, nil, false) + } + private func applyOrdinarySendResolution(_ resolution: OnchainSendLocalResolution) { guard paymentHash == nil, paykitPaymentRequestId == nil, !ordinarySendResolved, - resolution.walletId == OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex), + resolution + .walletId == + (ordinaryPendingContext?.walletId ?? OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex)), onchainAttempt?.id == resolution.attemptId else { return } ordinarySendResolved = true diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 0cdbf6e18..954416464 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -66,6 +66,7 @@ enum SendRoute: Hashable { paymentRequest: String?, paykitPaymentRequestId: PaykitPaymentRequest.ID? = nil ) + case onchainPending(OnchainSendPendingContext) case hardwarePending(requestId: PaykitPaymentRequest.ID, walletId: String, transactionId: String, paymentIdentity: String?) case success(paymentId: String, walletId: String = WalletScope.default) case failure(SendFailureContext) @@ -770,6 +771,12 @@ struct SendSheet: View { routingCacheResetAttempted: routingCacheResetAttempted, navigationPath: $navigationPath ) + case let .onchainPending(context): + SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: nil, + routingCacheResetAttempted: routingCacheResetAttempted, ordinaryPendingContext: context, + navigationPath: $navigationPath + ) case let .hardwarePending(requestId, walletId, transactionId, paymentIdentity): SendPendingScreen( paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId, diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 9d2c2474d..5ef703f56 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -98,6 +98,63 @@ final class TransferServiceActivityTests: XCTestCase { ) } + func testOrdinaryResolutionBeforePendingInitializationRestoresExactDurableResult() async throws { + for rejected in [false, true] { + let store = MemoryAttemptStore() + let txid = String(repeating: rejected ? "cd" : "ab", count: 32) + let node = AttemptNodeMock(result: rejected ? .rejected(txid: txid, reason: "fixture") : .unknown(txid: txid)) + let service = OnchainSendAttemptService(store: store) + _ = try await service.send(using: node, address: "bcrt1qoriginal", amountSats: 4321, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100)) + let original = try XCTUnwrap(store.snapshot().first) + let route = await SendConfirmationView.onchainPendingRoute(txid: txid, requestId: nil, using: service) + guard case let .onchainPending(context) = route else { return XCTFail("Original send did not retain its Pending identity") } + XCTAssertEqual(context.attemptId, original.id) + // Exact native observation and its Passthrough publication precede Pending initialization. + _ = try await service.resumeAcceptedOrdinarySend(walletId: original.walletId, observedTxid: txid) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + let restarted = OnchainSendAttemptService(store: store) + let loaded = try await SendPendingScreen.loadOrdinaryPending(using: restarted, context: context, walletId: "node:now-selected-other") + XCTAssertEqual(loaded.attempt?.id, original.id) + XCTAssertEqual(loaded.resolution?.txid, txid) + XCTAssertEqual(loaded.resolution?.amountSats, 4321) + XCTAssertEqual(node.calls, 1, "Pending recovery dispatched another payment") + } + } + + func testPendingInitializationRejectsOldCompletedAndReplacementContexts() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let service = OnchainSendAttemptService(store: store) + _ = try await service.send(using: node, address: "bcrt1qoriginal", amountSats: 4321, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100)) + let original = try XCTUnwrap(store.snapshot().first) + _ = try await service.resumeAcceptedOrdinarySend(walletId: original.walletId) + let noContext = try await SendPendingScreen.loadOrdinaryPending(using: service, context: nil, walletId: original.walletId) + XCTAssertNil(noContext.resolution, "Old completed result satisfied a new unsent Pending") + let unsentRoute = await SendConfirmationView.onchainPendingRoute(requestId: nil, using: service) + guard case .pending = unsentRoute else { return XCTFail("New unsent route acquired an earlier completed attempt") } + let contexts = [ + OnchainSendPendingContext(attemptId: UUID(), walletId: original.walletId, txid: txid), + OnchainSendPendingContext(attemptId: original.id, walletId: "node:other", txid: txid), + OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: String(repeating: "ef", count: 32)), + ] + for context in contexts { + let loaded = try await SendPendingScreen.loadOrdinaryPending(using: service, context: context, walletId: original.walletId) + XCTAssertNil(loaded.resolution) + } + _ = try await service.send(using: node, address: "bcrt1qreplacement", amountSats: 9999, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false) + let stale = OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: txid) + let replacement = try await SendPendingScreen.loadOrdinaryPending(using: service, context: stale, walletId: original.walletId) + XCTAssertNil(replacement.resolution, "Replacement attempt used the earlier Pending identity") + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) + XCTAssertEqual(node.calls, 2) + } + @MainActor func testPendingResumeShowsEarlierActivityWithoutSuccessForNewUnsentAmount() async throws { let store = MemoryAttemptStore() @@ -717,6 +774,74 @@ final class TransferServiceActivityTests: XCTestCase { } catch {} XCTAssertEqual(store.snapshot().first?.status, .accepted) XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) + let context = OnchainSendPendingContext(attemptId: id, walletId: walletId, txid: String(repeating: "ab", count: 32)) + let loaded = try await SendPendingScreen.loadOrdinaryPending( + using: OnchainSendAttemptService(store: store), context: context, walletId: walletId + ) + XCTAssertEqual(loaded.attempt?.id, id) + XCTAssertEqual(loaded.attempt?.amountSats, 1000) + XCTAssertEqual(loaded.attempt?.txid, context.txid) + XCTAssertEqual(loaded.attempt?.status, .accepted) + XCTAssertTrue(loaded.followupUnavailable) + XCTAssertNil(loaded.resolution) + } + + func testConcurrentPaidOrderFollowupCreatesOneOriginalRecord() async throws { + let storage = ConcurrentPaidOrderStorage(defaults: transferDefaults) + let service = Bitkit.TransferService( + storage: storage, lightningService: .shared, blocktankService: Bitkit.CoreService.shared.blocktank, + isGeoBlocked: { false } + ) + let txid = String(repeating: "ab", count: 32) + let first = Task.detached { + try await service.createTransfer(type: .toSpending, amountSats: 1000, fundingTxId: txid, + lspOrderId: "concurrent-paid-order", txTotalSats: 1100, preTransferOnchainSats: 9000) + } + await fulfillment(of: [storage.firstLookup], timeout: 5) + let second = Task.detached { + try await service.createTransfer(type: .toSpending, amountSats: 9999, fundingTxId: txid, + lspOrderId: "concurrent-paid-order", txTotalSats: 9999, preTransferOnchainSats: 9999) + } + let firstId = try await first.value + let secondId = try await second.value + XCTAssertEqual(firstId, secondId, "Concurrent original-operation follow-up created two record identities") + let records = try storage.getAll() + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records.first?.fundingTxId, txid) + XCTAssertEqual(records.first?.amountSats, 1000) + XCTAssertEqual(records.first?.txTotalSats, 1100) + XCTAssertEqual(records.first?.preTransferOnchainSats, 9000) + } + + @MainActor + func testHardwareResolutionDoesNotReplayContactIntoSavingsOrOverwriteHardwareEdit() async throws { + let identity = "pubky" + String(repeating: "z", count: 52) + let contact = "pubky" + String(repeating: "y", count: 52) + let edited = "pubky" + String(repeating: "x", count: 52) + let request = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord(), now: Date())) + for (index, hardwareEdit) in [nil, edited].enumerated() { + let txid = String(repeating: index == 0 ? "ab" : "cd", count: 32) + let walletId = "trezor:original-resolution-wallet" + let created = await activity.createSentOnchainActivityFromSendResult( + txid: txid, address: "bcrt1qoriginal", amount: 1000, fee: 100, feeRate: 1, + contact: hardwareEdit, walletId: walletId + ) + XCTAssertTrue(created) + let createdSavings = await activity.createSentOnchainActivityFromSendResult( + txid: txid, address: "bcrt1qsavings", amount: 1000, fee: 100, feeRate: 1, + contact: edited, walletId: WalletScope.default + ) + XCTAssertTrue(createdSavings) + await AppScene.associateResolvedPaykitOnchainPayment( + PaykitOnchainPaymentResolution(identity: identity, requestId: request.id, transactionId: txid, walletId: walletId), + activeIdentity: identity, activity: Bitkit.ActivityListViewModel(transferService: makeService()) + ) + let hardware = try await activity.getOnchainActivityByTxId(txid: txid, walletId: walletId) + let savings = try await activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual(hardware?.contact, hardwareEdit.flatMap(PubkyPublicKeyFormat.normalized)) + XCTAssertEqual(savings?.contact, PubkyPublicKeyFormat.normalized(edited), "Hardware resolution rewrote unrelated Savings contact") + XCTAssertNotEqual(savings?.contact, PubkyPublicKeyFormat.normalized(contact)) + } } func testPaidOrderFollowupIsIdempotentAndRejectsDifferentFundingTxid() async throws { @@ -804,3 +929,25 @@ private final class HardwarePendingSavingsSpy: OnchainSendAttemptStoring, @unche func save(_: [OnchainSendAttempt]) {} } + +private final class ConcurrentPaidOrderStorage: Bitkit.TransferStorage { + let firstLookup = XCTestExpectation(description: "Original paid-order lookup captured") + private let lock = NSLock() + private let secondLookup = DispatchSemaphore(value: 0) + private var lookupCount = 0 + + override func getAll() throws -> [Bitkit.Transfer] { + let snapshot = try super.getAll() + lock.lock() + lookupCount += 1 + let index = lookupCount + lock.unlock() + if index == 1 { + firstLookup.fulfill() + _ = secondLookup.wait(timeout: .now() + 2) + } else if index == 2 { + secondLookup.signal() + } + return snapshot + } +} From 994ab3b274633d356266aa6705ed88e85e37e76d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Oct 2026 21:47:09 +0200 Subject: [PATCH 10/77] fix: retain original payment context during startup recovery --- Bitkit/Views/Wallets/Send/SendSheet.swift | 6 ++- .../TransferServiceActivityTests.swift | 38 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 4966be8b8..bb3c8f776 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -265,7 +265,7 @@ struct SendSheet: View { isResumingAcceptedOrdinarySend = true hasValidatedAfterSync = true app.selectedWalletToPayFrom = .onchain - replaceRootRoute(with: .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil)) + replaceRootRoute(with: Self.acceptedOrdinaryStartupRoute(attempt: attempt)) return } } catch { @@ -417,6 +417,10 @@ struct SendSheet: View { return true } + static func acceptedOrdinaryStartupRoute(attempt: OnchainSendAttempt) -> SendRoute { + .onchainPending(OnchainSendPendingContext(attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid)) + } + /// Shows insufficient spending toast with amount-specific or generic description private func showInsufficientSpendingToast(invoiceAmount: UInt64, spendingBalance: UInt64) { let amountNeeded = invoiceAmount > spendingBalance ? invoiceAmount - spendingBalance : 0 diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 5ef703f56..837d8be7d 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -123,6 +123,38 @@ final class TransferServiceActivityTests: XCTestCase { } } + @MainActor + func testStartupAcceptedResolutionBeforePendingInitializationRetainsOriginalContext() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ac", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let service = OnchainSendAttemptService(store: store) + _ = try await service.send(using: node, address: "bcrt1qoriginal", amountSats: 4321, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100)) + let original = try XCTUnwrap(store.snapshot().first) + let retained = try await service.unresolvedAttempt(walletId: original.walletId) + let route = try SendSheet.acceptedOrdinaryStartupRoute(attempt: XCTUnwrap(retained)) + // The activity event publishes and durably acknowledges before Pending subscribes/initializes. + _ = try await service.resumeAcceptedOrdinarySend(walletId: original.walletId) + let context: OnchainSendPendingContext? = if case let .onchainPending(value) = route { + value + } else { + nil + } + let restarted = OnchainSendAttemptService(store: store) + let loaded = try await SendPendingScreen.loadOrdinaryPending( + using: restarted, context: context, walletId: "node:now-selected-other" + ) + XCTAssertEqual(context?.attemptId, original.id) + XCTAssertEqual(context?.walletId, original.walletId) + XCTAssertEqual(context?.txid, txid) + XCTAssertEqual(loaded.attempt?.id, original.id) + XCTAssertEqual(loaded.resolution?.txid, txid) + XCTAssertEqual(loaded.resolution?.amountSats, 4321) + XCTAssertEqual(node.calls, 1, "Startup recovery dispatched another payment") + } + func testPendingInitializationRejectsOldCompletedAndReplacementContexts() async throws { let store = MemoryAttemptStore() let txid = String(repeating: "ab", count: 32) @@ -132,6 +164,10 @@ final class TransferServiceActivityTests: XCTestCase { satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100)) let original = try XCTUnwrap(store.snapshot().first) + let startupRoute = await SendSheet.acceptedOrdinaryStartupRoute(attempt: original) + guard case let .onchainPending(startupContext) = startupRoute else { + return XCTFail("Startup lost the original retained identity") + } _ = try await service.resumeAcceptedOrdinarySend(walletId: original.walletId) let noContext = try await SendPendingScreen.loadOrdinaryPending(using: service, context: nil, walletId: original.walletId) XCTAssertNil(noContext.resolution, "Old completed result satisfied a new unsent Pending") @@ -148,7 +184,7 @@ final class TransferServiceActivityTests: XCTestCase { } _ = try await service.send(using: node, address: "bcrt1qreplacement", amountSats: 9999, satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false) - let stale = OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: txid) + let stale = startupContext let replacement = try await SendPendingScreen.loadOrdinaryPending(using: service, context: stale, walletId: original.walletId) XCTAssertNil(replacement.resolution, "Replacement attempt used the earlier Pending identity") XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) From 76326425afbd365d83ffd293b421c6a4df85100f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 18:46:15 +0200 Subject: [PATCH 11/77] fix: restore pending payment protection from backup --- Bitkit/Models/PaykitPaymentStateBackup.swift | 175 ++++++++++++- Bitkit/Services/BackupService.swift | 11 +- .../Services/OnchainSendAttemptService.swift | 60 ++++- .../Services/PaykitPaymentProofService.swift | 16 ++ Bitkit/ViewModels/TransferViewModel.swift | 3 +- Bitkit/ViewModels/WalletViewModel.swift | 6 + .../Wallets/Send/SendPendingScreen.swift | 30 ++- .../PaykitPaymentStateBackupTests.swift | 243 ++++++++++++++++++ .../TransferServiceActivityTests.swift | 89 +++++++ 9 files changed, 615 insertions(+), 18 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 8f68fe998..28980604c 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -1,9 +1,11 @@ +import CryptoKit import Foundation import Paykit struct PaykitPaymentStateBackup: Codable { let subscriptions: [String: Subscription] let pendingProofs: [Proof] + var activeOnchainAttempt: ActiveOnchainAttempt? = nil struct RequestID: Codable { let paymentRequestId: String @@ -15,7 +17,7 @@ struct PaykitPaymentStateBackup: Codable { paymentRequestId = id.paymentRequestId counterparty = id.counterparty counterpartyReceiverPath = id.counterpartyReceiverPath - billingPeriodStartsAt = billingPeriod?.sdkValue.startsAt + billingPeriodStartsAt = billingPeriod?.sdkValue.startsAt ?? id.billingPeriodStartsAt.map { PaykitPreciseInstant(date: $0).timestamp } } func restored(billingPeriod: PaykitBillingPeriod?) -> PaykitPaymentRequest.ID { @@ -115,6 +117,177 @@ struct PaykitPaymentStateBackup: Codable { } } + /// Shared v1 wire object; unsigned values are decimal strings on both platforms. + struct ActiveOnchainAttempt: Codable { + struct Wallet: Codable, Equatable { + let kind: String + let network: String + let binding: String + let sourceIndex: String + + static func binding(storeId: String) -> String { + SHA256.hash(data: Data(storeId.utf8)).map { String(format: "%02x", $0) }.joined() + } + + static func backupNamespace(index: Int) async throws -> Wallet { + let storeId = try await VssStoreIdProvider.shared.getVssStoreId(walletIndex: index) + return Wallet(kind: "software", network: Env.networkName, binding: binding(storeId: storeId), sourceIndex: String(index)) + } + + var originalWalletId: String? { + guard let index = Int(sourceIndex), index >= 0, index <= Int(Int32.max), String(index) == sourceIndex else { return nil } + return "node:\(network):\(WalletScope.default):\(index)" + } + } + + struct Input: Codable { let txid: String; let vout: String } + struct Followup: Codable { + let feeSats: String + let tags: [String] + let contact: String? + let createdAtMillis: String + let channelId: String? + } + + struct Transfer: Codable { + let txTotalSats: String + let preTransferOnchainSats: String + let originalOrderClientBalanceSats: String + let originalOrderFeeSats: String + } + + let version: Int + let wallet: Wallet + let attemptId: String + let requestId: RequestID? + let orderId: String? + let payerIdentity: String? + let address: String + let amountSats: String + let isMaxAmount: Bool + let status: String + let txid: String? + let rejectionReason: String? + let originalInputs: [Input]? + let candidateTxids: [String] + let feeRateSatsPerVByte: String + let followup: Followup? + let transfer: Transfer? + + init(_ attempt: OnchainSendAttempt, wallet: Wallet) throws { + guard attempt.walletId == wallet.originalWalletId else { throw invalidBackup("On-chain attempt belongs to another backup wallet") } + version = 1 + self.wallet = wallet + attemptId = attempt.id.uuidString.lowercased() + requestId = attempt.requestId.map { RequestID($0, billingPeriod: nil) } + orderId = attempt.orderId + payerIdentity = attempt.recoveryContext?.paymentIdentity + address = attempt.address + amountSats = String(attempt.amountSats) + isMaxAmount = attempt.isMaxAmount + status = attempt.status.rawValue + txid = attempt.txid?.lowercased() + rejectionReason = attempt.rejectionReason + originalInputs = attempt.recoveryContext.flatMap { recovery in + recovery.inputs.isEmpty ? nil : recovery.inputs.map { Input(txid: $0.txid.lowercased(), vout: String($0.vout)) } + } + candidateTxids = attempt.recoveryContext?.candidateTxids.map { $0.lowercased() } ?? [] + feeRateSatsPerVByte = String(attempt.recoveryContext?.satsPerVbyte ?? attempt.followupContext?.feeRate ?? 0) + guard attempt.followupContext?.createdAt.multipliedReportingOverflow(by: 1000).overflow != true else { + throw invalidBackup("Invalid local follow-up timestamp") + } + followup = attempt.followupContext.map { + Followup( + feeSats: String($0.feeSats), + tags: $0.tags, + contact: $0.contact, + createdAtMillis: String($0.backupCreatedAtMillis ?? $0.createdAt.multipliedReportingOverflow(by: 1000).partialValue), + channelId: $0.channelId + ) + } + transfer = try attempt.transferContext.map { + guard let fee = $0.originalOrderFeeSats else { throw invalidBackup("Missing original order fee") } + return Transfer(txTotalSats: String($0.txTotalSats), preTransferOnchainSats: String($0.preTransferOnchainSats), + originalOrderClientBalanceSats: String($0.clientBalanceSats), originalOrderFeeSats: String(fee)) + } + } + + func restored(wallet destination: Wallet, proofs: [PendingPaykitPaymentProof]) throws -> (OnchainSendAttempt, [PendingPaykitPaymentProof]) { + guard version == 1, wallet.kind == "software", destination.kind == "software", + ["bitcoin", "testnet", "signet", "regtest"].contains(wallet.network), + wallet.network == destination.network, wallet.binding == destination.binding, validTxid(wallet.binding), + wallet.originalWalletId != nil, let destinationWalletId = destination.originalWalletId, + let id = UUID(uuidString: attemptId), id.uuidString.lowercased() == attemptId, let state = OnchainSendAttempt.Status(rawValue: status), !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + requestId == nil || orderId == nil + else { throw invalidBackup("On-chain backup wallet or operation mismatch") } + let amount: UInt64 = try number(amountSats) + guard amount > 0 else { throw invalidBackup("Invalid on-chain backup amount") } + let rate: UInt32 = try number(feeRateSatsPerVByte) + let inputs = try originalInputs.map { values in + try values.map { input -> OnchainSendInput in + guard validTxid(input.txid) else { throw invalidBackup("Invalid original input") } + return try OnchainSendInput(txid: input.txid, vout: number(input.vout)) + } + } + guard candidateTxids.allSatisfy(validTxid), Set(candidateTxids).count == candidateTxids.count, + inputs == nil ? candidateTxids.isEmpty && txid == nil && state == .pending : + inputs?.isEmpty == false && Set(inputs ?? []).count == inputs?.count && !candidateTxids.isEmpty && + txid.map(candidateTxids.contains) == true && rate > 0, + state != .accepted || txid != nil + else { throw invalidBackup("Invalid on-chain prepared receipt") } + let restoredRequest = try requestId.map { value in + let date = try value.billingPeriodStartsAt.map { try parseTimestamp($0).date } + return PaykitPaymentRequest.ID(paymentRequestId: value.paymentRequestId, counterparty: value.counterparty, + counterpartyReceiverPath: value.counterpartyReceiverPath, billingPeriodStartsAt: date) + } + let restoredProofs = proofs + if let restoredRequest { + guard let payerIdentity, PubkyPublicKeyFormat.normalized(payerIdentity) == payerIdentity, + restoredProofs.contains(where: { + PubkyPublicKeyFormat.matches($0.identity, payerIdentity) && $0.requestId == restoredRequest && + $0.kind == .onchain && $0.paymentStarted && ($0.onchainWalletId == nil || $0.onchainWalletId == WalletScope.default) && + $0.onchainAddress == address && $0.onchainAmountSats == amount && + ($0.paymentIdentifier == nil || candidateTxids.contains($0.paymentIdentifier?.lowercased() ?? "")) && + ($0.proofData == nil || candidateTxids.contains($0.proofData?.lowercased() ?? "")) && + ($0.onchainAcceptanceVerified != true || $0.paymentIdentifier == txid && $0.proofData == txid && state == .accepted) + }) + else { throw invalidBackup("Original on-chain proof association is missing") } + // The validated active wallet supplies indexed association; proof keeps its software scope. + } + let context = try followup.map { value -> OnchainSendFollowupContext in + let milliseconds: UInt64 = try number(value.createdAtMillis) + return try OnchainSendFollowupContext(feeSats: number(value.feeSats), feeRate: rate, tags: value.tags, + contact: value.contact, createdAt: milliseconds / 1000, backupCreatedAtMillis: milliseconds, + channelId: value.channelId) + } + let orderContext = try transfer.map { value -> OnchainSendTransferContext in + guard orderId != nil else { throw invalidBackup("Original transfer context mismatch") } + return try OnchainSendTransferContext(clientBalanceSats: number(value.originalOrderClientBalanceSats), + txTotalSats: number(value.txTotalSats), + preTransferOnchainSats: number(value.preTransferOnchainSats), + originalOrderFeeSats: number(value.originalOrderFeeSats)) + } + guard orderId == nil || orderContext != nil else { throw invalidBackup("Missing original transfer context") } + let attempt = OnchainSendAttempt(id: id, walletId: destinationWalletId, requestId: restoredRequest, orderId: orderId, + address: address, amountSats: amount, isMaxAmount: isMaxAmount, status: state, txid: txid, + rejectionReason: rejectionReason, localFollowupComplete: false, + followupContext: context, transferContext: orderContext, + recoveryContext: OnchainSendRecoveryContext(inputs: inputs ?? [], satsPerVbyte: rate, + paymentIdentity: payerIdentity, + candidateTxids: candidateTxids)) + return (attempt, restoredProofs) + } + + private func validTxid(_ value: String) -> Bool { + value.count == 64 && value.allSatisfy { "0123456789abcdef".contains($0) } + } + + private func number(_ value: String) throws -> T { + guard let parsed = T(value), String(parsed) == value else { throw invalidBackup("Invalid unsigned on-chain backup value") } + return parsed + } + } + private static func parseTimestamp(_ value: String) throws -> PaykitPreciseInstant { guard let instant = PaykitPreciseInstant(timestamp: value) else { throw invalidBackup("Invalid Paykit timestamp") diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index c4d18607f..ce1e35c75 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -317,8 +317,9 @@ class BackupService { try walletRestoreGate.retain(dataBytes) let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes) if let paymentState = payload.paykitPaymentState { - try PaykitSubscriptionStateStore().restoreBackup(paymentState.subscriptions) - try await PaykitPaymentProofService.shared.restoreBackup(paymentState.pendingProofs) + // VssBackupClient's existing custom backup namespace is explicitly wallet index 0. + let backupWallet = try await PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0) + try await PaykitPaymentProofService.shared.restoreBackup(paymentState, wallet: backupWallet) } try TransferStorage.shared.upsertList(payload.transfers) await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType) @@ -508,6 +509,7 @@ class BackupService { PaykitSubscriptionStateStore.walletBackupDataChangedPublisher .merge(with: PaykitPaymentProofService.proofStateChangedPublisher) + .merge(with: OnchainSendAttemptStore.walletBackupDataChangedPublisher) .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } @@ -877,9 +879,8 @@ class BackupService { paykitSdkBackupState: paykitSdkBackupState, watchOnlyAccounts: watchOnlyAccountSnapshot.accounts, watchOnlyAccountAllocationState: watchOnlyAccountSnapshot.allocationState, - paykitPaymentState: PaykitPaymentStateBackup( - subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), - pendingProofs: PaykitPaymentProofService.shared.backupSnapshot() + paykitPaymentState: PaykitPaymentProofService.shared.backupSnapshot( + wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0) ) ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index d377832c1..149d35897 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -22,14 +22,13 @@ extension LightningService: OnchainSending { guard self.currentWalletIndex == expectedWalletIndex, self.onchainDispatchNode === node, expectedNode === node else { throw NodeError.NotRunning(message: "Wallet or node changed before on-chain preparation") } - let prepared: PreparedOnchainSend - if isMaxAmount { - prepared = try node.onchainPayment().prepareSendAllToAddress( + let prepared: PreparedOnchainSend = if isMaxAmount { + try node.onchainPayment().prepareSendAllToAddress( address: address, retainReserves: true, feeRate: .fromSatPerKwu(satKwu: max(UInt64(satsPerVbyte) * 250, 253)) ) } else { - prepared = try node.onchainPayment().prepareSendToAddress( + try node.onchainPayment().prepareSendToAddress( address: address, amountSats: sats, feeRate: .fromSatPerKwu(satKwu: max(UInt64(satsPerVbyte) * 250, 253)), utxosToSpend: utxosToSpend ) @@ -49,7 +48,6 @@ extension LightningService: OnchainSending { } ) } - } struct OnchainSendInput: Codable, Equatable, Hashable { @@ -162,12 +160,15 @@ struct OnchainSendFollowupContext: Codable, Equatable { let tags: [String] let contact: String? let createdAt: UInt64 + var backupCreatedAtMillis: UInt64? = nil + var channelId: String? = nil } struct OnchainSendTransferContext: Codable, Equatable { let clientBalanceSats: UInt64 let txTotalSats: UInt64 let preTransferOnchainSats: UInt64 + var originalOrderFeeSats: UInt64? = nil } struct OnchainSendLocalResolution { @@ -216,6 +217,11 @@ protocol OnchainSendAttemptStoring: Sendable { } struct OnchainSendAttemptStore: OnchainSendAttemptStoring { + private static let backupDataChanged = PassthroughSubject() + static var walletBackupDataChangedPublisher: AnyPublisher { + backupDataChanged.eraseToAnyPublisher() + } + func load() throws -> [OnchainSendAttempt] { guard let data = try Keychain.load(key: .onchainSendAttempts) else { return [] } return try JSONDecoder().decode([OnchainSendAttempt].self, from: data) @@ -223,6 +229,7 @@ struct OnchainSendAttemptStore: OnchainSendAttemptStoring { func save(_ attempts: [OnchainSendAttempt]) throws { try Keychain.upsert(key: .onchainSendAttempts, data: JSONEncoder().encode(attempts)) + Self.backupDataChanged.send() } } @@ -263,6 +270,26 @@ actor OnchainSendAttemptService { self.hasPaidOrder = hasPaidOrder } + func backupSnapshot(wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet, + proofs: [PendingPaykitPaymentProof]) throws -> PaykitPaymentStateBackup.ActiveOnchainAttempt? + { + guard let attempt = try currentAttempt(), attempt.blocksNewSend else { return nil } + let wire = try PaykitPaymentStateBackup.ActiveOnchainAttempt(attempt, wallet: wallet) + _ = try wire.restored(wallet: wallet, proofs: proofs) + return wire + } + + func restoreBackup(_ attempt: OnchainSendAttempt?) throws { + guard nativeDispatchInProgress == nil else { throw OnchainSendAttemptError.unresolved } + if let previous = try currentAttempt(), previous.blocksNewSend { + // A restore must not erase a newer unresolved candidate or another original operation. + guard let attempt, previous == attempt else { throw OnchainSendAttemptError.unresolved } + return + } + try store.save(attempt.map { [$0] } ?? []) + knownAttempt = attempt + } + func send( using lightningService: any OnchainSending, address: String, @@ -626,10 +653,33 @@ actor OnchainSendAttemptService { type: .toSpending, amountSats: transfer.clientBalanceSats, fundingTxId: txid, lspOrderId: savedOrderId, txTotalSats: transfer.txTotalSats, preTransferOnchainSats: transfer.preTransferOnchainSats ) + guard await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: txid, address: attempt.address, amount: attempt.amountSats, fee: context.feeSats, + feeRate: context.feeRate, isTransfer: true + ), let activity = try await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid), + activity.txType == .sent, activity.isTransfer + else { throw OnchainSendAttemptError.localFollowupNotSaved } try acknowledgeLocalFollowup(txid: txid) + Self.localResolutionSubject.send(OnchainSendLocalResolution( + attemptId: attempt.id, walletId: attempt.walletId, txid: txid, amountSats: activity.value, + contact: activity.contact, activity: activity + )) return true } + func resolvedAcceptedTransfer(context: OnchainSendPendingContext, using transferService: TransferService) async throws + -> OnchainSendLocalResolution? + { + guard let attempt = try pendingAttempt(context: context), attempt.orderId != nil, attempt.requestId == nil, + attempt.status == .accepted, let txid = attempt.txid, + try await restoreAcceptedTransfer(attempt, using: transferService), + let activity = try await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid), + activity.txType == .sent, activity.isTransfer + else { return nil } + return OnchainSendLocalResolution(attemptId: attempt.id, walletId: attempt.walletId, txid: txid, + amountSats: activity.value, contact: activity.contact, activity: activity) + } + func clearBeforeDispatch(attemptId: UUID) throws { var attempts = try store.load() guard let index = attempts.firstIndex(where: { $0.id == attemptId && $0.status == .pending }) else { return } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 802cdcc92..cee7ae5ce 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -218,6 +218,22 @@ actor PaykitPaymentProofService { try await store.load().map(PaykitPaymentStateBackup.Proof.init) } + func backupSnapshot(wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet) async throws -> PaykitPaymentStateBackup { + let proofs = try await store.load() + let active = try await attemptService.backupSnapshot(wallet: wallet, proofs: proofs) + return try PaykitPaymentStateBackup(subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), + pendingProofs: proofs.map(PaykitPaymentStateBackup.Proof.init), activeOnchainAttempt: active) + } + + func restoreBackup(_ state: PaykitPaymentStateBackup, wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet) async throws { + let proofs = try state.pendingProofs.map { try $0.restored() } + let restored = try state.activeOnchainAttempt.map { try $0.restored(wallet: wallet, proofs: proofs) } + // Persist the block first. A later proof/SDK restore failure leaves the original guard intact. + try await attemptService.restoreBackup(restored?.0) + try await mutationLock.withLock { try await persist(restored?.1 ?? proofs) } + try PaykitSubscriptionStateStore().restoreBackup(state.subscriptions) + } + func restoreBackup(_ proofs: [PaykitPaymentStateBackup.Proof]) async throws { let restoredProofs = try proofs.map { try $0.restored() } let unsupportedWalletProofCount = restoredProofs.filter { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index ec80fba97..6411e2525 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -478,7 +478,8 @@ class TransferViewModel: ObservableObject { transferContext: OnchainSendTransferContext( clientBalanceSats: order.clientBalanceSat, txTotalSats: txTotalSats, - preTransferOnchainSats: preTransferOnchainSats + preTransferOnchainSats: preTransferOnchainSats, + originalOrderFeeSats: order.feeSat ) ) let txid: String diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 7ad54514e..c4b05fda7 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -669,6 +669,12 @@ class WalletViewModel: ObservableObject { try await attempts.resumeAcceptedTransfer(walletId: walletId, using: transferService) } + func resolvedAcceptedOnchainTransfer(context: OnchainSendPendingContext, attempts: OnchainSendAttemptService) async throws + -> OnchainSendLocalResolution? + { + try await attempts.resolvedAcceptedTransfer(context: context, using: transferService) + } + /// Sets the fee rate for the send flow /// - Parameter speed: The transaction speed determining the fee rate. If nil, the user's default transaction speed will be used. func setFeeRate(speed: TransactionSpeed) async throws { diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 3bc2e7e62..51da07295 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -201,6 +201,13 @@ struct SendPendingScreen: View { localFollowupUnavailable = loaded.followupUnavailable if let resolution = loaded.resolution { applyOrdinarySendResolution(resolution) + } else if let attempt = onchainAttempt, attempt.orderId != nil { + let context = ordinaryPendingContext ?? OnchainSendPendingContext( + attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid + ) + if let resolution = try await wallet.resolvedAcceptedOnchainTransfer(context: context, attempts: attemptService) { + applyOrdinarySendResolution(resolution) + } } if let requestId = paykitPaymentRequestId, let identity = pendingOnchainProof?.identity ?? pubkyProfile.publicKey, let resolution = await proofService.resolvedOnchainPayment( @@ -286,8 +293,9 @@ struct SendPendingScreen: View { applyOnchainPaymentResolution(resolution) } } else if original.orderId != nil { - guard try await wallet.resumeAcceptedOnchainTransfer(walletId: original.walletId, attempts: attemptService) else { return } - navigationPath.append(.success(paymentId: txid)) + if let resolution = try await wallet.resolvedAcceptedOnchainTransfer(context: context, attempts: attemptService) { + applyOrdinarySendResolution(resolution) + } } else if let resolution = try await attemptService.resumeAcceptedOrdinarySend(walletId: original.walletId, pendingContext: context) { applyOrdinarySendResolution(resolution) } @@ -372,10 +380,8 @@ struct SendPendingScreen: View { private func applyOrdinarySendResolution(_ resolution: OnchainSendLocalResolution) { guard paymentHash == nil, paykitPaymentRequestId == nil, !ordinarySendResolved, - resolution - .walletId == - (ordinaryPendingContext?.walletId ?? OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex)), - onchainAttempt?.id == resolution.attemptId + Self.matchesLocalResolution(resolution, attempt: onchainAttempt, context: ordinaryPendingContext, + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex)) else { return } ordinarySendResolved = true foundActivity = .onchain(resolution.activity) @@ -383,6 +389,18 @@ struct SendPendingScreen: View { onchainAttempt?.localFollowupComplete = true } + static func matchesLocalResolution(_ resolution: OnchainSendLocalResolution, attempt: OnchainSendAttempt?, + context: OnchainSendPendingContext?, walletId: String) -> Bool + { + guard let attempt, attempt.requestId == nil, attempt.id == resolution.attemptId, + attempt.walletId == resolution.walletId, resolution.walletId == (context?.walletId ?? walletId), + context == nil || context?.attemptId == attempt.id, + attempt.containsCandidate(resolution.txid), + context?.txid == nil || attempt.containsCandidate(context?.txid) + else { return false } + return true + } + private func applyPendingResolutionIfNeeded(_ resolution: SendSheetPendingResolution?) { guard let paymentHash, let resolution, resolution.paymentHash == paymentHash else { return } app.consumeSendSheetPendingResolution(paymentHash: paymentHash) diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 9d8ca28a2..2c820070b 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -1,6 +1,8 @@ @testable import Bitkit import Combine +import CryptoKit import Paykit +import VssRustClientFfi import XCTest private enum PaykitPaymentStateBackupTestError: Error { @@ -10,6 +12,84 @@ private enum PaykitPaymentStateBackupTestError: Error { final class PaykitPaymentStateBackupTests: XCTestCase { private let identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + static let activeAttemptGolden = Data(#""" + { + "version": 1, + "createdAt": 1791234000000, + "transfers": [], + "paykitPaymentState": { + "subscriptions": {}, + "pendingProofs": [ + { + "identity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "counterpartyReceiverPath": "bitkit/server", + "billingPeriodStartsAt": null + }, + "paymentEndpointIdentifier": "btc-regtest-p2wpkh", + "kind": "bitcoin-onchain-txid", + "paymentStarted": true, + "paymentIdentifier": "abababababababababababababababababababababababababababababababab", + "proofData": null, + "billingPeriod": null, + "onchainAddress": "bcrt1qoriginal", + "onchainAmountSats": 1234, + "onchainWalletId": null, + "onchainMatchingTransactionIdsBeforeAttempt": [], + "onchainAcceptanceVerified": false + } + ], + "activeOnchainAttempt": { + "version": 1, + "wallet": { + "kind": "software", + "network": "regtest", + "binding": "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0", + "sourceIndex": "0" + }, + "attemptId": "550e8400-e29b-41d4-a716-446655440001", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "counterpartyReceiverPath": "bitkit/server", + "billingPeriodStartsAt": null + }, + "orderId": null, + "payerIdentity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "address": "bcrt1qoriginal", + "amountSats": "1234", + "isMaxAmount": false, + "status": "unknown", + "txid": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "rejectionReason": null, + "originalInputs": [ + { + "txid": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "vout": "4294967295" + } + ], + "candidateTxids": [ + "abababababababababababababababababababababababababababababababab", + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + ], + "feeRateSatsPerVByte": "2", + "followup": { + "feeSats": "123", + "tags": [ + "original tag" + ], + "contact": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "createdAtMillis": "1791234000000", + "channelId": null + }, + "transfer": null + } + } + } + """#.utf8) + Data([10]) + private struct LegacyState: Codable { let subscriptionsByIdentity: [String: LegacySubscriptionState] } @@ -24,6 +104,161 @@ final class PaykitPaymentStateBackupTests: XCTestCase { try Keychain.delete(key: .paykitSubscriptionState) try Keychain.delete(key: .paykitPendingPaymentProofs) try Keychain.delete(key: .paykitPendingBackupRestore) + try Keychain.delete(key: .onchainSendAttempts) + } + + func testSharedBackupBindingUsesActualVssDerivationAndNetworkNames() throws { + let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" + var bindings: [String: String] = [:] + for network in ["bitcoin", "testnet", "signet", "regtest"] { + let storeId = try vssDeriveStoreId(prefix: "bitkit_v1_" + network, mnemonic: mnemonic, passphrase: nil) + let binding = SHA256.hash(data: Data(storeId.utf8)).map { String(format: "%02x", $0) }.joined() + bindings[network] = binding + } + XCTAssertEqual(bindings, [ + "bitcoin": "28f258542d91310274942356e7777d8978bac6dbd3fadbfbfe5d663b19f1f6c5", + "testnet": "0ce1e7952991cf5ce84bfa135dbc194b3d55a7f2fc69807091c4ca1d6eaac809", + "signet": "966319adba79fb70afbf6eb1c7bde0cf93433e08d6f2f832c30467b812005fbe", + "regtest": "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0", + ]) + try print("BI717_SHARED_BINDING_VECTOR " + String(decoding: JSONEncoder().encode(bindings), as: UTF8.self)) + XCTAssertEqual(Env.vssStoreIdPrefix, "bitkit_v1_" + Env.networkName) + } + + static let goldenBinding = "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0" + + static func goldenWallet(index: Int = 2) -> PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet { + .init(kind: "software", network: "regtest", binding: goldenBinding, sourceIndex: String(index)) + } + + func testSharedGoldenRestoreRemapsOnlyOriginalWalletAndResetsLocalFollowup() throws { + let digest = SHA256.hash(data: Self.activeAttemptGolden).map { String(format: "%02x", $0) }.joined() + XCTAssertEqual(digest, "1e392cdfaa82f48bed7be194ddaa3efe62efbf41a54fd14b70590d9a11f6f7b2") + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let state = try XCTUnwrap(envelope.paykitPaymentState) + let wire = try XCTUnwrap(state.activeOnchainAttempt) + let proofs = try state.pendingProofs.map { try $0.restored() } + let restored = try wire.restored(wallet: Self.goldenWallet(), proofs: proofs) + XCTAssertEqual(restored.0.walletId, "node:regtest:" + WalletScope.default + ":2") + XCTAssertEqual(restored.0.recoveryContext?.candidateTxids, [String(repeating: "ab", count: 32), String(repeating: "cd", count: 32)]) + XCTAssertEqual(restored.0.recoveryContext?.inputs.first?.vout, UInt32.max) + XCTAssertEqual(restored.0.amountSats, 1234) + XCTAssertFalse(restored.0.localFollowupComplete) + XCTAssertTrue(restored.0.canRetrySamePayment) + XCTAssertNil(restored.1.first?.onchainWalletId, "Software proof must not be remapped to a hardware scope") + XCTAssertEqual(restored.1.first?.onchainLocalFollowupComplete, false) + let remappedWire = try PaykitPaymentStateBackup.ActiveOnchainAttempt(restored.0, wallet: Self.goldenWallet()) + let again = try remappedWire.restored(wallet: Self.goldenWallet(), proofs: proofs) + XCTAssertEqual(again.0, restored.0) + } + + func testSharedGoldenRejectsWrongWalletNetworkPayerAndMalformedReceipt() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let state = try XCTUnwrap(envelope.paykitPaymentState) + let wire = try XCTUnwrap(state.activeOnchainAttempt) + let proofs = try state.pendingProofs.map { try $0.restored() } + for destination in [ + PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet( + kind: "software", + network: "regtest", + binding: String(repeating: "a", count: 64), + sourceIndex: "2" + ), + .init(kind: "software", network: "bitcoin", binding: Self.goldenBinding, sourceIndex: "2"), + .init(kind: "software", network: "regtest", binding: Self.goldenBinding, sourceIndex: "-1"), + ] { + XCTAssertThrowsError(try wire.restored(wallet: destination, proofs: proofs)) + } + var foreignProof = try XCTUnwrap(proofs.first) + foreignProof = PendingPaykitPaymentProof(identity: "pubky" + String(repeating: "y", count: 52), requestId: foreignProof.requestId, + paymentEndpointIdentifier: foreignProof.paymentEndpointIdentifier, kind: .onchain, + paymentStarted: true, paymentIdentifier: foreignProof.paymentIdentifier, proofData: nil, + onchainAddress: foreignProof.onchainAddress, onchainAmountSats: foreignProof.onchainAmountSats) + XCTAssertThrowsError(try wire.restored(wallet: Self.goldenWallet(), proofs: [foreignProof])) + foreignProof = try XCTUnwrap(proofs.first) + foreignProof.onchainWalletId = "trezor:foreign" + XCTAssertThrowsError(try wire.restored(wallet: Self.goldenWallet(), proofs: [foreignProof])) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) + for patch: [String: Any] in [ + ["amountSats": "18446744073709551616"], ["amountSats": "01"], ["feeRateSatsPerVByte": "4294967296"], + ["candidateTxids": [String(repeating: "ab", count: 32), String(repeating: "ab", count: 32)]], + ["txid": String(repeating: "ef", count: 32)], ["version": 2], + ] { + let bad = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: object.merging(patch) { _, new in new })) + XCTAssertThrowsError(try bad.restored(wallet: Self.goldenWallet(), proofs: proofs)) + } + } + + func testAcceptedBackupRequiresMatchingVerifiedProofData() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let state = try XCTUnwrap(envelope.paykitPaymentState) + let wire = try XCTUnwrap(state.activeOnchainAttempt) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) + object["status"] = "accepted" + let accepted = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: object)) + var proof = try XCTUnwrap(state.pendingProofs.first).restored() + proof.paymentIdentifier = accepted.txid + proof.proofData = accepted.txid + proof.onchainAcceptanceVerified = true + _ = try accepted.restored(wallet: Self.goldenWallet(), proofs: [proof]) + proof.proofData = String(repeating: "ab", count: 32) + XCTAssertThrowsError(try accepted.restored(wallet: Self.goldenWallet(), proofs: [proof])) + } + + func testAndroidMillisecondFollowupRestoresAndPreservesWireProvenance() throws { + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: Self.activeAttemptGolden) as? [String: Any]) + var state = try XCTUnwrap(object["paykitPaymentState"] as? [String: Any]) + var active = try XCTUnwrap(state["activeOnchainAttempt"] as? [String: Any]) + var followup = try XCTUnwrap(active["followup"] as? [String: Any]) + followup["createdAtMillis"] = "1791234000123" + active["followup"] = followup + state["activeOnchainAttempt"] = active + object["paykitPaymentState"] = state + let decoded = try JSONDecoder().decode(WalletBackupV1.self, from: JSONSerialization.data(withJSONObject: object)) + let paymentState = try XCTUnwrap(decoded.paykitPaymentState) + let proofs = try paymentState.pendingProofs.map { try $0.restored() } + let restored = try XCTUnwrap(paymentState.activeOnchainAttempt).restored(wallet: Self.goldenWallet(), proofs: proofs).0 + XCTAssertEqual(restored.followupContext?.createdAt, 1_791_234_000) + XCTAssertEqual(restored.followupContext?.backupCreatedAtMillis, 1_791_234_000_123) + let again = try PaykitPaymentStateBackup.ActiveOnchainAttempt(restored, wallet: Self.goldenWallet()) + XCTAssertEqual(again.followup?.createdAtMillis, "1791234000123") + } + + func testRestoreCannotClobberDifferentUnresolvedOriginalOperation() async throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let state = try XCTUnwrap(envelope.paykitPaymentState) + let restored = try XCTUnwrap(state.activeOnchainAttempt).restored( + wallet: Self.goldenWallet(), proofs: state.pendingProofs.map { try $0.restored() } + ).0 + let store = MemoryAttemptStore() + let existing = OnchainSendAttempt(id: UUID(), walletId: restored.walletId, requestId: nil, orderId: nil, + address: "another original", amountSats: 9999, isMaxAmount: false, status: .unknown) + try store.save([existing]) + let service = OnchainSendAttemptService(store: store) + do { try await service.restoreBackup(restored); XCTFail("Restore overwrote unresolved local operation") } catch {} + XCTAssertEqual(store.snapshot(), [existing]) + do { try await service.restoreBackup(nil); XCTFail("Absent backup guard erased unresolved local operation") } catch {} + XCTAssertEqual(store.snapshot(), [existing]) + } + + func testWalletEnvelopeRetainsActiveAttemptWireAcrossDecodeAndEncode() throws { + let receipt: [String: Any] = [ + "version": 1, + "wallet": ["kind": "software", "network": "regtest", "binding": String(repeating: "a", count: 64), "sourceIndex": "0"], + "attemptId": UUID().uuidString.lowercased(), "address": "bcrt1qoriginal", "amountSats": "20000", + "isMaxAmount": true, "status": "unknown", "txid": String(repeating: "ab", count: 32), + "originalInputs": [["txid": String(repeating: "cd", count: 32), "vout": "0"]], + "candidateTxids": [String(repeating: "ab", count: 32)], "feeRateSatsPerVByte": "2", + "followup": ["feeSats": "100", "tags": ["original"], "createdAtMillis": "1000"], + ] + let envelope: [String: Any] = ["version": 1, "createdAt": 1000, "transfers": [], + "paykitPaymentState": ["subscriptions": [:], "pendingProofs": [], "activeOnchainAttempt": receipt]] + let decoded = try JSONDecoder().decode(WalletBackupV1.self, from: JSONSerialization.data(withJSONObject: envelope)) + let encoded = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(decoded)) as? [String: Any]) + let state = try XCTUnwrap(encoded["paykitPaymentState"] as? [String: Any]) + XCTAssertNotNil(state["activeOnchainAttempt"], "Wallet backup dropped original prepared inputs/candidate identity") } func testPaymentStateBackupRoundTrip() async throws { @@ -93,6 +328,14 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertEqual(try Keychain.load(key: .paykitPendingPaymentProofs), data) } + func testAttemptChangesNotifyWalletBackup() throws { + var changes = 0 + let observation = OnchainSendAttemptStore.walletBackupDataChangedPublisher.sink { changes += 1 } + defer { observation.cancel() } + try OnchainSendAttemptStore().save([]) + XCTAssertEqual(changes, 1) + } + func testSubscriptionChangesNotifyBackup() throws { var changes = 0 let observation = PaykitSubscriptionStateStore.walletBackupDataChangedPublisher.sink { changes += 1 } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 2310cc7aa..a4d433826 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import Combine import LDKNode import Paykit import SwiftUI @@ -660,6 +661,94 @@ final class TransferServiceActivityTests: XCTestCase { } } + @MainActor + func testRestoredGoldenShopAttemptCompletesObservedOriginalCandidateWithoutNewPayment() async throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: PaykitPaymentStateBackupTests.activeAttemptGolden) + let state = try XCTUnwrap(envelope.paykitPaymentState) + let store = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: store) + let proofs = PaymentProofMemoryStore() + let payer = "pubky" + String(repeating: "z", count: 52) + let sdk = PaymentProofSdkMock(identity: payer, records: []) + await sdk.setSubmissionFailure(true) + let service = PaykitPaymentProofService(sdk: sdk, store: proofs, attemptService: attempts, logInfo: { _ in }, logWarning: { _ in }) + try await service.restoreBackup(state, wallet: PaykitPaymentStateBackupTests.goldenWallet(index: 0)) + let restored = try XCTUnwrap(store.snapshot().first) + let requestId = try XCTUnwrap(restored.requestId) + let winner = String(repeating: "ab", count: 32) + XCTAssertEqual(restored.txid, String(repeating: "cd", count: 32)) + XCTAssertEqual(restored.status, .unknown) + XCTAssertFalse(restored.localFollowupComplete) + // This is the exact original-wallet received observation, not queued local activity. + let observed = try await attempts.observeTransaction(txid: winner, walletId: restored.walletId) + XCTAssertTrue(observed) + let context = OnchainSendPendingContext(attemptId: restored.id, walletId: restored.walletId, txid: restored.txid) + await sdk.setIdentity("pubky" + String(repeating: "y", count: 52)) + let foreignResolution = await service.resolvedOnchainPayment(requestId: requestId, identity: payer, context: context) + XCTAssertNil(foreignResolution, "Selected profile must not complete another original payer's proof") + await sdk.setIdentity(payer) + let resolution = await service.resolvedOnchainPayment(requestId: requestId, identity: payer, context: context) + XCTAssertEqual(resolution?.transactionId, winner) + let completed = try await proofs.load().first + XCTAssertEqual(completed?.paymentIdentifier, winner) + XCTAssertEqual(completed?.proofData, winner) + XCTAssertEqual(completed?.onchainAcceptanceVerified, true) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + let saved = try await activity.getOnchainActivityByTxId(txid: winner) + XCTAssertEqual(saved?.value, 1234) + XCTAssertEqual(saved?.contact, requestId.counterparty) + XCTAssertEqual(store.snapshot().first?.recoveryContext?.candidateTxids.count, 2) + // There is no OnchainSending/native broadcast dependency in restore or local completion. + } + + @MainActor + func testReceivedTransferPublishesExactResolutionForVisiblePending() async throws { + let confirmed = Bitkit.LightningService.shared.onchainTransactionConfirmed + let received = Bitkit.LightningService.shared.onchainTransactionReceived + defer { + Bitkit.LightningService.shared.onchainTransactionConfirmed = confirmed + Bitkit.LightningService.shared.onchainTransactionReceived = received + } + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .unknown(txid: txid)) + let service = OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }) + _ = try await service.send(using: node, address: "original", amountSats: 4321, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, orderId: "original-order", + followupContext: .init(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100), + transferContext: .init(clientBalanceSats: 3333, txTotalSats: 4444, preTransferOnchainSats: 10000)) + let original = try XCTUnwrap(store.snapshot().first) + let wallet = makeWallet(attempts: service) + let resolutionReady = expectation(description: "Visible Pending receives exact durable transfer resolution") + var resolved: OnchainSendLocalResolution? + let observation = OnchainSendAttemptService.localResolutionPublisher.sink { resolution in + if resolution.attemptId == original.id, resolution.walletId == original.walletId, resolution.txid == txid, resolved == nil { + resolved = resolution + resolutionReady.fulfill() + } + } + defer { observation.cancel() } + await Bitkit.LightningService.shared.onchainTransactionReceived?(txid) + await fulfillment(of: [resolutionReady], timeout: 2) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + XCTAssertEqual(resolved?.activity.txId, txid, "Pending Details must refer to the actual winning funding transaction") + XCTAssertEqual(resolved?.activity.isTransfer, true) + if let resolved { + let context = OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: original.txid) + XCTAssertTrue(SendPendingScreen.matchesLocalResolution(resolved, attempt: original, context: context, walletId: "new-selected-wallet")) + XCTAssertFalse(SendPendingScreen.matchesLocalResolution(resolved, attempt: original, + context: .init(attemptId: UUID(), walletId: original.walletId, txid: txid), + walletId: original.walletId)) + XCTAssertFalse(SendPendingScreen.matchesLocalResolution(resolved, attempt: original, + context: .init(attemptId: original.id, walletId: "other-wallet", txid: txid), + walletId: original.walletId)) + let reopened = try await service.resolvedAcceptedTransfer(context: context, using: makeService()) + XCTAssertEqual(reopened?.txid, txid, "Resolution preceding Pending initialization must still enable exact Details") + } + XCTAssertEqual(node.calls, 1) + withExtendedLifetime(wallet) {} + } + @MainActor func testTransferResumeRetainsOriginalBalanceMetadataAfterTrackingFailure() async throws { for isMax in [false, true] { From 2bccf53298ace12f5032a65a4e9a37697de21fc8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 19:45:35 +0200 Subject: [PATCH 12/77] fix: prevent unfundable send-all fee increases --- .../Localization/en.lproj/Localizable.strings | 2 + .../Services/OnchainSendAttemptService.swift | 5 +++ .../Wallets/Send/SendPendingScreen.swift | 4 +- .../OnchainSendAttemptServiceTests.swift | 45 ++++++++++++++----- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 69d4cbdf4..605beec83 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1800,3 +1800,5 @@ "wallet__onchain_retry_unavailable" = "The retry could not be prepared right now. The original payment may already have reached the network. Check your connection and retry only this original payment."; "wallet__onchain_retry_invalid_fee" = "Enter a whole-number fee rate from 1 to 4,294,967,295 sat/vB."; + +"wallet__onchain_retry_max_note" = "Retry {amount} sats to {address} using the same original coins and original fee rate of {fee} sat/vB. Send-all leaves no coins for a higher fee without reducing the recipient amount. The amount stays unchanged. This cannot create a separate payment."; diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 149d35897..19d07e15e 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -391,6 +391,11 @@ actor OnchainSendAttemptService { defer { nativeDispatchInProgress = nil } let authorizedFeeRate = satsPerVbyte ?? recovery.satsPerVbyte guard authorizedFeeRate > 0 else { throw OnchainSendAttemptError.unresolved } + // Send-all already spends the original inputs minus its fee. A bump cannot + // preserve both the exact input set and the original recipient amount. + guard !original.isMaxAmount || authorizedFeeRate == recovery.satsPerVbyte else { + throw OnchainSendAttemptError.retryConstruction + } try checkWallet(sender, index: index, node: node) if let winner = try winningResult(attemptId: original.id) { return winner diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 51da07295..40e93bc66 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -129,6 +129,7 @@ struct SendPendingScreen: View { } .alert(t("wallet__onchain_retry_original"), isPresented: $showingRetryConfirmation) { TextField(t("wallet__onchain_retry_fee"), text: $retryFeeRate).keyboardType(.numberPad) + .disabled(onchainAttempt?.isMaxAmount == true) Button(t("common__cancel"), role: .cancel) {} Button(t("common__retry")) { guard let rate = UInt32(retryFeeRate), rate > 0 else { @@ -139,9 +140,10 @@ struct SendPendingScreen: View { } .disabled(UInt32(retryFeeRate).map { $0 > 0 } != true) } message: { - Text(t("wallet__onchain_retry_note", variables: [ + Text(t(onchainAttempt?.isMaxAmount == true ? "wallet__onchain_retry_max_note" : "wallet__onchain_retry_note", variables: [ "amount": CurrencyFormatter.formatSats(onchainAttempt?.amountSats ?? 0), "address": onchainAttempt?.address ?? "", + "fee": String(onchainAttempt?.recoveryContext?.satsPerVbyte ?? 1), ])) } .navigationBarHidden(true) diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 5b879bfe1..51f286b2f 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -48,6 +48,28 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } + func testMaxRetryRejectsFeeIncreaseBeforePreparationOrAuthorization() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: true) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + var authorizationCount = 0 + do { + _ = try await service.retrySamePayment( + using: sender, context: OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 3, authorize: { _, _ in authorizationCount += 1 } + ) + XCTFail("A send-all retry cannot fund a higher fee without changing the original amount or inputs") + } catch {} + XCTAssertEqual(sender.preparations, 1) + XCTAssertEqual(sender.broadcasts, 1) + XCTAssertEqual(authorizationCount, 0) + XCTAssertEqual(store.snapshot().first, original) + } + func testExplicitRetryUsesOriginalMaxReceiptAndRetainsEveryCandidate() async throws { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) @@ -59,18 +81,18 @@ final class OnchainSendAttemptServiceTests: XCTestCase { var authorizationCount = 0 let result = try await service.retrySamePayment( using: sender, context: OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: original.txid), - satsPerVbyte: 3, + satsPerVbyte: 2, authorize: { admitted, feeRate in authorizationCount += 1 XCTAssertEqual(admitted.id, original.id) - XCTAssertEqual(feeRate, 3) + XCTAssertEqual(feeRate, 2) } ) XCTAssertEqual(result, .unknown(txid: sender.txid)) XCTAssertEqual(sender.lastAddress, "original") XCTAssertEqual(sender.lastAmount, original.amountSats) XCTAssertEqual(authorizationCount, 1) - XCTAssertEqual(sender.lastFeeRate, 3) + XCTAssertEqual(sender.lastFeeRate, 2) XCTAssertEqual(sender.lastMax, false) XCTAssertEqual(sender.lastInputs, sender.inputs) XCTAssertEqual(store.snapshot().first?.recoveryContext?.candidateTxids, try [XCTUnwrap(original.txid), sender.txid]) @@ -200,7 +222,7 @@ final class OnchainSendAttemptServiceTests: XCTestCase { let service = OnchainSendAttemptService(store: store) let sender = AttemptNodeMock(result: .unknown(txid: txid)) let id = try await service.admit(walletId: walletId, requestId: nil, orderId: nil, - address: "original", amountSats: 1234, isMaxAmount: false) + address: "original", amountSats: 1234, isMaxAmount: false) try await service.record(.unknown(txid: txid), attemptId: id) let original = try XCTUnwrap(store.snapshot().first) let wrongWallet = try await service.observeTransaction(txid: txid, walletId: "another-wallet") @@ -761,15 +783,18 @@ final class AttemptNodeMock: OnchainSending { func prepareOnchainSend(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, - expectedWalletIndex: Int, expectedNode: AnyObject?) async throws -> PreparedOnchainSendDispatch { + expectedWalletIndex: Int, expectedNode: AnyObject?) async throws -> PreparedOnchainSendDispatch + { try await onPrepare?() - if let preparationError { throw preparationError } - let candidate: String - switch result { - case let .accepted(txid), let .rejected(txid, _), let .unknown(txid): candidate = txid + if let preparationError { + throw preparationError + } + let candidate: String = switch result { + case let .accepted(txid), let .rejected(txid, _), let .unknown(txid): txid } return PreparedOnchainSendDispatch(txid: candidate, - inputs: [OnchainSendInput(txid: String(repeating: "ef", count: 32), vout: 0)], recipientAmountSats: sats) { + inputs: [OnchainSendInput(txid: String(repeating: "ef", count: 32), vout: 0)], recipientAmountSats: sats) + { try await self.send(address: address, sats: sats, satsPerVbyte: satsPerVbyte, utxosToSpend: utxosToSpend, isMaxAmount: isMaxAmount, expectedWalletIndex: expectedWalletIndex, expectedNode: expectedNode) } From 95f75661442f6f1c51f1404753539cb74efbe70a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 20:46:20 +0200 Subject: [PATCH 13/77] fix: show the winning payment fee during recovery --- Bitkit/Models/PaykitPaymentStateBackup.swift | 27 ++++- Bitkit/Services/CoreService.swift | 7 +- .../Services/OnchainSendAttemptService.swift | 113 +++++++++++++++-- .../Wallets/Send/SendPendingScreen.swift | 5 +- .../OnchainSendAttemptServiceTests.swift | 114 ++++++++++++++++++ .../PaykitPaymentStateBackupTests.swift | 30 +++++ .../TransferServiceActivityTests.swift | 37 +++++- .../send/onchain-original-payment-retry.xml | 8 +- 8 files changed, 321 insertions(+), 20 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 28980604c..2f57034cb 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -171,6 +171,7 @@ struct PaykitPaymentStateBackup: Codable { let originalInputs: [Input]? let candidateTxids: [String] let feeRateSatsPerVByte: String + let candidateFeeRates: [String: String]? let followup: Followup? let transfer: Transfer? @@ -192,6 +193,17 @@ struct PaykitPaymentStateBackup: Codable { recovery.inputs.isEmpty ? nil : recovery.inputs.map { Input(txid: $0.txid.lowercased(), vout: String($0.vout)) } } candidateTxids = attempt.recoveryContext?.candidateTxids.map { $0.lowercased() } ?? [] + let receiptCandidates = Set(candidateTxids) + if let rates = attempt.recoveryContext?.candidateFeeRates { + guard rates.allSatisfy({ $0.key.count == 64 && $0.key.allSatisfy { "0123456789abcdef".contains($0) } && + receiptCandidates.contains($0.key) && $0.value > 0 + }) else { + throw invalidBackup("Invalid candidate fee rate association") + } + candidateFeeRates = rates.mapValues { String($0) } + } else { + candidateFeeRates = nil + } feeRateSatsPerVByte = String(attempt.recoveryContext?.satsPerVbyte ?? attempt.followupContext?.feeRate ?? 0) guard attempt.followupContext?.createdAt.multipliedReportingOverflow(by: 1000).overflow != true else { throw invalidBackup("Invalid local follow-up timestamp") @@ -217,7 +229,8 @@ struct PaykitPaymentStateBackup: Codable { ["bitcoin", "testnet", "signet", "regtest"].contains(wallet.network), wallet.network == destination.network, wallet.binding == destination.binding, validTxid(wallet.binding), wallet.originalWalletId != nil, let destinationWalletId = destination.originalWalletId, - let id = UUID(uuidString: attemptId), id.uuidString.lowercased() == attemptId, let state = OnchainSendAttempt.Status(rawValue: status), !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let id = UUID(uuidString: attemptId), id.uuidString.lowercased() == attemptId, + let state = OnchainSendAttempt.Status(rawValue: status), !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, requestId == nil || orderId == nil else { throw invalidBackup("On-chain backup wallet or operation mismatch") } let amount: UInt64 = try number(amountSats) @@ -235,6 +248,16 @@ struct PaykitPaymentStateBackup: Codable { txid.map(candidateTxids.contains) == true && rate > 0, state != .accepted || txid != nil else { throw invalidBackup("Invalid on-chain prepared receipt") } + let rates = try candidateFeeRates.map { values in + try values.mapValues { value -> UInt32 in + let rate: UInt32 = try number(value) + guard rate > 0 else { throw invalidBackup("Invalid candidate fee rate") } + return rate + } + } + guard rates?.keys.allSatisfy({ validTxid($0) && candidateTxids.contains($0) }) != false else { + throw invalidBackup("Candidate fee does not belong to this operation") + } let restoredRequest = try requestId.map { value in let date = try value.billingPeriodStartsAt.map { try parseTimestamp($0).date } return PaykitPaymentRequest.ID(paymentRequestId: value.paymentRequestId, counterparty: value.counterparty, @@ -274,7 +297,7 @@ struct PaykitPaymentStateBackup: Codable { followupContext: context, transferContext: orderContext, recoveryContext: OnchainSendRecoveryContext(inputs: inputs ?? [], satsPerVbyte: rate, paymentIdentity: payerIdentity, - candidateTxids: candidateTxids)) + candidateTxids: candidateTxids, candidateFeeRates: rates)) return (attempt, restoredProofs) } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index f586b518b..cf0b8e51d 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1274,13 +1274,18 @@ class ActivityService { feeRate: UInt32, isTransfer: Bool = false, contact: String? = nil, - walletId: String = WalletScope.default + walletId: String = WalletScope.default, + feeIsExact: Bool = false ) async -> Bool { let normalizedContact = contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } do { return try await ServiceQueue.background(.core) { if let existing = try? BitkitCore.getActivityByTxId(walletId: walletId, txId: txid) { var updated = existing + if feeIsExact { + updated.fee = fee + updated.feeRate = UInt64(feeRate) + } if isTransfer { updated.isTransfer = true } diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 19d07e15e..08d998012 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -71,6 +71,14 @@ struct OnchainSendRecoveryContext: Codable, Equatable { let satsPerVbyte: UInt32 let paymentIdentity: String? var candidateTxids: [String] + var candidateFeeRates: [String: UInt32]? = nil + + func feeRate(for txid: String) -> UInt32? { + if let rate = candidateFeeRates?[txid.lowercased()] { + return rate > 0 ? rate : nil + } + return candidateTxids.first?.caseInsensitiveCompare(txid) == .orderedSame ? satsPerVbyte : nil + } } enum OnchainSendAttemptError: LocalizedError { @@ -155,8 +163,8 @@ struct OnchainSendAttempt: Codable, Equatable { } struct OnchainSendFollowupContext: Codable, Equatable { - let feeSats: UInt64 - let feeRate: UInt32 + var feeSats: UInt64 + var feeRate: UInt32 let tags: [String] let contact: String? let createdAt: UInt64 @@ -185,6 +193,52 @@ protocol OnchainSendLocalFollowupHandling { } struct OnchainSendLocalFollowup: OnchainSendLocalFollowupHandling { + static func observedFee(_ attempt: OnchainSendAttempt) async throws -> UInt64? { + let service = LightningService.shared + guard attempt.walletId == OnchainSendAttemptService.walletId(index: service.currentWalletIndex), + let node = service.onchainDispatchNode as? Node, let txid = attempt.txid + else { + throw OnchainSendAttemptError.localFollowupNotSaved + } + return try await ServiceQueue.background(.ldk, wrapErrors: false) { + guard attempt.walletId == OnchainSendAttemptService.walletId(index: service.currentWalletIndex), + service.onchainDispatchNode === node, + let details = node.getTransactionDetails(txid: txid) else { return nil } + guard Set(details.inputs.map { OnchainSendInput(txid: $0.txid, vout: $0.vout) }) == Set(attempt.recoveryContext?.inputs ?? []) else { + throw OnchainSendAttemptError.localFollowupNotSaved + } + return try exactFee(details: details, previous: { node.getTransactionDetails(txid: $0) }) + } + } + + static func exactFee(details: LDKNode.TransactionDetails, + previous: (String) -> LDKNode.TransactionDetails?) throws -> UInt64? + { + var inputTotal: UInt64 = 0 + var outputTotal: UInt64 = 0 + guard !details.inputs.isEmpty, + Set(details.inputs.map { OnchainSendInput(txid: $0.txid, vout: $0.vout) }).count == details.inputs.count + else { throw OnchainSendAttemptError.localFollowupNotSaved } + for input in details.inputs { + guard let parent = previous(input.txid) else { return nil } + let outputs = parent.outputs.filter { $0.n == input.vout } + guard outputs.count == 1, let value = outputs.first?.value, value >= 0, + !inputTotal.addingReportingOverflow(UInt64(value)).overflow + else { + throw OnchainSendAttemptError.localFollowupNotSaved + } + inputTotal += UInt64(value) + } + for output in details.outputs { + guard output.value >= 0, !outputTotal.addingReportingOverflow(UInt64(output.value)).overflow else { + throw OnchainSendAttemptError.localFollowupNotSaved + } + outputTotal += UInt64(output.value) + } + guard inputTotal >= outputTotal else { throw OnchainSendAttemptError.localFollowupNotSaved } + return inputTotal - outputTotal + } + func save(_ attempt: OnchainSendAttempt) async throws -> OnchainActivity { guard let txid = attempt.txid, attempt.status == .accepted else { throw OnchainSendAttemptError.localFollowupNotSaved } let activity = CoreService.shared.activity @@ -196,7 +250,8 @@ struct OnchainSendLocalFollowup: OnchainSendLocalFollowupHandling { )]) guard await activity.createSentOnchainActivityFromSendResult( txid: txid, address: attempt.address, amount: attempt.amountSats, - fee: context.feeSats, feeRate: context.feeRate, contact: context.contact + fee: context.feeSats, feeRate: context.feeRate, contact: context.contact, + feeIsExact: attempt.recoveryContext.map { $0.candidateTxids.first?.caseInsensitiveCompare(txid) != .orderedSame } ?? false ) else { throw OnchainSendAttemptError.localFollowupNotSaved } } else { guard let metadata = try await activity.getPreActivityMetadata(searchKey: txid), @@ -253,6 +308,7 @@ actor OnchainSendAttemptService { } private let localFollowup: any OnchainSendLocalFollowupHandling + private let winningFee: (OnchainSendAttempt) async throws -> UInt64? private let store: any OnchainSendAttemptStoring private let hasPaidOrder: (String) throws -> Bool private var knownAttempt: OnchainSendAttempt? @@ -261,11 +317,13 @@ actor OnchainSendAttemptService { init( store: any OnchainSendAttemptStoring = OnchainSendAttemptStore(), localFollowup: any OnchainSendLocalFollowupHandling = OnchainSendLocalFollowup(), + winningFee: @escaping (OnchainSendAttempt) async throws -> UInt64? = OnchainSendLocalFollowup.observedFee, hasPaidOrder: @escaping (String) throws -> Bool = { orderId in try TransferStorage.shared.getAll().contains(where: { $0.lspOrderId == orderId }) } ) { self.localFollowup = localFollowup + self.winningFee = winningFee self.store = store self.hasPaidOrder = hasPaidOrder } @@ -345,7 +403,7 @@ actor OnchainSendAttemptService { attempt.txid = prepared.txid attempt.recoveryContext = OnchainSendRecoveryContext( inputs: prepared.inputs, satsPerVbyte: satsPerVbyte, paymentIdentity: paymentIdentity, - candidateTxids: [prepared.txid] + candidateTxids: [prepared.txid], candidateFeeRates: [prepared.txid.lowercased(): satsPerVbyte] ) try store.save([attempt]) knownAttempt = attempt @@ -428,6 +486,18 @@ actor OnchainSendAttemptService { if !attempt.containsCandidate(prepared.txid) { attempt.recoveryContext?.candidateTxids.append(prepared.txid) } + if let previousRate = attempt.recoveryContext?.candidateFeeRates?[prepared.txid.lowercased()], + previousRate != authorizedFeeRate + { + throw OnchainSendAttemptError.unresolved + } + if attempt.recoveryContext?.candidateFeeRates == nil { + attempt.recoveryContext?.candidateFeeRates = [:] + if let originalTxid = recovery.candidateTxids.first { + attempt.recoveryContext?.candidateFeeRates?[originalTxid.lowercased()] = recovery.satsPerVbyte + } + } + attempt.recoveryContext?.candidateFeeRates?[prepared.txid.lowercased()] = authorizedFeeRate // Keep the original rejected/unknown state until a real new outcome arrives. // A crash here still recognizes both possible payments and cannot unlock the wallet. try store.save([attempt]) @@ -600,6 +670,7 @@ actor OnchainSendAttemptService { attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: saved.value, contact: saved.contact, activity: saved ) } + attempt = try await winningFollowupAttempt(attempt) let activity = try await localFollowup.save(attempt) try acknowledgeLocalFollowup(txid: txid) let resolution = OnchainSendLocalResolution( @@ -609,6 +680,29 @@ actor OnchainSendAttemptService { return resolution } + private func winningFollowupAttempt(_ original: OnchainSendAttempt) async throws -> OnchainSendAttempt { + guard let recovery = original.recoveryContext, var context = original.followupContext, + let txid = original.txid else { return original } + guard let rate = recovery.feeRate(for: txid), rate > 0 else { + throw OnchainSendAttemptError.localFollowupNotSaved + } + let isOriginal = recovery.candidateTxids.first?.caseInsensitiveCompare(txid) == .orderedSame + let observed = isOriginal ? context.feeSats : try await winningFee(original) + guard let fee = observed, + !original.amountSats.addingReportingOverflow(fee).overflow, + var attempt = try currentAttempt(), attempt.id == original.id, + attempt.walletId == original.walletId, attempt.status == .accepted, attempt.txid == txid, + !attempt.localFollowupComplete + else { throw OnchainSendAttemptError.localFollowupNotSaved } + context.feeRate = rate + context.feeSats = fee + guard context != attempt.followupContext else { return attempt } + attempt.followupContext = context + try store.save([attempt]) + knownAttempt = attempt + return attempt + } + func acceptedRequestAttempt() throws -> OnchainSendAttempt? { guard nativeDispatchInProgress == nil else { return nil } return try currentAttempt().flatMap { $0.requestId != nil && $0.status == .accepted && !$0.localFollowupComplete ? $0 : nil } @@ -620,7 +714,8 @@ actor OnchainSendAttemptService { attempt.status == .accepted, attempt.txid?.caseInsensitiveCompare(txid) == .orderedSame else { return false } if !attempt.localFollowupComplete { - _ = try await localFollowup.save(attempt) + let winner = try await winningFollowupAttempt(attempt) + _ = try await localFollowup.save(winner) try acknowledgeLocalFollowup(txid: txid) } return true @@ -646,6 +741,7 @@ actor OnchainSendAttemptService { private func restoreAcceptedTransfer(_ attempt: OnchainSendAttempt, using transferService: TransferService) async throws -> Bool { guard let savedOrderId = attempt.orderId, let txid = attempt.txid else { return false } guard !attempt.localFollowupComplete else { return true } + let attempt = try await winningFollowupAttempt(attempt) guard let context = attempt.followupContext, let transfer = attempt.transferContext else { throw OnchainSendAttemptError.localFollowupNotSaved } @@ -654,13 +750,16 @@ actor OnchainSendAttemptService { txId: txid, address: attempt.address, isReceive: false, feeRate: UInt64(context.feeRate), isTransfer: true, channelId: nil, createdAt: context.createdAt )]) + let isSuccessor = attempt.recoveryContext.map { $0.candidateTxids.first?.caseInsensitiveCompare(txid) != .orderedSame } ?? false _ = try await transferService.createTransfer( type: .toSpending, amountSats: transfer.clientBalanceSats, fundingTxId: txid, - lspOrderId: savedOrderId, txTotalSats: transfer.txTotalSats, preTransferOnchainSats: transfer.preTransferOnchainSats + lspOrderId: savedOrderId, txTotalSats: isSuccessor ? attempt.amountSats + context.feeSats : transfer.txTotalSats, + preTransferOnchainSats: transfer.preTransferOnchainSats ) guard await CoreService.shared.activity.createSentOnchainActivityFromSendResult( txid: txid, address: attempt.address, amount: attempt.amountSats, fee: context.feeSats, - feeRate: context.feeRate, isTransfer: true + feeRate: context.feeRate, isTransfer: true, + feeIsExact: attempt.recoveryContext.map { $0.candidateTxids.first?.caseInsensitiveCompare(txid) != .orderedSame } ?? false ), let activity = try await CoreService.shared.activity.getOnchainActivityByTxId(txid: txid), activity.txType == .sent, activity.isTransfer else { throw OnchainSendAttemptError.localFollowupNotSaved } diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 40e93bc66..65671c7c9 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -128,8 +128,9 @@ struct SendPendingScreen: View { } } .alert(t("wallet__onchain_retry_original"), isPresented: $showingRetryConfirmation) { - TextField(t("wallet__onchain_retry_fee"), text: $retryFeeRate).keyboardType(.numberPad) - .disabled(onchainAttempt?.isMaxAmount == true) + if onchainAttempt?.isMaxAmount != true { + TextField(t("wallet__onchain_retry_fee"), text: $retryFeeRate).keyboardType(.numberPad) + } Button(t("common__cancel"), role: .cancel) {} Button(t("common__retry")) { guard let rate = UInt32(retryFeeRate), rate > 0 else { diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 51f286b2f..59e8a3ff5 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import LDKNode import XCTest @@ -48,6 +49,111 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } + func testSuccessorFollowupRetainsWinningFeeInsteadOfOriginalFee() async throws { + let store = MemoryAttemptStore() + let followup = CapturingWinningFeeFollowup() + let service = OnchainSendAttemptService(store: store, localFollowup: followup, winningFee: { _ in 281 }) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, + followupContext: .init(feeSats: 143, feeRate: 1, tags: [], contact: nil, createdAt: 123)) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + sender.result = .accepted(txid: sender.txid) + _ = try await service.retrySamePayment(using: sender, + context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 2, authorize: { _, _ in }) + do { _ = try await service.resumeAcceptedOrdinarySend(walletId: original.walletId) } catch {} + XCTAssertEqual(followup.attempt?.followupContext?.feeRate, 2) + XCTAssertEqual(followup.attempt?.followupContext?.feeSats, 281) + XCTAssertEqual(followup.attempt?.txid, sender.txid) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) + } + + func testWinningFeeCalculationUsesExactPreviousOutputAndRejectsMissingOrInvalidDetails() throws { + let parentId = String(repeating: "ef", count: 32) + let details = LDKNode.TransactionDetails(amountSats: -1000, + inputs: [.init(txid: parentId, vout: 1, scriptsig: "", witness: [], sequence: UInt32.max - 2)], + outputs: [ + .init( + scriptpubkey: "recipient", + scriptpubkeyType: nil, + scriptpubkeyAddress: nil, + value: 1000, + n: 0 + ), + .init( + scriptpubkey: "change", + scriptpubkeyType: nil, + scriptpubkeyAddress: nil, + value: 18719, + n: 1 + ), + ]) + let parent = LDKNode.TransactionDetails(amountSats: 20000, inputs: [], + outputs: [.init( + scriptpubkey: "wallet", + scriptpubkeyType: nil, + scriptpubkeyAddress: nil, + value: 20000, + n: 1 + )]) + XCTAssertEqual(try OnchainSendLocalFollowup.exactFee(details: details) { id in + XCTAssertEqual(id, parentId) + return parent + }, 281) + XCTAssertNil(try OnchainSendLocalFollowup.exactFee(details: details, previous: { _ in nil })) + var wrong = parent + wrong.outputs[0].n = 0 + XCTAssertThrowsError(try OnchainSendLocalFollowup.exactFee(details: details, previous: { _ in wrong })) + wrong = parent + wrong.outputs[0].value = 100 + XCTAssertThrowsError(try OnchainSendLocalFollowup.exactFee(details: details, previous: { _ in wrong })) + } + + func testOriginalWinnerRetainsItsRateAfterHigherFeeSuccessorIsPrepared() async throws { + let store = MemoryAttemptStore() + let followup = CapturingWinningFeeFollowup() + let service = OnchainSendAttemptService(store: store, localFollowup: followup, winningFee: { _ in 143 }) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, + followupContext: .init(feeSats: 143, feeRate: 1, tags: [], contact: nil, createdAt: 123)) + let original = try XCTUnwrap(store.snapshot().first) + let originalTxid = try XCTUnwrap(original.txid) + sender.txid = String(repeating: "cd", count: 32) + _ = try await service.retrySamePayment(using: sender, + context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), + satsPerVbyte: 2, authorize: { _, _ in }) + _ = try await service.observeTransaction(txid: originalTxid, walletId: original.walletId) + do { _ = try await service.resumeAcceptedOrdinarySend(walletId: original.walletId) } catch {} + XCTAssertEqual(followup.attempt?.followupContext?.feeRate, 1) + XCTAssertEqual(followup.attempt?.followupContext?.feeSats, 143) + XCTAssertEqual(store.snapshot().first?.recoveryContext?.candidateFeeRates, + [originalTxid: 1, sender.txid: 2]) + } + + func testMissingSuccessorFeeKeepsLocalFollowupGuardedWithoutStaleActivity() async throws { + let store = MemoryAttemptStore() + let followup = CapturingWinningFeeFollowup() + let service = OnchainSendAttemptService(store: store, localFollowup: followup, winningFee: { _ in nil }) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, + followupContext: .init(feeSats: 143, feeRate: 1, tags: [], contact: nil, createdAt: 123)) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + sender.result = .accepted(txid: sender.txid) + _ = try await service.retrySamePayment(using: sender, + context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 2, authorize: { _, _ in }) + do { _ = try await service.resumeAcceptedOrdinarySend(walletId: original.walletId); XCTFail("Missing successor fee must stay pending") + } catch {} + XCTAssertNil(followup.attempt) + XCTAssertEqual(store.snapshot().first?.status, .accepted) + XCTAssertEqual(store.snapshot().first?.blocksNewSend, true) + } + func testMaxRetryRejectsFeeIncreaseBeforePreparationOrAuthorization() async throws { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) @@ -860,3 +966,11 @@ final class PreparedAttemptNodeMock: OnchainSending { return .unknown(txid: txid) } } + +private final class CapturingWinningFeeFollowup: OnchainSendLocalFollowupHandling { + var attempt: OnchainSendAttempt? + func save(_ attempt: OnchainSendAttempt) async throws -> OnchainActivity { + self.attempt = attempt + throw OnchainSendAttemptError.localFollowupNotSaved + } +} diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 2c820070b..1dd8053a6 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -190,6 +190,36 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } } + func testCandidateFeeRatesRoundtripAndRejectForeignOrMalformedRates() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let state = try XCTUnwrap(envelope.paykitPaymentState) + let wire = try XCTUnwrap(state.activeOnchainAttempt) + let proofs = try state.pendingProofs.map { try $0.restored() } + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) + let original = String(repeating: "ab", count: 32) + let successor = String(repeating: "cd", count: 32) + var patched = object + patched["candidateFeeRates"] = [original: "2", successor: "4"] + let current = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: patched)) + let restored = try current.restored(wallet: Self.goldenWallet(), proofs: proofs).0 + XCTAssertEqual(restored.recoveryContext?.feeRate(for: original), 2) + XCTAssertEqual(restored.recoveryContext?.feeRate(for: successor), 4) + let again = try PaykitPaymentStateBackup.ActiveOnchainAttempt(restored, wallet: Self.goldenWallet()) + XCTAssertEqual(again.candidateFeeRates, [original: "2", successor: "4"]) + let old = try wire.restored(wallet: Self.goldenWallet(), proofs: proofs).0 + XCTAssertEqual(old.recoveryContext?.feeRate(for: original), 2) + XCTAssertNil(old.recoveryContext?.feeRate(for: successor), "Original fee must not be guessed for an unmapped successor") + for values in [[successor: "0"], [successor: "4294967296"], [successor: "01"], [String(repeating: "ef", count: 32): "4"], + [successor.uppercased(): "4"]] + { + patched["candidateFeeRates"] = values + let bad = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: patched)) + XCTAssertThrowsError(try bad.restored(wallet: Self.goldenWallet(), proofs: proofs)) + } + } + func testAcceptedBackupRequiresMatchingVerifiedProofData() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) let state = try XCTUnwrap(envelope.paykitPaymentState) diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index a4d433826..812bcf6aa 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -622,6 +622,34 @@ final class TransferServiceActivityTests: XCTestCase { } } + @MainActor + func testSuccessorWinnerWritesExactFeeAndRateIntoDetailsAndDurableMetadata() async throws { + let store = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: store, winningFee: { _ in 281 }) + let sender = PreparedAttemptNodeMock() + _ = try await attempts.send(using: sender, address: "bcrt1qoriginal", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, + followupContext: .init(feeSats: 143, feeRate: 1, tags: ["original"], contact: nil, createdAt: 100)) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "da", count: 32) + sender.result = .accepted(txid: sender.txid) + _ = try await attempts.retrySamePayment(using: sender, + context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 2, authorize: { _, _ in }) + _ = await activity.createSentOnchainActivityFromSendResult( + txid: sender.txid, address: "bcrt1qoriginal", amount: sender.amount, fee: 143, feeRate: 1 + ) + let resolved = try await attempts.resumeAcceptedOrdinarySend(walletId: original.walletId) + XCTAssertEqual(resolved?.activity.fee, 281) + XCTAssertEqual(resolved?.activity.feeRate, 2) + XCTAssertEqual(resolved?.activity.txId, sender.txid) + XCTAssertEqual(resolved?.activity.value, sender.amount) + let savedTags = try await activity.tags(forActivity: sender.txid) + XCTAssertEqual(savedTags, ["original"]) + XCTAssertEqual(store.snapshot().first?.followupContext?.feeSats, 281) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + } + @MainActor func testNativeExactObservationFinishesUnknownAndRejectedOrdinaryActivityAndAck() async throws { let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed @@ -903,10 +931,11 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(firstResolution?.amountSats, 4321) XCTAssertEqual(repeatedResolution?.txid, txid) XCTAssertEqual(node.calls, 1, "Local resume invoked node dispatch") - let metadata = try await activity.getPreActivityMetadata(searchKey: txid) - XCTAssertEqual(metadata?.address, "bcrt1qoriginal") - XCTAssertEqual(metadata?.tags, ["saved-tag"]) - XCTAssertEqual(metadata?.feeRate, 2) + let durableActivity = try await activity.getOnchainActivityByTxId(txid: txid) + let durableTags = try await activity.tags(forActivity: txid) + XCTAssertEqual(durableActivity?.address, "bcrt1qoriginal") + XCTAssertEqual(durableTags, ["saved-tag"]) + XCTAssertEqual(durableActivity?.feeRate, 2) let activities = try await activity.get(filter: .onchain, limit: 50, sortDirection: .desc) XCTAssertEqual(activities.count, 2, "Repeated follow-up duplicated a durable activity") XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 8ec431f08..5ac058e96 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -11,13 +11,13 @@ Send a fixed amount to the fixture recipient; for the Max variant use the initial native send-all route, retaining reserves Make the owned fault fixture refuse this broadcast or lose its response after dispatch, and verify Payment Pending retains the original amount and transaction ID without Bitcoin Sent or a Shop proof Close and reopen Send; verify the original Pending context is restored, rather than a fresh payment or selected wallet amount - Tap Retry original payment (id "RetryOriginalOnchainPayment"); verify the confirmation shows the original recipient and actual amount, and requires an explicit fee rate in sat/vB - Clear the fee, enter zero, and enter a value above 4294967295; verify confirmation is disabled or gives explicit invalid fee feedback, never silently dismissing or sending - Enter a valid fee rate and confirm; authorize exactly once with the original payment PIN. Cancel once and verify the original durable guard remains, with no broadcast + Tap Retry original payment (id "RetryOriginalOnchainPayment"); verify the confirmation shows the original recipient and actual amount, and for fixed sends requires an explicit fee rate in sat/vB; for Max shows the original fee rate without any editable fee field + For fixed sends, clear the fee, enter zero, and enter a value above 4294967295; verify confirmation is disabled or gives explicit invalid fee feedback, never silently dismissing or sending + For fixed sends enter a valid fee rate; for Max confirm the displayed original fee. Confirm; authorize exactly once with the original payment PIN. Cancel once and verify the original durable guard remains, with no broadcast Confirm a valid retry again; the successor must pay the original exact amount using the original exact inputs. A Max recovery is fixed amount, not another drain. If the original coins cannot pay the chosen fee, verify the fee-headroom explanation and unchanged recipient amount/guard Restore the fixture backend and authorize the original payment retry. Record every attempted candidate txid; query exact backend transactions and verify all candidates conflict on the original input set and preserve the original recipient amount Allow the original candidate to be observed while successor preparation or authorization awaits; verify no new broadcast once the original wins and no later Unknown or Rejected result replaces that winner - Verify the exact positively accepted or original-wallet observed winner creates one local Sent activity and enables its original Details, with durable local completion + Verify the exact positively accepted or original-wallet observed winner creates one local Sent activity and enables its original Details, with durable local completion; verify Details displays the exact winning transaction fee and its own authorized fee rate, including when an earlier candidate wins For Shop, verify proof uses only that exact winner and the original payer/request, with no fresh request acceptance or private-list consume. For a channel order, verify original order tracking and balance metadata are retained Change the selected wallet or payer during preparation or authorization; verify no mismatched wallet broadcast or proof, and no success for a different operation Restore all owned fault/PIN fixture settings, stop recording and shut down only the task-owned device and helpers From b229508dae68a3b85023b978f962b0f481da97b0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 21:24:39 +0200 Subject: [PATCH 14/77] fix: preserve pending context after broadcast read failures --- .../Services/OnchainSendAttemptService.swift | 16 +++++-- .../OnchainSendAttemptServiceTests.swift | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 08d998012..a3b603272 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -430,7 +430,7 @@ actor OnchainSendAttemptService { } catch { Logger.warn("Could not persist the known on-chain outcome; the durable attempt still blocks another send", context: "OnchainSendAttempt") } - return try winningResult(attemptId: attemptId) ?? result + return (try? winningResult(attemptId: attemptId)) ?? result } func retrySamePayment( @@ -515,7 +515,7 @@ actor OnchainSendAttemptService { catch { result = .unknown(txid: prepared.txid) } do { try record(result, attemptId: original.id) } catch { Logger.warn("Could not persist the known recovery outcome; original inputs remain guarded", context: "OnchainSendAttempt") } - return try winningResult(attemptId: original.id) ?? result + return (try? winningResult(attemptId: original.id)) ?? result } private func checkWallet(_ sender: any OnchainSending, index: Int, node: AnyObject?) throws { @@ -810,8 +810,16 @@ actor OnchainSendAttemptService { try currentAttempt()?.requestId == requestId } + private func currentAttemptForPending() throws -> OnchainSendAttempt? { + do { return try currentAttempt() } + catch { + guard let knownAttempt else { throw error } + return knownAttempt + } + } + func pendingContext(requestId: PaykitPaymentRequest.ID? = nil, txid: String? = nil) throws -> OnchainSendPendingContext? { - guard let attempt = try currentAttempt(), attempt.requestId == requestId, + guard let attempt = try currentAttemptForPending(), attempt.requestId == requestId, txid == nil ? attempt.blocksNewSend : attempt.containsCandidate(txid) else { return nil } return .init(attemptId: attempt.id, walletId: attempt.walletId, txid: txid ?? attempt.txid) @@ -824,7 +832,7 @@ actor OnchainSendAttemptService { } func ordinaryPendingContext(txid: String? = nil) throws -> OnchainSendPendingContext? { - guard let attempt = try currentAttempt(), attempt.requestId == nil, attempt.orderId == nil else { return nil } + guard let attempt = try currentAttemptForPending(), attempt.requestId == nil, attempt.orderId == nil else { return nil } if let txid { guard attempt.containsCandidate(txid) else { return nil } } else { diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 59e8a3ff5..2339b51e9 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -547,6 +547,50 @@ final class OnchainSendAttemptServiceTests: XCTestCase { XCTAssertEqual(store.snapshot().first?.status, .accepted) } + func testPostBroadcastReadFailurePreservesResultAndExactPendingContext() async throws { + for retry in [false, true] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + var original: OnchainSendAttempt? + if retry { + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + } + sender.result = .accepted(txid: sender.txid) + sender.onBroadcast = { store.failLoad = true } + do { + let result: OnchainSendResult = if let original { + try await service.retrySamePayment(using: sender, + context: .init( + attemptId: original.id, + walletId: original.walletId, + txid: original.txid + ), + satsPerVbyte: 2, authorize: { _, _ in }) + } else { + try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + } + XCTAssertEqual(result, .accepted(txid: sender.txid)) + let context = try await service.pendingContext(txid: sender.txid) + XCTAssertEqual(context?.attemptId, store.snapshot().first?.id) + XCTAssertEqual(context?.walletId, store.snapshot().first?.walletId) + XCTAssertEqual(context?.txid, sender.txid) + } catch { XCTFail("Post-dispatch read failure escaped: \(error)") } + store.failLoad = false + let restarted = OnchainSendAttemptService(store: store) + do { + _ = try await restarted.send(using: sender, address: "other", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + XCTFail("Restart released the original guard") + } catch {} + XCTAssertEqual(sender.broadcasts, retry ? 2 : 1) + } + } + func testSendPreservesAcceptedWhenOutcomeStorageFailsAndRestartBlocksDispatch() async throws { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) From b1012afaa18da2e20a7dd9c8adda18d35efa0b92 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 00:19:20 +0200 Subject: [PATCH 15/77] fix: use validated native payment recovery release --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 4188da8a9..536894449 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1202,7 +1202,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.69"; + version = "0.7.0-rc.70"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3548b2a93..459136997 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "4ef96f934bcd459aec2f791f1a924ca455b057f0", - "version" : "0.7.0-rc.69" + "revision" : "71953e8081966dcd83a64fe255e8662b658aec38", + "version" : "0.7.0-rc.70" } }, { From fc2eb3009f7195224026b07eb0f299d66ba2ed60 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 01:58:51 +0200 Subject: [PATCH 16/77] test: preserve original payment guard expectations --- .../PaykitPaymentProofServiceTests.swift | 18 +++++++++++++++--- .../PaykitPaymentRequestServiceTests.swift | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 9abc2de74..1ea866e49 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -821,7 +821,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await completionTask.value } - func testOnchainCompletionRetainsTransactionIdWithoutLiveIdentity() async throws { + func testAcceptedOnchainCompletionWaitsForOriginalIdentityBeforeProofDelivery() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -831,13 +831,25 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let txid = String(repeating: "ab", count: 32) try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await recordAcceptedAttempt(service: service, request: request, txid: txid) await sdk.setIdentityAvailable(false) - await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) + let completed = await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) + XCTAssertFalse(completed) + let waitingProof = await store.snapshot().first + XCTAssertEqual(waitingProof?.paymentStarted, true) + XCTAssertNil(waitingProof?.proofData) + let attempts = try XCTUnwrap(serviceAttempts[ObjectIdentifier(service)]) + let acceptedTxid = try await attempts.acceptedTransactionId(for: request.id) + XCTAssertEqual(acceptedTxid, txid) + await sdk.suspendSubmission() + await sdk.setIdentityAvailable(true) + await service.reconcile() let proof = await store.snapshot().first XCTAssertEqual(proof?.paymentIdentifier, txid) XCTAssertEqual(proof?.proofData, txid) + await sdk.resumeSubmission() } func testStartedOnchainPaymentSurvivesPreparationCancellation() async throws { @@ -1110,7 +1122,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(try proofValues(XCTUnwrap(submitted).proof.exportText()), ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue]) XCTAssertEqual(node.calls, 1) XCTAssertEqual(attemptStore.snapshot().first?.status, .pending) - XCTAssertNil(attemptStore.snapshot().first?.txid) + XCTAssertEqual(attemptStore.snapshot().first?.txid, txid, "Pre-dispatch receipt remains durable even when the acceptance update fails") attemptStore.failSave = false let restarted = OnchainSendAttemptService(store: attemptStore) let knownAfterRestart = try await restarted.acceptedTransactionId(for: request.id) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index bc34c408d..ccf45d3f4 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1577,7 +1577,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error)) XCTAssertEqual(SendConfirmationView.privatePaymentListOutcomeAfterFailure( currentOutcome: .uncertain, walletType: .onchain, onchainPaymentStarted: true, error: error - ), .definitePreBroadcastFailure) + ), .uncertain, "A raw queue error cannot release a started payment; only the typed pre-dispatch result proves no broadcast") } } } From 30a4de1bfa1000f976389db297e7a6aa015a69fb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 02:11:13 +0200 Subject: [PATCH 17/77] fix: preserve payment expiry during original retry --- .../Services/OnchainSendAttemptService.swift | 3 +- .../Services/PaykitPaymentProofService.swift | 4 +-- .../Wallets/Send/SendPendingScreen.swift | 9 ++++- .../OnchainSendAttemptServiceTests.swift | 35 +++++++++++++++++++ .../PaykitPaymentProofServiceTests.swift | 14 ++++---- 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 89e2229c4..8c792cc6c 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -438,6 +438,7 @@ actor OnchainSendAttemptService { func retrySamePayment( using sender: any OnchainSending, context: OnchainSendPendingContext, satsPerVbyte: UInt32? = nil, + paymentDeadline: PaykitPreciseInstant? = nil, authorize: (OnchainSendAttempt, UInt32) async throws -> Void ) async throws -> OnchainSendResult { guard nativeDispatchInProgress == nil, let original = try currentAttempt(), @@ -466,7 +467,7 @@ actor OnchainSendAttemptService { let receipt = try await sender.prepareOnchainSend( address: original.address, sats: original.amountSats, satsPerVbyte: authorizedFeeRate, utxosToSpend: recovery.inputs.map(\.utxo), isMaxAmount: false, - expectedWalletIndex: index, expectedNode: node, paymentDeadline: nil + expectedWalletIndex: index, expectedNode: node, paymentDeadline: paymentDeadline ) prepared = receipt do { try validateReceipt(prepared, amount: original.amountSats, inputs: recovery.inputs) } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index ee77c76a3..e4ff70f3a 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -400,7 +400,7 @@ actor PaykitPaymentProofService { return identity } - func authorizeOnchainRecovery(_ attempt: OnchainSendAttempt) async throws -> PaykitPaymentRequest { + func authorizeOnchainRecovery(_ attempt: OnchainSendAttempt, restoreStartedProof: Bool = true) async throws -> PaykitPaymentRequest { guard let requestId = attempt.requestId, let payer = attempt.recoveryContext?.paymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } @@ -424,7 +424,7 @@ actor PaykitPaymentProofService { !record.paymentProofs.contains(where: { Self.billingPeriod($0.billingPeriod, matches: proof.billingPeriod) }) else { throw PaykitPaymentRequestError.requestUnavailable } try await requireRecoveryPayer(payer) - if !proof.paymentStarted { + if restoreStartedProof && !proof.paymentStarted { // Receipt persistence can precede a crash before the first proof-start write. // Restore only this original approved request; never accept/consume a new one. try await mutationLock.withLock { diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 65671c7c9..b4f83e0b9 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -243,8 +243,14 @@ struct SendPendingScreen: View { defer { retryingOnchain = false } let context = OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: original.txid) do { + // Capture the original request deadline without mutating its proof before authentication. + let paymentDeadline: PaykitPreciseInstant? = if original.requestId != nil { + try await proofService.authorizeOnchainRecovery(original, restoreStartedProof: false).paymentDeadline + } else { + nil + } let result = try await attemptService.retrySamePayment( - using: LightningService.shared, context: context, satsPerVbyte: feeRate, + using: LightningService.shared, context: context, satsPerVbyte: feeRate, paymentDeadline: paymentDeadline, authorize: { attempt, _ in if settings.requirePinForPayments && settings.pinEnabled { if settings.useBiometrics && BiometricAuth.isAvailable { @@ -255,6 +261,7 @@ struct SendPendingScreen: View { } if attempt.requestId != nil { let request = try await proofService.authorizeOnchainRecovery(attempt) + guard request.paymentDeadline == paymentDeadline else { throw PaykitPaymentRequestError.requestUnavailable } try await paymentRequests.ensurePaymentAllowed(request) guard let payer = attempt.recoveryContext?.paymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } try await proofService.requireRecoveryPayer(payer) diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index b17eb8d2a..d9e2c4761 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -7,6 +7,37 @@ final class OnchainSendAttemptServiceTests: XCTestCase { private let walletId = "node-0" private let txid = String(repeating: "ab", count: 32) + func testOriginalRetryChecksDeadlineAfterAuthorizationBeforeNativeDispatch() async throws { + for expired in [false, true] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + let originalTxid = try XCTUnwrap(original.txid) + let deadlineDate = Date(timeIntervalSince1970: 1_800_000_000) + let deadline = PaykitPreciseInstant(date: deadlineDate) + var dispatchTime = deadlineDate.addingTimeInterval(-1) + sender.deadlineClock = { dispatchTime } + sender.txid = String(repeating: "cd", count: 32) + let result = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), + paymentDeadline: deadline, authorize: { _, _ in + // Deadline crosses after request authorization while native dispatch awaits its queue. + sender.onNativeQueueDispatch = { dispatchTime = deadlineDate.addingTimeInterval(expired ? 0.001 : 0) } + } + ) + XCTAssertEqual(sender.broadcasts, expired ? 1 : 2, "An expired successor must never reach native broadcast") + XCTAssertEqual(result, .unknown(txid: sender.txid)) + XCTAssertEqual(store.snapshot().first?.id, original.id) + XCTAssertEqual(store.snapshot().first?.address, original.address) + XCTAssertEqual(store.snapshot().first?.amountSats, original.amountSats) + XCTAssertEqual(store.snapshot().first?.recoveryContext?.inputs, original.recoveryContext?.inputs) + XCTAssertEqual(store.snapshot().first?.blocksNewSend, true) + } + } + func testPublishedPreparedSendNativeEntryPointsRequireRunningNode() async throws { try await ServiceQueue.background(.ldk, wrapErrors: false) { let storage = FileManager.default.temporaryDirectory.appendingPathComponent("bi717-rc69-native-\(UUID().uuidString)") @@ -983,6 +1014,8 @@ final class PreparedAttemptNodeMock: OnchainSending { var onBroadcast: (() async throws -> Void)? var onPrepare: (() async throws -> Void)? var result: OnchainSendResult? + var deadlineClock: () -> Date = Date.init + var onNativeQueueDispatch: (() -> Void)? func prepareOnchainSend(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, @@ -997,6 +1030,8 @@ final class PreparedAttemptNodeMock: OnchainSending { try await onPrepare?() let candidateId = txid return PreparedOnchainSendDispatch(txid: candidateId, inputs: inputs, recipientAmountSats: amount) { + self.onNativeQueueDispatch?() + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: self.deadlineClock()) self.broadcasts += 1 try await self.onBroadcast?() return self.result ?? .unknown(txid: candidateId) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 1ea866e49..13ec13364 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -91,7 +91,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { func testRecoveryAuthorizationUsesOriginalStartedRequestWithoutProofMutationOrSubmission() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue - for invalid in 0 ..< 4 { + for invalid in 0 ..< 8 { let record = try paymentRequestRecord(endpoints: [endpoint], state: .accepted) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) let attemptsStore = MemoryAttemptStore() @@ -104,20 +104,20 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let original = try XCTUnwrap(attemptsStore.snapshot().first) let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, - paymentStarted: true, paymentIdentifier: original.txid, proofData: nil, - onchainAddress: invalid == 2 ? "foreign-address" : onchainAddress, + paymentStarted: invalid < 4, paymentIdentifier: original.txid, proofData: nil, + onchainAddress: invalid % 4 == 2 ? "foreign-address" : onchainAddress, onchainAmountSats: request.amountSats ) let store = PaymentProofMemoryStore() await store.seed([proof]) - let sdk = PaymentProofSdkMock(identity: invalid == 1 ? counterparty : identity, records: invalid == 3 ? [] : [record]) + let sdk = PaymentProofSdkMock(identity: invalid % 4 == 1 ? counterparty : identity, records: invalid % 4 == 3 ? [] : [record]) let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) do { - let authorized = try await service.authorizeOnchainRecovery(original) - XCTAssertEqual(invalid, 0) + let authorized = try await service.authorizeOnchainRecovery(original, restoreStartedProof: false) + XCTAssertEqual(invalid % 4, 0) XCTAssertEqual(authorized.id, request.id) XCTAssertEqual(authorized.amountSats, request.amountSats) - } catch { XCTAssertNotEqual(invalid, 0) } + } catch { XCTAssertNotEqual(invalid % 4, 0) } let saved = await store.snapshot() let submissions = await sdk.submissionCount() XCTAssertEqual(saved, [proof]) From 182b0a02f43b8364d2e5b631482f0ac757a27716 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 04:51:24 +0200 Subject: [PATCH 18/77] fix: refresh visible pending payment details --- .../Wallets/Send/SendPendingScreen.swift | 26 ++++++++++++++++++- .../send/onchain-original-payment-retry.xml | 1 + 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index b4f83e0b9..0c44f5e85 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -53,6 +53,7 @@ struct SendPendingScreen: View { @State private var onchainStateUnavailable = false @State private var ordinarySendResolved = false @State private var localFollowupUnavailable = false + @State private var refreshingOrdinaryOutcome = false @State private var showingRetryConfirmation = false @State private var retryFeeRate = "" @State private var retryingOnchain = false @@ -228,7 +229,10 @@ struct SendPendingScreen: View { .onChange(of: app.sendSheetPendingResolution) { _, resolution in applyPendingResolutionIfNeeded(resolution) } - .onReceive(OnchainSendAttemptService.localResolutionPublisher) { resolution in + .onReceive(activityList.activitiesChangedPublisher.receive(on: DispatchQueue.main)) { _ in + Task { await refreshVisibleOrdinaryOutcome() } + } + .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in applyOrdinarySendResolution(resolution) } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in @@ -388,6 +392,26 @@ struct SendPendingScreen: View { return (attempt, nil, false) } + @MainActor + private func refreshVisibleOrdinaryOutcome() async { + guard paymentHash == nil, paykitPaymentRequestId == nil, pendingHardwareWalletId == nil, + !ordinarySendResolved, !refreshingOrdinaryOutcome, let original = onchainAttempt, + original.requestId == nil + else { return } + refreshingOrdinaryOutcome = true + defer { refreshingOrdinaryOutcome = false } + let context = ordinaryPendingContext ?? OnchainSendPendingContext( + attemptId: original.id, walletId: original.walletId, txid: original.txid + ) + do { + // Recover a one-shot resolution missed while the visible sheet changed routes. + // The existing path validates the original operation before reading durable Details. + try await refreshOriginalOutcome(context: context) + } catch { + localFollowupUnavailable = true + } + } + private func applyOrdinarySendResolution(_ resolution: OnchainSendLocalResolution) { guard paymentHash == nil, paykitPaymentRequestId == nil, !ordinarySendResolved, Self.matchesLocalResolution(resolution, attempt: onchainAttempt, context: ordinaryPendingContext, diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 5ac058e96..9313f7f44 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -17,6 +17,7 @@ Confirm a valid retry again; the successor must pay the original exact amount using the original exact inputs. A Max recovery is fixed amount, not another drain. If the original coins cannot pay the chosen fee, verify the fee-headroom explanation and unchanged recipient amount/guard Restore the fixture backend and authorize the original payment retry. Record every attempted candidate txid; query exact backend transactions and verify all candidates conflict on the original input set and preserve the original recipient amount Allow the original candidate to be observed while successor preparation or authorization awaits; verify no new broadcast once the original wins and no later Unknown or Rejected result replaces that winner + Keep Payment Pending open while restoring observation of the original candidate after a rejected successor; verify its uncertainty warning and Retry button disappear and Details becomes enabled without closing or reopening the sheet Verify the exact positively accepted or original-wallet observed winner creates one local Sent activity and enables its original Details, with durable local completion; verify Details displays the exact winning transaction fee and its own authorized fee rate, including when an earlier candidate wins For Shop, verify proof uses only that exact winner and the original payer/request, with no fresh request acceptance or private-list consume. For a channel order, verify original order tracking and balance metadata are retained Change the selected wallet or payer during preparation or authorization; verify no mismatched wallet broadcast or proof, and no success for a different operation From 73d2b35b1367614dd3f593fab1d2e12a61c3ea00 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 05:19:20 +0200 Subject: [PATCH 19/77] fix: open pending for unknown channel funding --- Bitkit/ViewModels/TransferViewModel.swift | 7 +++++ Bitkit/Views/Transfer/SpendingConfirm.swift | 3 ++ .../TransferServiceActivityTests.swift | 31 +++++++++++++++++++ .../send/onchain-original-payment-retry.xml | 1 + 4 files changed, 42 insertions(+) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 3bd26486b..34f8c696e 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -2,6 +2,9 @@ import BitkitCore import LDKNode import SwiftUI +/// Funding was dispatched but unresolved; confirmation must yield to its retained Pending sheet. +struct OnchainFundingPendingError: Error {} + struct TransferUiState { var clientBalanceSat: UInt64 = 0 var lspBalanceSat: UInt64 = 0 @@ -492,6 +495,10 @@ class TransferViewModel: ObservableObject { debugMessage: "Broadcast rejected for \(rejectedTxid): \(reason)" ) case let .unknown(unknownTxid): + if let context = try await onchainAttemptService.pendingContext(txid: unknownTxid) { + sheetViewModel.showSheet(.send, data: SendConfig(view: .onchainPending(context))) + throw OnchainFundingPendingError() + } throw AppError( message: t("wallet__onchain_funding_unknown"), debugMessage: "Broadcast outcome unknown for \(unknownTxid)" diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 6347b4c31..c01cbb8f5 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -237,6 +237,9 @@ struct SpendingConfirm: View { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { hideSwipeButton = true } + } catch is OnchainFundingPendingError { + // The exact operation's Pending sheet replaces funding confirmation, not setup success. + return } catch { if !(error is SpendingFeesIncreasedError) { app.toast(error) diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index ea037a841..017104e58 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -782,6 +782,37 @@ final class TransferServiceActivityTests: XCTestCase { withExtendedLifetime(wallet) {} } + @MainActor + func testUnknownOrderFundingOpensExactPendingWithoutFinishingSetup() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .unknown(txid: txid)) + let sheets = SheetViewModel() + let order = IBtOrder.mock() + let vm = TransferViewModel( + transferService: makeService(), sheetViewModel: sheets, + onchainAttemptService: OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }), + onchainSender: node, onchainBalanceProvider: { 50000 } + ) + do { + try await vm.payOrder(order: order, speed: .normal, txFee: 123, satsPerVbyte: 2) + XCTFail("Unknown funding must not finish setup") + } catch {} + let original = try XCTUnwrap(store.snapshot().first) + XCTAssertEqual(original.orderId, order.id) + XCTAssertTrue(original.blocksNewSend) + XCTAssertTrue(try Bitkit.TransferStorage(defaults: transferDefaults).getAll().isEmpty) + XCTAssertEqual(node.calls, 1) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? SendConfig) + guard case let .onchainPending(context) = config.initialRoute else { + return XCTFail("Funding lost the exact original Pending route") + } + XCTAssertEqual(context.attemptId, original.id) + XCTAssertEqual(context.walletId, original.walletId) + XCTAssertEqual(context.txid, txid) + } + @MainActor func testTransferResumeRetainsOriginalBalanceMetadataAfterTrackingFailure() async throws { for isMax in [false, true] { diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 9313f7f44..103a45950 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -10,6 +10,7 @@ Enable payment PIN authorization in the test wallet, with biometrics disabled for deterministic PIN entry Send a fixed amount to the fixture recipient; for the Max variant use the initial native send-all route, retaining reserves Make the owned fault fixture refuse this broadcast or lose its response after dispatch, and verify Payment Pending retains the original amount and transaction ID without Bitcoin Sent or a Shop proof + For a channel-funding variant, begin Transfer To Spending and withhold the funding acknowledgement after dispatch; verify confirmation yields directly to Payment Pending with the original order/wallet/transaction context, not a generic toast or Lightning Setting Up. Retry must recover this original funding operation only Close and reopen Send; verify the original Pending context is restored, rather than a fresh payment or selected wallet amount Tap Retry original payment (id "RetryOriginalOnchainPayment"); verify the confirmation shows the original recipient and actual amount, and for fixed sends requires an explicit fee rate in sat/vB; for Max shows the original fee rate without any editable fee field For fixed sends, clear the fee, enter zero, and enter a value above 4294967295; verify confirmation is disabled or gives explicit invalid fee feedback, never silently dismissing or sending From 892fe2da344203a9f755c8d61b9bd7a95fcfba6c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 05:25:28 +0200 Subject: [PATCH 20/77] fix: open pending for rejected channel funding --- Bitkit/ViewModels/TransferViewModel.swift | 4 +++ .../TransferServiceActivityTests.swift | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 34f8c696e..ebe03ee43 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -490,6 +490,10 @@ class TransferViewModel: ObservableObject { case let .accepted(acceptedTxid): txid = acceptedTxid case let .rejected(rejectedTxid, reason): + if let context = try await onchainAttemptService.pendingContext(txid: rejectedTxid) { + sheetViewModel.showSheet(.send, data: SendConfig(view: .onchainPending(context))) + throw OnchainFundingPendingError() + } throw AppError( message: t("wallet__onchain_funding_rejected"), debugMessage: "Broadcast rejected for \(rejectedTxid): \(reason)" diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 017104e58..f6b847aab 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -813,6 +813,37 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(context.txid, txid) } + @MainActor + func testRejectedOrderFundingOpensExactPendingWithoutFinishingSetup() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .rejected(txid: txid, reason: "fixture refusal")) + let sheets = SheetViewModel() + let order = IBtOrder.mock() + let vm = TransferViewModel( + transferService: makeService(), sheetViewModel: sheets, + onchainAttemptService: OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }), + onchainSender: node, onchainBalanceProvider: { 50000 } + ) + do { + try await vm.payOrder(order: order, speed: .normal, txFee: 123, satsPerVbyte: 2) + XCTFail("Rejected funding must not finish setup") + } catch {} + let original = try XCTUnwrap(store.snapshot().first) + XCTAssertEqual(original.orderId, order.id) + XCTAssertTrue(original.blocksNewSend) + XCTAssertTrue(try Bitkit.TransferStorage(defaults: transferDefaults).getAll().isEmpty) + XCTAssertEqual(node.calls, 1) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? SendConfig) + guard case let .onchainPending(context) = config.initialRoute else { + return XCTFail("Funding lost the exact original Pending route") + } + XCTAssertEqual(context.attemptId, original.id) + XCTAssertEqual(context.walletId, original.walletId) + XCTAssertEqual(context.txid, txid) + } + @MainActor func testTransferResumeRetainsOriginalBalanceMetadataAfterTrackingFailure() async throws { for isMax in [false, true] { From 4b2dea6f3cd6f96d953e6d7fc33ad69a93e0c13a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 05:42:47 +0200 Subject: [PATCH 21/77] fix: reset funding swipe while payment is pending --- Bitkit/Views/Transfer/SpendingConfirm.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index c01cbb8f5..8c9caa597 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -237,9 +237,9 @@ struct SpendingConfirm: View { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { hideSwipeButton = true } - } catch is OnchainFundingPendingError { - // The exact operation's Pending sheet replaces funding confirmation, not setup success. - return + } catch let error as OnchainFundingPendingError { + // Reset the underlying swipe while the exact operation remains in Pending. + throw error } catch { if !(error is SpendingFeesIncreasedError) { app.toast(error) From 3000b91c3c7539e12f440369154f34565ba3d7d9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 06:24:52 +0200 Subject: [PATCH 22/77] fix: resume original funding after recovery --- .../Services/OnchainSendAttemptService.swift | 6 ++ Bitkit/ViewModels/TransferViewModel.swift | 49 ++++++++++++- Bitkit/Views/Transfer/SpendingConfirm.swift | 4 ++ .../TransferServiceActivityTests.swift | 68 +++++++++++++++++-- .../send/onchain-original-payment-retry.xml | 2 + 5 files changed, 122 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 8c792cc6c..3dc90fef6 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -829,6 +829,12 @@ actor OnchainSendAttemptService { return .init(attemptId: attempt.id, walletId: attempt.walletId, txid: txid ?? attempt.txid) } + func orderPendingContext(orderId: String) throws -> OnchainSendPendingContext? { + guard let attempt = try currentAttemptForPending(), attempt.requestId == nil, + attempt.orderId == orderId, attempt.status != .accepted, attempt.blocksNewSend else { return nil } + return .init(attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid) + } + func pendingAttempt(context: OnchainSendPendingContext) throws -> OnchainSendAttempt? { try currentAttempt().flatMap { $0.id == context.attemptId && $0.walletId == context.walletId && $0.containsCandidate(context.txid) ? $0 : nil diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index ebe03ee43..105c78510 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -1,4 +1,5 @@ import BitkitCore +import Combine import LDKNode import SwiftUI @@ -137,6 +138,9 @@ class TransferViewModel: ObservableObject { @Published var uiState = TransferUiState() private var fundedOrderId: String? + @Published private(set) var recoveredOnchainFundingOrderId: String? + private var pendingOnchainFunding: OnchainSendPendingContext? + private var fundingResolutionObservation: AnyCancellable? @Published var lightningSetupStep: Int = 0 @Published var transferValues = TransferValues() @@ -241,6 +245,13 @@ class TransferViewModel: ObservableObject { self.hwConnecting = nil hwSigner = nil } + fundingResolutionObservation = OnchainSendAttemptService.localResolutionPublisher.sink { [weak self] resolution in + Task { @MainActor [weak self] in + guard let self, let context = pendingOnchainFunding, + context.attemptId == resolution.attemptId, context.walletId == resolution.walletId else { return } + await resumeRecoveredOnchainFunding(context: context) + } + } } /// Convenience initializer for testing and previews. Leave `transferDefaults` nil to persist through @@ -368,6 +379,10 @@ class TransferViewModel: ObservableObject { func orderForSwipe( createOrder: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> IBtOrder ) async throws -> IBtOrder { + if let order = uiState.order, let context = try await onchainAttemptService.orderPendingContext(orderId: order.id) { + await showFundingPending(context) + throw OnchainFundingPendingError() + } guard let order = try await currentOrder(createOrder: createOrder, isCurrent: { true }) else { throw CancellationError() } return order } @@ -443,6 +458,10 @@ class TransferViewModel: ObservableObject { isMaxAmount: Bool = false, maxSendableAmount: UInt64? = nil ) async throws { + if let context = try await onchainAttemptService.orderPendingContext(orderId: order.id) { + await showFundingPending(context) + throw OnchainFundingPendingError() + } guard let address = order.payment?.onchain?.address else { throw AppError(message: "Order payment onchain address is nil", debugMessage: nil) } @@ -491,7 +510,7 @@ class TransferViewModel: ObservableObject { txid = acceptedTxid case let .rejected(rejectedTxid, reason): if let context = try await onchainAttemptService.pendingContext(txid: rejectedTxid) { - sheetViewModel.showSheet(.send, data: SendConfig(view: .onchainPending(context))) + await showFundingPending(context) throw OnchainFundingPendingError() } throw AppError( @@ -500,7 +519,7 @@ class TransferViewModel: ObservableObject { ) case let .unknown(unknownTxid): if let context = try await onchainAttemptService.pendingContext(txid: unknownTxid) { - sheetViewModel.showSheet(.send, data: SendConfig(view: .onchainPending(context))) + await showFundingPending(context) throw OnchainFundingPendingError() } throw AppError( @@ -526,6 +545,32 @@ class TransferViewModel: ObservableObject { watchOrder(orderId: order.id) } + private func showFundingPending(_ context: OnchainSendPendingContext) async { + pendingOnchainFunding = context + sheetViewModel.showSheet(.send, data: SendConfig(view: .onchainPending(context))) + // Acceptance can precede the sheet and its subscription's next delivery. + await resumeRecoveredOnchainFunding(context: context) + } + + private func resumeRecoveredOnchainFunding(context: OnchainSendPendingContext) async { + do { + guard pendingOnchainFunding?.attemptId == context.attemptId, + pendingOnchainFunding?.walletId == context.walletId, + let attempt = try await onchainAttemptService.pendingAttempt(context: context), + attempt.status == .accepted, attempt.localFollowupComplete, + attempt.requestId == nil, let orderId = attempt.orderId, + fundedOrderId != orderId else { return } + pendingOnchainFunding = nil + fundedOrderId = orderId + lightningSetupStep = 0 + watchOrder(orderId: orderId) + recoveredOnchainFundingOrderId = orderId + await onBalanceRefresh?() + } catch { + Logger.warn("Recovered funding order remains guarded: \(error)", context: "TransferViewModel") + } + } + @discardableResult private func fundPaidOrder( order: IBtOrder, diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 8c9caa597..57dcea4f0 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -150,6 +150,10 @@ struct SpendingConfirm: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .offlineOverlay(title: t("lightning__transfer__nav_title")) + .onChange(of: transfer.recoveredOnchainFundingOrderId) { _, orderId in + guard let orderId, orderId == transfer.uiState.order?.id else { return } + navigation.navigate(.settingUp) + } .task(id: transfer.uiState.feeSat) { await sizeFunding() } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index f6b847aab..70f5eecff 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -746,10 +746,30 @@ final class TransferServiceActivityTests: XCTestCase { let txid = String(repeating: "ab", count: 32) let node = AttemptNodeMock(result: .unknown(txid: txid)) let service = OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }) - _ = try await service.send(using: node, address: "original", amountSats: 4321, - satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, orderId: "original-order", - followupContext: .init(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100), - transferContext: .init(clientBalanceSats: 3333, txTotalSats: 4444, preTransferOnchainSats: 10000)) + let order = IBtOrder.mock(id: "original-order") + let recoveryReady = expectation(description: "Recovered original order resumes funding setup") + var balanceRefreshes = 0 + let vm = TransferViewModel( + transferService: makeService(), sheetViewModel: SheetViewModel(), + onBalanceRefresh: { balanceRefreshes += 1; recoveryReady.fulfill() }, + onchainAttemptService: service, onchainSender: node, onchainBalanceProvider: { 10000 } + ) + vm.onOrderCreated(order: order) + do { try await vm.payOrder(order: order, speed: .normal, txFee: 123, satsPerVbyte: 2) } catch {} + XCTAssertNil(vm.recoveredOnchainFundingOrderId) + XCTAssertEqual(balanceRefreshes, 0, "Unknown funding must not advance setup") + let unrelatedStore = MemoryAttemptStore() + let unrelatedNode = AttemptNodeMock(result: .unknown(txid: String(repeating: "cd", count: 32))) + var unrelatedRefreshes = 0 + let unrelatedVM = TransferViewModel( + transferService: makeService(), sheetViewModel: SheetViewModel(), + onBalanceRefresh: { unrelatedRefreshes += 1 }, + onchainAttemptService: OnchainSendAttemptService(store: unrelatedStore, hasPaidOrder: { _ in false }), + onchainSender: unrelatedNode, onchainBalanceProvider: { 10000 } + ) + do { + try await unrelatedVM.payOrder(order: .mock(id: "other-order"), speed: .normal, txFee: 123, satsPerVbyte: 2) + } catch {} let original = try XCTUnwrap(store.snapshot().first) let wallet = makeWallet(attempts: service) let resolutionReady = expectation(description: "Visible Pending receives exact durable transfer resolution") @@ -762,7 +782,14 @@ final class TransferServiceActivityTests: XCTestCase { } defer { observation.cancel() } await Bitkit.LightningService.shared.onchainTransactionReceived?(txid) - await fulfillment(of: [resolutionReady], timeout: 2) + await fulfillment(of: [resolutionReady, recoveryReady], timeout: 2) + XCTAssertEqual(vm.recoveredOnchainFundingOrderId, order.id) + XCTAssertEqual(balanceRefreshes, 1) + XCTAssertNil(unrelatedVM.recoveredOnchainFundingOrderId) + XCTAssertEqual(unrelatedRefreshes, 0) + XCTAssertEqual(unrelatedNode.calls, 1) + await Bitkit.LightningService.shared.onchainTransactionReceived?(txid) + XCTAssertEqual(balanceRefreshes, 1, "A delayed repeat event must not resume order setup twice") XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) XCTAssertEqual(resolved?.activity.txId, txid, "Pending Details must refer to the actual winning funding transaction") XCTAssertEqual(resolved?.activity.isTransfer, true) @@ -813,6 +840,37 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(context.txid, txid) } + @MainActor + func testDismissedFundingPendingReopensWithoutCreatingOrSendingAnotherOrder() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .unknown(txid: txid)) + let sheets = SheetViewModel() + let order = IBtOrder.mock() + let vm = TransferViewModel( + transferService: makeService(), sheetViewModel: sheets, + onchainAttemptService: OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }), + onchainSender: node, onchainBalanceProvider: { 50000 } + ) + vm.onOrderCreated(order: order) + do { try await vm.payOrder(order: order, speed: .normal, txFee: 123, satsPerVbyte: 2) } catch {} + let original = try XCTUnwrap(store.snapshot().first) + sheets.hideSheet() + do { + _ = try await vm.orderForSwipe { _, _ in + XCTFail("An unresolved funding operation must not create a new order") + return IBtOrder.mock(id: "replacement-order") + } + XCTFail("The reset swipe must yield to original Pending") + } catch is OnchainFundingPendingError {} catch { XCTFail("Wrong funding re-entry error: \(error)") } + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? SendConfig) + guard case let .onchainPending(context) = config.initialRoute else { return XCTFail("Missing Pending") } + XCTAssertEqual(context.attemptId, original.id) + XCTAssertEqual(context.walletId, original.walletId) + XCTAssertEqual(context.txid, txid) + XCTAssertEqual(node.calls, 1) + } + @MainActor func testRejectedOrderFundingOpensExactPendingWithoutFinishingSetup() async throws { let store = MemoryAttemptStore() diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 103a45950..e6feed994 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -11,6 +11,7 @@ Send a fixed amount to the fixture recipient; for the Max variant use the initial native send-all route, retaining reserves Make the owned fault fixture refuse this broadcast or lose its response after dispatch, and verify Payment Pending retains the original amount and transaction ID without Bitcoin Sent or a Shop proof For a channel-funding variant, begin Transfer To Spending and withhold the funding acknowledgement after dispatch; verify confirmation yields directly to Payment Pending with the original order/wallet/transaction context, not a generic toast or Lightning Setting Up. Retry must recover this original funding operation only + For iOS channel funding, dismiss Pending and swipe the original confirmation again, including after the original order expires; verify the exact original Pending reopens without creating an order, broadcasting or showing a generic failure toast Close and reopen Send; verify the original Pending context is restored, rather than a fresh payment or selected wallet amount Tap Retry original payment (id "RetryOriginalOnchainPayment"); verify the confirmation shows the original recipient and actual amount, and for fixed sends requires an explicit fee rate in sat/vB; for Max shows the original fee rate without any editable fee field For fixed sends, clear the fee, enter zero, and enter a value above 4294967295; verify confirmation is disabled or gives explicit invalid fee feedback, never silently dismissing or sending @@ -21,6 +22,7 @@ Keep Payment Pending open while restoring observation of the original candidate after a rejected successor; verify its uncertainty warning and Retry button disappear and Details becomes enabled without closing or reopening the sheet Verify the exact positively accepted or original-wallet observed winner creates one local Sent activity and enables its original Details, with durable local completion; verify Details displays the exact winning transaction fee and its own authorized fee rate, including when an earlier candidate wins For Shop, verify proof uses only that exact winner and the original payer/request, with no fresh request acceptance or private-list consume. For a channel order, verify original order tracking and balance metadata are retained + For iOS channel funding, observe the original accepted funding candidate while the confirmation flow remains open; verify the same original order resumes Lightning Setting Up and order watching without app restart, and a paid order proceeds to channel opening. An unrelated payment resolution must not advance this flow Change the selected wallet or payer during preparation or authorization; verify no mismatched wallet broadcast or proof, and no success for a different operation Restore all owned fault/PIN fixture settings, stop recording and shut down only the task-owned device and helpers From 2db78a16cf09f35bb2dff008d2bd67dc757ba3b1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 06:46:58 +0200 Subject: [PATCH 23/77] fix: reopen incomplete accepted funding --- .../Services/OnchainSendAttemptService.swift | 2 +- Bitkit/ViewModels/TransferViewModel.swift | 10 +++++ .../TransferServiceActivityTests.swift | 37 +++++++++++++++++++ .../send/onchain-original-payment-retry.xml | 2 +- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 3dc90fef6..3f0a78542 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -831,7 +831,7 @@ actor OnchainSendAttemptService { func orderPendingContext(orderId: String) throws -> OnchainSendPendingContext? { guard let attempt = try currentAttemptForPending(), attempt.requestId == nil, - attempt.orderId == orderId, attempt.status != .accepted, attempt.blocksNewSend else { return nil } + attempt.orderId == orderId, attempt.blocksNewSend else { return nil } return .init(attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid) } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 105c78510..96afc8a9d 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -460,6 +460,7 @@ class TransferViewModel: ObservableObject { ) async throws { if let context = try await onchainAttemptService.orderPendingContext(orderId: order.id) { await showFundingPending(context) + if recoveredOnchainFundingOrderId == order.id { return } throw OnchainFundingPendingError() } guard let address = order.payment?.onchain?.address else { @@ -548,6 +549,15 @@ class TransferViewModel: ObservableObject { private func showFundingPending(_ context: OnchainSendPendingContext) async { pendingOnchainFunding = context sheetViewModel.showSheet(.send, data: SendConfig(view: .onchainPending(context))) + do { + if let attempt = try await onchainAttemptService.pendingAttempt(context: context), + attempt.status == .accepted, !attempt.localFollowupComplete, + let orderId = attempt.orderId, let txid = attempt.txid { + _ = try await onchainAttemptService.resumeAcceptedTransfer(orderId: orderId, txid: txid, using: transferService) + } + } catch { + Logger.warn("Accepted original funding follow-up remains Pending: \(error)", context: "TransferViewModel") + } // Acceptance can precede the sheet and its subscription's next delivery. await resumeRecoveredOnchainFunding(context: context) } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 70f5eecff..87e7e2f8c 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -943,6 +943,43 @@ final class TransferServiceActivityTests: XCTestCase { } } + @MainActor + func testAcceptedIncompleteFundingReopensExpiredOriginalAndRepairsWithoutBroadcast() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "ab", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let attempts = OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }) + let sheets = SheetViewModel() + let order = IBtOrder.mock() + let vm = TransferViewModel(transferService: makeService(), sheetViewModel: sheets, + onchainAttemptService: attempts, onchainSender: node, onchainBalanceProvider: { 50000 }) + vm.onOrderCreated(order: order) + transferDefaults.set(Data("broken-transfer-store".utf8), forKey: "transfers") + do { try await vm.payOrder(order: order, speed: .normal, txFee: 123, satsPerVbyte: 2) } catch {} + let original = try XCTUnwrap(store.snapshot().first) + XCTAssertEqual(original.status, .accepted) + XCTAssertFalse(original.localFollowupComplete) + sheets.hideSheet() + do { + _ = try await vm.orderForSwipe { _, _ in + XCTFail("Accepted incomplete funding must retain the expired original order") + return IBtOrder.mock(id: "replacement-order") + } + XCTFail("Incomplete accepted funding must reopen Pending") + } catch is OnchainFundingPendingError {} catch { XCTFail("Wrong error: \(error)") } + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? SendConfig) + guard case let .onchainPending(context) = config.initialRoute else { return XCTFail("Missing Pending") } + XCTAssertEqual(context.attemptId, original.id) + XCTAssertEqual(context.walletId, original.walletId) + XCTAssertEqual(context.txid, txid) + XCTAssertEqual(node.calls, 1) + transferDefaults.removeObject(forKey: "transfers") + try await vm.payOrder(order: order, speed: .normal, txFee: 999, satsPerVbyte: 9) + XCTAssertTrue(try XCTUnwrap(store.snapshot().first).localFollowupComplete) + XCTAssertEqual(vm.recoveredOnchainFundingOrderId, order.id) + XCTAssertEqual(node.calls, 1) + } + @MainActor func testAcceptedOrderFollowupKeepsAdmissionContextWhenWalletChangesAfterDispatch() async throws { let store = MemoryAttemptStore() diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index e6feed994..26498140e 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -11,7 +11,7 @@ Send a fixed amount to the fixture recipient; for the Max variant use the initial native send-all route, retaining reserves Make the owned fault fixture refuse this broadcast or lose its response after dispatch, and verify Payment Pending retains the original amount and transaction ID without Bitcoin Sent or a Shop proof For a channel-funding variant, begin Transfer To Spending and withhold the funding acknowledgement after dispatch; verify confirmation yields directly to Payment Pending with the original order/wallet/transaction context, not a generic toast or Lightning Setting Up. Retry must recover this original funding operation only - For iOS channel funding, dismiss Pending and swipe the original confirmation again, including after the original order expires; verify the exact original Pending reopens without creating an order, broadcasting or showing a generic failure toast + For iOS channel funding, dismiss Pending and swipe the original confirmation again, including after the original order expires and when broadcast was accepted but local transfer/activity persistence failed; verify the exact original Pending reopens without creating an order, broadcasting or showing a generic failure toast Close and reopen Send; verify the original Pending context is restored, rather than a fresh payment or selected wallet amount Tap Retry original payment (id "RetryOriginalOnchainPayment"); verify the confirmation shows the original recipient and actual amount, and for fixed sends requires an explicit fee rate in sat/vB; for Max shows the original fee rate without any editable fee field For fixed sends, clear the fee, enter zero, and enter a value above 4294967295; verify confirmation is disabled or gives explicit invalid fee feedback, never silently dismissing or sending From cd18c1f4ff512855317ff81b6c677e5d4d2e031e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 11:18:50 +0200 Subject: [PATCH 24/77] fix: refresh completed funding in pending --- .../Wallets/Send/SendPendingScreen.swift | 19 ++++-- .../TransferServiceActivityTests.swift | 65 +++++++++++++++++++ .../send/onchain-original-payment-retry.xml | 1 + 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 0c44f5e85..1ddb41401 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -102,7 +102,7 @@ struct SendPendingScreen: View { Spacer() - if pendingHardwareWalletId == nil, onchainAttempt?.canRetrySamePayment == true, !onchainStateUnavailable { + if pendingHardwareWalletId == nil, onchainAttempt?.canRetrySamePayment == true, !ordinarySendResolved, !onchainStateUnavailable { CustomButton(title: t("wallet__onchain_retry_original"), isDisabled: retryingOnchain) { retryFeeRate = String(onchainAttempt?.recoveryContext?.satsPerVbyte ?? 1) showingRetryConfirmation = true @@ -225,6 +225,15 @@ struct SendPendingScreen: View { } applyPendingResolutionIfNeeded(app.sendSheetPendingResolution) await searchForActivity() + // Completion events are one-shot. Keep the visible original operation in sync + // with its durable result even when an event preceded subscription or initialization. + while paymentHash == nil, paykitPaymentRequestId == nil, pendingHardwareWalletId == nil, + !ordinarySendResolved, !Task.isCancelled + { + do { try await Task.sleep(for: .seconds(2)) } + catch { return } + await refreshVisibleOrdinaryOutcome() + } } .onChange(of: app.sendSheetPendingResolution) { _, resolution in applyPendingResolutionIfNeeded(resolution) @@ -242,7 +251,7 @@ struct SendPendingScreen: View { @MainActor private func retryOriginalPayment(feeRate: UInt32) async { - guard !retryingOnchain, let original = onchainAttempt, original.canRetrySamePayment else { return } + guard !ordinarySendResolved, !retryingOnchain, let original = onchainAttempt, original.canRetrySamePayment else { return } retryingOnchain = true defer { retryingOnchain = false } let context = OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: original.txid) @@ -353,15 +362,15 @@ struct SendPendingScreen: View { if pendingHardwareWalletId != nil { return t("wallet__onchain_hardware_pending") } + if ordinarySendResolved { + return t("wallet__onchain_earlier_restored") + } switch onchainAttempt?.status { case .rejected: return t("wallet__onchain_rejected_pending", variables: ["reason": onchainAttempt?.rejectionReason ?? ""]) case .unknown, .pending: return t("wallet__onchain_unknown_pending") case .accepted: - if ordinarySendResolved { - return t("wallet__onchain_earlier_restored") - } if localFollowupUnavailable { return t("wallet__onchain_pending_followup_failed") } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 87e7e2f8c..b762fff96 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -263,6 +263,71 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(saved?.value, 4321) } + @MainActor + func testVisiblePendingRecoversAcceptedFundingWithoutCompletionEvent() async throws { + let store = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: store) + let walletId = OnchainSendAttemptService.walletId(index: 0) + let txid = String(repeating: "ab", count: 32) + let id = try await attempts.admit( + walletId: walletId, requestId: nil, orderId: "original-lost-event-order", + address: "bcrt1qoriginal", amountSats: 1200, isMaxAmount: true, + followupContext: OnchainSendFollowupContext(feeSats: 100, feeRate: 1, tags: [], contact: nil, createdAt: 100), + transferContext: OnchainSendTransferContext(clientBalanceSats: 1000, txTotalSats: 1300, + preTransferOnchainSats: 1300, originalOrderFeeSats: 200) + ) + try await attempts.record(.unknown(txid: txid), attemptId: id) + let received = Bitkit.LightningService.shared.onchainTransactionReceived + let confirmed = Bitkit.LightningService.shared.onchainTransactionConfirmed + defer { + Bitkit.LightningService.shared.onchainTransactionReceived = received + Bitkit.LightningService.shared.onchainTransactionConfirmed = confirmed + } + let wallet = makeWallet(attempts: attempts) + var path: [SendRoute] = [] + let context = OnchainSendPendingContext(attemptId: id, walletId: walletId, txid: txid) + let view = SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: nil, + routingCacheResetAttempted: false, attemptService: attempts, ordinaryPendingContext: context, + navigationPath: Binding(get: { path }, set: { path = $0 }) + ) + .environment(PaykitPaymentRequestManager()) + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(ActivityListViewModel()) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) + .environmentObject(SheetViewModel()) + .environmentObject(wallet) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIHostingController(rootView: view) + window.isHidden = false + defer { + window.isHidden = true + window.rootViewController = nil + } + try await Task.sleep(for: .milliseconds(500)) + // Store the positive original result after initialization, without delivering a + // native callback or a completion event to the already-visible Pending screen. + _ = try await attempts.observeConfirmedTransaction(txid: txid) + for _ in 0 ..< 12 { + if store.snapshot().first?.localFollowupComplete == true { + break + } + try await Task.sleep(for: .milliseconds(500)) + } + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true, + "Visible Pending never reloaded the durable accepted original") + let saved = try await activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual(saved?.txId, txid) + XCTAssertEqual(saved?.isTransfer, true) + let records = try makeService().getActiveTransfers() + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records.first?.lspOrderId, "original-lost-event-order") + XCTAssertTrue(path.isEmpty, "Funding recovery must not mark a new unsent payment successful") + } + @MainActor func testHardwareShopCandidateDoesNotCreateSentActivityUntilVerified() async throws { let requestId = PaykitPaymentRequest.ID( diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 26498140e..a83a912e7 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -21,6 +21,7 @@ Allow the original candidate to be observed while successor preparation or authorization awaits; verify no new broadcast once the original wins and no later Unknown or Rejected result replaces that winner Keep Payment Pending open while restoring observation of the original candidate after a rejected successor; verify its uncertainty warning and Retry button disappear and Details becomes enabled without closing or reopening the sheet Verify the exact positively accepted or original-wallet observed winner creates one local Sent activity and enables its original Details, with durable local completion; verify Details displays the exact winning transaction fee and its own authorized fee rate, including when an earlier candidate wins + For iOS channel funding, keep Payment Pending open while the exact funding transaction becomes accepted and local transfer completion is saved; verify Unknown and Retry disappear and Details is enabled without reopening, including when the completion event is missed For Shop, verify proof uses only that exact winner and the original payer/request, with no fresh request acceptance or private-list consume. For a channel order, verify original order tracking and balance metadata are retained For iOS channel funding, observe the original accepted funding candidate while the confirmation flow remains open; verify the same original order resumes Lightning Setting Up and order watching without app restart, and a paid order proceeds to channel opening. An unrelated payment resolution must not advance this flow Change the selected wallet or payer during preparation or authorization; verify no mismatched wallet broadcast or proof, and no success for a different operation From bce32a86a5ad7a2db23579c3085bf8aa6555ca37 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 14:31:00 +0200 Subject: [PATCH 25/77] fix: prevent unsigned payment backups --- .../Services/OnchainSendAttemptService.swift | 9 ++- .../OnchainSendAttemptServiceTests.swift | 57 ++++++++++++++++++- .../send/onchain-original-payment-retry.xml | 1 + 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 3f0a78542..11678e640 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -8,7 +8,8 @@ protocol OnchainSending { var onchainDispatchNode: AnyObject? { get } func prepareOnchainSend(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, - expectedWalletIndex: Int, expectedNode: AnyObject?, paymentDeadline: PaykitPreciseInstant?) async throws -> PreparedOnchainSendDispatch + expectedWalletIndex: Int, expectedNode: AnyObject?, paymentDeadline: PaykitPreciseInstant?) async throws + -> PreparedOnchainSendDispatch } extension LightningService: OnchainSending { @@ -334,6 +335,12 @@ actor OnchainSendAttemptService { proofs: [PendingPaykitPaymentProof]) throws -> PaykitPaymentStateBackup.ActiveOnchainAttempt? { guard let attempt = try currentAttempt(), attempt.blocksNewSend else { return nil } + if attempt.status == .pending, attempt.txid == nil, + attempt.recoveryContext?.inputs.isEmpty != false, + attempt.recoveryContext?.candidateTxids.isEmpty != false + { + throw OnchainSendAttemptError.unresolved + } let wire = try PaykitPaymentStateBackup.ActiveOnchainAttempt(attempt, wallet: wallet) _ = try wire.restored(wallet: wallet, proofs: proofs) return wire diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index d9e2c4761..d2d10478b 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -7,6 +7,57 @@ final class OnchainSendAttemptServiceTests: XCTestCase { private let walletId = "node-0" private let txid = String(repeating: "ab", count: 32) + func testBackupDefersUnsignedOrdinaryAndTransferAdmissions() async throws { + let wallet = PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet( + kind: "software", network: "regtest", binding: String(repeating: "12", count: 32), sourceIndex: "0" + ) + for orderId in [nil, "original-order"] as [String?] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }) + let id = try await service.admit( + walletId: XCTUnwrap(wallet.originalWalletId), requestId: nil, orderId: orderId, + address: "bcrt1qoriginal", amountSats: 1000, isMaxAmount: false, + followupContext: .init(feeSats: 100, feeRate: 1, tags: [], contact: nil, createdAt: 123), + transferContext: orderId.map { _ in + .init(clientBalanceSats: 1000, txTotalSats: 1000, preTransferOnchainSats: 2000, originalOrderFeeSats: 0) + } + ) + do { + _ = try await service.backupSnapshot(wallet: wallet, proofs: []) + XCTFail("Unsigned preparation must defer the wallet snapshot") + } catch {} + XCTAssertEqual(store.snapshot().first?.id, id) + XCTAssertEqual(store.snapshot().first?.blocksNewSend, true) + XCTAssertEqual(store.snapshot().first?.canRetrySamePayment, false) + } + } + + func testBackupRetainsPreparedPendingReceiptForSamePaymentRecovery() async throws { + let wallet = PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet( + kind: "software", network: "regtest", binding: String(repeating: "12", count: 32), sourceIndex: "0" + ) + let store = MemoryAttemptStore() + let original = try OnchainSendAttempt( + id: UUID(), walletId: XCTUnwrap(wallet.originalWalletId), requestId: nil, orderId: nil, + address: "bcrt1qoriginal", amountSats: 1000, isMaxAmount: false, status: .pending, txid: txid, + followupContext: .init(feeSats: 100, feeRate: 1, tags: [], contact: nil, createdAt: 123), + recoveryContext: .init(inputs: [.init(txid: String(repeating: "cd", count: 32), vout: 0)], + satsPerVbyte: 1, paymentIdentity: nil, candidateTxids: [txid]) + ) + try store.save([original]) + let service = OnchainSendAttemptService(store: store) + let snapshot = try await service.backupSnapshot(wallet: wallet, proofs: []) + let wire = try XCTUnwrap(snapshot) + let (restored, _) = try wire.restored(wallet: wallet, proofs: []) + XCTAssertEqual(restored.id, original.id) + XCTAssertEqual(restored.walletId, original.walletId) + XCTAssertEqual(restored.address, original.address) + XCTAssertEqual(restored.amountSats, original.amountSats) + XCTAssertEqual(restored.recoveryContext?.inputs, original.recoveryContext?.inputs) + XCTAssertEqual(restored.recoveryContext?.candidateTxids, original.recoveryContext?.candidateTxids) + XCTAssertTrue(restored.canRetrySamePayment) + } + func testOriginalRetryChecksDeadlineAfterAuthorizationBeforeNativeDispatch() async throws { for expired in [false, true] { let store = MemoryAttemptStore() @@ -964,7 +1015,8 @@ final class AttemptNodeMock: OnchainSending { func prepareOnchainSend(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, - expectedWalletIndex: Int, expectedNode: AnyObject?, paymentDeadline: PaykitPreciseInstant?) async throws -> PreparedOnchainSendDispatch + expectedWalletIndex: Int, expectedNode: AnyObject?, + paymentDeadline: PaykitPreciseInstant?) async throws -> PreparedOnchainSendDispatch { try await onPrepare?() if let preparationError { @@ -1019,7 +1071,8 @@ final class PreparedAttemptNodeMock: OnchainSending { func prepareOnchainSend(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, - expectedWalletIndex: Int, expectedNode: AnyObject?, paymentDeadline: PaykitPreciseInstant?) async throws -> PreparedOnchainSendDispatch + expectedWalletIndex: Int, expectedNode: AnyObject?, + paymentDeadline: PaykitPreciseInstant?) async throws -> PreparedOnchainSendDispatch { preparations += 1 lastAddress = address diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index a83a912e7..1c12c13b4 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -8,6 +8,7 @@ Enable payment PIN authorization in the test wallet, with biometrics disabled for deterministic PIN entry + For ordinary sends and channel funding, pause the owned signing fixture after admission but before a prepared transaction exists; request wallet backup and verify it defers without replacing the remote wallet snapshot. Release signing and verify the prepared receipt retains the exact original operation for backup and recovery Send a fixed amount to the fixture recipient; for the Max variant use the initial native send-all route, retaining reserves Make the owned fault fixture refuse this broadcast or lose its response after dispatch, and verify Payment Pending retains the original amount and transaction ID without Bitcoin Sent or a Shop proof For a channel-funding variant, begin Transfer To Spending and withhold the funding acknowledgement after dispatch; verify confirmation yields directly to Payment Pending with the original order/wallet/transaction context, not a generic toast or Lightning Setting Up. Retry must recover this original funding operation only From 1fbbb80cb04a08c02f6ff115d8bfa822e361285d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 14:50:01 +0200 Subject: [PATCH 26/77] fix: retain failed hardware shop payments --- Bitkit/ViewModels/HwFundingSigner.swift | 17 +++++++--- .../Views/Wallets/Send/HwSendSignView.swift | 1 + BitkitTests/HwFundingSignerTests.swift | 32 ++++++++++++++++++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 6e8ca7772..30191d23d 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -506,6 +506,7 @@ final class HwSendCoordinator { sats: UInt64, satsPerVByte: UInt64, paymentDeadline: PaykitPreciseInstant? = nil, + paymentRequestId: PaykitPaymentRequest.ID? = nil, beforeFirstBroadcast: @escaping () async throws -> Void = {}, beforeBroadcastAttempt: @escaping () async throws -> Void = {}, afterBroadcast: @escaping (HwFundingBroadcastResult) async -> Void = { _ in }, @@ -515,6 +516,11 @@ final class HwSendCoordinator { throw AppError(message: "Unknown hardware wallet", debugMessage: "The send flow has no wallet id") } let request = PaymentRequest(address: address, sats: sats, satsPerVByte: satsPerVByte) + if isBroadcastUnresolved, let pendingPayment { + guard pendingPayment.request == request, pendingPayment.paymentRequestId == paymentRequestId else { + throw PaykitPaymentRequestError.operationInProgress + } + } if let operationTask { guard operationRequest == request else { throw HwTransferError.deviceBusy(manager.vendor(walletId: walletId)) } return try await operationTask.value @@ -552,7 +558,7 @@ final class HwSendCoordinator { } ) try Task.checkCancellation() - pendingPayment = PendingPayment(request: request, signedTx: signed) + pendingPayment = PendingPayment(request: request, signedTx: signed, paymentRequestId: paymentRequestId) } if pendingPayment?.isPreparedForBroadcast != true { @@ -584,18 +590,20 @@ final class HwSendCoordinator { await afterBroadcast(result) return result } catch { - isBroadcastUnresolved = false + isBroadcastUnresolved = paymentRequestId != nil let underlyingError = (error as? AppError)?.underlyingError ?? error if underlyingError as? PaykitPaymentRequestError == .requestExpired { // A queued retry can expire without changing the uncertainty of an earlier attempt. isBroadcastUnresolved = hadPriorBroadcastAttempt broadcastWasAttempted = hadPriorBroadcastAttempt pendingPayment?.hasBroadcastAttempted = hadPriorBroadcastAttempt - if !hadPriorBroadcastAttempt { pendingPayment = nil } + if !hadPriorBroadcastAttempt { + pendingPayment = nil + } throw error } let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain - if !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { + if paymentRequestId == nil, !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { pendingPayment = nil } throw error @@ -704,6 +712,7 @@ final class HwSendCoordinator { private struct PendingPayment { let request: PaymentRequest let signedTx: HwFundingSignedTx + let paymentRequestId: PaykitPaymentRequest.ID? var isPreparedForBroadcast = false var hasBroadcastAttempted = false } diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 61cfef428..0d8b695ed 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -128,6 +128,7 @@ struct HwSendSignView: View { sats: amount, satsPerVByte: UInt64(feeRate), paymentDeadline: contactPaymentDeadline, + paymentRequestId: requestId, beforeFirstBroadcast: prepareContactPayment, beforeBroadcastAttempt: authorizeContactPayment, afterBroadcast: { result in diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 34d0d972f..c65853b3d 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -809,6 +809,36 @@ final class HwFundingSignerTests: XCTestCase { _ = try await payment.value } + func testShopBroadcastFailurePreservesSignedPaymentAcrossCancel() async throws { + let funding = MockHwFunding() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let manager = HwWalletManager() + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId + ) + } + XCTAssertTrue(coordinator.isBroadcastUnresolved) + XCTAssertFalse(coordinator.canLeave) + coordinator.cancel() + XCTAssertTrue(coordinator.hasPendingBroadcast) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentRequestId: .init(paymentRequestId: "other-request", counterparty: "original-merchant") + ) + } + XCTAssertEqual(funding.broadcastCalls, 1) + funding.broadcastError = nil + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId + ) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 2) + } + func testCoordinatorCannotBeLeftWhileABroadcastIsUnresolved() async throws { let funding = MockHwFunding() let broadcast = AsyncGate() @@ -937,7 +967,7 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertTrue(connecting.staleDisconnects.isEmpty, "a finished payment keeps its session") } - func testCoordinatorCanBeLeftWhileTheDeviceReconnectsBeforeASignRetry() async throws { + func testCoordinatorCanBeLeftWhileTheDeviceReconnectsBeforeASignRetry() async { let walletId = "jade:wallet" let funding = MockHwFunding() funding.signErrors = [Bitkit.AppError(error: JadeError.DeviceDisconnected)] From ceb327a08b40caebc4d8f91ae71af19dc59fc3b3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 15:27:17 +0200 Subject: [PATCH 27/77] fix: restore original hardware shop payments --- Bitkit/Models/HwFunding.swift | 75 ++++++++++++++ Bitkit/Models/PaykitPaymentStateBackup.swift | 11 ++- .../Services/PaykitPaymentProofService.swift | 60 +++++++++++- Bitkit/ViewModels/HwFundingSigner.swift | 18 +++- .../Views/Wallets/Send/HwSendSignView.swift | 15 +++ BitkitTests/HwFundingSignerTests.swift | 18 ++++ .../PaykitPaymentProofServiceTests.swift | 97 +++++++++++++++++++ 7 files changed, 287 insertions(+), 7 deletions(-) diff --git a/Bitkit/Models/HwFunding.swift b/Bitkit/Models/HwFunding.swift index c63f06c39..89e3dad66 100644 --- a/Bitkit/Models/HwFunding.swift +++ b/Bitkit/Models/HwFunding.swift @@ -1,4 +1,6 @@ import BitkitCore +import CryptoKit +import Foundation import LDKNode /// The default address type funds are sourced from when transferring from a hardware wallet to @@ -47,3 +49,76 @@ struct HwFundingBroadcastResult: Equatable { let feeRate: UInt64 let totalSpent: UInt64 } + +/// Matches Android's txid calculation: hash consensus bytes without marker, flag or witness. +/// This identifies a signed candidate; it does not establish backend acceptance. +enum SignedTransactionId { + static func fromHex(_ hex: String) throws -> String { + func invalid() -> PaykitPaymentRequestError { + .requestUnavailable + } + guard (20 ... 2_000_000).contains(hex.count), hex.count.isMultiple(of: 2) else { throw invalid() } + let chars = Array(hex.utf8) + var bytes = [UInt8]() + for index in stride(from: 0, to: chars.count, by: 2) { + guard let byte = UInt8(String(decoding: chars[index ... index + 1], as: UTF8.self), radix: 16) else { throw invalid() } + bytes.append(byte) + } + var offset = 4 + func skip(_ length: Int) throws { + guard length >= 0, length <= bytes.count - offset else { throw invalid() } + offset += length + } + func compactSize() throws -> Int { + guard offset < bytes.count else { throw invalid() } + let prefix = bytes[offset] + offset += 1 + if prefix < 253 { + return Int(prefix) + } + let length = prefix == 253 ? 2 : prefix == 254 ? 4 : 8 + guard length <= bytes.count - offset else { throw invalid() } + var value: UInt64 = 0 + for index in 0 ..< length { + value |= UInt64(bytes[offset]) << (8 * index) + offset += 1 + } + guard value <= UInt64(Int.max) else { throw invalid() } + return Int(value) + } + let hasWitness = bytes[offset] == 0 + if hasWitness { + guard bytes[offset + 1] == 1 else { throw invalid() } + try skip(2) + } + let baseStart = offset + let inputCount = try compactSize() + guard inputCount > 0, inputCount <= bytes.count / 41 else { throw invalid() } + for _ in 0 ..< inputCount { + try skip(36) + try skip(compactSize()) + try skip(4) + } + let outputCount = try compactSize() + guard outputCount > 0, outputCount <= bytes.count / 9 else { throw invalid() } + for _ in 0 ..< outputCount { + try skip(8) + try skip(compactSize()) + } + let baseEnd = offset + if hasWitness { + for _ in 0 ..< inputCount { + let count = try compactSize() + guard count <= bytes.count - offset else { throw invalid() } + for _ in 0 ..< count { + try skip(compactSize()) + } + } + } + let lockTimeStart = offset + try skip(4) + guard offset == bytes.count else { throw invalid() } + let canonical = Data(bytes[0 ..< 4] + bytes[baseStart ..< baseEnd] + bytes[lockTimeStart ..< offset]) + return SHA256.hash(data: Data(SHA256.hash(data: canonical))).reversed().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 2575c493c..e99153473 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -72,6 +72,7 @@ struct PaykitPaymentStateBackup: Codable { let onchainAddress: String? let onchainAmountSats: UInt64? let onchainWalletId: String? + let hardwareSignedTransaction: String? let onchainMatchingTransactionIdsBeforeAttempt: Set let onchainAcceptanceVerified: Bool? @@ -88,6 +89,7 @@ struct PaykitPaymentStateBackup: Codable { onchainAddress = proof.onchainAddress onchainAmountSats = proof.onchainAmountSats onchainWalletId = proof.onchainWalletId + hardwareSignedTransaction = proof.hardwareSignedTransaction onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] onchainAcceptanceVerified = proof.onchainAcceptanceVerified } @@ -99,7 +101,7 @@ struct PaykitPaymentStateBackup: Codable { } return period } - return PendingPaykitPaymentProof( + var proof = PendingPaykitPaymentProof( identity: identity, requestId: requestId.restored(billingPeriod: period), paymentAppId: paymentAppId, @@ -115,6 +117,13 @@ struct PaykitPaymentStateBackup: Codable { onchainMatchingTransactionIdsBeforeAttempt: onchainMatchingTransactionIdsBeforeAttempt, onchainAcceptanceVerified: onchainAcceptanceVerified ) + if let hardwareSignedTransaction { + guard kind == .onchain, paymentStarted, let walletId = onchainWalletId, walletId != WalletScope.default, + try SignedTransactionId.fromHex(hardwareSignedTransaction) == paymentIdentifier + else { throw invalidBackup("Invalid hardware payment receipt") } + proof.hardwareSignedTransaction = hardwareSignedTransaction + } + return proof } } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index e4ff70f3a..a9f41a433 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -56,6 +56,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var onchainAddress: String? var onchainAmountSats: UInt64? var onchainWalletId: String? + /// Original signed candidate retained before hardware dispatch; never a payment proof. + var hardwareSignedTransaction: String? var onchainMatchingTransactionIdsBeforeAttempt: Set? var onchainAcceptanceVerified: Bool? /// Device-local acknowledgement: backup restore reruns local activity proof. @@ -225,7 +227,8 @@ actor PaykitPaymentProofService { let active = try await attemptService.backupSnapshot(wallet: wallet, proofs: proofs) return try PaykitPaymentStateBackup(subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), pendingProofs: proofs.map(PaykitPaymentStateBackup.Proof.init), activeOnchainAttempt: active, - acceptedOneTimeRequests: PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests).backupSnapshot()) + acceptedOneTimeRequests: PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) + .backupSnapshot()) } func restoreBackup(_ state: PaykitPaymentStateBackup, wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet) async throws { @@ -288,7 +291,12 @@ actor PaykitPaymentProofService { } throw PaykitPaymentRequestError.operationInProgress } - let proof = try await pendingProof(request: request, paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind) + let proof = try await pendingProof( + request: request, + paymentAppId: paymentAppId, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: kind + ) try await mutationLock.withLock { try await prepareLocked(request: request, proof: proof) } } @@ -524,6 +532,54 @@ actor PaykitPaymentProofService { try await persist(pendingProofs) } + func retainHardwareOnchainCandidate( + requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, + address: String, amountSats: UInt64, serializedTx: String + ) async throws { + let txid = try SignedTransactionId.fromHex(serializedTx) + let identity = try await currentIdentity() + guard PubkyPublicKeyFormat.matches(identity, paymentIdentity), walletId != WalletScope.default, + hardwareTransactionLookup.hasWallet(walletId: walletId) + else { throw PaykitPaymentRequestError.requestUnavailable } + try await mutationLock.withLock { + guard try await PubkyPublicKeyFormat.matches(currentIdentity(), identity) else { throw PaykitPaymentRequestError.requestUnavailable } + var proofs = try await loadProofs() + guard let index = proofs.lastIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && $0.paymentStarted && $0.onchainWalletId == walletId && + $0.onchainAddress == address && $0.onchainAmountSats == amountSats && + $0.proofData == nil && $0.onchainAcceptanceVerified != true && + ($0.paymentIdentifier == nil || $0.paymentIdentifier == txid) && + ($0.hardwareSignedTransaction == nil || $0.hardwareSignedTransaction == serializedTx) + }) else { throw PaykitPaymentRequestError.operationInProgress } + proofs[index].paymentIdentifier = txid + proofs[index].hardwareSignedTransaction = serializedTx + try await persist(proofs) + } + } + + /// Only the coordinator's definite first queued-dispatch expiry may remove this exact candidate. + func clearHardwareCandidateBeforeDispatch( + requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, serializedTx: String + ) async -> Bool { + guard let identity = PubkyPublicKeyFormat.normalized(paymentIdentity), walletId != WalletScope.default, + let txid = try? SignedTransactionId.fromHex(serializedTx) + else { return false } + do { + return try await mutationLock.withLock { + let proofs = try await loadProofs() + guard let original = proofs.first(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.onchainWalletId == walletId && $0.kind == .onchain && $0.paymentStarted && + $0.paymentIdentifier == txid && $0.hardwareSignedTransaction == serializedTx && + $0.proofData == nil && $0.onchainAcceptanceVerified != true + }) else { return false } + try await persist(proofs.filter { $0 != original }) + return true + } + } catch { return false } + } + @discardableResult func completeHardwareOnchainPayment(_ request: PaykitPaymentRequest, paymentIdentity: String, walletId: String, txid: String) async -> Bool { guard let identity = PubkyPublicKeyFormat.normalized(paymentIdentity) else { return false } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 30191d23d..8d580b115 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -509,6 +509,8 @@ final class HwSendCoordinator { paymentRequestId: PaykitPaymentRequest.ID? = nil, beforeFirstBroadcast: @escaping () async throws -> Void = {}, beforeBroadcastAttempt: @escaping () async throws -> Void = {}, + retainSignedPayment: @escaping (HwFundingSignedTx) async throws -> Void = { _ in }, + clearSignedPaymentBeforeDispatch: @escaping (HwFundingSignedTx) async -> Bool = { _ in true }, afterBroadcast: @escaping (HwFundingBroadcastResult) async -> Void = { _ in }, afterFailure: @escaping (PrivatePaymentListSendOutcome) async -> Void = { _ in } ) async throws -> HwFundingBroadcastResult { @@ -582,6 +584,7 @@ final class HwSendCoordinator { throw error } + try await retainSignedPayment(signed) isBroadcastUnresolved = true broadcastWasAttempted = true pendingPayment?.hasBroadcastAttempted = true @@ -594,10 +597,17 @@ final class HwSendCoordinator { let underlyingError = (error as? AppError)?.underlyingError ?? error if underlyingError as? PaykitPaymentRequestError == .requestExpired { // A queued retry can expire without changing the uncertainty of an earlier attempt. - isBroadcastUnresolved = hadPriorBroadcastAttempt - broadcastWasAttempted = hadPriorBroadcastAttempt - pendingPayment?.hasBroadcastAttempted = hadPriorBroadcastAttempt - if !hadPriorBroadcastAttempt { + let cleared: Bool + if hadPriorBroadcastAttempt { + cleared = false + } else { + cleared = await clearSignedPaymentBeforeDispatch(signed) + } + let unresolved = hadPriorBroadcastAttempt || !cleared + isBroadcastUnresolved = unresolved + broadcastWasAttempted = unresolved + pendingPayment?.hasBroadcastAttempted = unresolved + if !unresolved { pendingPayment = nil } throw error diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 0d8b695ed..7d28be0d2 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -131,6 +131,21 @@ struct HwSendSignView: View { paymentRequestId: requestId, beforeFirstBroadcast: prepareContactPayment, beforeBroadcastAttempt: authorizeContactPayment, + retainSignedPayment: { signed in + guard let requestId else { return } + guard let identity = contactPaymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + try await PaykitPaymentProofService.shared.retainHardwareOnchainCandidate( + requestId: requestId, paymentIdentity: identity, walletId: walletId, + address: invoice.address, amountSats: amount, serializedTx: signed.serializedTx + ) + }, + clearSignedPaymentBeforeDispatch: { signed in + guard let requestId else { return true } + guard let identity = contactPaymentIdentity else { return false } + return await PaykitPaymentProofService.shared.clearHardwareCandidateBeforeDispatch( + requestId: requestId, paymentIdentity: identity, walletId: walletId, serializedTx: signed.serializedTx + ) + }, afterBroadcast: { result in if requestId != nil { // Save original tags before proof reconciliation can complete. diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index c65853b3d..16d9280a1 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -839,6 +839,24 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastCalls, 2) } + func testHardwareCandidateSaveFailurePreventsNativeDispatch() async throws { + let funding = MockHwFunding() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: HwWalletManager(), address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentRequestId: .init(paymentRequestId: "original", counterparty: "merchant"), + retainSignedPayment: { signed in + XCTAssertEqual(signed, funding.signedTx) + XCTAssertEqual(funding.broadcastCalls, 0) + throw MockHwFunding.TestError() + } + ) + } + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 0) + } + func testCoordinatorCannotBeLeftWhileABroadcastIsUnresolved() async throws { let funding = MockHwFunding() let broadcast = AsyncGate() diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 13ec13364..f25fb539a 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -322,6 +322,103 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } + func testExactHardwareCandidateReleaseRequiresDurableClear() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let signed = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, hardwareWalletId: hardwareWalletId, paymentIdentity: identity) + try await service.retainHardwareOnchainCandidate( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats, serializedTx: signed + ) + await store.failNextSave() + let failed = await service.clearHardwareCandidateBeforeDispatch( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, serializedTx: signed + ) + XCTAssertFalse(failed) + let retained = await store.snapshot() + XCTAssertEqual(retained.count, 1) + let foreign = await service.clearHardwareCandidateBeforeDispatch( + requestId: request.id, paymentIdentity: identity, walletId: "trezor:other-wallet", serializedTx: signed + ) + XCTAssertFalse(foreign) + let cleared = await service.clearHardwareCandidateBeforeDispatch( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, serializedTx: signed + ) + XCTAssertTrue(cleared) + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + } + + func testHardwareTransactionIdMatchesBackendAndRejectsInvalidReceipt() throws { + let signed = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" + let legacy = "0200000001f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa724c0300" + let expected = "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e" + XCTAssertEqual(try SignedTransactionId.fromHex(signed), expected) + XCTAssertEqual(try SignedTransactionId.fromHex(legacy), expected) + XCTAssertThrowsError(try SignedTransactionId.fromHex(String(signed.dropLast(2)))) + XCTAssertThrowsError(try SignedTransactionId.fromHex(signed + "00")) + XCTAssertThrowsError(try SignedTransactionId.fromHex("not-a-transaction")) + } + + func testHardwareRestartWithoutBroadcastReturnObservesOriginalCandidate() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let txid = "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e" + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + let lookup = PaymentProofHardwareLookup(result: .success(hardwareTransaction(txid: txid))) + let service = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, hardwareWalletId: hardwareWalletId, paymentIdentity: identity) + let serializedTx = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" + await store.failNextSave() + do { + try await service.retainHardwareOnchainCandidate( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx + ) + XCTFail("Failed receipt persistence must stop dispatch") + } catch {} + let unsaved = await store.snapshot().first + XCTAssertNil(unsaved?.paymentIdentifier) + try await service.retainHardwareOnchainCandidate( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx + ) + let retained = await store.snapshot().first + XCTAssertEqual(retained?.paymentIdentifier, txid) + XCTAssertNil(retained?.proofData) + XCTAssertEqual(retained?.onchainAcceptanceVerified, false) + do { + try await service.retainHardwareOnchainCandidate( + requestId: request.id, paymentIdentity: identity, walletId: "trezor:other-wallet", + address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx + ) + XCTFail("Another wallet cannot replace the original hardware receipt") + } catch {} + // The native dispatch has no return value: recreate the service from its durable backup. + let snapshot = try await service.backupSnapshot() + let backup = try JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: JSONEncoder().encode(snapshot)) + await store.clear() + let restarted = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) + try await restarted.restoreBackup(backup) + await restarted.reconcile() + let observed = await lookup.calls() + XCTAssertEqual(observed, [.init(walletId: hardwareWalletId, txid: txid)]) + let restored = await store.snapshot().first + XCTAssertEqual(restored?.hardwareSignedTransaction, serializedTx) + XCTAssertEqual(restored?.paymentIdentifier, txid) + XCTAssertEqual(restored?.onchainAcceptanceVerified, true) + } + func testHardwareCandidateCannotBeReplacedByDifferentWalletRequestOrTxid() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) From bdfad61b26469b3d987a5b14716f6e7b7715e35b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 15:52:10 +0200 Subject: [PATCH 28/77] fix: finish observed hardware shop payments --- Bitkit/ViewModels/HwFundingSigner.swift | 19 +++++++ .../Views/Wallets/Send/HwSendSignView.swift | 30 ++++++++++ BitkitTests/HwFundingSignerTests.swift | 57 ++++++++++++++++++- 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 8d580b115..7575b3045 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -670,6 +670,25 @@ final class HwSendCoordinator { return .success(paymentId: result.txId, walletId: walletId) } + func resolveObservedShopPayment( + _ resolution: PaykitOnchainPaymentResolution, + paymentIdentity: String?, + currentIdentity: String? + ) -> SendRoute? { + guard !isSigning, isBroadcastUnresolved, + let pendingPayment, + pendingPayment.hasBroadcastAttempted, + pendingPayment.paymentRequestId == resolution.requestId, + walletId == resolution.walletId, + PubkyPublicKeyFormat.matches(resolution.identity, paymentIdentity), + PubkyPublicKeyFormat.matches(resolution.identity, currentIdentity), + let txid = try? SignedTransactionId.fromHex(pendingPayment.signedTx.serializedTx), + txid.caseInsensitiveCompare(resolution.transactionId) == .orderedSame + else { return nil } + completeBroadcast() + return .success(paymentId: txid, walletId: resolution.walletId) + } + func completeBroadcast() { pendingPayment = nil isBroadcastUnresolved = false diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 7d28be0d2..19766bd66 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -19,6 +19,8 @@ struct HwSendSignView: View { let cancelContactPayment: (PrivatePaymentListSendOutcome) async -> Void @State private var signingTask: Task? @State private var passphraseTask: Task? + @State private var observedResolution: PaykitOnchainPaymentResolution? + @State private var appliedResolution = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -77,6 +79,19 @@ struct HwSendSignView: View { onCancel: dismissPassphrase ) } + .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in + guard resolution.requestId == contactPaymentRequestId, + resolution.walletId == hwSend.walletId, + PubkyPublicKeyFormat.matches(resolution.identity, contactPaymentIdentity) + else { return } + observedResolution = resolution + applyObservedResolution() + } + .onChange(of: hwSend.isSigning) { _, isSigning in + if !isSigning { + applyObservedResolution() + } + } .onDisappear { guard !hwSend.isBroadcastUnresolved else { return } signingTask?.cancel() @@ -104,6 +119,20 @@ struct HwSendSignView: View { ) } + private func applyObservedResolution() { + guard !appliedResolution, let resolution = observedResolution, + let route = hwSend.resolveObservedShopPayment( + resolution, paymentIdentity: contactPaymentIdentity, currentIdentity: pubkyProfile.publicKey + ) + else { return } + appliedResolution = true + app.addPendingContactPaymentContext( + resolution.transactionId, context: ContactPaymentContext(publicKey: resolution.requestId.counterparty) + ) + navigationPath.append(route) + Task { await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) } + } + private func startSigning() { guard signingTask == nil else { return } signingTask = Task { @MainActor in @@ -170,6 +199,7 @@ struct HwSendSignView: View { requestId: requestId, proofVerified: proofVerified ) + guard !appliedResolution else { return } hwSend.completeBroadcast() let completionRoute = await hwSend.completionRoute( result: result, walletId: walletId, requestId: requestId, paymentIdentity: contactPaymentIdentity, diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 16d9280a1..d85d5a553 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -839,7 +839,62 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastCalls, 2) } - func testHardwareCandidateSaveFailurePreventsNativeDispatch() async throws { + func testObservedShopPaymentUnlocksOnlyOriginalSignedCandidate() async throws { + let funding = MockHwFunding() + funding.signedTx = HwFundingSignedTx( + serializedTx: "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300", + miningFeeSats: 141, + feeRate: 2, + totalSpent: 42141 + ) + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + let identity = "pubky" + String(repeating: "z", count: 52) + let txid = try SignedTransactionId.fromHex(funding.signedTx.serializedTx) + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "response lost") + let broadcast = AsyncGate() + funding.broadcastGate = broadcast + let original = PaykitOnchainPaymentResolution(identity: identity, requestId: requestId, transactionId: txid, walletId: "jade:wallet") + let payment = Task { try await coordinator.signAndBroadcast( + manager: HwWalletManager(), address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId + ) } + await waitUntil { funding.broadcastCalls == 1 } + XCTAssertNil(coordinator.resolveObservedShopPayment(original, paymentIdentity: identity, currentIdentity: identity)) + XCTAssertFalse(coordinator.canLeave) + broadcast.open() + await assertThrowsAsync { _ = try await payment.value } + for unrelated in [ + PaykitOnchainPaymentResolution(identity: identity, requestId: requestId, transactionId: "other", walletId: "jade:wallet"), + PaykitOnchainPaymentResolution(identity: identity, requestId: requestId, transactionId: txid, walletId: "other"), + PaykitOnchainPaymentResolution( + identity: identity, + requestId: .init(paymentRequestId: "other", counterparty: "original-merchant"), + transactionId: txid, + walletId: "jade:wallet" + ), + PaykitOnchainPaymentResolution( + identity: "pubky" + String(repeating: "x", count: 52), + requestId: requestId, + transactionId: txid, + walletId: "jade:wallet" + ), + ] { + XCTAssertNil(coordinator.resolveObservedShopPayment(unrelated, paymentIdentity: identity, currentIdentity: identity)) + XCTAssertFalse(coordinator.canLeave) + } + XCTAssertNil(coordinator.resolveObservedShopPayment(original, paymentIdentity: identity, currentIdentity: "pubky" + String(repeating: "x", count: 52))) + XCTAssertEqual( + coordinator.resolveObservedShopPayment(original, paymentIdentity: identity, currentIdentity: identity), + .success(paymentId: txid, walletId: "jade:wallet") + ) + XCTAssertTrue(coordinator.canLeave) + XCTAssertFalse(coordinator.hasPendingBroadcast) + XCTAssertNil(coordinator.resolveObservedShopPayment(original, paymentIdentity: identity, currentIdentity: identity)) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 1) + } + + func testHardwareCandidateSaveFailurePreventsNativeDispatch() async { let funding = MockHwFunding() let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) await assertThrowsAsync { From a302ea2960ff64de5cd3b3a565605a026f373cf5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 16:30:28 +0200 Subject: [PATCH 29/77] fix: resume original shop hardware payments --- Bitkit/Models/PaykitPaymentStateBackup.swift | 9 +++ .../Services/PaykitPaymentProofService.swift | 55 ++++++++++++++++++- Bitkit/ViewModels/HwFundingSigner.swift | 11 +++- .../Views/Wallets/Send/HwSendSignView.swift | 10 +++- Bitkit/Views/Wallets/Send/SendSheet.swift | 11 ++-- BitkitTests/HwFundingSignerTests.swift | 50 +++++++++++++---- .../PaykitPaymentProofServiceTests.swift | 50 +++++++++++++++++ 7 files changed, 174 insertions(+), 22 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index e99153473..1d8d23e2f 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -73,6 +73,9 @@ struct PaykitPaymentStateBackup: Codable { let onchainAmountSats: UInt64? let onchainWalletId: String? let hardwareSignedTransaction: String? + let hardwareMiningFeeSats: UInt64? + let hardwareFeeRate: UInt64? + let hardwareTotalSpent: UInt64? let onchainMatchingTransactionIdsBeforeAttempt: Set let onchainAcceptanceVerified: Bool? @@ -90,6 +93,9 @@ struct PaykitPaymentStateBackup: Codable { onchainAmountSats = proof.onchainAmountSats onchainWalletId = proof.onchainWalletId hardwareSignedTransaction = proof.hardwareSignedTransaction + hardwareMiningFeeSats = proof.hardwareMiningFeeSats + hardwareFeeRate = proof.hardwareFeeRate + hardwareTotalSpent = proof.hardwareTotalSpent onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] onchainAcceptanceVerified = proof.onchainAcceptanceVerified } @@ -122,6 +128,9 @@ struct PaykitPaymentStateBackup: Codable { try SignedTransactionId.fromHex(hardwareSignedTransaction) == paymentIdentifier else { throw invalidBackup("Invalid hardware payment receipt") } proof.hardwareSignedTransaction = hardwareSignedTransaction + proof.hardwareMiningFeeSats = hardwareMiningFeeSats + proof.hardwareFeeRate = hardwareFeeRate + proof.hardwareTotalSpent = hardwareTotalSpent } return proof } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index a9f41a433..98c60356a 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -58,6 +58,9 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var onchainWalletId: String? /// Original signed candidate retained before hardware dispatch; never a payment proof. var hardwareSignedTransaction: String? + var hardwareMiningFeeSats: UInt64? + var hardwareFeeRate: UInt64? + var hardwareTotalSpent: UInt64? var onchainMatchingTransactionIdsBeforeAttempt: Set? var onchainAcceptanceVerified: Bool? /// Device-local acknowledgement: backup restore reruns local activity proof. @@ -485,7 +488,8 @@ actor PaykitPaymentProofService { _ request: PaykitPaymentRequest, address: String, hardwareWalletId: String? = nil, - paymentIdentity: String? = nil + paymentIdentity: String? = nil, + signedTx: HwFundingSignedTx? = nil ) async throws { let identity = try await currentIdentity() try await mutationLock.withLock { @@ -494,7 +498,9 @@ actor PaykitPaymentProofService { address: address, hardwareWalletId: hardwareWalletId, paymentIdentity: paymentIdentity, - identity: identity + identity: identity, + serializedTx: signedTx?.serializedTx, + signedTx: signedTx ) } } @@ -504,7 +510,9 @@ actor PaykitPaymentProofService { address: String, hardwareWalletId: String?, paymentIdentity: String?, - identity: String + identity: String, + serializedTx: String?, + signedTx: HwFundingSignedTx? ) async throws { if let hardwareWalletId { guard hardwareWalletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: hardwareWalletId) @@ -513,6 +521,15 @@ actor PaykitPaymentProofService { if hardwareWalletId != nil || paymentIdentity != nil { guard PubkyPublicKeyFormat.matches(identity, paymentIdentity) else { throw PaykitPaymentRequestError.requestUnavailable } } + if let signedTx { + guard signedTx.feeRate.isFinite, signedTx.feeRate >= 0, + UInt64(exactly: signedTx.feeRate.rounded(.up)) != nil + else { throw PaykitPaymentRequestError.requestUnavailable } + } + let signedTxid = try serializedTx.map { raw in + guard hardwareWalletId != nil else { throw PaykitPaymentRequestError.requestUnavailable } + return try SignedTransactionId.fromHex(raw) + } var pendingProofs = try await loadProofs() guard let index = pendingProofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && @@ -529,9 +546,41 @@ actor PaykitPaymentProofService { pendingProofs[index].onchainAddress = address pendingProofs[index].onchainAmountSats = request.amountSats pendingProofs[index].onchainWalletId = hardwareWalletId + pendingProofs[index].paymentIdentifier = signedTxid + pendingProofs[index].hardwareSignedTransaction = serializedTx + pendingProofs[index].hardwareMiningFeeSats = signedTx?.miningFeeSats + pendingProofs[index].hardwareFeeRate = signedTx.map { UInt64($0.feeRate.rounded(.up)) } + pendingProofs[index].hardwareTotalSpent = signedTx?.totalSpent try await persist(pendingProofs) } + func retainedHardwareOnchainPayment( + requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, + address: String, amountSats: UInt64 + ) async throws -> HwFundingSignedTx? { + let identity = try await currentIdentity() + guard PubkyPublicKeyFormat.matches(identity, paymentIdentity), walletId != WalletScope.default, + hardwareTransactionLookup.hasWallet(walletId: walletId) + else { throw PaykitPaymentRequestError.requestUnavailable } + return try await mutationLock.withLock { + guard try await PubkyPublicKeyFormat.matches(currentIdentity(), identity) else { + throw PaykitPaymentRequestError.requestUnavailable + } + let proofs = try await loadProofs() + guard let proof = proofs.first(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && $0.paymentStarted && $0.onchainWalletId == walletId && + $0.onchainAddress == address && $0.onchainAmountSats == amountSats && + $0.proofData == nil && $0.onchainAcceptanceVerified != true + }), let raw = proof.hardwareSignedTransaction, + try SignedTransactionId.fromHex(raw) == proof.paymentIdentifier, + let fee = proof.hardwareMiningFeeSats, let rate = proof.hardwareFeeRate, + let spent = proof.hardwareTotalSpent + else { return nil } + return HwFundingSignedTx(serializedTx: raw, miningFeeSats: fee, feeRate: Float(rate), totalSpent: spent) + } + } + func retainHardwareOnchainCandidate( requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, address: String, amountSats: UInt64, serializedTx: String diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 7575b3045..3666c51c8 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -507,7 +507,8 @@ final class HwSendCoordinator { satsPerVByte: UInt64, paymentDeadline: PaykitPreciseInstant? = nil, paymentRequestId: PaykitPaymentRequest.ID? = nil, - beforeFirstBroadcast: @escaping () async throws -> Void = {}, + loadSignedPayment: @escaping () async throws -> HwFundingSignedTx? = { nil }, + beforeFirstBroadcast: @escaping (HwFundingSignedTx) async throws -> Void = { _ in }, beforeBroadcastAttempt: @escaping () async throws -> Void = {}, retainSignedPayment: @escaping (HwFundingSignedTx) async throws -> Void = { _ in }, clearSignedPaymentBeforeDispatch: @escaping (HwFundingSignedTx) async -> Bool = { _ in true }, @@ -541,6 +542,12 @@ final class HwSendCoordinator { } } + if pendingPayment == nil, let restored = try await loadSignedPayment() { + pendingPayment = PendingPayment(request: request, signedTx: restored, paymentRequestId: paymentRequestId) + pendingPayment?.isPreparedForBroadcast = true + pendingPayment?.hasBroadcastAttempted = true + isBroadcastUnresolved = true + } let signed: HwFundingSignedTx if let pendingPayment, pendingPayment.request == request { signed = pendingPayment.signedTx @@ -564,7 +571,7 @@ final class HwSendCoordinator { } if pendingPayment?.isPreparedForBroadcast != true { - try await beforeFirstBroadcast() + try await beforeFirstBroadcast(signed) try Task.checkCancellation() pendingPayment?.isPreparedForBroadcast = true } diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 19766bd66..3676beeab 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -13,7 +13,7 @@ struct HwSendSignView: View { let contactPaymentRequestId: PaykitPaymentRequest.ID? let contactPaymentIdentity: String? let contactPaymentDeadline: PaykitPreciseInstant? - let prepareContactPayment: () async throws -> Void + let prepareContactPayment: (HwFundingSignedTx) async throws -> Void let authorizeContactPayment: () async throws -> Void let completeContactPayment: (String) async -> Bool let cancelContactPayment: (PrivatePaymentListSendOutcome) async -> Void @@ -158,6 +158,14 @@ struct HwSendSignView: View { satsPerVByte: UInt64(feeRate), paymentDeadline: contactPaymentDeadline, paymentRequestId: requestId, + loadSignedPayment: { + guard let requestId else { return nil } + guard let identity = contactPaymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + return try await PaykitPaymentProofService.shared.retainedHardwareOnchainPayment( + requestId: requestId, paymentIdentity: identity, walletId: walletId, + address: invoice.address, amountSats: amount + ) + }, beforeFirstBroadcast: prepareContactPayment, beforeBroadcastAttempt: authorizeContactPayment, retainSignedPayment: { signed in diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 43da5bd15..6ba3b8115 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -782,12 +782,13 @@ struct SendSheet: View { contactPaymentRequestId: contactContext?.incomingPaymentRequest?.id, contactPaymentIdentity: paymentIdentity, contactPaymentDeadline: contactContext?.incomingPaymentRequest?.paymentDeadline, - prepareContactPayment: { + prepareContactPayment: { signedTx in try await prepareHardwareContactPayment( context: contactContext, address: address, walletId: walletId, - paymentIdentity: paymentIdentity + paymentIdentity: paymentIdentity, + signedTx: signedTx ) }, authorizeContactPayment: { @@ -921,7 +922,8 @@ struct SendSheet: View { context: ContactPaymentContext?, address: String?, walletId: String?, - paymentIdentity: String? + paymentIdentity: String?, + signedTx: HwFundingSignedTx ) async throws { guard let request = context?.incomingPaymentRequest else { try await prepareIncomingPaymentRequest(context: context) @@ -945,7 +947,8 @@ struct SendSheet: View { do { try await prepareIncomingPaymentRequest(context: context) try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( - request, address: address, hardwareWalletId: walletId, paymentIdentity: paymentIdentity + request, address: address, hardwareWalletId: walletId, paymentIdentity: paymentIdentity, + signedTx: signedTx ) } catch { _ = await paykitPaymentRequestManager.paymentRequestForRetry(request.id) diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index d85d5a553..af10f8a5e 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -321,7 +321,7 @@ final class HwFundingSignerTests: XCTestCase { var preparationCalls = 0 var authorizationCalls = 0 var isPaymentAllowed = true - let preparePayment: () async throws -> Void = { + let preparePayment: (HwFundingSignedTx) async throws -> Void = { _ in preparationCalls += 1 await store.seed([PendingPaykitPaymentProof( identity: identity, requestId: request.id, paymentAppId: "bitkit", @@ -441,7 +441,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { + beforeFirstBroadcast: { _ in try await proofService.prepare( request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: originalProof.paymentEndpointIdentifier, @@ -542,7 +542,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { preparationCalls += 1 }, + beforeFirstBroadcast: { _ in preparationCalls += 1 }, beforeBroadcastAttempt: { throw authorizationError }, afterFailure: { failureOutcomes.append($0) } ) @@ -558,7 +558,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { preparationCalls += 1 } + beforeFirstBroadcast: { _ in preparationCalls += 1 } ) XCTAssertEqual(preparationCalls, 2) @@ -654,7 +654,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { throw PaykitPaymentRequestError.operationInProgress }, + beforeFirstBroadcast: { _ in throw PaykitPaymentRequestError.operationInProgress }, afterFailure: { failureOutcomes.append($0) } ) } @@ -685,7 +685,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { + beforeFirstBroadcast: { _ in beforeFirstBroadcastCalls += 1 throw MockHwFunding.TestError() } @@ -700,7 +700,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { beforeFirstBroadcastCalls += 1 } + beforeFirstBroadcast: { _ in beforeFirstBroadcastCalls += 1 } ) XCTAssertEqual(beforeFirstBroadcastCalls, 2) @@ -734,7 +734,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { preparationCalls += 1 }, + beforeFirstBroadcast: { _ in preparationCalls += 1 }, beforeBroadcastAttempt: { authorizationCalls += 1 }, afterBroadcast: { completedTransactionIds.append($0.txId) } ) @@ -750,7 +750,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { preparationCalls += 1 }, + beforeFirstBroadcast: { _ in preparationCalls += 1 }, beforeBroadcastAttempt: { authorizationCalls += 1 }, afterBroadcast: { completedTransactionIds.append($0.txId) } ) @@ -839,6 +839,32 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastCalls, 2) } + func testRestoredShopReceiptRequiresAuthorizationAndNeverSignsAgain() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + let receipt = funding.signedTx + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId, + loadSignedPayment: { receipt }, + beforeFirstBroadcast: { _ in XCTFail("Restored proof must not prepare another payment") }, + beforeBroadcastAttempt: { throw MockHwFunding.TestError() } + ) + } + XCTAssertEqual(funding.signCalls, 0) + XCTAssertEqual(funding.broadcastCalls, 0) + XCTAssertTrue(coordinator.isBroadcastUnresolved) + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId, + beforeFirstBroadcast: { _ in XCTFail("Original preparation retained") } + ) + XCTAssertEqual(funding.signCalls, 0) + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertEqual(funding.broadcastTransactions, [receipt.serializedTx]) + } + func testObservedShopPaymentUnlocksOnlyOriginalSignedCandidate() async throws { let funding = MockHwFunding() funding.signedTx = HwFundingSignedTx( @@ -989,7 +1015,7 @@ final class HwFundingSignerTests: XCTestCase { let secondPreparation = AsyncGate() let first = Task { - try await self.signAndBroadcast(coordinator, manager: manager) { + try await self.signAndBroadcast(coordinator, manager: manager) { _ in preparations.record("first") await firstPreparation.wait() } @@ -998,7 +1024,7 @@ final class HwFundingSignerTests: XCTestCase { coordinator.cancel() let second = Task { - try await self.signAndBroadcast(coordinator, manager: manager) { + try await self.signAndBroadcast(coordinator, manager: manager) { _ in preparations.record("second") await secondPreparation.wait() } @@ -1131,7 +1157,7 @@ final class HwFundingSignerTests: XCTestCase { private func signAndBroadcast( _ coordinator: HwSendCoordinator, manager: HwWalletManager, - beforeFirstBroadcast: @escaping () async throws -> Void = {} + beforeFirstBroadcast: @escaping (HwFundingSignedTx) async throws -> Void = { _ in } ) async throws -> HwFundingBroadcastResult { try await coordinator.signAndBroadcast( manager: manager, diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index f25fb539a..08e0bc0a7 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -355,6 +355,56 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remaining.isEmpty) } + func testHardwareStartAtomicallyRetainsReceiptAcrossAuthorizationBackup() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let serializedTx = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" + let receipt = HwFundingSignedTx(serializedTx: serializedTx, miningFeeSats: 141, feeRate: 2, totalSpent: request.amountSats + 141) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + await store.failNextSave() + do { + try await service.markOnchainPaymentStarted( + request, address: onchainAddress, hardwareWalletId: hardwareWalletId, + paymentIdentity: identity, signedTx: receipt + ) + XCTFail("Failed atomic receipt save must stop preparation") + } catch {} + let unsaved = await store.snapshot().first + XCTAssertEqual(unsaved?.paymentStarted, false) + XCTAssertNil(unsaved?.hardwareSignedTransaction) + try await service.markOnchainPaymentStarted( + request, address: onchainAddress, hardwareWalletId: hardwareWalletId, + paymentIdentity: identity, signedTx: receipt + ) + let started = await store.snapshot().first + XCTAssertEqual(started?.hardwareSignedTransaction, serializedTx) + XCTAssertEqual(started?.paymentIdentifier, try SignedTransactionId.fromHex(serializedTx)) + XCTAssertEqual(started?.paymentStarted, true) + XCTAssertNil(started?.proofData) + let snapshot = try await service.backupSnapshot() + let encoded = try JSONEncoder().encode(snapshot) + await store.clear() + let reopened = paymentProofService(sdk: sdk, store: store) + try await reopened.restoreBackup(JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: encoded)) + let restored = await store.snapshot().first + XCTAssertEqual(restored?.hardwareSignedTransaction, serializedTx) + XCTAssertEqual(restored?.paymentIdentifier, started?.paymentIdentifier) + let retained = try await reopened.retainedHardwareOnchainPayment( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats + ) + XCTAssertEqual(retained, receipt) + let wrongAmount = try await reopened.retainedHardwareOnchainPayment( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats + 1 + ) + XCTAssertNil(wrongAmount) + } + func testHardwareTransactionIdMatchesBackendAndRejectsInvalidReceipt() throws { let signed = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" let legacy = "0200000001f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa724c0300" From c742e3755280bbffac02ab6d9340ddccf6e485ba Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 16:53:01 +0200 Subject: [PATCH 30/77] fix: restore hardware dispatch state --- Bitkit/Models/PaykitPaymentStateBackup.swift | 3 +++ .../Services/PaykitPaymentProofService.swift | 15 +++++++++--- Bitkit/ViewModels/HwFundingSigner.swift | 13 +++++++---- BitkitTests/HwFundingSignerTests.swift | 23 ++++++++++++++++++- .../PaykitPaymentProofServiceTests.swift | 23 ++++++++++++++++++- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 1d8d23e2f..c3d9c92ac 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -76,6 +76,7 @@ struct PaykitPaymentStateBackup: Codable { let hardwareMiningFeeSats: UInt64? let hardwareFeeRate: UInt64? let hardwareTotalSpent: UInt64? + let hardwareDispatchAttempted: Bool? let onchainMatchingTransactionIdsBeforeAttempt: Set let onchainAcceptanceVerified: Bool? @@ -96,6 +97,7 @@ struct PaykitPaymentStateBackup: Codable { hardwareMiningFeeSats = proof.hardwareMiningFeeSats hardwareFeeRate = proof.hardwareFeeRate hardwareTotalSpent = proof.hardwareTotalSpent + hardwareDispatchAttempted = proof.hardwareDispatchAttempted onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] onchainAcceptanceVerified = proof.onchainAcceptanceVerified } @@ -131,6 +133,7 @@ struct PaykitPaymentStateBackup: Codable { proof.hardwareMiningFeeSats = hardwareMiningFeeSats proof.hardwareFeeRate = hardwareFeeRate proof.hardwareTotalSpent = hardwareTotalSpent + proof.hardwareDispatchAttempted = hardwareDispatchAttempted } return proof } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 98c60356a..85899d052 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -61,6 +61,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var hardwareMiningFeeSats: UInt64? var hardwareFeeRate: UInt64? var hardwareTotalSpent: UInt64? + var hardwareDispatchAttempted: Bool? var onchainMatchingTransactionIdsBeforeAttempt: Set? var onchainAcceptanceVerified: Bool? /// Device-local acknowledgement: backup restore reruns local activity proof. @@ -551,13 +552,14 @@ actor PaykitPaymentProofService { pendingProofs[index].hardwareMiningFeeSats = signedTx?.miningFeeSats pendingProofs[index].hardwareFeeRate = signedTx.map { UInt64($0.feeRate.rounded(.up)) } pendingProofs[index].hardwareTotalSpent = signedTx?.totalSpent + pendingProofs[index].hardwareDispatchAttempted = signedTx.map { _ in false } try await persist(pendingProofs) } func retainedHardwareOnchainPayment( requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, address: String, amountSats: UInt64 - ) async throws -> HwFundingSignedTx? { + ) async throws -> RetainedHardwareOnchainPayment? { let identity = try await currentIdentity() guard PubkyPublicKeyFormat.matches(identity, paymentIdentity), walletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: walletId) @@ -577,7 +579,10 @@ actor PaykitPaymentProofService { let fee = proof.hardwareMiningFeeSats, let rate = proof.hardwareFeeRate, let spent = proof.hardwareTotalSpent else { return nil } - return HwFundingSignedTx(serializedTx: raw, miningFeeSats: fee, feeRate: Float(rate), totalSpent: spent) + return RetainedHardwareOnchainPayment( + signedTx: HwFundingSignedTx(serializedTx: raw, miningFeeSats: fee, feeRate: Float(rate), totalSpent: spent), + hasAttemptedBroadcast: proof.hardwareDispatchAttempted != false + ) } } @@ -603,6 +608,7 @@ actor PaykitPaymentProofService { }) else { throw PaykitPaymentRequestError.operationInProgress } proofs[index].paymentIdentifier = txid proofs[index].hardwareSignedTransaction = serializedTx + proofs[index].hardwareDispatchAttempted = true try await persist(proofs) } } @@ -950,7 +956,10 @@ actor PaykitPaymentProofService { await removeProofs { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == request.id && $0.kind == .onchain && $0.onchainWalletId == walletId && $0.paymentStarted && - $0.paymentIdentifier == nil && $0.proofData == nil && $0.onchainAcceptanceVerified != true + ($0.paymentIdentifier == nil || + ($0.hardwareDispatchAttempted == false && $0.paymentIdentifier != nil && + $0.hardwareSignedTransaction.flatMap { try? SignedTransactionId.fromHex($0) } == $0.paymentIdentifier)) && + $0.proofData == nil && $0.onchainAcceptanceVerified != true } } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 3666c51c8..74ea730c3 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -1,6 +1,11 @@ import BitkitCore import Observation +struct RetainedHardwareOnchainPayment { + let signedTx: HwFundingSignedTx + let hasAttemptedBroadcast: Bool +} + /// Orchestrates an on-chain payment from a hardware wallet: reconnect the device, compose the exact /// payment, sign it on-device, and broadcast. Owns the per-phase timeouts and fee-reserve math. /// @@ -507,7 +512,7 @@ final class HwSendCoordinator { satsPerVByte: UInt64, paymentDeadline: PaykitPreciseInstant? = nil, paymentRequestId: PaykitPaymentRequest.ID? = nil, - loadSignedPayment: @escaping () async throws -> HwFundingSignedTx? = { nil }, + loadSignedPayment: @escaping () async throws -> RetainedHardwareOnchainPayment? = { nil }, beforeFirstBroadcast: @escaping (HwFundingSignedTx) async throws -> Void = { _ in }, beforeBroadcastAttempt: @escaping () async throws -> Void = {}, retainSignedPayment: @escaping (HwFundingSignedTx) async throws -> Void = { _ in }, @@ -543,10 +548,10 @@ final class HwSendCoordinator { } if pendingPayment == nil, let restored = try await loadSignedPayment() { - pendingPayment = PendingPayment(request: request, signedTx: restored, paymentRequestId: paymentRequestId) + pendingPayment = PendingPayment(request: request, signedTx: restored.signedTx, paymentRequestId: paymentRequestId) pendingPayment?.isPreparedForBroadcast = true - pendingPayment?.hasBroadcastAttempted = true - isBroadcastUnresolved = true + pendingPayment?.hasBroadcastAttempted = restored.hasAttemptedBroadcast + isBroadcastUnresolved = restored.hasAttemptedBroadcast } let signed: HwFundingSignedTx if let pendingPayment, pendingPayment.request == request { diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index af10f8a5e..df99ad28f 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -848,7 +848,7 @@ final class HwFundingSignerTests: XCTestCase { await assertThrowsAsync { _ = try await coordinator.signAndBroadcast( manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId, - loadSignedPayment: { receipt }, + loadSignedPayment: { RetainedHardwareOnchainPayment(signedTx: receipt, hasAttemptedBroadcast: true) }, beforeFirstBroadcast: { _ in XCTFail("Restored proof must not prepare another payment") }, beforeBroadcastAttempt: { throw MockHwFunding.TestError() } ) @@ -865,6 +865,27 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastTransactions, [receipt.serializedTx]) } + func testRestoredUnattemptedShopReceiptDenialIsDefinitelyBeforeDispatch() async { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + var outcomes: [PrivatePaymentListSendOutcome] = [] + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId, + loadSignedPayment: { RetainedHardwareOnchainPayment(signedTx: funding.signedTx, hasAttemptedBroadcast: false) }, + beforeFirstBroadcast: { _ in XCTFail("Restored proof must not prepare another payment") }, + beforeBroadcastAttempt: { throw MockHwFunding.TestError() }, + afterFailure: { outcomes.append($0) } + ) + } + XCTAssertEqual(outcomes, [.definitePreBroadcastFailure]) + XCTAssertFalse(coordinator.isBroadcastUnresolved) + XCTAssertEqual(funding.signCalls, 0) + XCTAssertEqual(funding.broadcastCalls, 0) + } + func testObservedShopPaymentUnlocksOnlyOriginalSignedCandidate() async throws { let funding = MockHwFunding() funding.signedTx = HwFundingSignedTx( diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 08e0bc0a7..582217f7d 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -397,12 +397,33 @@ final class PaykitPaymentProofServiceTests: XCTestCase { requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, address: onchainAddress, amountSats: request.amountSats ) - XCTAssertEqual(retained, receipt) + XCTAssertEqual(retained?.signedTx, receipt) + XCTAssertEqual(retained?.hasAttemptedBroadcast, false) let wrongAmount = try await reopened.retainedHardwareOnchainPayment( requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, address: onchainAddress, amountSats: request.amountSats + 1 ) XCTAssertNil(wrongAmount) + await reopened.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: hardwareWalletId) + let cleared = await store.snapshot() + XCTAssertTrue(cleared.isEmpty, "Known pre-dispatch receipt must be removed") + try await reopened.restoreBackup(JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: encoded)) + try await reopened.retainHardwareOnchainCandidate( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx + ) + let attemptedBackup = try await reopened.backupSnapshot() + let attemptedEncoded = try JSONEncoder().encode(attemptedBackup) + await store.clear() + try await reopened.restoreBackup(JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: attemptedEncoded)) + let attempted = try await reopened.retainedHardwareOnchainPayment( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats + ) + XCTAssertEqual(attempted?.hasAttemptedBroadcast, true) + await reopened.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: hardwareWalletId) + let guarded = await store.snapshot() + XCTAssertEqual(guarded.count, 1) } func testHardwareTransactionIdMatchesBackendAndRejectsInvalidReceipt() throws { From f2f14d025ef04053544bcd52b68226a94035a76b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 17:57:38 +0200 Subject: [PATCH 31/77] fix: keep private shop cleanup retryable --- Bitkit/Models/PaykitPaymentStateBackup.swift | 3 + .../Services/PaykitPaymentProofService.swift | 50 ++++++++--- .../PrivatePaykitService+Payments.swift | 15 ++++ Bitkit/Views/Wallets/Send/SendSheet.swift | 7 +- .../PaykitPaymentRequestServiceTests.swift | 86 ++++++++++++++++++- 5 files changed, 145 insertions(+), 16 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index c3d9c92ac..e35e9c958 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -77,6 +77,7 @@ struct PaykitPaymentStateBackup: Codable { let hardwareFeeRate: UInt64? let hardwareTotalSpent: UInt64? let hardwareDispatchAttempted: Bool? + let privatePaymentListVersion: UInt64? let onchainMatchingTransactionIdsBeforeAttempt: Set let onchainAcceptanceVerified: Bool? @@ -98,6 +99,7 @@ struct PaykitPaymentStateBackup: Codable { hardwareFeeRate = proof.hardwareFeeRate hardwareTotalSpent = proof.hardwareTotalSpent hardwareDispatchAttempted = proof.hardwareDispatchAttempted + privatePaymentListVersion = proof.privatePaymentListVersion onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] onchainAcceptanceVerified = proof.onchainAcceptanceVerified } @@ -135,6 +137,7 @@ struct PaykitPaymentStateBackup: Codable { proof.hardwareTotalSpent = hardwareTotalSpent proof.hardwareDispatchAttempted = hardwareDispatchAttempted } + proof.privatePaymentListVersion = privatePaymentListVersion return proof } } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 85899d052..73c12be56 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -62,6 +62,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var hardwareFeeRate: UInt64? var hardwareTotalSpent: UInt64? var hardwareDispatchAttempted: Bool? + var privatePaymentListVersion: UInt64? var onchainMatchingTransactionIdsBeforeAttempt: Set? var onchainAcceptanceVerified: Bool? /// Device-local acknowledgement: backup restore reruns local activity proof. @@ -490,7 +491,8 @@ actor PaykitPaymentProofService { address: String, hardwareWalletId: String? = nil, paymentIdentity: String? = nil, - signedTx: HwFundingSignedTx? = nil + signedTx: HwFundingSignedTx? = nil, + privatePaymentListVersion: UInt64? = nil ) async throws { let identity = try await currentIdentity() try await mutationLock.withLock { @@ -501,7 +503,8 @@ actor PaykitPaymentProofService { paymentIdentity: paymentIdentity, identity: identity, serializedTx: signedTx?.serializedTx, - signedTx: signedTx + signedTx: signedTx, + privatePaymentListVersion: privatePaymentListVersion ) } } @@ -513,7 +516,8 @@ actor PaykitPaymentProofService { paymentIdentity: String?, identity: String, serializedTx: String?, - signedTx: HwFundingSignedTx? + signedTx: HwFundingSignedTx?, + privatePaymentListVersion: UInt64? ) async throws { if let hardwareWalletId { guard hardwareWalletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: hardwareWalletId) @@ -553,6 +557,7 @@ actor PaykitPaymentProofService { pendingProofs[index].hardwareFeeRate = signedTx.map { UInt64($0.feeRate.rounded(.up)) } pendingProofs[index].hardwareTotalSpent = signedTx?.totalSpent pendingProofs[index].hardwareDispatchAttempted = signedTx.map { _ in false } + pendingProofs[index].privatePaymentListVersion = privatePaymentListVersion try await persist(pendingProofs) } @@ -629,6 +634,12 @@ actor PaykitPaymentProofService { $0.paymentIdentifier == txid && $0.hardwareSignedTransaction == serializedTx && $0.proofData == nil && $0.onchainAcceptanceVerified != true }) else { return false } + if let version = original.privatePaymentListVersion { + try await PrivatePaykitService.shared.releaseUnsentPaymentListVersion( + publicKey: original.requestId.counterparty, + version: version + ) + } try await persist(proofs.filter { $0 != original }) return true } @@ -948,18 +959,31 @@ actor PaykitPaymentProofService { } } - func cancelHardwarePaymentBeforeDispatch(_ request: PaykitPaymentRequest, paymentIdentity: String, walletId: String) async { + func cancelHardwarePaymentBeforeDispatch( + _ request: PaykitPaymentRequest, paymentIdentity: String, walletId: String, + privatePaykitService: PrivatePaykitService = .shared + ) async { guard let identity = PubkyPublicKeyFormat.normalized(paymentIdentity), walletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: walletId) else { return } - // Called only by the coordinator after authorization fails before its first native dispatch. - // A profile switch does not change ownership of the original prepared operation. - await removeProofs { - PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == request.id && - $0.kind == .onchain && $0.onchainWalletId == walletId && $0.paymentStarted && - ($0.paymentIdentifier == nil || - ($0.hardwareDispatchAttempted == false && $0.paymentIdentifier != nil && - $0.hardwareSignedTransaction.flatMap { try? SignedTransactionId.fromHex($0) } == $0.paymentIdentifier)) && - $0.proofData == nil && $0.onchainAcceptanceVerified != true + do { + try await mutationLock.withLock { + let proofs = try await loadProofs() + guard let original = proofs.first(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == request.id && + $0.kind == .onchain && $0.onchainWalletId == walletId && $0.paymentStarted && + ($0.paymentIdentifier == nil || + ($0.hardwareDispatchAttempted == false && $0.paymentIdentifier != nil && + $0.hardwareSignedTransaction.flatMap { try? SignedTransactionId.fromHex($0) } == $0.paymentIdentifier)) && + $0.proofData == nil && $0.onchainAcceptanceVerified != true + }) else { return } + // Only an exact, definitely unsent receipt authorizes releasing its consumed version. + if let version = original.privatePaymentListVersion { + try await privatePaykitService.releaseUnsentPaymentListVersion(publicKey: request.counterparty, version: version) + } + try await persist(proofs.filter { $0 != original }) + } + } catch { + logWarning("Failed to clear definitely unsent hardware payment: \(error)") } } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index c3843766e..c85374ba3 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -318,6 +318,21 @@ extension PrivatePaykitService { ) } + /// The caller must establish an exact receipt that definitely never reached native dispatch. + func releaseUnsentPaymentListVersion(publicKey: String, version: UInt64) throws { + guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } + guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version else { return } + let previous = contact + contact.consumedPrivatePaymentListVersion = nil + state.contacts[key] = contact + do { + try persistStateOrThrow(markWalletBackup: true) + } catch { + state.contacts[key] = previous + throw error + } + } + private func currentLinkState( publicKey: String, preparedState: LinkedPeerState? = nil diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 6ba3b8115..61889079e 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -948,7 +948,7 @@ struct SendSheet: View { try await prepareIncomingPaymentRequest(context: context) try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( request, address: address, hardwareWalletId: walletId, paymentIdentity: paymentIdentity, - signedTx: signedTx + signedTx: signedTx, privatePaymentListVersion: privateContext.paymentListVersion ) } catch { _ = await paykitPaymentRequestManager.paymentRequestForRetry(request.id) @@ -1006,7 +1006,10 @@ struct SendSheet: View { guard outcome == .definitePreBroadcastFailure else { return } if let hardwareWalletId { guard let paymentIdentity else { return } - await paymentProofService.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: paymentIdentity, walletId: hardwareWalletId) + await paymentProofService.cancelHardwarePaymentBeforeDispatch( + request, paymentIdentity: paymentIdentity, walletId: hardwareWalletId, + privatePaykitService: privatePaykitService + ) } else { await paymentProofService.failOnchainPayment(request) } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index ccf45d3f4..b8628559c 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4492,6 +4492,81 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.requestsForPresentation(), [request]) } + func testHardwareFailureCleanupReleasesConsumptionAfterPrivateServiceRestart() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(counterparty: counterparty, endpoints: [endpoint])]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let privateContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [endpoint: "bitkit"], paymentListVersion: 7) + let context = ContactPaymentContext(publicKey: counterparty, privatePaymentContext: privateContext, incomingPaymentRequest: request) + let app = AppViewModel() + XCTAssertTrue(app.claimContactPaymentContext(context)) + let privateService = PrivatePaykitService() + let store = HardwarePaymentProofMemoryStore() + let identity = "pubky" + String(repeating: "z", count: 52) + let walletId = "trezor:original-ios-wallet" + let proofService = PaykitPaymentProofService( + sdk: sdk, + store: store, + hardwareTransactionLookup: PaymentProofHardwareLookup(result: .failure(NSError(domain: "fixture", code: 1))), + logInfo: { _ in }, + logWarning: { _ in } + ) + try await proofService.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await manager.prepareForPayment(request) { + try await privateService.consumePrivatePaymentList(publicKey: counterparty, context: privateContext, attemptId: context.id) + } + let signed = HwFundingSignedTx( + serializedTx: "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300", + miningFeeSats: 141, + feeRate: 2, + totalSpent: request.amountSats + 141 + ) + try await proofService.markOnchainPaymentStarted( + request, + address: "bcrt1qhardwarecleanup", + hardwareWalletId: walletId, + paymentIdentity: identity, + signedTx: signed, + privatePaymentListVersion: privateContext.paymentListVersion + ) + let snapshot = try await proofService.backupSnapshot() + let encoded = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: encoded) + let restored = try decoded.map { try $0.restored() } + XCTAssertEqual(restored.first?.privatePaymentListVersion, 7) + try await store.save(restored) + // A new private service loads durable consumption but has no in-memory attempt ledger. + let restartedPrivateService = PrivatePaykitService() + let consumedBefore = await restartedPrivateService.state.contacts[counterparty]?.consumedPrivatePaymentListVersion + XCTAssertEqual(consumedBefore, 7) + await store.failNextSave() + await SendSheet.cancelHardwareContactPayment( + context, outcome: .definitePreBroadcastFailure, app: app, manager: manager, + privatePaykitService: restartedPrivateService, paymentProofService: proofService, hardwareWalletId: walletId, paymentIdentity: identity + ) + let retainedAfterFailure = try await store.load() + XCTAssertEqual(retainedAfterFailure, restored, "Failed proof deletion must retain the original receipt for retry") + await SendSheet.cancelHardwareContactPayment( + context, outcome: .definitePreBroadcastFailure, app: app, manager: manager, + privatePaykitService: restartedPrivateService, paymentProofService: proofService, hardwareWalletId: walletId, paymentIdentity: identity + ) + let remaining = try await store.load() + XCTAssertTrue(remaining.isEmpty) + let consumedAfter = await PrivatePaykitService().state.contacts[counterparty]?.consumedPrivatePaymentListVersion + XCTAssertNil(consumedAfter, + "Removing a definitely unsent proof must not strand its consumed private version after restart") + let newerContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [endpoint: "bitkit"], paymentListVersion: 8) + try await restartedPrivateService.consumePrivatePaymentList(publicKey: counterparty, context: newerContext, attemptId: UUID()) + try await restartedPrivateService.releaseUnsentPaymentListVersion(publicKey: counterparty, version: 7) + let newerVersion = await restartedPrivateService.state.contacts[counterparty]?.consumedPrivatePaymentListVersion + XCTAssertEqual(newerVersion, 8, "Cleanup of the original receipt cannot release a newer version") + } + func testHardwareFailureCleanupPreservesConsumptionProofAndRetryOwnership() async throws { snapshotAppDefaultsDomain() let counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -8067,12 +8142,21 @@ private func waitUntil( private actor HardwarePaymentProofMemoryStore: PaykitPaymentProofStoring { private var proofs: [PendingPaykitPaymentProof] = [] + private var shouldFailSave = false + + func failNextSave() { + shouldFailSave = true + } func load() -> [PendingPaykitPaymentProof] { proofs } - func save(_ proofs: [PendingPaykitPaymentProof]) { + func save(_ proofs: [PendingPaykitPaymentProof]) throws { + if shouldFailSave { + shouldFailSave = false + throw NSError(domain: "proof-store-fixture", code: 1) + } self.proofs = proofs } } From 7deedcdb09bef326a12746629f85949fd6bc8e35 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 18:22:59 +0200 Subject: [PATCH 32/77] fix: preserve prior private payment boundary --- Bitkit/Models/PaykitPaymentStateBackup.swift | 8 ++++++++ Bitkit/Services/PaykitPaymentProofService.swift | 13 +++++++++---- Bitkit/Services/PrivatePaykitService+Payments.swift | 13 +++++++++++-- Bitkit/Views/Wallets/Send/SendSheet.swift | 4 +++- BitkitTests/PaykitPaymentRequestServiceTests.swift | 12 +++++++++--- 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index e35e9c958..bf71e5b9e 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -78,6 +78,7 @@ struct PaykitPaymentStateBackup: Codable { let hardwareTotalSpent: UInt64? let hardwareDispatchAttempted: Bool? let privatePaymentListVersion: UInt64? + let previousPrivatePaymentListVersion: UInt64? let onchainMatchingTransactionIdsBeforeAttempt: Set let onchainAcceptanceVerified: Bool? @@ -100,6 +101,7 @@ struct PaykitPaymentStateBackup: Codable { hardwareTotalSpent = proof.hardwareTotalSpent hardwareDispatchAttempted = proof.hardwareDispatchAttempted privatePaymentListVersion = proof.privatePaymentListVersion + previousPrivatePaymentListVersion = proof.previousPrivatePaymentListVersion onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] onchainAcceptanceVerified = proof.onchainAcceptanceVerified } @@ -137,7 +139,13 @@ struct PaykitPaymentStateBackup: Codable { proof.hardwareTotalSpent = hardwareTotalSpent proof.hardwareDispatchAttempted = hardwareDispatchAttempted } + if let previousPrivatePaymentListVersion { + guard let privatePaymentListVersion, previousPrivatePaymentListVersion < privatePaymentListVersion else { + throw invalidBackup("Invalid private payment-list boundary") + } + } proof.privatePaymentListVersion = privatePaymentListVersion + proof.previousPrivatePaymentListVersion = previousPrivatePaymentListVersion return proof } } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 73c12be56..d5c496194 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -63,6 +63,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var hardwareTotalSpent: UInt64? var hardwareDispatchAttempted: Bool? var privatePaymentListVersion: UInt64? + var previousPrivatePaymentListVersion: UInt64? var onchainMatchingTransactionIdsBeforeAttempt: Set? var onchainAcceptanceVerified: Bool? /// Device-local acknowledgement: backup restore reruns local activity proof. @@ -492,7 +493,8 @@ actor PaykitPaymentProofService { hardwareWalletId: String? = nil, paymentIdentity: String? = nil, signedTx: HwFundingSignedTx? = nil, - privatePaymentListVersion: UInt64? = nil + privatePaymentListVersion: UInt64? = nil, + previousPrivatePaymentListVersion: UInt64? = nil ) async throws { let identity = try await currentIdentity() try await mutationLock.withLock { @@ -504,7 +506,8 @@ actor PaykitPaymentProofService { identity: identity, serializedTx: signedTx?.serializedTx, signedTx: signedTx, - privatePaymentListVersion: privatePaymentListVersion + privatePaymentListVersion: privatePaymentListVersion, + previousPrivatePaymentListVersion: previousPrivatePaymentListVersion ) } } @@ -517,7 +520,8 @@ actor PaykitPaymentProofService { identity: String, serializedTx: String?, signedTx: HwFundingSignedTx?, - privatePaymentListVersion: UInt64? + privatePaymentListVersion: UInt64?, + previousPrivatePaymentListVersion: UInt64? ) async throws { if let hardwareWalletId { guard hardwareWalletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: hardwareWalletId) @@ -558,6 +562,7 @@ actor PaykitPaymentProofService { pendingProofs[index].hardwareTotalSpent = signedTx?.totalSpent pendingProofs[index].hardwareDispatchAttempted = signedTx.map { _ in false } pendingProofs[index].privatePaymentListVersion = privatePaymentListVersion + pendingProofs[index].previousPrivatePaymentListVersion = privatePaymentListVersion == nil ? nil : previousPrivatePaymentListVersion try await persist(pendingProofs) } @@ -978,7 +983,7 @@ actor PaykitPaymentProofService { }) else { return } // Only an exact, definitely unsent receipt authorizes releasing its consumed version. if let version = original.privatePaymentListVersion { - try await privatePaykitService.releaseUnsentPaymentListVersion(publicKey: request.counterparty, version: version) + try await privatePaykitService.releaseUnsentPaymentListVersion(publicKey: request.counterparty, version: version, previousVersion: original.previousPrivatePaymentListVersion) } try await persist(proofs.filter { $0 != original }) } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index c85374ba3..6aea5aaae 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -318,12 +318,21 @@ extension PrivatePaykitService { ) } + func consumedPaymentListVersion(publicKey: String, attemptId: UUID? = nil) -> UInt64? { + guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { return nil } + if let attemptId, let consumption = privatePaymentListConsumptions[PrivatePaymentListConsumptionKey(attemptId: attemptId, publicKey: key)] { + return consumption.previousPaymentListVersion + } + return state.contacts[key]?.consumedPrivatePaymentListVersion + } + /// The caller must establish an exact receipt that definitely never reached native dispatch. - func releaseUnsentPaymentListVersion(publicKey: String, version: UInt64) throws { + func releaseUnsentPaymentListVersion(publicKey: String, version: UInt64, previousVersion: UInt64? = nil) throws { guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version else { return } let previous = contact - contact.consumedPrivatePaymentListVersion = nil + guard previousVersion.map({ $0 < version }) ?? true else { throw PaykitPaymentRequestError.requestUnavailable } + contact.consumedPrivatePaymentListVersion = previousVersion state.contacts[key] = contact do { try persistStateOrThrow(markWalletBackup: true) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 61889079e..635dd214b 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -944,11 +944,13 @@ struct SendSheet: View { paymentEndpointIdentifier: endpointIdentifier, kind: .onchain ) + let previousVersion = await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: request.counterparty, attemptId: context?.id) do { try await prepareIncomingPaymentRequest(context: context) try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( request, address: address, hardwareWalletId: walletId, paymentIdentity: paymentIdentity, - signedTx: signedTx, privatePaymentListVersion: privateContext.paymentListVersion + signedTx: signedTx, privatePaymentListVersion: privateContext.paymentListVersion, + previousPrivatePaymentListVersion: previousVersion ) } catch { _ = await paykitPaymentRequestManager.paymentRequestForRetry(request.id) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index b8628559c..a9b202106 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4506,6 +4506,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let app = AppViewModel() XCTAssertTrue(app.claimContactPaymentContext(context)) let privateService = PrivatePaykitService() + let priorContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [endpoint: "bitkit"], paymentListVersion: 6) + try await privateService.consumePrivatePaymentList(publicKey: counterparty, context: priorContext, attemptId: UUID()) let store = HardwarePaymentProofMemoryStore() let identity = "pubky" + String(repeating: "z", count: 52) let walletId = "trezor:original-ios-wallet" @@ -4517,6 +4519,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { logWarning: { _ in } ) try await proofService.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + let previousVersion = await privateService.consumedPaymentListVersion(publicKey: counterparty) + XCTAssertEqual(previousVersion, 6) try await manager.prepareForPayment(request) { try await privateService.consumePrivatePaymentList(publicKey: counterparty, context: privateContext, attemptId: context.id) } @@ -4532,13 +4536,15 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { hardwareWalletId: walletId, paymentIdentity: identity, signedTx: signed, - privatePaymentListVersion: privateContext.paymentListVersion + privatePaymentListVersion: privateContext.paymentListVersion, + previousPrivatePaymentListVersion: previousVersion ) let snapshot = try await proofService.backupSnapshot() let encoded = try JSONEncoder().encode(snapshot) let decoded = try JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: encoded) let restored = try decoded.map { try $0.restored() } XCTAssertEqual(restored.first?.privatePaymentListVersion, 7) + XCTAssertEqual(restored.first?.previousPrivatePaymentListVersion, 6) try await store.save(restored) // A new private service loads durable consumption but has no in-memory attempt ledger. let restartedPrivateService = PrivatePaykitService() @@ -4558,8 +4564,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let remaining = try await store.load() XCTAssertTrue(remaining.isEmpty) let consumedAfter = await PrivatePaykitService().state.contacts[counterparty]?.consumedPrivatePaymentListVersion - XCTAssertNil(consumedAfter, - "Removing a definitely unsent proof must not strand its consumed private version after restart") + XCTAssertEqual(consumedAfter, 6, + "Cancelling the unsent version must preserve the previously consumed boundary") let newerContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [endpoint: "bitkit"], paymentListVersion: 8) try await restartedPrivateService.consumePrivatePaymentList(publicKey: counterparty, context: newerContext, attemptId: UUID()) try await restartedPrivateService.releaseUnsentPaymentListVersion(publicKey: counterparty, version: 7) From cbe12e97840ebe733093198fa846225d0b1a2648 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 18:52:21 +0200 Subject: [PATCH 33/77] fix: preserve private boundary on queued expiry --- .../Services/PaykitPaymentProofService.swift | 8 +- .../PaykitPaymentRequestServiceTests.swift | 84 +++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index d5c496194..718334cd6 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -625,7 +625,8 @@ actor PaykitPaymentProofService { /// Only the coordinator's definite first queued-dispatch expiry may remove this exact candidate. func clearHardwareCandidateBeforeDispatch( - requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, serializedTx: String + requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, serializedTx: String, + privatePaykitService: PrivatePaykitService = .shared ) async -> Bool { guard let identity = PubkyPublicKeyFormat.normalized(paymentIdentity), walletId != WalletScope.default, let txid = try? SignedTransactionId.fromHex(serializedTx) @@ -640,9 +641,10 @@ actor PaykitPaymentProofService { $0.proofData == nil && $0.onchainAcceptanceVerified != true }) else { return false } if let version = original.privatePaymentListVersion { - try await PrivatePaykitService.shared.releaseUnsentPaymentListVersion( + try await privatePaykitService.releaseUnsentPaymentListVersion( publicKey: original.requestId.counterparty, - version: version + version: version, + previousVersion: original.previousPrivatePaymentListVersion ) } try await persist(proofs.filter { $0 != original }) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index a9b202106..01da62a5d 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4573,6 +4573,90 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(newerVersion, 8, "Cleanup of the original receipt cannot release a newer version") } + func testQueuedHardwareExpiryRestoresPreviousConsumptionAfterRestart() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(counterparty: counterparty, endpoints: [endpoint])]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let privateContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [endpoint: "bitkit"], paymentListVersion: 7) + let context = ContactPaymentContext(publicKey: counterparty, privatePaymentContext: privateContext, incomingPaymentRequest: request) + let app = AppViewModel() + XCTAssertTrue(app.claimContactPaymentContext(context)) + let privateService = PrivatePaykitService() + let priorContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [endpoint: "bitkit"], paymentListVersion: 6) + try await privateService.consumePrivatePaymentList(publicKey: counterparty, context: priorContext, attemptId: UUID()) + let store = HardwarePaymentProofMemoryStore() + let identity = "pubky" + String(repeating: "z", count: 52) + let walletId = "trezor:original-ios-wallet" + let proofService = PaykitPaymentProofService( + sdk: sdk, + store: store, + hardwareTransactionLookup: PaymentProofHardwareLookup(result: .failure(NSError(domain: "fixture", code: 1))), + logInfo: { _ in }, + logWarning: { _ in } + ) + try await proofService.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + let previousVersion = await privateService.consumedPaymentListVersion(publicKey: counterparty) + XCTAssertEqual(previousVersion, 6) + try await manager.prepareForPayment(request) { + try await privateService.consumePrivatePaymentList(publicKey: counterparty, context: privateContext, attemptId: context.id) + } + let signed = HwFundingSignedTx( + serializedTx: "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300", + miningFeeSats: 141, + feeRate: 2, + totalSpent: request.amountSats + 141 + ) + try await proofService.markOnchainPaymentStarted( + request, + address: "bcrt1qhardwarecleanup", + hardwareWalletId: walletId, + paymentIdentity: identity, + signedTx: signed, + privatePaymentListVersion: privateContext.paymentListVersion, + previousPrivatePaymentListVersion: previousVersion + ) + let snapshot = try await proofService.backupSnapshot() + let encoded = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: encoded) + let restored = try decoded.map { try $0.restored() } + XCTAssertEqual(restored.first?.privatePaymentListVersion, 7) + XCTAssertEqual(restored.first?.previousPrivatePaymentListVersion, 6) + try await store.save(restored) + // A new private service loads durable consumption but has no in-memory attempt ledger. + let restartedPrivateService = PrivatePaykitService() + let consumedBefore = await restartedPrivateService.state.contacts[counterparty]?.consumedPrivatePaymentListVersion + XCTAssertEqual(consumedBefore, 7) + await store.failNextSave() + let failed = await proofService.clearHardwareCandidateBeforeDispatch( + requestId: request.id, paymentIdentity: identity, walletId: walletId, + serializedTx: signed.serializedTx, privatePaykitService: restartedPrivateService + ) + XCTAssertFalse(failed) + let retainedAfterFailure = try await store.load() + XCTAssertEqual(retainedAfterFailure, restored) + let cleared = await proofService.clearHardwareCandidateBeforeDispatch( + requestId: request.id, paymentIdentity: identity, walletId: walletId, + serializedTx: signed.serializedTx, privatePaykitService: restartedPrivateService + ) + XCTAssertTrue(cleared) + let remaining = try await store.load() + XCTAssertTrue(remaining.isEmpty) + let consumedAfter = await PrivatePaykitService().state.contacts[counterparty]?.consumedPrivatePaymentListVersion + XCTAssertEqual(consumedAfter, 6, + "Cancelling the unsent version must preserve the previously consumed boundary") + let newerContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [endpoint: "bitkit"], paymentListVersion: 8) + try await restartedPrivateService.consumePrivatePaymentList(publicKey: counterparty, context: newerContext, attemptId: UUID()) + try await restartedPrivateService.releaseUnsentPaymentListVersion(publicKey: counterparty, version: 7) + let newerVersion = await restartedPrivateService.state.contacts[counterparty]?.consumedPrivatePaymentListVersion + XCTAssertEqual(newerVersion, 8, "Cleanup of the original receipt cannot release a newer version") + } + + func testHardwareFailureCleanupPreservesConsumptionProofAndRetryOwnership() async throws { snapshotAppDefaultsDomain() let counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" From 309e5c998b69bbab526b34a6ce92de5af9638c67 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 19:44:09 +0200 Subject: [PATCH 34/77] fix: retain private payment version during cleanup --- .../Services/PaykitPaymentProofService.swift | 29 +++++++++++++++---- .../PrivatePaykitService+Payments.swift | 17 +++++++++++ .../PaykitPaymentRequestServiceTests.swift | 15 +++++++++- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 718334cd6..49fbdf36c 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -316,8 +316,9 @@ actor PaykitPaymentProofService { var pendingProofs = try await loadProofs() guard !pendingProofs.contains(where: { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && - $0.requestId == request.id && - ($0.paymentStarted || $0.paymentIdentifier != nil || $0.proofData != nil) + (($0.requestId == request.id && ($0.paymentStarted || $0.paymentIdentifier != nil || $0.proofData != nil)) || + (PubkyPublicKeyFormat.matches($0.requestId.counterparty, request.counterparty) && + $0.hardwareSignedTransaction != nil && $0.onchainAcceptanceVerified != true)) }) else { throw PaykitPaymentRequestError.operationInProgress } @@ -568,7 +569,8 @@ actor PaykitPaymentProofService { func retainedHardwareOnchainPayment( requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, - address: String, amountSats: UInt64 + address: String, amountSats: UInt64, + privatePaykitService: PrivatePaykitService = .shared ) async throws -> RetainedHardwareOnchainPayment? { let identity = try await currentIdentity() guard PubkyPublicKeyFormat.matches(identity, paymentIdentity), walletId != WalletScope.default, @@ -589,6 +591,9 @@ actor PaykitPaymentProofService { let fee = proof.hardwareMiningFeeSats, let rate = proof.hardwareFeeRate, let spent = proof.hardwareTotalSpent else { return nil } + if let version = proof.privatePaymentListVersion { + try await privatePaykitService.retainOriginalPaymentListVersion(publicKey: proof.requestId.counterparty, version: version) + } return RetainedHardwareOnchainPayment( signedTx: HwFundingSignedTx(serializedTx: raw, miningFeeSats: fee, feeRate: Float(rate), totalSpent: spent), hasAttemptedBroadcast: proof.hardwareDispatchAttempted != false @@ -647,7 +652,14 @@ actor PaykitPaymentProofService { previousVersion: original.previousPrivatePaymentListVersion ) } - try await persist(proofs.filter { $0 != original }) + do { + try await persist(proofs.filter { $0 != original }) + } catch { + if let version = original.privatePaymentListVersion { + try await privatePaykitService.retainOriginalPaymentListVersion(publicKey: original.requestId.counterparty, version: version) + } + throw error + } return true } } catch { return false } @@ -987,7 +999,14 @@ actor PaykitPaymentProofService { if let version = original.privatePaymentListVersion { try await privatePaykitService.releaseUnsentPaymentListVersion(publicKey: request.counterparty, version: version, previousVersion: original.previousPrivatePaymentListVersion) } - try await persist(proofs.filter { $0 != original }) + do { + try await persist(proofs.filter { $0 != original }) + } catch { + if let version = original.privatePaymentListVersion { + try await privatePaykitService.retainOriginalPaymentListVersion(publicKey: original.requestId.counterparty, version: version) + } + throw error + } } } catch { logWarning("Failed to clear definitely unsent hardware payment: \(error)") diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 6aea5aaae..f6995d082 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -326,6 +326,23 @@ extension PrivatePaykitService { return state.contacts[key]?.consumedPrivatePaymentListVersion } + /// Preserve the version captured by a retained signed receipt before it can be retried. + func retainOriginalPaymentListVersion(publicKey: String, version: UInt64) throws { + guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } + var contact = state.contacts[key, default: ContactState()] + if let consumed = contact.consumedPrivatePaymentListVersion, consumed >= version { return } + let previous = state.contacts[key] + contact.consumedPrivatePaymentListVersion = version + contact.cachedResolvedEndpoints.removeAll() + state.contacts[key] = contact + do { + try persistStateOrThrow(markWalletBackup: true) + } catch { + state.contacts[key] = previous + throw error + } + } + /// The caller must establish an exact receipt that definitely never reached native dispatch. func releaseUnsentPaymentListVersion(publicKey: String, version: UInt64, previousVersion: UInt64? = nil) throws { guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 8c5cae645..4930a96fd 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4647,9 +4647,22 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertFalse(failed) let retainedAfterFailure = try await store.load() XCTAssertEqual(retainedAfterFailure, restored) + let retainedVersion = await PrivatePaykitService().state.contacts[counterparty]?.consumedPrivatePaymentListVersion + XCTAssertEqual(retainedVersion, 7, "A failed deletion must retain consumption with the signed receipt") + // A process can restart after the version release but before proof removal finishes. + try await restartedPrivateService.releaseUnsentPaymentListVersion(publicKey: counterparty, version: 7, previousVersion: 6) + let recoveredPrivateService = PrivatePaykitService() + let recoveredReceipt = try await proofService.retainedHardwareOnchainPayment( + requestId: request.id, paymentIdentity: identity, walletId: walletId, + address: "bcrt1qhardwarecleanup", amountSats: request.amountSats, + privatePaykitService: recoveredPrivateService + ) + XCTAssertEqual(recoveredReceipt?.signedTx, signed) + let recoveredVersion = await PrivatePaykitService().state.contacts[counterparty]?.consumedPrivatePaymentListVersion + XCTAssertEqual(recoveredVersion, 7, "Restored receipt must consume its original version before retry") let cleared = await proofService.clearHardwareCandidateBeforeDispatch( requestId: request.id, paymentIdentity: identity, walletId: walletId, - serializedTx: signed.serializedTx, privatePaykitService: restartedPrivateService + serializedTx: signed.serializedTx, privatePaykitService: recoveredPrivateService ) XCTAssertTrue(cleared) let remaining = try await store.load() From 40e97a280198afd3a7558ea5d7898c29acb16b4b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 19:51:56 +0200 Subject: [PATCH 35/77] fix: keep accepted payments pending until completed --- .../Wallets/Send/SendConfirmationView.swift | 15 +++++++- .../OnchainSendAttemptServiceTests.swift | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index c5879cd54..8c6a6bbba 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -163,6 +163,16 @@ struct SendConfirmationView: View { !hwSend.isActive && !requiresPaymentConfirmation } + static func acceptedOnchainRoute( + txid: String, requestId: PaykitPaymentRequest.ID?, localFollowupComplete: Bool, + using attempts: OnchainSendAttemptService = .shared + ) async -> SendRoute { + guard localFollowupComplete else { + return await onchainPendingRoute(txid: txid, requestId: requestId, using: attempts) + } + return .success(paymentId: txid) + } + static func onchainPendingRoute( txid: String? = nil, requestId: PaykitPaymentRequest.ID?, using attempts: OnchainSendAttemptService = .shared @@ -1178,7 +1188,10 @@ struct SendConfirmationView: View { Logger.info("Onchain send result txid: \(txid)") - navigationPath.append(.success(paymentId: txid)) + await navigationPath.append(Self.acceptedOnchainRoute( + txid: txid, requestId: incomingPaymentRequest?.id, + localFollowupComplete: proofSaved && savedActivity != nil + )) } else { throw NSError( domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid payment method or missing invoice data"] diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index d2d10478b..1d13ff23d 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -7,6 +7,43 @@ final class OnchainSendAttemptServiceTests: XCTestCase { private let walletId = "node-0" private let txid = String(repeating: "ab", count: 32) + func testAcceptedConfirmationStaysPendingUntilOriginalFollowupCompletes() async throws { + let shopRequest = PaykitPaymentRequest.ID(paymentRequestId: "original-shop-order", counterparty: "original-merchant", billingPeriodStartsAt: nil) + for requestId in [nil, shopRequest] as [PaykitPaymentRequest.ID?] { + let store = MemoryAttemptStore() + let original = OnchainSendAttempt(id: UUID(), walletId: walletId, requestId: requestId, orderId: nil, + address: "bcrt1qoriginal", amountSats: 1000, isMaxAmount: false, + status: .accepted, txid: txid) + try store.save([original]) + let service = OnchainSendAttemptService(store: store) + let route = await SendConfirmationView.acceptedOnchainRoute( + txid: txid, requestId: requestId, localFollowupComplete: false, using: service + ) + switch route { + case let .onchainPending(context): + XCTAssertNil(requestId) + XCTAssertEqual(context.attemptId, original.id) + XCTAssertEqual(context.walletId, original.walletId) + XCTAssertEqual(context.txid, txid) + case let .onchainOperationPending(context, actualRequest): + XCTAssertEqual(actualRequest, requestId) + XCTAssertEqual(context.attemptId, original.id) + XCTAssertEqual(context.walletId, original.walletId) + XCTAssertEqual(context.txid, txid) + default: XCTFail("Accepted payment with incomplete local follow-up must stay Pending") + } + XCTAssertEqual(store.snapshot(), [original]) + let completedRoute = await SendConfirmationView.acceptedOnchainRoute( + txid: txid, requestId: requestId, localFollowupComplete: true, using: service + ) + guard case let .success(paymentId, _) = completedRoute else { + XCTFail("Durably completed payment must show Sent") + continue + } + XCTAssertEqual(paymentId, txid) + } + } + func testBackupDefersUnsignedOrdinaryAndTransferAdmissions() async throws { let wallet = PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet( kind: "software", network: "regtest", binding: String(repeating: "12", count: 32), sourceIndex: "0" From a4eacf2e5d59a191e7e20bde7bcb0790d92151e0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 20:23:05 +0200 Subject: [PATCH 36/77] fix: finish shop sends only after durable local follow-up --- .../Services/PaykitPaymentProofService.swift | 11 ++-- Bitkit/ViewModels/HwFundingSigner.swift | 3 ++ BitkitTests/HwFundingSignerTests.swift | 22 ++++++++ .../PaykitPaymentProofServiceTests.swift | 52 +++++++++++++++++-- 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 49fbdf36c..f29bc1b36 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -920,9 +920,11 @@ actor PaykitPaymentProofService { return (true, proofs[index]) } guard result.0 else { return false } - await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) if let completed = result.1 { submitInBackground(completed) + } + guard await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) else { return false } + if let completed = result.1 { Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( identity: completed.identity, requestId: requestId, transactionId: txid.lowercased() )) @@ -1050,7 +1052,7 @@ actor PaykitPaymentProofService { }) } if hasDurableProof { - await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) + _ = await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) } } pendingProofs = await removingSettledUnsupportedWalletProofs(from: pendingProofs, identity: identity) @@ -1110,11 +1112,12 @@ actor PaykitPaymentProofService { } } - private func resumeAcceptedRequestFollowup(requestId: PaykitPaymentRequest.ID, txid: String) async { + private func resumeAcceptedRequestFollowup(requestId: PaykitPaymentRequest.ID, txid: String) async -> Bool { do { - _ = try await attemptService.resumeAcceptedRequestSend(requestId: requestId, txid: txid) + return try await attemptService.resumeAcceptedRequestSend(requestId: requestId, txid: txid) } catch { logWarning("Accepted Paykit payment local follow-up remains guarded: \(error)") + return false } } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 74ea730c3..53137f458 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -631,6 +631,9 @@ final class HwSendCoordinator { throw error } } catch { + if !broadcastWasAttempted { + pendingPayment = nil + } await afterFailure(broadcastWasAttempted ? .uncertain : .definitePreBroadcastFailure) throw error } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index df99ad28f..38d89defd 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -959,6 +959,28 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastCalls, 0) } + func testHardwareCandidateSaveFailureRetriesFreshPreparation() async throws { + let funding = MockHwFunding() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original", counterparty: "merchant") + var preparations = 0 + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: HwWalletManager(), address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentRequestId: requestId, + beforeFirstBroadcast: { _ in preparations += 1 }, + retainSignedPayment: { _ in throw MockHwFunding.TestError() } + ) + } + _ = try await coordinator.signAndBroadcast( + manager: HwWalletManager(), address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentRequestId: requestId, + beforeFirstBroadcast: { _ in preparations += 1 } + ) + XCTAssertEqual(preparations, 2) + XCTAssertEqual(funding.broadcastCalls, 1) + } + func testCoordinatorCannotBeLeftWhileABroadcastIsUnresolved() async throws { let funding = MockHwFunding() let broadcast = AsyncGate() diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 582217f7d..3da376fd6 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -54,8 +54,16 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let sdk = PaymentProofSdkMock(identity: identity, records: [record]) await sdk.setSubmissionFailure(true) let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) - let completed = await service.completeOnchainPayment(request, txid: sender.txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) - XCTAssertEqual(completed, mismatch == 0) + let completed = await service.completeOnchainPayment( + request, + txid: sender.txid, + paymentAppId: "bitkit", + paymentEndpointIdentifier: endpoint + ) + XCTAssertFalse( + completed, + "Local follow-up deliberately fails; proof binding alone cannot finish the send" + ) let saved = await store.snapshot() if mismatch == 0 { XCTAssertEqual(saved.first?.paymentIdentifier, sender.txid) @@ -1123,7 +1131,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await attempts.record(.accepted(txid: txid), attemptId: id) await sdk.setSubmissionFailure(true) let completed = await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) - XCTAssertTrue(completed) + XCTAssertFalse(completed, "No local follow-up receipt exists until the explicit acknowledgement below") await sdk.waitForSubmissionStart() await sdk.setIdentityAvailable(false) try await attempts.acknowledgeLocalFollowup(txid: txid) @@ -1265,6 +1273,35 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try? FileManager.default.removeItem(at: dbPath) } + func testAcceptedShopResolutionWaitsForDurableLocalFollowup() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let attemptStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptStore, localFollowup: FailingShopActivityFollowup()) + let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) + let txid = String(repeating: "ab", count: 32) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + _ = try await attempts.send( + using: AttemptNodeMock(result: .accepted(txid: txid)), address: onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, requestId: request.id, + followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100) + ) { try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) } + var resolutions: [PaykitOnchainPaymentResolution] = [] + let subscription = PaykitPaymentProofService.onchainPaymentResolutionPublisher.sink { resolution in + if resolution.requestId == request.id { + resolutions.append(resolution) + } + } + defer { subscription.cancel() } + let completed = await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) + XCTAssertFalse(completed) + XCTAssertTrue(resolutions.isEmpty) + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false) + } + func testAcceptedResultStorageFailureStillCompletesProofWithExactTxid() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -1333,7 +1370,14 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ) XCTAssertEqual(resumed, .accepted(txid: txid)) let retrySaved = await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) - XCTAssertTrue(retrySaved) + XCTAssertFalse(retrySaved) + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false) + await sdk.waitForSubmissionStart() + let submitted = await sdk.lastSubmission() + XCTAssertEqual(try proofValues(XCTUnwrap(submitted).proof.exportText()), ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue]) + try await attempts.acknowledgeLocalFollowup(txid: txid) + let completed = await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) + XCTAssertTrue(completed) XCTAssertEqual(node.calls, 1) } From b452a490a3c42bcf2971fe5255e1b08ba66a6da9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 21:42:01 +0200 Subject: [PATCH 37/77] fix: finish pending shop payments after recovery --- Bitkit/Services/PaykitPaymentProofService.swift | 6 ++++-- .../PaykitPaymentProofServiceTests.swift | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index f29bc1b36..4e3354691 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1051,8 +1051,10 @@ actor PaykitPaymentProofService { Self.hasExactOnchainProof(requestId: requestId, txid: txid, in: $0) }) } - if hasDurableProof { - _ = await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) + if hasDurableProof, await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) { + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: identity, requestId: requestId, transactionId: txid.lowercased() + )) } } pendingProofs = await removingSettledUnsupportedWalletProofs(from: pendingProofs, identity: identity) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 3da376fd6..6dd7475fc 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1259,11 +1259,24 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let deliveredProofs = await store.snapshot() XCTAssertTrue(deliveredProofs.isEmpty) XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false) + var isObservingResolutions = false + var resolutions: [PaykitOnchainPaymentResolution] = [] + let subscription = PaykitPaymentProofService.onchainPaymentResolutionPublisher.sink { resolution in + if isObservingResolutions, resolution.requestId == request.id { + resolutions.append(resolution) + } + } + isObservingResolutions = true + defer { subscription.cancel() } let restarted = paymentProofService( sdk: sdk, store: store, attemptService: OnchainSendAttemptService(store: attemptStore) ) await restarted.reconcile() XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, true) + XCTAssertEqual(resolutions.map(\.transactionId), [txid]) + XCTAssertEqual(resolutions.first?.identity, identity) + await restarted.reconcile() + XCTAssertEqual(resolutions.count, 1) let saved = try await Bitkit.CoreService.shared.activity.getOnchainActivityByTxId(txid: txid) XCTAssertEqual(saved?.value, request.amountSats) XCTAssertEqual(saved?.address, onchainAddress) @@ -1289,12 +1302,14 @@ final class PaykitPaymentProofServiceTests: XCTestCase { satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, requestId: request.id, followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100) ) { try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) } + var isObservingResolutions = false var resolutions: [PaykitOnchainPaymentResolution] = [] let subscription = PaykitPaymentProofService.onchainPaymentResolutionPublisher.sink { resolution in - if resolution.requestId == request.id { + if isObservingResolutions, resolution.requestId == request.id { resolutions.append(resolution) } } + isObservingResolutions = true defer { subscription.cancel() } let completed = await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) XCTAssertFalse(completed) From 132848fb4b7769827186c25fed105c98de7a6ee3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 22:11:06 +0200 Subject: [PATCH 38/77] fix: keep ambiguous payment candidates pending --- .../Services/OnchainSendAttemptService.swift | 11 +++++-- Bitkit/ViewModels/WalletViewModel.swift | 8 ++--- .../OnchainSendAttemptServiceTests.swift | 30 ++++++++++++++++++- .../TransferServiceActivityTests.swift | 4 +-- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 11678e640..fccb3545e 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -871,8 +871,15 @@ actor OnchainSendAttemptService { } @discardableResult - func observeTransaction(txid: String, walletId: String) throws -> Bool { - guard try currentAttempt()?.walletId == walletId else { return false } + func observeTransaction(txid: String, walletId: String, isConfirmed: Bool = false) throws -> Bool { + guard let attempt = try currentAttempt(), attempt.walletId == walletId else { return false } + // Received events can be queued for an original that a retained retry replaces. + // With multiple unresolved candidates, only confirmation establishes the winner. + if !isConfirmed, attempt.status != .accepted, + (attempt.recoveryContext?.candidateTxids.count ?? 0) > 1 + { + return false + } return try observeConfirmedTransaction(txid: txid) } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index c741b7c5b..edc147c92 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -127,10 +127,10 @@ class WalletViewModel: ObservableObject { Logger.warn("Accepted order local follow-up remains guarded: \(error)", context: "WalletViewModel") } } - let onchainObservation: @Sendable (String) async -> Void = { txid in + let onchainObservation: @Sendable (String, Bool) async -> Void = { txid, isConfirmed in let walletId = OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex) do { - guard try await onchainAttemptService.observeTransaction(txid: txid, walletId: walletId) else { return } + guard try await onchainAttemptService.observeTransaction(txid: txid, walletId: walletId, isConfirmed: isConfirmed) else { return } _ = try await onchainAttemptService.resumeAcceptedOrdinarySend(walletId: walletId, observedTxid: txid) _ = try await onchainAttemptService.resumeAcceptedTransfer(walletId: walletId, using: transferService) await PaykitPaymentProofService.shared.reconcile() @@ -138,8 +138,8 @@ class WalletViewModel: ObservableObject { Logger.warn("Observed payment local follow-up remains guarded: \(error)", context: "WalletViewModel") } } - lightningService.onchainTransactionReceived = onchainObservation - lightningService.onchainTransactionConfirmed = onchainObservation + lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } + lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } } /// Convenience initializer for previews and testing diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 1d13ff23d..1c744909d 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -230,6 +230,34 @@ final class OnchainSendAttemptServiceTests: XCTestCase { XCTAssertThrowsError(try OnchainSendLocalFollowup.exactFee(details: details, previous: { _ in wrong })) } + func testReceivedOriginalCannotWinWhileReplacementIsUnresolved() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + let originalTxid = try XCTUnwrap(original.txid) + sender.txid = String(repeating: "cd", count: 32) + _ = try await service.retrySamePayment(using: sender, + context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), + satsPerVbyte: 2, authorize: { _, _ in }) + let before = store.snapshot() + let received = try await service.observeTransaction(txid: originalTxid, walletId: original.walletId) + XCTAssertFalse(received, "An unconfirmed original must not finalize a multi-candidate payment") + XCTAssertEqual(store.snapshot(), before) + let confirmed = try await service.observeConfirmedTransaction(txid: sender.txid) + XCTAssertTrue(confirmed) + let winner = try XCTUnwrap(store.snapshot().first) + XCTAssertEqual(winner.id, original.id) + XCTAssertEqual(winner.walletId, original.walletId) + XCTAssertEqual(winner.amountSats, original.amountSats) + XCTAssertEqual(winner.recoveryContext?.inputs, original.recoveryContext?.inputs) + XCTAssertEqual(winner.txid, sender.txid) + XCTAssertEqual(winner.status, .accepted) + XCTAssertFalse(winner.localFollowupComplete) + } + func testOriginalWinnerRetainsItsRateAfterHigherFeeSuccessorIsPrepared() async throws { let store = MemoryAttemptStore() let followup = CapturingWinningFeeFollowup() @@ -244,7 +272,7 @@ final class OnchainSendAttemptServiceTests: XCTestCase { _ = try await service.retrySamePayment(using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), satsPerVbyte: 2, authorize: { _, _ in }) - _ = try await service.observeTransaction(txid: originalTxid, walletId: original.walletId) + _ = try await service.observeTransaction(txid: originalTxid, walletId: original.walletId, isConfirmed: true) do { _ = try await service.resumeAcceptedOrdinarySend(walletId: original.walletId) } catch {} XCTAssertEqual(followup.attempt?.followupContext?.feeRate, 1) XCTAssertEqual(followup.attempt?.followupContext?.feeSats, 143) diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index b762fff96..63fd86b04 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -777,8 +777,8 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(restored.txid, String(repeating: "cd", count: 32)) XCTAssertEqual(restored.status, .unknown) XCTAssertFalse(restored.localFollowupComplete) - // This is the exact original-wallet received observation, not queued local activity. - let observed = try await attempts.observeTransaction(txid: winner, walletId: restored.walletId) + // Multiple restored candidates require exact original-wallet confirmation, not a queued received event. + let observed = try await attempts.observeTransaction(txid: winner, walletId: restored.walletId, isConfirmed: true) XCTAssertTrue(observed) let context = OnchainSendPendingContext(attemptId: restored.id, walletId: restored.walletId, txid: restored.txid) await sdk.setIdentity("pubky" + String(repeating: "y", count: 52)) From 5ef04eb3370e8d1139d07858e00ae57d5c0604f3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 22:40:45 +0200 Subject: [PATCH 39/77] fix: complete recovered shop payments once --- .../Services/OnchainSendAttemptService.swift | 21 ++++-- .../Services/PaykitPaymentProofService.swift | 8 +- .../PaykitPaymentProofServiceTests.swift | 74 ++++++++++++++++++- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index fccb3545e..768afb9ca 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -316,6 +316,7 @@ actor OnchainSendAttemptService { private let hasPaidOrder: (String) throws -> Bool private var knownAttempt: OnchainSendAttempt? private var nativeDispatchInProgress: UUID? + private var requestFollowupInProgress: UUID? init( store: any OnchainSendAttemptStoring = OnchainSendAttemptStore(), @@ -720,15 +721,21 @@ actor OnchainSendAttemptService { } @discardableResult - func resumeAcceptedRequestSend(requestId: PaykitPaymentRequest.ID, txid: String) async throws -> Bool { - guard nativeDispatchInProgress == nil, let attempt = try currentAttempt(), attempt.requestId == requestId, attempt.orderId == nil, + func resumeAcceptedRequestSend(requestId: PaykitPaymentRequest.ID, txid: String, + onlyIfIncomplete: Bool = false) async throws -> Bool + { + guard nativeDispatchInProgress == nil, requestFollowupInProgress == nil, + let attempt = try currentAttempt(), attempt.requestId == requestId, attempt.orderId == nil, attempt.status == .accepted, attempt.txid?.caseInsensitiveCompare(txid) == .orderedSame else { return false } - if !attempt.localFollowupComplete { - let winner = try await winningFollowupAttempt(attempt) - _ = try await localFollowup.save(winner) - try acknowledgeLocalFollowup(txid: txid) - } + if attempt.localFollowupComplete { return !onlyIfIncomplete } + // Keep the transition owned across suspending activity/fee operations. A competing + // reconciliation must neither repeat the write nor publish this call's completion. + requestFollowupInProgress = attempt.id + defer { requestFollowupInProgress = nil } + let winner = try await winningFollowupAttempt(attempt) + _ = try await localFollowup.save(winner) + try acknowledgeLocalFollowup(txid: txid) return true } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 4e3354691..807d8f93d 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1051,7 +1051,7 @@ actor PaykitPaymentProofService { Self.hasExactOnchainProof(requestId: requestId, txid: txid, in: $0) }) } - if hasDurableProof, await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid) { + if hasDurableProof, await resumeAcceptedRequestFollowup(requestId: requestId, txid: txid, onlyIfIncomplete: true) { Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( identity: identity, requestId: requestId, transactionId: txid.lowercased() )) @@ -1114,9 +1114,11 @@ actor PaykitPaymentProofService { } } - private func resumeAcceptedRequestFollowup(requestId: PaykitPaymentRequest.ID, txid: String) async -> Bool { + private func resumeAcceptedRequestFollowup(requestId: PaykitPaymentRequest.ID, txid: String, + onlyIfIncomplete: Bool = false) async -> Bool + { do { - return try await attemptService.resumeAcceptedRequestSend(requestId: requestId, txid: txid) + return try await attemptService.resumeAcceptedRequestSend(requestId: requestId, txid: txid, onlyIfIncomplete: onlyIfIncomplete) } catch { logWarning("Accepted Paykit payment local follow-up remains guarded: \(error)") return false diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 6dd7475fc..1b8c518ac 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1119,7 +1119,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let attemptStore = MemoryAttemptStore() - let attempts = OnchainSendAttemptService(store: attemptStore) + let attempts = OnchainSendAttemptService(store: attemptStore, localFollowup: FailingShopActivityFollowup()) let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) let txid = String(repeating: "ab", count: 32) try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) @@ -1286,6 +1286,50 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try? FileManager.default.removeItem(at: dbPath) } + func testOverlappingReconciliationCompletesOriginalShopFollowupOnlyOnce() async throws { + let dbPath = FileManager.default.temporaryDirectory.appendingPathComponent("ShopOverlap-\(UUID())") + await drainCoreServiceQueue() + try FileManager.default.createDirectory(at: dbPath, withIntermediateDirectories: true) + _ = try initDb(basePath: dbPath.path) + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let txid = String(repeating: "ab", count: 32) + let proof = try paymentProofRecord(endpoint: endpoint, kind: .onchain, data: txid) + let record = try paymentRequestRecord(endpoints: [endpoint], paymentProofs: [proof]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let firstSaveStarted = expectation(description: "Original local completion suspended") + let followup = SuspendedShopActivityFollowup { firstSaveStarted.fulfill() } + let attemptStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptStore, localFollowup: followup) + _ = try await attempts.send( + using: AttemptNodeMock(result: .accepted(txid: txid)), address: onchainAddress, + amountSats: request.amountSats, satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, requestId: request.id, + followupContext: .init(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100) + ) + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: PaymentProofMemoryStore(), attemptService: attempts + ) + var isObservingResolutions = false + var resolutions: [PaykitOnchainPaymentResolution] = [] + let subscription = PaykitPaymentProofService.onchainPaymentResolutionPublisher.sink { resolution in + if isObservingResolutions, resolution.requestId == request.id { resolutions.append(resolution) } + } + isObservingResolutions = true + defer { subscription.cancel() } + let first = Task { await service.reconcile() } + await fulfillment(of: [firstSaveStarted], timeout: 2) + await service.reconcile() + await followup.resumeFirstSave() + await first.value + let saves = await followup.saveCount() + XCTAssertEqual(saves, 1, "Overlapping reconciliation must not repeat the original activity write") + XCTAssertEqual(resolutions.map(\.transactionId), [txid]) + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, true) + await service.reconcile() + XCTAssertEqual(resolutions.count, 1) + await repointCoreToAppStorage() + try? FileManager.default.removeItem(at: dbPath) + } + func testAcceptedShopResolutionWaitsForDurableLocalFollowup() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -1362,7 +1406,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) let attemptStore = MemoryAttemptStore() - let attempts = OnchainSendAttemptService(store: attemptStore) + let attempts = OnchainSendAttemptService(store: attemptStore, localFollowup: FailingShopActivityFollowup()) let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) @@ -2347,6 +2391,32 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } +private actor SuspendedShopActivityFollowup: OnchainSendLocalFollowupHandling { + private let onFirstSave: @Sendable () -> Void + private var saves = 0 + private var firstContinuation: CheckedContinuation? + + init(onFirstSave: @escaping @Sendable () -> Void) { self.onFirstSave = onFirstSave } + + func save(_ attempt: OnchainSendAttempt) async throws -> OnchainActivity { + saves += 1 + if saves == 1 { + await withCheckedContinuation { continuation in + firstContinuation = continuation + onFirstSave() + } + } + return try await OnchainSendLocalFollowup().save(attempt) + } + + func resumeFirstSave() { + firstContinuation?.resume() + firstContinuation = nil + } + + func saveCount() -> Int { saves } +} + private struct FailingShopActivityFollowup: OnchainSendLocalFollowupHandling { func save(_: OnchainSendAttempt) async throws -> OnchainActivity { throw OnchainSendAttemptError.localFollowupNotSaved From d5dcac6673be1c76d1bc22c981efc2514dd20d01 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 23:09:31 +0200 Subject: [PATCH 40/77] fix: defer inconsistent payment backups --- .../Services/PaykitPaymentProofService.swift | 24 ++++++--- .../PaykitPaymentProofServiceTests.swift | 53 ++++++++++++++++++- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 807d8f93d..a033e67e3 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -229,12 +229,24 @@ actor PaykitPaymentProofService { } func backupSnapshot(wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet) async throws -> PaykitPaymentStateBackup { - let proofs = try await store.load() - let active = try await attemptService.backupSnapshot(wallet: wallet, proofs: proofs) - return try PaykitPaymentStateBackup(subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), - pendingProofs: proofs.map(PaykitPaymentStateBackup.Proof.init), activeOnchainAttempt: active, - acceptedOneTimeRequests: PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) - .backupSnapshot()) + try await mutationLock.withLock { + let proofs = try await store.load() + let active = try await attemptService.backupSnapshot(wallet: wallet, proofs: proofs) + // An attempt can clear before its proof cleanup acquires this lock. Defer that + // transition gap instead of uploading a started proof without its recovery guard. + for proof in proofs where proof.kind == .onchain && !proof.hasUnsupportedOnchainWallet && + proof.paymentStarted && proof.onchainAcceptanceVerified != true + { + guard active?.requestId?.restored(billingPeriod: proof.billingPeriod) == proof.requestId else { + throw OnchainSendAttemptError.unresolved + } + } + guard try await store.load() == proofs else { throw OnchainSendAttemptError.unresolved } + return try PaykitPaymentStateBackup(subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), + pendingProofs: proofs.map(PaykitPaymentStateBackup.Proof.init), activeOnchainAttempt: active, + acceptedOneTimeRequests: PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) + .backupSnapshot()) + } } func restoreBackup(_ state: PaykitPaymentStateBackup, wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet) async throws { diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 1b8c518ac..1dd0c4b8c 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1286,6 +1286,38 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try? FileManager.default.removeItem(at: dbPath) } + func testBackupDefersStartedProofWhenOriginalAttemptClearsDuringCapture() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let attempts = OnchainSendAttemptService(store: MemoryAttemptStore()) + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store, attemptService: attempts + ) + let wallet = PaykitPaymentStateBackupTests.goldenWallet(index: 0) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + let id = try await attempts.admit( + walletId: XCTUnwrap(wallet.originalWalletId), requestId: request.id, orderId: nil, + address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false + ) + let captured = expectation(description: "Backup captured the original started proof") + await store.pauseNextLoad { captured.fulfill() } + let backup = Task { try await service.backupSnapshot(wallet: wallet) } + await fulfillment(of: [captured], timeout: 2) + try await attempts.clearBeforeDispatch(attemptId: id) + await store.seed([]) + await store.resumePausedLoad() + do { + _ = try await backup.value + XCTFail("Backup must defer rather than upload an orphaned started proof") + } catch {} + let settled = try await service.backupSnapshot(wallet: wallet) + XCTAssertTrue(settled.pendingProofs.isEmpty) + XCTAssertNil(settled.activeOnchainAttempt) + } + func testOverlappingReconciliationCompletesOriginalShopFollowupOnlyOnce() async throws { let dbPath = FileManager.default.temporaryDirectory.appendingPathComponent("ShopOverlap-\(UUID())") await drainCoreServiceQueue() @@ -2428,17 +2460,34 @@ actor PaymentProofMemoryStore: PaykitPaymentProofStoring { private var shouldFailNextLoad = false private var shouldFailNextSave = false private let onSave: @Sendable ([PendingPaykitPaymentProof]) -> Void + private var pausedLoad: CheckedContinuation? + private var onPausedLoad: (@Sendable () -> Void)? init(onSave: @escaping @Sendable ([PendingPaykitPaymentProof]) -> Void = { _ in }) { self.onSave = onSave } - func load() throws -> [PendingPaykitPaymentProof] { + func load() async throws -> [PendingPaykitPaymentProof] { if shouldFailNextLoad { shouldFailNextLoad = false throw PaymentProofStoreMockError.load } - return proofs + let snapshot = proofs + if let onPausedLoad { + self.onPausedLoad = nil + await withCheckedContinuation { continuation in + pausedLoad = continuation + onPausedLoad() + } + } + return snapshot + } + + func pauseNextLoad(onCaptured: @escaping @Sendable () -> Void) { onPausedLoad = onCaptured } + + func resumePausedLoad() { + pausedLoad?.resume() + pausedLoad = nil } func save(_ proofs: [PendingPaykitPaymentProof]) throws { From b27a96cb5ac16d820ba0f056102015d89193dc08 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 23:39:03 +0200 Subject: [PATCH 41/77] fix: stop cancelled hardware sends before submission --- Bitkit/ViewModels/HwFundingSigner.swift | 13 +++++ BitkitTests/HwFundingSignerTests.swift | 65 +++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 53137f458..c49637cee 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -586,8 +586,11 @@ final class HwSendCoordinator { do { do { try await beforeBroadcastAttempt() + try Task.checkCancellation() + guard signingAttempt == attempt else { throw CancellationError() } try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } catch { + guard signingAttempt == attempt else { throw error } if !broadcastWasAttempted { pendingPayment = nil } else if ((error as? AppError)?.underlyingError ?? error) as? PaykitPaymentRequestError == .requestExpired { @@ -597,6 +600,15 @@ final class HwSendCoordinator { } try await retainSignedPayment(signed) + do { + try Task.checkCancellation() + guard signingAttempt == attempt else { throw CancellationError() } + } catch { + if !hadPriorBroadcastAttempt { + _ = await clearSignedPaymentBeforeDispatch(signed) + } + throw error + } isBroadcastUnresolved = true broadcastWasAttempted = true pendingPayment?.hasBroadcastAttempted = true @@ -631,6 +643,7 @@ final class HwSendCoordinator { throw error } } catch { + guard signingAttempt == attempt else { throw error } if !broadcastWasAttempted { pendingPayment = nil } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 38d89defd..ae8068009 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -1092,6 +1092,71 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertFalse(coordinator.isSigning) } + func testCancelledAuthorizationOrRetentionCannotBroadcastOrResetANewerAttempt() async throws { + for cancelDuringRetention in [false, true] { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let suspended = AsyncGate() + let nextPreparation = AsyncGate() + var suspensionStarted = false + var nextStarted = false + var retained = 0 + var cleared = 0 + var staleFailures = 0 + let first = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + beforeBroadcastAttempt: { + if !cancelDuringRetention { + suspensionStarted = true + await suspended.wait() + } + }, + retainSignedPayment: { _ in + retained += 1 + if cancelDuringRetention { + suspensionStarted = true + await suspended.wait() + } + }, + clearSignedPaymentBeforeDispatch: { _ in + cleared += 1 + return true + }, + afterFailure: { _ in staleFailures += 1 } + ) + } + await waitUntil { suspensionStarted } + XCTAssertTrue(suspensionStarted) + coordinator.cancel() + let second = Task { + try await self.signAndBroadcast(coordinator, manager: manager) { _ in + nextStarted = true + await nextPreparation.wait() + } + } + await waitUntil { nextStarted } + XCTAssertTrue(nextStarted) + suspended.open() + await assertThrowsAsync { + _ = try await first.value + } _: { error in + XCTAssertTrue(error is CancellationError, "\(error)") + } + XCTAssertEqual(funding.broadcastCalls, 0, "the abandoned payment must never submit") + XCTAssertEqual(retained, cancelDuringRetention ? 1 : 0) + XCTAssertEqual(cleared, cancelDuringRetention ? 1 : 0) + XCTAssertEqual(staleFailures, 0, "the old callback must not cancel the new payment") + XCTAssertTrue(coordinator.isSigning) + XCTAssertTrue(coordinator.hasPendingBroadcast) + XCTAssertFalse(coordinator.isBroadcastUnresolved) + nextPreparation.open() + _ = try await second.value + XCTAssertEqual(funding.broadcastCalls, 1) + } + } + func testCancelWithNothingInFlightKeepsTheSession() async throws { let funding = MockHwFunding() let connecting = MockHwConnecting() From 6a5f8e1316f43d4b958c62e41442023a70ee58c9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 00:09:23 +0200 Subject: [PATCH 42/77] fix: keep hardware receipt cleanup serialized --- Bitkit/ViewModels/HwFundingSigner.swift | 29 +++-- BitkitTests/HwFundingSignerTests.swift | 158 +++++++++++++++++------- 2 files changed, 132 insertions(+), 55 deletions(-) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index c49637cee..d3c4b5c59 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -368,6 +368,7 @@ final class HwSendCoordinator { private var operationTask: Task? private var operationRequest: PaymentRequest? private var operationSession: OperationSession? + private var isRetainingSignedPayment = false /// Bumped by every sign attempt and every cancel. A cancelled task can keep running until its /// device call returns, and must not write over the state of an attempt started after it. private var signingAttempt = 0 @@ -599,15 +600,26 @@ final class HwSendCoordinator { throw error } - try await retainSignedPayment(signed) do { - try Task.checkCancellation() - guard signingAttempt == attempt else { throw CancellationError() } - } catch { - if !hadPriorBroadcastAttempt { - _ = await clearSignedPaymentBeforeDispatch(signed) + // Cancellation keeps this operation's ownership until its receipt is either + // retained for submission or durably cleared. No newer task may reuse it meanwhile. + isRetainingSignedPayment = true + defer { isRetainingSignedPayment = false } + try await retainSignedPayment(signed) + do { + try Task.checkCancellation() + guard signingAttempt == attempt else { throw CancellationError() } + } catch { + if !hadPriorBroadcastAttempt { + let cleared = await clearSignedPaymentBeforeDispatch(signed) + if !cleared { + isBroadcastUnresolved = true + broadcastWasAttempted = true + pendingPayment?.hasBroadcastAttempted = true + } + } + throw error } - throw error } isBroadcastUnresolved = true broadcastWasAttempted = true @@ -732,6 +744,9 @@ final class HwSendCoordinator { guard !isBroadcastUnresolved else { return } let abandonedSession = operationSession operationTask?.cancel() + // Receipt persistence/cleanup is suspended work. Keep the cancelled operation registered + // until it finishes, so a concurrent send joins it instead of adopting its receipt. + guard !isRetainingSignedPayment else { return } signingAttempt += 1 operationTask = nil operationRequest = nil diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index ae8068009..af50d8a2a 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -1092,68 +1092,130 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertFalse(coordinator.isSigning) } - func testCancelledAuthorizationOrRetentionCannotBroadcastOrResetANewerAttempt() async throws { - for cancelDuringRetention in [false, true] { + func testCancelledAuthorizationCannotBroadcastOrResetANewerAttempt() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let suspended = AsyncGate() + let nextPreparation = AsyncGate() + var suspensionStarted = false + var nextStarted = false + var retained = 0 + var cleared = 0 + var staleFailures = 0 + let first = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + beforeBroadcastAttempt: { + suspensionStarted = true + await suspended.wait() + }, + retainSignedPayment: { _ in + retained += 1 + }, + clearSignedPaymentBeforeDispatch: { _ in + cleared += 1 + return true + }, + afterFailure: { _ in staleFailures += 1 } + ) + } + await waitUntil { suspensionStarted } + XCTAssertTrue(suspensionStarted) + coordinator.cancel() + let second = Task { + try await self.signAndBroadcast(coordinator, manager: manager) { _ in + nextStarted = true + await nextPreparation.wait() + } + } + await waitUntil { nextStarted } + XCTAssertTrue(nextStarted) + suspended.open() + await assertThrowsAsync { + _ = try await first.value + } _: { error in + XCTAssertTrue(error is CancellationError, "\(error)") + } + XCTAssertEqual(funding.broadcastCalls, 0, "the abandoned payment must never submit") + XCTAssertEqual(retained, 0) + XCTAssertEqual(cleared, 0) + XCTAssertEqual(staleFailures, 0, "the old callback must not cancel the new payment") + XCTAssertTrue(coordinator.isSigning) + XCTAssertTrue(coordinator.hasPendingBroadcast) + XCTAssertFalse(coordinator.isBroadcastUnresolved) + nextPreparation.open() + _ = try await second.value + XCTAssertEqual(funding.broadcastCalls, 1) + } + + func testCancellationKeepsReceiptOwnershipUntilRetentionCleanupCompletes() async throws { + for cleanupSucceeds in [true, false] { let funding = MockHwFunding() let manager = HwWalletManager() let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) - let suspended = AsyncGate() - let nextPreparation = AsyncGate() - var suspensionStarted = false - var nextStarted = false - var retained = 0 - var cleared = 0 - var staleFailures = 0 - let first = Task { + let retaining = AsyncGate() + let clearing = AsyncGate() + var receipt: HwFundingSignedTx? + var retainingStarted = false + var clearingStarted = false + var concurrentStarted = false + var loads = 0 + var authCalls = 0 + let send = { try await coordinator.signAndBroadcast( manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeBroadcastAttempt: { - if !cancelDuringRetention { - suspensionStarted = true - await suspended.wait() - } + loadSignedPayment: { + loads += 1 + return receipt.map { RetainedHardwareOnchainPayment(signedTx: $0, hasAttemptedBroadcast: false) } }, - retainSignedPayment: { _ in - retained += 1 - if cancelDuringRetention { - suspensionStarted = true - await suspended.wait() - } + beforeBroadcastAttempt: { authCalls += 1 }, + retainSignedPayment: { signed in + receipt = signed + retainingStarted = true + await retaining.wait() }, clearSignedPaymentBeforeDispatch: { _ in - cleared += 1 - return true - }, - afterFailure: { _ in staleFailures += 1 } + clearingStarted = true + await clearing.wait() + if cleanupSucceeds { receipt = nil } + return cleanupSucceeds + } ) } - await waitUntil { suspensionStarted } - XCTAssertTrue(suspensionStarted) + let first = Task { try await send() } + await waitUntil { retainingStarted } + XCTAssertTrue(retainingStarted) coordinator.cancel() - let second = Task { - try await self.signAndBroadcast(coordinator, manager: manager) { _ in - nextStarted = true - await nextPreparation.wait() + let concurrent = Task { + concurrentStarted = true + return try await send() + } + await waitUntil { concurrentStarted } + retaining.open() + await waitUntil { clearingStarted } + XCTAssertTrue(clearingStarted) + XCTAssertEqual(loads, 1, "no newer attempt may reuse the receipt being cleared") + XCTAssertEqual(authCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 0) + clearing.open() + for payment in [first, concurrent] { + await assertThrowsAsync { + _ = try await payment.value + } _: { error in + XCTAssertTrue(error is CancellationError, "\(error)") } } - await waitUntil { nextStarted } - XCTAssertTrue(nextStarted) - suspended.open() - await assertThrowsAsync { - _ = try await first.value - } _: { error in - XCTAssertTrue(error is CancellationError, "\(error)") + XCTAssertEqual(coordinator.isBroadcastUnresolved, !cleanupSucceeds) + XCTAssertEqual(coordinator.hasPendingBroadcast, !cleanupSucceeds) + XCTAssertEqual(receipt == nil, cleanupSucceeds) + XCTAssertEqual(funding.broadcastCalls, 0) + if cleanupSucceeds { + _ = try await send() + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertEqual(loads, 2) + XCTAssertNotNil(receipt, "the new submitted attempt retains its own receipt") } - XCTAssertEqual(funding.broadcastCalls, 0, "the abandoned payment must never submit") - XCTAssertEqual(retained, cancelDuringRetention ? 1 : 0) - XCTAssertEqual(cleared, cancelDuringRetention ? 1 : 0) - XCTAssertEqual(staleFailures, 0, "the old callback must not cancel the new payment") - XCTAssertTrue(coordinator.isSigning) - XCTAssertTrue(coordinator.hasPendingBroadcast) - XCTAssertFalse(coordinator.isBroadcastUnresolved) - nextPreparation.open() - _ = try await second.value - XCTAssertEqual(funding.broadcastCalls, 1) } } From c86b04d1530fe8110ca0ad8a78e09176dece8404 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 00:40:57 +0200 Subject: [PATCH 43/77] fix: serialize recovered hardware payment completion --- .../Services/PaykitPaymentProofService.swift | 36 ++++++--- .../PaykitPaymentProofServiceTests.swift | 76 ++++++++++++++++++- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index a033e67e3..bef9cfa98 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -223,6 +223,14 @@ actor PaykitPaymentProofService { private let logInfo: @Sendable (String) -> Void private let logWarning: @Sendable (String) -> Void private let mutationLock = PaykitProofMutationLock() + private struct HardwareCompletionKey: Hashable { + let identity: String + let requestId: PaykitPaymentRequest.ID + let walletId: String + } + + private var hardwareCompletionsInProgress: Set = [] + private var hardwareResolutionsAwaitingIdentity: Set = [] func backupSnapshot() async throws -> [PaykitPaymentStateBackup.Proof] { try await store.load().map(PaykitPaymentStateBackup.Proof.init) @@ -691,6 +699,9 @@ actor PaykitPaymentProofService { deliverInBackground: Bool = true ) async -> Bool { guard walletId != WalletScope.default, Self.isHex(txid, byteCount: 32) else { return false } + let key = HardwareCompletionKey(identity: identity, requestId: requestId, walletId: walletId) + guard hardwareCompletionsInProgress.insert(key).inserted else { return false } + defer { hardwareCompletionsInProgress.remove(key) } do { let original: PendingPaykitPaymentProof? = try await mutationLock.withLock { var proofs = try await loadProofs() @@ -709,6 +720,7 @@ actor PaykitPaymentProofService { guard let original else { return false } var detail: TransactionDetail? var completed = original + var didCompleteLocalFollowup = false if original.onchainAcceptanceVerified != true || original.proofData == nil { let observed = try await hardwareTransactionLookup.transactionDetail(walletId: walletId, txid: txid) guard observed.txid.caseInsensitiveCompare(txid) == .orderedSame, observed.sent > 0 else { return false } @@ -733,26 +745,28 @@ actor PaykitPaymentProofService { try await persist(proofs) return proofs[index] } - // Another reconciliation may have acknowledged this exact original proof. - return proofs.first { - PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && - $0.onchainWalletId == walletId && $0.kind == .onchain && $0.paymentStarted && - $0.onchainAcceptanceVerified == true && $0.onchainLocalFollowupComplete == true && - $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && - $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame - } + return nil } guard let followedUp else { return false } completed = followedUp + didCompleteLocalFollowup = true } if deliverInBackground { submitInBackground(completed) } else { _ = await submit(completed) } - Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( - identity: completed.identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId - )) + let currentIdentity = (try? await sdk.identityStatus())?.publicKey + let identityIsActive = PubkyPublicKeyFormat.matches(currentIdentity, identity) + if didCompleteLocalFollowup, !identityIsActive { + hardwareResolutionsAwaitingIdentity.insert(key) + } + let replayForReturningPayer = identityIsActive && hardwareResolutionsAwaitingIdentity.remove(key) != nil + if didCompleteLocalFollowup || replayForReturningPayer { + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: completed.identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId + )) + } return true } catch { logWarning("Hardware Paykit payment remains pending exact transaction/local follow-up: \(error)") diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 1dd0c4b8c..159097f83 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -227,10 +227,56 @@ final class PaykitPaymentProofServiceTests: XCTestCase { [originalResolution, originalResolution], "Returning to the original payer must resume its already verified local result" ) + await service.reconcile() + XCTAssertEqual(resolutions, [originalResolution, originalResolution], "Returning-payer replay occurs once") let lookups = await lookup.calls() XCTAssertEqual(lookups.count, 1, "Already verified original follow-up must not repeat payment or observation") } + func testOverlappingHardwareReconciliationCompletesFollowupAndPublishesOnce() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let txid = String(repeating: "ab", count: 32) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + let began = expectation(description: "Hardware local follow-up suspended") + let followup = SuspendedHardwareShopFollowup { began.fulfill() } + let service = paymentProofService( + sdk: sdk, store: store, + hardwareLookup: PaymentProofHardwareLookup(result: .success(hardwareTransaction(txid: txid))), + hardwareFollowup: { _, _ in await followup.save() } + ) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, hardwareWalletId: hardwareWalletId, paymentIdentity: identity) + var observing = false + var resolutions: [PaykitOnchainPaymentResolution] = [] + let subscription = PaykitPaymentProofService.onchainPaymentResolutionPublisher.sink { resolution in + if observing, resolution.requestId == request.id { resolutions.append(resolution) } + } + observing = true + defer { subscription.cancel() } + let first = Task { await service.completeHardwareOnchainPayment(request, paymentIdentity: self.identity, walletId: self.hardwareWalletId, txid: txid) } + await fulfillment(of: [began], timeout: 2) + await service.reconcile() + await followup.resume() + let completed = await first.value + XCTAssertTrue(completed) + await sdk.waitForSubmissionStart() + let saves = await followup.saveCount() + let submissions = await sdk.submissionCount() + XCTAssertEqual(saves, 1) + XCTAssertEqual(submissions, 1) + XCTAssertEqual(resolutions.map(\.transactionId), [txid]) + await service.reconcile() + let retrySaves = await followup.saveCount() + let retriedSubmissions = await sdk.submissionCount() + XCTAssertEqual(retrySaves, 1) + XCTAssertEqual(retriedSubmissions, 2, "Failed proof delivery retries without repeating local completion") + XCTAssertEqual(resolutions.count, 1) + } + func testHardwareShopProofRequiresExactFreshOriginalWalletObservation() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -2332,14 +2378,15 @@ final class PaykitPaymentProofServiceTests: XCTestCase { store: PaymentProofMemoryStore, lightningStatus: PaykitLightningPaymentProofStatus = .unknown, hardwareLookup: any PaykitHardwareTransactionLookingUp = PaymentProofHardwareLookup(result: .failure(PaymentProofStoreMockError.load)), - attemptService: OnchainSendAttemptService = OnchainSendAttemptService(store: MemoryAttemptStore()) + attemptService: OnchainSendAttemptService = OnchainSendAttemptService(store: MemoryAttemptStore()), + hardwareFollowup: @escaping @Sendable (PendingPaykitPaymentProof, TransactionDetail?) async throws -> Void = { _, _ in } ) -> PaykitPaymentProofService { let service = PaykitPaymentProofService( sdk: sdk, store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), hardwareTransactionLookup: hardwareLookup, - hardwareFollowup: { _, _ in }, + hardwareFollowup: hardwareFollowup, attemptService: attemptService, logInfo: { _ in }, logWarning: { _ in } @@ -2748,3 +2795,28 @@ private actor SuspendedProofSaveStore: PaykitPaymentProofStoring { saveWaiter?.resume(); saveWaiter = nil } } + +private actor SuspendedHardwareShopFollowup { + private let onFirstSave: @Sendable () -> Void + private var saves = 0 + private var continuation: CheckedContinuation? + + init(onFirstSave: @escaping @Sendable () -> Void) { self.onFirstSave = onFirstSave } + + func save() async { + saves += 1 + if saves == 1 { + await withCheckedContinuation { continuation in + self.continuation = continuation + onFirstSave() + } + } + } + + func resume() { + continuation?.resume() + continuation = nil + } + + func saveCount() -> Int { saves } +} From 0079e447ffd0501f72be43a1118de8ae56ab3b16 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 02:06:53 +0200 Subject: [PATCH 44/77] fix: preserve hardware receipts across reopened sends --- .../Services/PaykitPaymentProofService.swift | 13 +++ .../Views/Wallets/Send/HwSendSignView.swift | 92 ++++++++++--------- BitkitTests/HwFundingSignerTests.swift | 83 ++++++++++++++++- 3 files changed, 141 insertions(+), 47 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index bef9cfa98..6a2066622 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -232,6 +232,19 @@ actor PaykitPaymentProofService { private var hardwareCompletionsInProgress: Set = [] private var hardwareResolutionsAwaitingIdentity: Set = [] + private var hardwarePaymentsInProgress: Set = [] + + /// Own receipt loading through submission or pre-dispatch cleanup across recreated send sheets. + /// Cancellation does not release ownership until the original coordinator has finished cleanup. + func withHardwarePaymentOwnership(walletId: String, operation: @MainActor () async throws -> T) async throws -> T { + try Task.checkCancellation() + guard hardwarePaymentsInProgress.insert(walletId).inserted else { + throw PaykitPaymentRequestError.operationInProgress + } + defer { hardwarePaymentsInProgress.remove(walletId) } + return try await operation() + } + func backupSnapshot() async throws -> [PaykitPaymentStateBackup.Proof] { try await store.load().map(PaykitPaymentStateBackup.Proof.init) } diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 9c5fef289..e6fff1dab 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -165,52 +165,54 @@ struct HwSendSignView: View { do { var proofVerified = requestId == nil - let result = try await hwSend.signAndBroadcast( - manager: hwWalletManager, - address: invoice.address, - sats: amount, - satsPerVByte: UInt64(feeRate), - paymentDeadline: contactPaymentDeadline, - paymentRequestId: requestId, - loadSignedPayment: { - guard let requestId else { return nil } - guard let identity = contactPaymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } - return try await PaykitPaymentProofService.shared.retainedHardwareOnchainPayment( - requestId: requestId, paymentIdentity: identity, walletId: walletId, - address: invoice.address, amountSats: amount - ) - }, - beforeFirstBroadcast: prepareContactPayment, - beforeBroadcastAttempt: authorizeContactPayment, - retainSignedPayment: { signed in - guard let requestId else { return } - guard let identity = contactPaymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } - try await PaykitPaymentProofService.shared.retainHardwareOnchainCandidate( - requestId: requestId, paymentIdentity: identity, walletId: walletId, - address: invoice.address, amountSats: amount, serializedTx: signed.serializedTx - ) - }, - clearSignedPaymentBeforeDispatch: { signed in - guard let requestId else { return true } - guard let identity = contactPaymentIdentity else { return false } - return await PaykitPaymentProofService.shared.clearHardwareCandidateBeforeDispatch( - requestId: requestId, paymentIdentity: identity, walletId: walletId, serializedTx: signed.serializedTx - ) - }, - afterBroadcast: { result in - if requestId != nil { - // Save original tags before proof reconciliation can complete. - // This retains metadata only; a bare Core txid is not Sent. - await Self.recordPaymentResult( - result, walletId: walletId, address: invoice.address, amount: amount, - contactPublicKey: contactPublicKey, tags: tags, requestId: requestId, - proofVerified: false + let result = try await PaykitPaymentProofService.shared.withHardwarePaymentOwnership(walletId: walletId) { + try await hwSend.signAndBroadcast( + manager: hwWalletManager, + address: invoice.address, + sats: amount, + satsPerVByte: UInt64(feeRate), + paymentDeadline: contactPaymentDeadline, + paymentRequestId: requestId, + loadSignedPayment: { + guard let requestId else { return nil } + guard let identity = contactPaymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + return try await PaykitPaymentProofService.shared.retainedHardwareOnchainPayment( + requestId: requestId, paymentIdentity: identity, walletId: walletId, + address: invoice.address, amountSats: amount ) - } - proofVerified = await completeContactPayment(result.txId) - }, - afterFailure: cancelContactPayment - ) + }, + beforeFirstBroadcast: prepareContactPayment, + beforeBroadcastAttempt: authorizeContactPayment, + retainSignedPayment: { signed in + guard let requestId else { return } + guard let identity = contactPaymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + try await PaykitPaymentProofService.shared.retainHardwareOnchainCandidate( + requestId: requestId, paymentIdentity: identity, walletId: walletId, + address: invoice.address, amountSats: amount, serializedTx: signed.serializedTx + ) + }, + clearSignedPaymentBeforeDispatch: { signed in + guard let requestId else { return true } + guard let identity = contactPaymentIdentity else { return false } + return await PaykitPaymentProofService.shared.clearHardwareCandidateBeforeDispatch( + requestId: requestId, paymentIdentity: identity, walletId: walletId, serializedTx: signed.serializedTx + ) + }, + afterBroadcast: { result in + if requestId != nil { + // Save original tags before proof reconciliation can complete. + // This retains metadata only; a bare Core txid is not Sent. + await Self.recordPaymentResult( + result, walletId: walletId, address: invoice.address, amount: amount, + contactPublicKey: contactPublicKey, tags: tags, requestId: requestId, + proofVerified: false + ) + } + proofVerified = await completeContactPayment(result.txId) + }, + afterFailure: cancelContactPayment + ) + } await Self.recordPaymentResult( result, walletId: walletId, diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index af50d8a2a..fd2ae90ed 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -929,7 +929,11 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertNil(coordinator.resolveObservedShopPayment(unrelated, paymentIdentity: identity, currentIdentity: identity)) XCTAssertFalse(coordinator.canLeave) } - XCTAssertNil(coordinator.resolveObservedShopPayment(original, paymentIdentity: identity, currentIdentity: "pubky" + String(repeating: "x", count: 52))) + XCTAssertNil(coordinator.resolveObservedShopPayment( + original, + paymentIdentity: identity, + currentIdentity: "pubky" + String(repeating: "x", count: 52) + )) XCTAssertEqual( coordinator.resolveObservedShopPayment(original, paymentIdentity: identity, currentIdentity: identity), .success(paymentId: txid, walletId: "jade:wallet") @@ -1178,7 +1182,9 @@ final class HwFundingSignerTests: XCTestCase { clearSignedPaymentBeforeDispatch: { _ in clearingStarted = true await clearing.wait() - if cleanupSucceeds { receipt = nil } + if cleanupSucceeds { + receipt = nil + } return cleanupSucceeds } ) @@ -1219,6 +1225,79 @@ final class HwFundingSignerTests: XCTestCase { } } + func testRecreatedCoordinatorCannotAdoptReceiptUntilCancelledOwnerFinishesCleanup() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let firstCoordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let reopenedCoordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let proofService = PaykitPaymentProofService( + sdk: PaymentProofSdkMock(identity: "pubky" + String(repeating: "z", count: 52), records: []), + store: PaymentProofMemoryStore(), logInfo: { _ in }, logWarning: { _ in } + ) + let retaining = AsyncGate() + let clearing = AsyncGate() + var receipt: HwFundingSignedTx? + var retainingStarted = false + var clearingStarted = false + var loads = 0 + let first = Task { + try await proofService.withHardwarePaymentOwnership(walletId: "jade:wallet") { + try await firstCoordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + loadSignedPayment: { loads += 1; return nil }, + retainSignedPayment: { signed in + receipt = signed + retainingStarted = true + await retaining.wait() + }, + clearSignedPaymentBeforeDispatch: { _ in + clearingStarted = true + await clearing.wait() + receipt = nil + return true + } + ) + } + } + await waitUntil { retainingStarted } + XCTAssertTrue(retainingStarted) + firstCoordinator.cancel() + let sendFromReopenedSheet = { + try await proofService.withHardwarePaymentOwnership(walletId: "jade:wallet") { + try await reopenedCoordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + loadSignedPayment: { + loads += 1 + return receipt.map { RetainedHardwareOnchainPayment(signedTx: $0, hasAttemptedBroadcast: false) } + }, + retainSignedPayment: { receipt = $0 } + ) + } + } + await assertThrowsAsync { _ = try await sendFromReopenedSheet() } _: { error in + XCTAssertEqual(error as? PaykitPaymentRequestError, .operationInProgress) + } + retaining.open() + await waitUntil { clearingStarted } + XCTAssertTrue(clearingStarted) + await assertThrowsAsync { _ = try await sendFromReopenedSheet() } _: { error in + XCTAssertEqual(error as? PaykitPaymentRequestError, .operationInProgress) + } + XCTAssertEqual(loads, 1, "the recreated sheet must not even load the old owner's receipt") + XCTAssertEqual(funding.broadcastCalls, 0) + let otherWallet = try await proofService.withHardwarePaymentOwnership(walletId: "jade:other-wallet") { true } + XCTAssertTrue(otherWallet, "ownership is scoped to the original hardware wallet") + clearing.open() + await assertThrowsAsync { _ = try await first.value } _: { error in + XCTAssertTrue(error is CancellationError) + } + XCTAssertNil(receipt) + _ = try await sendFromReopenedSheet() + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertEqual(loads, 2) + XCTAssertNotNil(receipt, "cleanup from the old owner cannot remove the new payment") + } + func testCancelWithNothingInFlightKeepsTheSession() async throws { let funding = MockHwFunding() let connecting = MockHwConnecting() From 50e8d3af5083d3554f73528a8dc52d99b097f5e5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 02:13:19 +0200 Subject: [PATCH 45/77] fix: discard cancelled on-chain retry candidates --- .../Services/OnchainSendAttemptService.swift | 40 +++++++++-- .../OnchainSendAttemptServiceTests.swift | 67 +++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 768afb9ca..c733a3ad9 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -495,7 +495,8 @@ actor OnchainSendAttemptService { guard var attempt = try currentAttempt(), attempt.id == original.id, attempt.recoveryContext == recovery else { throw OnchainSendAttemptError.unresolved } - if !attempt.containsCandidate(prepared.txid) { + let addedCandidate = !attempt.containsCandidate(prepared.txid) + if addedCandidate { attempt.recoveryContext?.candidateTxids.append(prepared.txid) } if let previousRate = attempt.recoveryContext?.candidateFeeRates?[prepared.txid.lowercased()], @@ -517,10 +518,21 @@ actor OnchainSendAttemptService { // One authorization, after preparation and immediately before native dispatch. // It validates the original payer/request/order and the chosen fee policy without // repeating initial proof-start/consume side effects. - try await authorize(attempt, authorizedFeeRate) - try checkWallet(sender, index: index, node: node) - if let winner = try winningResult(attemptId: original.id) { - return winner + do { + try Task.checkCancellation() + try await authorize(attempt, authorizedFeeRate) + try Task.checkCancellation() + try checkWallet(sender, index: index, node: node) + if let winner = try winningResult(attemptId: original.id) { + try discardUnsubmittedRetryCandidate(prepared.txid, attemptId: original.id, wasAdded: addedCandidate) + return winner + } + } catch { + // The native dispatch closure has not been entered. Remove only the new + // candidate from this retry; earlier submitted candidates remain guarded. + do { try discardUnsubmittedRetryCandidate(prepared.txid, attemptId: original.id, wasAdded: addedCandidate) } + catch { throw OnchainSendAttemptError.unresolved } + throw error } let result: OnchainSendResult do { result = try await normalized(prepared.broadcast(), candidate: prepared.txid) } @@ -530,6 +542,24 @@ actor OnchainSendAttemptService { return (try? winningResult(attemptId: original.id)) ?? result } + private func discardUnsubmittedRetryCandidate(_ txid: String, attemptId: UUID, wasAdded: Bool) throws { + guard wasAdded else { return } + guard var current = try currentAttempt(), current.id == attemptId, var recovery = current.recoveryContext else { + throw OnchainSendAttemptError.unresolved + } + guard current.containsCandidate(txid) else { return } + // Never overwrite an accepted winner or remove an earlier submitted candidate. + guard !(current.status == .accepted && current.txid?.caseInsensitiveCompare(txid) == .orderedSame), + recovery.candidateTxids.count > 1, + recovery.candidateTxids.first?.caseInsensitiveCompare(txid) != .orderedSame + else { return } + recovery.candidateTxids.removeAll { $0.caseInsensitiveCompare(txid) == .orderedSame } + recovery.candidateFeeRates?.removeValue(forKey: txid.lowercased()) + current.recoveryContext = recovery + try store.save([current]) + knownAttempt = current + } + private func checkWallet(_ sender: any OnchainSending, index: Int, node: AnyObject?) throws { guard sender.currentWalletIndex == index, sender.onchainDispatchNode === node else { throw NodeError.NotRunning(message: "Wallet or node changed before on-chain dispatch") diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 1c744909d..89d57e58b 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -470,6 +470,73 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } + func testCancelledRetryDropsOnlyItsUnsubmittedCandidateAndOriginalCanComplete() async throws { + for walletChanges in [false, true] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + let originalTxid = try XCTUnwrap(original.txid) + sender.txid = String(repeating: "cd", count: 32) + do { + _ = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), + satsPerVbyte: 2, authorize: { _, _ in + if walletChanges { sender.currentWalletIndex = 1 } + else { throw CancellationError() } + } + ) + XCTFail("Pre-dispatch exit submitted the successor") + } catch {} + let retained = try XCTUnwrap(store.snapshot().first) + XCTAssertEqual(retained.id, original.id) + XCTAssertEqual(retained.recoveryContext?.inputs, original.recoveryContext?.inputs) + XCTAssertEqual(retained.recoveryContext?.candidateTxids, [originalTxid]) + XCTAssertNil(retained.recoveryContext?.candidateFeeRates?[sender.txid]) + XCTAssertEqual(sender.broadcasts, 1) + XCTAssertTrue(retained.blocksNewSend) + let restarted = OnchainSendAttemptService(store: store) + let observed = try await restarted.observeTransaction(txid: originalTxid, walletId: original.walletId) + XCTAssertTrue(observed, "an undispatched successor must not suppress the original received event") + XCTAssertEqual(store.snapshot().first?.txid, originalTxid) + XCTAssertEqual(store.snapshot().first?.status, .accepted) + } + } + + func testCancelledRetryCleanupFailureRetainsTheOriginalGuard() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + do { + _ = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 2, authorize: { _, _ in + store.failSave = true + throw CancellationError() + } + ) + XCTFail("Failed cleanup submitted the successor") + } catch let error as OnchainSendAttemptError { + guard case .unresolved = error else { return XCTFail("Failed cleanup was not reported as unresolved") } + } catch { XCTFail("Failed cleanup was not reported as unresolved: \(error)") } + store.failSave = false + XCTAssertEqual(sender.broadcasts, 1) + XCTAssertEqual(store.snapshot().first?.id, original.id) + XCTAssertTrue(store.snapshot().first?.blocksNewSend == true) + do { + _ = try await service.send(using: sender, address: "different", amountSats: 1, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + XCTFail("Failed cleanup released the original payment") + } catch {} + XCTAssertEqual(sender.broadcasts, 1) + } + func testAmbiguousGuardWithoutReceiptCannotRetryAndWrongWalletCannotPromote() async throws { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) From 237afc9648fe11f04ffa2e1a521e4742d9562b8e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 04:12:35 +0200 Subject: [PATCH 46/77] fix: reject incomplete accepted replacement backups --- Bitkit/Models/PaykitPaymentStateBackup.swift | 3 ++ .../PaykitPaymentStateBackupTests.swift | 31 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index bf71e5b9e..7b1b16051 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -291,6 +291,9 @@ struct PaykitPaymentStateBackup: Codable { guard rates?.keys.allSatisfy({ validTxid($0) && candidateTxids.contains($0) }) != false else { throw invalidBackup("Candidate fee does not belong to this operation") } + if state == .accepted, txid != candidateTxids.first, txid.flatMap({ rates?[$0] }) == nil { + throw invalidBackup("Missing accepted candidate fee rate") + } let restoredRequest = try requestId.map { value in let date = try value.billingPeriodStartsAt.map { try parseTimestamp($0).date } return PaykitPaymentRequest.ID(paymentRequestId: value.paymentRequestId, counterparty: value.counterparty, diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index dfd643400..2ee151008 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -171,7 +171,8 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } var foreignProof = try XCTUnwrap(proofs.first) foreignProof = PendingPaykitPaymentProof(identity: "pubky" + String(repeating: "y", count: 52), requestId: foreignProof.requestId, - paymentAppId: "bitkit", paymentEndpointIdentifier: foreignProof.paymentEndpointIdentifier, kind: .onchain, + paymentAppId: "bitkit", paymentEndpointIdentifier: foreignProof.paymentEndpointIdentifier, + kind: .onchain, paymentStarted: true, paymentIdentifier: foreignProof.paymentIdentifier, proofData: nil, onchainAddress: foreignProof.onchainAddress, onchainAmountSats: foreignProof.onchainAmountSats) XCTAssertThrowsError(try wire.restored(wallet: Self.goldenWallet(), proofs: [foreignProof])) @@ -220,12 +221,40 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } } + func testAcceptedSuccessorBackupRequiresItsFeeRate() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let state = try XCTUnwrap(envelope.paykitPaymentState) + let wire = try XCTUnwrap(state.activeOnchainAttempt) + let proofs = try state.pendingProofs.map { try $0.restored() } + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) + let original = try XCTUnwrap(wire.candidateTxids.first) + let successor = try XCTUnwrap(wire.candidateTxids.last) + object["status"] = "accepted" + object["txid"] = successor + for rates: [String: String]? in [nil, [:], [original: "2"]] { + object["candidateFeeRates"] = rates + let backup = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: object)) + XCTAssertThrowsError(try backup.restored(wallet: Self.goldenWallet(), proofs: proofs)) + } + object["candidateFeeRates"] = [successor: "4"] + let valid = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: object)) + XCTAssertEqual(try valid.restored(wallet: Self.goldenWallet(), proofs: proofs).0.recoveryContext?.feeRate(for: successor), 4) + object["candidateFeeRates"] = nil + object["txid"] = original + let firstWinner = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: object)) + XCTAssertEqual(try firstWinner.restored(wallet: Self.goldenWallet(), proofs: proofs).0.recoveryContext?.feeRate(for: original), 2) + } + func testAcceptedBackupRequiresMatchingVerifiedProofData() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) let state = try XCTUnwrap(envelope.paykitPaymentState) let wire = try XCTUnwrap(state.activeOnchainAttempt) var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) object["status"] = "accepted" + object["candidateFeeRates"] = try [XCTUnwrap(wire.txid): "4"] let accepted = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, from: JSONSerialization.data(withJSONObject: object)) var proof = try XCTUnwrap(state.pendingProofs.first).restored() From 8918b67f11c7b372e0bda377c2222d99a19e2b2c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 05:12:13 +0200 Subject: [PATCH 47/77] fix: reject incomplete hardware payment backups --- Bitkit/Models/PaykitPaymentStateBackup.swift | 1 + .../PaykitPaymentStateBackupTests.swift | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 7b1b16051..0f86cb8df 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -131,6 +131,7 @@ struct PaykitPaymentStateBackup: Codable { ) if let hardwareSignedTransaction { guard kind == .onchain, paymentStarted, let walletId = onchainWalletId, walletId != WalletScope.default, + let hardwareMiningFeeSats, let hardwareFeeRate, let hardwareTotalSpent, try SignedTransactionId.fromHex(hardwareSignedTransaction) == paymentIdentifier else { throw invalidBackup("Invalid hardware payment receipt") } proof.hardwareSignedTransaction = hardwareSignedTransaction diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 2ee151008..7b3291002 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -152,6 +152,37 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertEqual(again.0, restored.0) } + func testHardwareBackupRequiresCompleteSignedReceipt() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let proof = try XCTUnwrap(envelope.paykitPaymentState?.pendingProofs.first) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(proof)) as? [String: Any]) + let signed = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" + object["hardwareSignedTransaction"] = signed + object["paymentIdentifier"] = try SignedTransactionId.fromHex(signed) + object["onchainWalletId"] = "trezor:backup-wallet" + object["hardwareMiningFeeSats"] = 141 + object["hardwareFeeRate"] = 2 + object["hardwareTotalSpent"] = 1375 + let complete = try JSONDecoder().decode(PaykitPaymentStateBackup.Proof.self, from: JSONSerialization.data(withJSONObject: object)) + let restored = try complete.restored() + XCTAssertEqual(restored.hardwareSignedTransaction, signed) + XCTAssertEqual(restored.hardwareMiningFeeSats, 141) + XCTAssertEqual(restored.hardwareFeeRate, 2) + XCTAssertEqual(restored.hardwareTotalSpent, 1375) + for field in ["hardwareMiningFeeSats", "hardwareFeeRate", "hardwareTotalSpent"] { + for missing in [true, false] { + var incomplete = object + if missing { + incomplete.removeValue(forKey: field) + } else { + incomplete[field] = NSNull() + } + let wire = try JSONDecoder().decode(PaykitPaymentStateBackup.Proof.self, from: JSONSerialization.data(withJSONObject: incomplete)) + XCTAssertThrowsError(try wire.restored(), field) + } + } + } + func testSharedGoldenRejectsWrongWalletNetworkPayerAndMalformedReceipt() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) let state = try XCTUnwrap(envelope.paykitPaymentState) From 4108d10ee4b1e6208a9ffc9aeaeb79955b783d9c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 06:41:45 +0200 Subject: [PATCH 48/77] test: restore complete hardware payment receipt --- BitkitTests/PaykitPaymentProofServiceTests.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 159097f83..cb480fcbe 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -502,8 +502,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let lookup = PaymentProofHardwareLookup(result: .success(hardwareTransaction(txid: txid))) let service = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) - try await service.markOnchainPaymentStarted(request, address: onchainAddress, hardwareWalletId: hardwareWalletId, paymentIdentity: identity) let serializedTx = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" + // The first-broadcast coordinator persists the full signed receipt before dispatch. + let signedTx = HwFundingSignedTx(serializedTx: serializedTx, miningFeeSats: 120, feeRate: 2, totalSpent: request.amountSats + 120) + try await service.markOnchainPaymentStarted( + request, address: onchainAddress, hardwareWalletId: hardwareWalletId, + paymentIdentity: identity, signedTx: signedTx + ) await store.failNextSave() do { try await service.retainHardwareOnchainCandidate( @@ -513,7 +518,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTFail("Failed receipt persistence must stop dispatch") } catch {} let unsaved = await store.snapshot().first - XCTAssertNil(unsaved?.paymentIdentifier) + XCTAssertEqual(unsaved?.paymentIdentifier, txid) + XCTAssertEqual(unsaved?.hardwareDispatchAttempted, false) try await service.retainHardwareOnchainCandidate( requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx @@ -540,6 +546,9 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(observed, [.init(walletId: hardwareWalletId, txid: txid)]) let restored = await store.snapshot().first XCTAssertEqual(restored?.hardwareSignedTransaction, serializedTx) + XCTAssertEqual(restored?.hardwareMiningFeeSats, signedTx.miningFeeSats) + XCTAssertEqual(restored?.hardwareFeeRate, UInt64(signedTx.feeRate)) + XCTAssertEqual(restored?.hardwareTotalSpent, signedTx.totalSpent) XCTAssertEqual(restored?.paymentIdentifier, txid) XCTAssertEqual(restored?.onchainAcceptanceVerified, true) } From 30d7df3a1d46b80f233a83590b148bb6231c563e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 10:43:48 +0200 Subject: [PATCH 49/77] fix: preserve original payment terms on restore --- Bitkit/Models/PaykitPaymentStateBackup.swift | 10 ++- .../PaykitPaymentStateBackupTests.swift | 79 ++++++++++++++++--- .../TransferServiceActivityTests.swift | 2 +- 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 0f86cb8df..8a7c6d682 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -292,8 +292,11 @@ struct PaykitPaymentStateBackup: Codable { guard rates?.keys.allSatisfy({ validTxid($0) && candidateTxids.contains($0) }) != false else { throw invalidBackup("Candidate fee does not belong to this operation") } - if state == .accepted, txid != candidateTxids.first, txid.flatMap({ rates?[$0] }) == nil { - throw invalidBackup("Missing accepted candidate fee rate") + guard candidateTxids.dropFirst().allSatisfy({ rates?[$0] != nil }) else { + throw invalidBackup("Missing successor candidate fee rate") + } + if let original = candidateTxids.first, let originalRate = rates?[original], originalRate != rate { + throw invalidBackup("Conflicting original candidate fee rate") } let restoredRequest = try requestId.map { value in let date = try value.billingPeriodStartsAt.map { try parseTimestamp($0).date } @@ -321,7 +324,8 @@ struct PaykitPaymentStateBackup: Codable { channelId: value.channelId) } let orderContext = try transfer.map { value -> OnchainSendTransferContext in - guard orderId != nil else { throw invalidBackup("Original transfer context mismatch") } + let originalFee: UInt64 = try number(value.originalOrderFeeSats) + guard orderId != nil, amount == originalFee else { throw invalidBackup("Original transfer context mismatch") } return try OnchainSendTransferContext(clientBalanceSats: number(value.originalOrderClientBalanceSats), txTotalSats: number(value.txTotalSats), preTransferOnchainSats: number(value.preTransferOnchainSats), diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 7b3291002..aded54675 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -125,6 +125,16 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertEqual(Env.vssStoreIdPrefix, "bitkit_v1_" + Env.networkName) } + static func completeAttemptGolden() throws -> Data { + var envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: activeAttemptGolden) as? [String: Any]) + var state = try XCTUnwrap(envelope["paykitPaymentState"] as? [String: Any]) + var attempt = try XCTUnwrap(state["activeOnchainAttempt"] as? [String: Any]) + attempt["candidateFeeRates"] = [String(repeating: "ab", count: 32): "2", String(repeating: "cd", count: 32): "4"] + state["activeOnchainAttempt"] = attempt + envelope["paykitPaymentState"] = state + return try JSONSerialization.data(withJSONObject: envelope) + } + static let goldenBinding = "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0" static func goldenWallet(index: Int = 2) -> PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet { @@ -134,7 +144,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { func testSharedGoldenRestoreRemapsOnlyOriginalWalletAndResetsLocalFollowup() throws { let digest = SHA256.hash(data: Self.activeAttemptGolden).map { String(format: "%02x", $0) }.joined() XCTAssertEqual(digest, "42bd135dbc91aa0b2004f2633f6f8b28c46dddf8da3c6f949cf2ff036c07e0a6") - let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) let wire = try XCTUnwrap(state.activeOnchainAttempt) let proofs = try state.pendingProofs.map { try $0.restored() } @@ -153,7 +163,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } func testHardwareBackupRequiresCompleteSignedReceipt() throws { - let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let proof = try XCTUnwrap(envelope.paykitPaymentState?.pendingProofs.first) var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(proof)) as? [String: Any]) let signed = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" @@ -184,7 +194,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } func testSharedGoldenRejectsWrongWalletNetworkPayerAndMalformedReceipt() throws { - let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) let wire = try XCTUnwrap(state.activeOnchainAttempt) let proofs = try state.pendingProofs.map { try $0.restored() } @@ -222,8 +232,53 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } } + func testRestoredCandidateFeesAndTransferAmountAreConsistent() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) + let wire = try XCTUnwrap(envelope.paykitPaymentState?.activeOnchainAttempt) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) + let original = try XCTUnwrap(wire.candidateTxids.first) + let successor = try XCTUnwrap(wire.candidateTxids.last) + let later = String(repeating: "12", count: 32) + object["requestId"] = nil + object["payerIdentity"] = nil + object["txid"] = original + object["candidateTxids"] = [original, successor, later] + func restored(_ fields: [String: Any]) throws -> OnchainSendAttempt { + let backup = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: fields)) + return try backup.restored(wallet: Self.goldenWallet(), proofs: []).0 + } + for status in ["pending", "unknown", "rejected", "accepted"] { + object["status"] = status + for rates: [String: String]? in [nil, [:], [successor: "4"], [later: "5"], + [original: "3", successor: "4", later: "5"]] { + object["candidateFeeRates"] = rates + XCTAssertThrowsError(try restored(object)) + } + for rates in [[successor: "4", later: "5"], [original: "2", successor: "4", later: "5"]] { + object["candidateFeeRates"] = rates + let attempt = try restored(object) + XCTAssertEqual(attempt.recoveryContext?.feeRate(for: original), 2) + XCTAssertEqual(attempt.recoveryContext?.feeRate(for: successor), 4) + XCTAssertEqual(attempt.recoveryContext?.feeRate(for: later), 5) + } + } + object["status"] = "unknown" + object["orderId"] = "original-order" + object["transfer"] = ["txTotalSats": "2000", "preTransferOnchainSats": "3000", + "originalOrderClientBalanceSats": "900", "originalOrderFeeSats": "1000"] + for amount in ["999", "1001", "1234"] { + object["amountSats"] = amount + XCTAssertThrowsError(try restored(object)) + } + object["amountSats"] = "1000" + let attempt = try restored(object) + XCTAssertEqual(attempt.amountSats, 1000) + XCTAssertEqual(attempt.transferContext?.originalOrderFeeSats, attempt.amountSats) + } + func testCandidateFeeRatesRoundtripAndRejectForeignOrMalformedRates() throws { - let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) let wire = try XCTUnwrap(state.activeOnchainAttempt) let proofs = try state.pendingProofs.map { try $0.restored() } @@ -239,9 +294,9 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertEqual(restored.recoveryContext?.feeRate(for: successor), 4) let again = try PaykitPaymentStateBackup.ActiveOnchainAttempt(restored, wallet: Self.goldenWallet()) XCTAssertEqual(again.candidateFeeRates, [original: "2", successor: "4"]) - let old = try wire.restored(wallet: Self.goldenWallet(), proofs: proofs).0 - XCTAssertEqual(old.recoveryContext?.feeRate(for: original), 2) - XCTAssertNil(old.recoveryContext?.feeRate(for: successor), "Original fee must not be guessed for an unmapped successor") + let incomplete = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + XCTAssertThrowsError(try XCTUnwrap(incomplete.paykitPaymentState?.activeOnchainAttempt) + .restored(wallet: Self.goldenWallet(), proofs: proofs)) for values in [[successor: "0"], [successor: "4294967296"], [successor: "01"], [String(repeating: "ef", count: 32): "4"], [successor.uppercased(): "4"]] { @@ -253,7 +308,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } func testAcceptedSuccessorBackupRequiresItsFeeRate() throws { - let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) let wire = try XCTUnwrap(state.activeOnchainAttempt) let proofs = try state.pendingProofs.map { try $0.restored() } @@ -272,7 +327,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let valid = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, from: JSONSerialization.data(withJSONObject: object)) XCTAssertEqual(try valid.restored(wallet: Self.goldenWallet(), proofs: proofs).0.recoveryContext?.feeRate(for: successor), 4) - object["candidateFeeRates"] = nil + object["candidateFeeRates"] = [successor: "4"] object["txid"] = original let firstWinner = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, from: JSONSerialization.data(withJSONObject: object)) @@ -280,7 +335,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } func testAcceptedBackupRequiresMatchingVerifiedProofData() throws { - let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) let wire = try XCTUnwrap(state.activeOnchainAttempt) var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) @@ -298,7 +353,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } func testAndroidMillisecondFollowupRestoresAndPreservesWireProvenance() throws { - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: Self.activeAttemptGolden) as? [String: Any]) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: Self.completeAttemptGolden()) as? [String: Any]) var state = try XCTUnwrap(object["paykitPaymentState"] as? [String: Any]) var active = try XCTUnwrap(state["activeOnchainAttempt"] as? [String: Any]) var followup = try XCTUnwrap(active["followup"] as? [String: Any]) @@ -317,7 +372,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } func testRestoreCannotClobberDifferentUnresolvedOriginalOperation() async throws { - let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.activeAttemptGolden) + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) let restored = try XCTUnwrap(state.activeOnchainAttempt).restored( wallet: Self.goldenWallet(), proofs: state.pendingProofs.map { try $0.restored() } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 63fd86b04..9422cbe4c 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -761,7 +761,7 @@ final class TransferServiceActivityTests: XCTestCase { @MainActor func testRestoredGoldenShopAttemptCompletesObservedOriginalCandidateWithoutNewPayment() async throws { - let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: PaykitPaymentStateBackupTests.activeAttemptGolden) + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: PaykitPaymentStateBackupTests.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) let store = MemoryAttemptStore() let attempts = OnchainSendAttemptService(store: store) From 8dea5f290a1eeb36826d16e274dacd60c12028a9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 12:13:55 +0200 Subject: [PATCH 50/77] fix: preserve send-all funding backups --- Bitkit/Models/PaykitPaymentStateBackup.swift | 5 ++- .../PaykitPaymentStateBackupTests.swift | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 8a7c6d682..3e0ea734b 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -325,7 +325,10 @@ struct PaykitPaymentStateBackup: Codable { } let orderContext = try transfer.map { value -> OnchainSendTransferContext in let originalFee: UInt64 = try number(value.originalOrderFeeSats) - guard orderId != nil, amount == originalFee else { throw invalidBackup("Original transfer context mismatch") } + let transactionTotal: UInt64 = try number(value.txTotalSats) + guard orderId != nil, + isMaxAmount ? amount >= originalFee && amount <= transactionTotal : amount == originalFee + else { throw invalidBackup("Original transfer context mismatch") } return try OnchainSendTransferContext(clientBalanceSats: number(value.originalOrderClientBalanceSats), txTotalSats: number(value.txTotalSats), preTransferOnchainSats: number(value.preTransferOnchainSats), diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index aded54675..63e0e1524 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -277,6 +277,41 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertEqual(attempt.transferContext?.originalOrderFeeSats, attempt.amountSats) } + func testSendAllFundingBackupPreservesActualAmountAndOriginalOrderTerms() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) + let wire = try XCTUnwrap(envelope.paykitPaymentState?.activeOnchainAttempt) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) + object["requestId"] = nil + object["payerIdentity"] = nil + object["orderId"] = "original-order" + object["isMaxAmount"] = true + object["amountSats"] = "99500" + object["transfer"] = ["txTotalSats": "100000", "preTransferOnchainSats": "100000", + "originalOrderClientBalanceSats": "97000", "originalOrderFeeSats": "99000"] + func restored(_ fields: [String: Any]) throws -> OnchainSendAttempt { + let backup = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: fields)) + return try backup.restored(wallet: Self.goldenWallet(), proofs: []).0 + } + for status in ["pending", "unknown", "rejected", "accepted"] { + object["status"] = status + let attempt = try restored(object) + XCTAssertEqual(attempt.amountSats, 99500) + XCTAssertEqual(attempt.transferContext?.originalOrderFeeSats, 99000) + let encoded = try PaykitPaymentStateBackup.ActiveOnchainAttempt(attempt, wallet: Self.goldenWallet()) + let roundTrip = try encoded.restored(wallet: Self.goldenWallet(), proofs: []).0 + XCTAssertEqual(roundTrip.amountSats, 99500) + XCTAssertEqual(roundTrip.transferContext, attempt.transferContext) + } + for amount in ["98999", "100001"] { + object["amountSats"] = amount + XCTAssertThrowsError(try restored(object)) + } + object["amountSats"] = "99500" + object["isMaxAmount"] = false + XCTAssertThrowsError(try restored(object)) + } + func testCandidateFeeRatesRoundtripAndRejectForeignOrMalformedRates() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) From c30704341ca600b87778010ffb01c55ecae9f73b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 13:45:43 +0200 Subject: [PATCH 51/77] fix: validate prepared funding before dispatch --- Bitkit/Models/PaykitPaymentStateBackup.swift | 4 ++-- .../Services/OnchainSendAttemptService.swift | 7 ++++++ .../OnchainSendAttemptServiceTests.swift | 24 +++++++++++++++++++ .../PaykitPaymentStateBackupTests.swift | 4 ++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 3e0ea734b..d66dc218e 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -326,8 +326,8 @@ struct PaykitPaymentStateBackup: Codable { let orderContext = try transfer.map { value -> OnchainSendTransferContext in let originalFee: UInt64 = try number(value.originalOrderFeeSats) let transactionTotal: UInt64 = try number(value.txTotalSats) - guard orderId != nil, - isMaxAmount ? amount >= originalFee && amount <= transactionTotal : amount == originalFee + guard orderId != nil, amount <= transactionTotal, + isMaxAmount ? amount >= originalFee : amount == originalFee else { throw invalidBackup("Original transfer context mismatch") } return try OnchainSendTransferContext(clientBalanceSats: number(value.originalOrderClientBalanceSats), txTotalSats: number(value.txTotalSats), diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index c733a3ad9..c22cf92a2 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -410,6 +410,13 @@ actor OnchainSendAttemptService { inputs: isMaxAmount ? nil : utxosToSpend? .map { OnchainSendInput(txid: $0.outpoint.txid, vout: $0.outpoint.vout) }) guard var attempt = try currentAttempt(), attempt.id == attemptId else { throw OnchainSendAttemptError.unresolved } + if orderId != nil && isMaxAmount { + guard let context = attempt.transferContext, + let originalFee = context.originalOrderFeeSats, + prepared.recipientAmountSats >= originalFee, + prepared.recipientAmountSats <= context.txTotalSats + else { throw OnchainSendAttemptError.unresolved } + } attempt.amountSats = prepared.recipientAmountSats attempt.txid = prepared.txid attempt.recoveryContext = OnchainSendRecoveryContext( diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 89d57e58b..63c38ae8a 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -148,6 +148,30 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } + func testPreparedMaxFundingMustRespectOriginalOrderBeforeBroadcast() async throws { + for amount: UInt64 in [98999, 100001, 99500] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + sender.amount = amount + let context = OnchainSendTransferContext(clientBalanceSats: 97000, txTotalSats: 100000, + preTransferOnchainSats: 100000, originalOrderFeeSats: 99000) + do { + _ = try await service.send(using: sender, address: "original", amountSats: 99000, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: true, + orderId: "original-order", transferContext: context) + XCTAssertEqual(amount, 99500) + XCTAssertEqual(sender.broadcasts, 1) + XCTAssertEqual(store.snapshot().first?.amountSats, 99500) + XCTAssertEqual(store.snapshot().first?.transferContext, context) + } catch { + XCTAssertNotEqual(amount, 99500, "Valid surplus funding must remain supported") + XCTAssertEqual(sender.broadcasts, 0) + XCTAssertTrue(store.snapshot().isEmpty) + } + } + } + func testPreparedReceiptIsDurableBeforeNativeDispatch() async throws { for max in [false, true] { let store = MemoryAttemptStore() diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 63e0e1524..b3df4fa8f 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -272,6 +272,10 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertThrowsError(try restored(object)) } object["amountSats"] = "1000" + var understated = object + understated["transfer"] = ["txTotalSats": "1", "preTransferOnchainSats": "3000", + "originalOrderClientBalanceSats": "900", "originalOrderFeeSats": "1000"] + XCTAssertThrowsError(try restored(understated)) let attempt = try restored(object) XCTAssertEqual(attempt.amountSats, 1000) XCTAssertEqual(attempt.transferContext?.originalOrderFeeSats, attempt.amountSats) From b2b05e276eb2f94863721684b453804bcf8ebd31 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 16:42:46 +0200 Subject: [PATCH 52/77] test: keep concurrent funding attempts valid --- BitkitTests/OnchainSendAttemptServiceTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 63c38ae8a..3dd059f9a 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -984,7 +984,11 @@ final class OnchainSendAttemptServiceTests: XCTestCase { do { _ = try await service.send( using: node, address: "bcrt1qexample", amountSats: 1000, satsPerVbyte: 1, - utxosToSpend: nil, isMaxAmount: index % 2 == 0, orderId: index % 3 == 0 ? "order" : nil + utxosToSpend: nil, isMaxAmount: index % 2 == 0, orderId: index % 3 == 0 ? "order" : nil, + transferContext: index % 3 == 0 ? OnchainSendTransferContext( + clientBalanceSats: 900, txTotalSats: 1100, + preTransferOnchainSats: 1100, originalOrderFeeSats: 1000 + ) : nil ) return true } catch { return false } From 2f6a202a6300bd678165e360a4c56b0eb94f37eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 18:28:05 +0200 Subject: [PATCH 53/77] fix: retain payment guard when proof rollback fails --- .../Services/OnchainSendAttemptService.swift | 6 ++- .../Services/PaykitPaymentProofService.swift | 37 +++++++++----- .../Wallets/Send/SendConfirmationView.swift | 17 ++++--- .../PaykitPaymentProofServiceTests.swift | 51 +++++++++++++++++++ 4 files changed, 90 insertions(+), 21 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index c22cf92a2..67997706d 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -92,6 +92,7 @@ enum OnchainSendAttemptError: LocalizedError { case retryConstruction case retryUnavailable case preDispatch(Error) + case preDispatchCleanupFailed var errorDescription: String? { switch self { @@ -101,7 +102,7 @@ enum OnchainSendAttemptError: LocalizedError { t("wallet__onchain_send_duplicate") case .outcomeNotSaved: t("wallet__onchain_outcome_save_failed") - case .localFollowupNotSaved: + case .localFollowupNotSaved, .preDispatchCleanupFailed: t("wallet__onchain_followup_failed") case .retryConstruction: t("wallet__onchain_retry_construction") @@ -429,6 +430,9 @@ actor OnchainSendAttemptService { try await beforeBroadcastAttempt() try checkWallet(lightningService, index: walletIndex, node: dispatchNode) } catch { + if case OnchainSendAttemptError.preDispatchCleanupFailed = error { + throw OnchainSendAttemptError.unresolved + } let preDispatchError = error do { try clearBeforeDispatch(attemptId: attemptId) } catch { throw OnchainSendAttemptError.unresolved } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 6a2066622..de30da68f 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1001,21 +1001,30 @@ actor PaykitPaymentProofService { return true } - func failOnchainPayment(_ request: PaykitPaymentRequest, paymentIdentity: String? = nil) async { - if let paymentIdentity { - await removeProofs { - PubkyPublicKeyFormat.matches($0.identity, paymentIdentity) && $0.requestId == request.id && - $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && $0.paymentStarted && - $0.paymentIdentifier == nil && $0.proofData == nil + @discardableResult + func failOnchainPayment(_ request: PaykitPaymentRequest, paymentIdentity: String? = nil) async -> Bool { + let liveIdentity = paymentIdentity == nil ? try? await currentIdentity() : nil + return await mutationLock.withLock { + do { + let pendingProofs = try await loadProofs() + let candidates = pendingProofs.filter { + $0.requestId == request.id && $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet && + $0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil + } + guard !candidates.isEmpty else { return true } + let candidateIdentities = Set(candidates.compactMap { PubkyPublicKeyFormat.normalized($0.identity) }) + guard let targetIdentity = paymentIdentity ?? liveIdentity ?? + (candidateIdentities.count == 1 ? candidateIdentities.first : nil) + else { return false } + let remainingProofs = pendingProofs.filter { proof in + !(candidates.contains(proof) && PubkyPublicKeyFormat.matches(proof.identity, targetIdentity)) + } + if remainingProofs != pendingProofs { try await persist(remainingProofs) } + return true + } catch { + logWarning("Failed to roll back an undispatched Paykit payment proof: \(error)") + return false } - return - } - await removeRequestProofs(request) { - $0.kind == .onchain && - !$0.hasUnsupportedOnchainWallet && - $0.paymentStarted && - $0.paymentIdentifier == nil && - $0.proofData == nil } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 8c6a6bbba..bf9f67199 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -781,7 +781,7 @@ struct SendConfirmationView: View { request: PaykitPaymentRequest?, prepareBroadcast: @escaping (PaykitPaymentRequest) async throws -> Void, authorize: @escaping (PaykitPaymentRequest) async throws -> Void, - onAuthorizationFailure: @escaping (Error) async -> Void, + onAuthorizationFailure: @escaping (Error) async throws -> Void, onAuthorized: @escaping (PaykitPaymentRequest) async -> Void, send: (@escaping () async throws -> Void) async throws -> Result ) async throws -> Result { @@ -791,7 +791,7 @@ struct SendConfirmationView: View { do { try await authorize(request) } catch { - await onAuthorizationFailure(error) + try await onAuthorizationFailure(error) throw error } await onAuthorized(request) @@ -1104,7 +1104,9 @@ struct SendConfirmationView: View { authorize: { try await paykitPaymentRequestManager.ensurePaymentAllowed($0) }, onAuthorizationFailure: { _ in guard let incomingPaymentRequest else { return } - await PaykitPaymentProofService.shared.failOnchainPayment(incomingPaymentRequest, paymentIdentity: originalPaymentIdentity) + guard await PaykitPaymentProofService.shared.failOnchainPayment( + incomingPaymentRequest, paymentIdentity: originalPaymentIdentity + ) else { throw OnchainSendAttemptError.preDispatchCleanupFailed } }, onAuthorized: { _ in onchainPaymentStarted = true @@ -1210,8 +1212,11 @@ struct SendConfirmationView: View { onchainPaymentStarted: onchainPaymentStarted, error: error ) if onchainPaymentStarted, privatePaymentListOutcome == .definitePreBroadcastFailure { - await PaykitPaymentProofService.shared.failOnchainPayment(incomingPaymentRequest, paymentIdentity: originalPaymentIdentity) - onchainPaymentStarted = false + let rolledBack = await PaykitPaymentProofService.shared.failOnchainPayment( + incomingPaymentRequest, paymentIdentity: originalPaymentIdentity + ) + onchainPaymentStarted = !rolledBack + if !rolledBack { privatePaymentListOutcome = .uncertain } } else if onchainPaymentStarted { shouldCancelPaymentProof = false await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) @@ -1228,7 +1233,7 @@ struct SendConfirmationView: View { } if let attemptError = error as? OnchainSendAttemptError { switch attemptError { - case .unresolved, .duplicate, .outcomeNotSaved, .localFollowupNotSaved, .retryConstruction, .retryUnavailable: + case .unresolved, .duplicate, .outcomeNotSaved, .localFollowupNotSaved, .retryConstruction, .retryUnavailable, .preDispatchCleanupFailed: await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) shouldCancelPaymentProof = false app.toast(attemptError) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index cb480fcbe..079f5e276 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -2101,6 +2101,57 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(remainingProofs, [onchainProof]) } + func testAuthorizationRollbackFailureKeepsMatchingAttemptWithoutBroadcast() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let attemptsStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptsStore) + let sender = PreparedAttemptNodeMock() + sender.amount = request.amountSats + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store, attemptService: attempts + ) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + do { + _ = try await SendConfirmationView.sendOnchainPayment( + request: request, + prepareBroadcast: { request in + try await service.markOnchainPaymentStarted(request, address: self.onchainAddress, paymentIdentity: self.identity) + }, + authorize: { _ in + await store.failNextSave() + throw CancellationError() + }, + onAuthorizationFailure: { _ in + guard await service.failOnchainPayment(request, paymentIdentity: self.identity) else { + throw OnchainSendAttemptError.preDispatchCleanupFailed + } + }, + onAuthorized: { _ in }, + send: { beforeBroadcast in + try await attempts.send( + using: sender, address: self.onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, + requestId: request.id, paymentIdentity: self.identity, beforeBroadcastAttempt: beforeBroadcast + ) + } + ) + XCTFail("Cancelled authorization completed") + } catch {} + let proofs = await store.snapshot() + XCTAssertEqual(proofs.first?.paymentStarted, true) + XCTAssertNil(proofs.first?.paymentIdentifier) + XCTAssertEqual(attemptsStore.snapshot().first?.requestId, request.id, + "Failed proof rollback must keep its matching recovery guard") + XCTAssertEqual(sender.broadcasts, 0) + let rolledBack = await service.failOnchainPayment(request, paymentIdentity: identity) + XCTAssertTrue(rolledBack, "A later successful cleanup must remove the retained proof") + let remainingProofs = await store.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + } + func testDefiniteOnchainFailureClearsStartedProof() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) From d14c64aed9e3eb2204ea4bdc1682724ebb7285d7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 18:34:20 +0200 Subject: [PATCH 54/77] fix: release unsigned payments abandoned before dispatch --- .../Services/OnchainSendAttemptService.swift | 6 +++ .../OnchainSendAttemptServiceTests.swift | 41 ++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 67997706d..32a4e1f62 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -676,6 +676,12 @@ actor OnchainSendAttemptService { let attempts = try store.load() guard attempts.count <= 1 else { throw OnchainSendAttemptError.unresolved } guard let saved = attempts.first else { return nil } + if knownAttempt?.id != saved.id, nativeDispatchInProgress == nil, + saved.status == .pending, saved.txid == nil, saved.recoveryContext == nil, saved.requestId == nil + { + try store.save([]) + return nil + } return knownAttempt?.id == saved.id ? knownAttempt : saved } diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 3dd059f9a..cc2a93923 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -680,18 +680,39 @@ final class OnchainSendAttemptServiceTests: XCTestCase { XCTAssertTrue(store.snapshot().isEmpty) } - func testPendingWithoutTxidStillBlocksAfterServiceRestart() async throws { - let store = MemoryAttemptStore() - _ = try await admit(OnchainSendAttemptService(store: store)) - let restarted = OnchainSendAttemptService(store: store) - - do { + func testUnsignedOrdinaryAttemptDoesNotBlockAfterServiceRestart() async throws { + for orderId in [nil, "original-order"] { + let store = MemoryAttemptStore() + let originalService = OnchainSendAttemptService(store: store) + _ = try await originalService.admit( + walletId: WalletScope.default, requestId: nil, orderId: orderId, + address: "bcrt1qexample", amountSats: 1000, isMaxAmount: false + ) + let live = try await originalService.unresolvedAttempt(walletId: WalletScope.default) + XCTAssertNotNil(live, "A live preparation must remain guarded") + let restarted = OnchainSendAttemptService(store: store) + let wallet = PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet( + kind: "software", network: "regtest", binding: String(repeating: "12", count: 32), sourceIndex: "0" + ) + let snapshot = try await restarted.backupSnapshot(wallet: wallet, proofs: []) + XCTAssertNil(snapshot, "An unsigned abandoned admission must not stall wallet backup") + let unresolved = try await restarted.unresolvedAttempt(walletId: WalletScope.default) + XCTAssertNil(unresolved, "No signed receipt means native dispatch could not have happened") _ = try await admit(restarted) - XCTFail("A crash gap without a txid admitted another send") - } catch let error as OnchainSendAttemptError { - guard case .unresolved = error else { return XCTFail("Expected an unresolved-attempt guard") } } - XCTAssertNil(store.snapshot().first?.txid) + } + + func testUnsignedShopAttemptStillRequiresProofCleanupAfterRestart() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + _ = try await service.admit( + walletId: WalletScope.default, + requestId: .init(paymentRequestId: "original-request", counterparty: "payer", billingPeriodStartsAt: nil), + orderId: nil, address: "bcrt1qexample", amountSats: 1000, isMaxAmount: false + ) + let restarted = OnchainSendAttemptService(store: store) + let pending = try await restarted.unresolvedAttempt(walletId: WalletScope.default) + XCTAssertNotNil(pending, "Shop proof cleanup must precede releasing its guard") } func testRejectedAndUnknownRetainTxidAndBlockAnotherSend() async throws { From b797647f2f50b8d4e9a3fe2458c40cc66ab9dc13 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:08:24 +0200 Subject: [PATCH 55/77] fix: preserve recurring payment identity in backups --- Bitkit/Models/PaykitPaymentStateBackup.swift | 4 +-- .../Services/OnchainSendAttemptService.swift | 5 +++- .../PaykitPaymentStateBackupTests.swift | 30 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index d66dc218e..3bfd51b43 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -209,12 +209,12 @@ struct PaykitPaymentStateBackup: Codable { let followup: Followup? let transfer: Transfer? - init(_ attempt: OnchainSendAttempt, wallet: Wallet) throws { + init(_ attempt: OnchainSendAttempt, wallet: Wallet, billingPeriod: PaykitBillingPeriod? = nil) throws { guard attempt.walletId == wallet.originalWalletId else { throw invalidBackup("On-chain attempt belongs to another backup wallet") } version = 1 self.wallet = wallet attemptId = attempt.id.uuidString.lowercased() - requestId = attempt.requestId.map { RequestID($0, billingPeriod: nil) } + requestId = attempt.requestId.map { RequestID($0, billingPeriod: billingPeriod) } orderId = attempt.orderId payerIdentity = attempt.recoveryContext?.paymentIdentity address = attempt.address diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 32a4e1f62..f49fc1b76 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -343,7 +343,10 @@ actor OnchainSendAttemptService { { throw OnchainSendAttemptError.unresolved } - let wire = try PaykitPaymentStateBackup.ActiveOnchainAttempt(attempt, wallet: wallet) + let billingPeriod = proofs.first { + $0.requestId == attempt.requestId && $0.identity == attempt.recoveryContext?.paymentIdentity + }?.billingPeriod + let wire = try PaykitPaymentStateBackup.ActiveOnchainAttempt(attempt, wallet: wallet, billingPeriod: billingPeriod) _ = try wire.restored(wallet: wallet, proofs: proofs) return wire } diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index b3df4fa8f..d5a7f4c29 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -162,6 +162,36 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertEqual(again.0, restored.0) } + func testRecurringActiveAttemptPreservesExactBillingTimestamp() async throws { + for timestamp in ["2026-09-24T10:00:00.100Z", "2026-09-24T10:00:00.123456789Z"] { + var envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: Self.completeAttemptGolden()) as? [String: Any]) + var state = try XCTUnwrap(envelope["paykitPaymentState"] as? [String: Any]) + var active = try XCTUnwrap(state["activeOnchainAttempt"] as? [String: Any]) + var request = try XCTUnwrap(active["requestId"] as? [String: Any]) + request["billingPeriodStartsAt"] = timestamp + active["requestId"] = request + var proofs = try XCTUnwrap(state["pendingProofs"] as? [[String: Any]]) + proofs[0]["requestId"] = request + proofs[0]["billingPeriod"] = ["startsAt": timestamp, "endsAt": "2026-09-25T10:00:00.123456789Z"] + state["activeOnchainAttempt"] = active + state["pendingProofs"] = proofs + envelope["paykitPaymentState"] = state + let decoded = try JSONDecoder().decode(WalletBackupV1.self, from: JSONSerialization.data(withJSONObject: envelope)) + let paymentState = try XCTUnwrap(decoded.paykitPaymentState) + let restoredProofs = try paymentState.pendingProofs.map { try $0.restored() } + let attempt = try XCTUnwrap(paymentState.activeOnchainAttempt).restored( + wallet: Self.goldenWallet(), proofs: restoredProofs + ).0 + let store = MemoryAttemptStore() + try store.save([attempt]) + let service = OnchainSendAttemptService(store: store) + let backup = try await service.backupSnapshot(wallet: Self.goldenWallet(), proofs: restoredProofs) + let snapshot = try XCTUnwrap(backup) + XCTAssertEqual(snapshot.requestId?.billingPeriodStartsAt, timestamp) + XCTAssertEqual(snapshot.requestId?.billingPeriodStartsAt, paymentState.pendingProofs.first?.requestId.billingPeriodStartsAt) + } + } + func testHardwareBackupRequiresCompleteSignedReceipt() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let proof = try XCTUnwrap(envelope.paykitPaymentState?.pendingProofs.first) From db7dcc906b747a7b9e806f12a6bc6e76a42ec18c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:13:03 +0200 Subject: [PATCH 56/77] fix: serialize ordinary payment activity completion --- .../Services/OnchainSendAttemptService.swift | 4 ++ .../OnchainSendAttemptServiceTests.swift | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index f49fc1b76..75c89641c 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -318,6 +318,7 @@ actor OnchainSendAttemptService { private var knownAttempt: OnchainSendAttempt? private var nativeDispatchInProgress: UUID? private var requestFollowupInProgress: UUID? + private var ordinaryFollowupInProgress: UUID? init( store: any OnchainSendAttemptStoring = OnchainSendAttemptStore(), @@ -701,6 +702,7 @@ actor OnchainSendAttemptService { func resumeAcceptedOrdinarySend(walletId: String, observedTxid: String? = nil, pendingContext: OnchainSendPendingContext? = nil) async throws -> OnchainSendLocalResolution? { + guard ordinaryFollowupInProgress == nil else { return nil } guard var attempt = try currentAttempt(), attempt.walletId == walletId, attempt.requestId == nil, attempt.orderId == nil, let txid = attempt.txid else { return nil } @@ -732,6 +734,8 @@ actor OnchainSendAttemptService { attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: saved.value, contact: saved.contact, activity: saved ) } + ordinaryFollowupInProgress = attempt.id + defer { ordinaryFollowupInProgress = nil } attempt = try await winningFollowupAttempt(attempt) let activity = try await localFollowup.save(attempt) try acknowledgeLocalFollowup(txid: txid) diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index cc2a93923..b75ac7098 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -192,6 +192,27 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } + func testOrdinaryFollowupIsOwnedAcrossSuspensionAndReleasedAfterFailure() async throws { + let store = MemoryAttemptStore() + let attempt = OnchainSendAttempt(id: UUID(), walletId: "original-wallet", requestId: nil, orderId: nil, + address: "original", amountSats: 1000, isMaxAmount: false, + status: .accepted, txid: String(repeating: "ab", count: 32)) + try store.save([attempt]) + let followup = SuspendedOrdinaryFollowup() + let service = OnchainSendAttemptService(store: store, localFollowup: followup) + let first = Task { try? await service.resumeAcceptedOrdinarySend(walletId: attempt.walletId) } + await followup.waitForStart() + let competing = try? await service.resumeAcceptedOrdinarySend(walletId: attempt.walletId) + XCTAssertNil(competing) + let calls = await followup.callCount() + XCTAssertEqual(calls, 1, "A competing call must not repeat a suspended activity write") + await followup.release() + _ = await first.value + _ = try? await service.resumeAcceptedOrdinarySend(walletId: attempt.walletId) + let retriedCalls = await followup.callCount() + XCTAssertEqual(retriedCalls, calls + 1, "A failed owner must allow a later retry") + } + func testSuccessorFollowupRetainsWinningFeeInsteadOfOriginalFee() async throws { let store = MemoryAttemptStore() let followup = CapturingWinningFeeFollowup() @@ -1287,3 +1308,31 @@ private final class CapturingWinningFeeFollowup: OnchainSendLocalFollowupHandlin throw OnchainSendAttemptError.localFollowupNotSaved } } + +private actor SuspendedOrdinaryFollowup: OnchainSendLocalFollowupHandling { + private var calls = 0 + private var started: CheckedContinuation? + private var suspended: CheckedContinuation? + + func waitForStart() async { + if calls > 0 { return } + await withCheckedContinuation { started = $0 } + } + + func save(_ attempt: OnchainSendAttempt) async throws -> OnchainActivity { + calls += 1 + if calls == 1 { + started?.resume() + started = nil + await withCheckedContinuation { suspended = $0 } + } + throw OnchainSendAttemptError.localFollowupNotSaved + } + + func release() { + suspended?.resume() + suspended = nil + } + + func callCount() -> Int { calls } +} From af4a5fe46c18559eaf0c72ba9a070328b13ab1bd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:28:02 +0200 Subject: [PATCH 57/77] fix: show the original payment when a new send is blocked --- .../Services/OnchainSendAttemptService.swift | 4 ++++ .../Wallets/Send/SendConfirmationView.swift | 4 ++++ .../OnchainSendAttemptServiceTests.swift | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 75c89641c..2091eaf5c 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -927,6 +927,10 @@ actor OnchainSendAttemptService { } } + func blockingAttempt() throws -> OnchainSendAttempt? { + try currentAttemptForPending().flatMap { $0.blocksNewSend ? $0 : nil } + } + func unresolvedAttempt(walletId: String) throws -> OnchainSendAttempt? { try currentAttempt().flatMap { $0.walletId == walletId && $0.blocksNewSend ? $0 : nil } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index bf9f67199..1b7fc6e44 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -180,6 +180,10 @@ struct SendConfirmationView: View { if let context = try? await attempts.pendingContext(requestId: requestId, txid: txid) { return requestId == nil ? .onchainPending(context) : .onchainOperationPending(context, requestId: requestId) } + if let retained = try? await attempts.blockingAttempt() { + let context = OnchainSendPendingContext(attemptId: retained.id, walletId: retained.walletId, txid: retained.txid) + return .onchainOperationPending(context, requestId: retained.requestId) + } return .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId) } diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index b75ac7098..2d5f26bfb 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -7,6 +7,28 @@ final class OnchainSendAttemptServiceTests: XCTestCase { private let walletId = "node-0" private let txid = String(repeating: "ab", count: 32) + func testBlockedNewRequestRoutesToOriginalPayment() async throws { + let originalShop = PaykitPaymentRequest.ID(paymentRequestId: "original-shop", counterparty: "original-merchant", billingPeriodStartsAt: nil) + let newShop = PaykitPaymentRequest.ID(paymentRequestId: "new-shop", counterparty: "new-merchant", billingPeriodStartsAt: nil) + for originalRequest in [nil, originalShop] as [PaykitPaymentRequest.ID?] { + let store = MemoryAttemptStore() + let retained = OnchainSendAttempt(id: UUID(), walletId: walletId, requestId: originalRequest, orderId: nil, + address: "bcrt1qoriginal", amountSats: 1000, isMaxAmount: false, + status: .unknown, txid: txid) + try store.save([retained]) + let service = OnchainSendAttemptService(store: store) + let route = await SendConfirmationView.onchainPendingRoute(requestId: newShop, using: service) + guard case let .onchainOperationPending(context, requestId) = route else { + return XCTFail("Blocked new request must display the original payment") + } + XCTAssertEqual(context.attemptId, retained.id) + XCTAssertEqual(context.walletId, retained.walletId) + XCTAssertEqual(context.txid, retained.txid) + XCTAssertEqual(requestId, originalRequest) + XCTAssertEqual(store.snapshot(), [retained]) + } + } + func testAcceptedConfirmationStaysPendingUntilOriginalFollowupCompletes() async throws { let shopRequest = PaykitPaymentRequest.ID(paymentRequestId: "original-shop-order", counterparty: "original-merchant", billingPeriodStartsAt: nil) for requestId in [nil, shopRequest] as [PaykitPaymentRequest.ID?] { From c705902e2c18cf2c92125184e3f1b580b3f58d3f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:44:47 +0200 Subject: [PATCH 58/77] fix: bound prepared funding by the approved total --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Bitkit/Services/OnchainSendAttemptService.swift | 11 +++++++---- BitkitTests/OnchainSendAttemptServiceTests.swift | 13 +++++++++---- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f808c8a29..a35f5fd88 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1202,7 +1202,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.70"; + version = "0.7.0-rc.71"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5a82c990b..66adc6480 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "71953e8081966dcd83a64fe255e8662b658aec38", - "version" : "0.7.0-rc.70" + "revision" : "daeee4d2cb7164680ea686c0c7623cf866636310", + "version" : "0.7.0-rc.71" } }, { diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 2091eaf5c..4bdad80d5 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -19,7 +19,7 @@ extension LightningService: OnchainSending { expectedWalletIndex: Int, expectedNode: AnyObject?, paymentDeadline: PaykitPreciseInstant? ) async throws -> PreparedOnchainSendDispatch { guard let node = onchainDispatchNode as? Node else { throw NodeError.NotRunning(message: "Node not set up") } - let (prepared, txid, inputs, recipientAmountSats) = try await ServiceQueue.background(.ldk, wrapErrors: false) { + let (prepared, txid, inputs, recipientAmountSats, miningFeeSats) = try await ServiceQueue.background(.ldk, wrapErrors: false) { guard self.currentWalletIndex == expectedWalletIndex, self.onchainDispatchNode === node, expectedNode === node else { throw NodeError.NotRunning(message: "Wallet or node changed before on-chain preparation") } @@ -36,10 +36,10 @@ extension LightningService: OnchainSending { ) } return (prepared, prepared.txid(), prepared.inputs().map { OnchainSendInput(txid: $0.txid, vout: $0.vout) }, - prepared.recipientAmountSats()) + prepared.recipientAmountSats(), prepared.miningFeeSats()) } return PreparedOnchainSendDispatch( - txid: txid, inputs: inputs, recipientAmountSats: recipientAmountSats, + txid: txid, inputs: inputs, recipientAmountSats: recipientAmountSats, miningFeeSats: miningFeeSats, broadcast: { try await ServiceQueue.background(.ldk, wrapErrors: false) { guard self.currentWalletIndex == expectedWalletIndex, self.onchainDispatchNode === node, expectedNode === node else { @@ -66,6 +66,7 @@ struct PreparedOnchainSendDispatch { let txid: String let inputs: [OnchainSendInput] let recipientAmountSats: UInt64 + var miningFeeSats: UInt64? = nil let broadcast: () async throws -> OnchainSendResult } @@ -418,8 +419,10 @@ actor OnchainSendAttemptService { if orderId != nil && isMaxAmount { guard let context = attempt.transferContext, let originalFee = context.originalOrderFeeSats, + let miningFee = prepared.miningFeeSats, + miningFee <= context.txTotalSats, prepared.recipientAmountSats >= originalFee, - prepared.recipientAmountSats <= context.txTotalSats + prepared.recipientAmountSats <= context.txTotalSats - miningFee else { throw OnchainSendAttemptError.unresolved } } attempt.amountSats = prepared.recipientAmountSats diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 2d5f26bfb..aaab8efba 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -171,23 +171,27 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } func testPreparedMaxFundingMustRespectOriginalOrderBeforeBroadcast() async throws { - for amount: UInt64 in [98999, 100001, 99500] { + for (amount, fee, valid): (UInt64, UInt64?, Bool) in [ + (98999, 500, false), (100001, 500, false), (99500, 500, true), + (99500, nil, false), (99500, 501, false), (99500, UInt64.max, false) + ] { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) let sender = PreparedAttemptNodeMock() sender.amount = amount + sender.miningFee = fee let context = OnchainSendTransferContext(clientBalanceSats: 97000, txTotalSats: 100000, preTransferOnchainSats: 100000, originalOrderFeeSats: 99000) do { _ = try await service.send(using: sender, address: "original", amountSats: 99000, satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: true, orderId: "original-order", transferContext: context) - XCTAssertEqual(amount, 99500) + XCTAssertTrue(valid, "Funding total must include its actual mining fee") XCTAssertEqual(sender.broadcasts, 1) XCTAssertEqual(store.snapshot().first?.amountSats, 99500) XCTAssertEqual(store.snapshot().first?.transferContext, context) } catch { - XCTAssertNotEqual(amount, 99500, "Valid surplus funding must remain supported") + XCTAssertFalse(valid, "Valid surplus funding must remain supported") XCTAssertEqual(sender.broadcasts, 0) XCTAssertTrue(store.snapshot().isEmpty) } @@ -1278,6 +1282,7 @@ final class PreparedAttemptNodeMock: OnchainSending { var txid = String(repeating: "ab", count: 32) var amount: UInt64 = 1234 + var miningFee: UInt64? = 500 var inputs = [OnchainSendInput(txid: String(repeating: "ef", count: 32), vout: 0)] var preparations = 0 var broadcasts = 0 @@ -1306,7 +1311,7 @@ final class PreparedAttemptNodeMock: OnchainSending { lastInputs = utxosToSpend?.map { OnchainSendInput(txid: $0.outpoint.txid, vout: $0.outpoint.vout) } try await onPrepare?() let candidateId = txid - return PreparedOnchainSendDispatch(txid: candidateId, inputs: inputs, recipientAmountSats: amount) { + return PreparedOnchainSendDispatch(txid: candidateId, inputs: inputs, recipientAmountSats: amount, miningFeeSats: miningFee) { self.onNativeQueueDispatch?() try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: self.deadlineClock()) self.broadcasts += 1 From 3c1f4ae59548ad317ccff2d63f5b81be73d83758 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 20:00:48 +0200 Subject: [PATCH 59/77] fix: recover the original recurring payment period --- .../Services/PaykitPaymentProofService.swift | 25 +++++-- .../PaykitPaymentProofServiceTests.swift | 69 +++++++++++++++++-- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index de30da68f..4994fb802 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -465,11 +465,26 @@ actor PaykitPaymentProofService { guard let record = records.first(where: { $0.localRole == .payer && $0.paymentRequestId == requestId.paymentRequestId && PubkyPublicKeyFormat.matches($0.counterparty, requestId.counterparty) - }), let request = PaykitPaymentRequest(record: record, now: Date()), - request.id == requestId, request.amountSats == attempt.amountSats, !request.requiresAcceptance, - request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier), - !request.isExpired(at: Date()), record.state != .proofSubmitted, - !record.paymentProofs.contains(where: { Self.billingPeriod($0.billingPeriod, matches: proof.billingPeriod) }) + }) else { throw PaykitPaymentRequestError.requestUnavailable } + let now = Date() + let request: PaykitPaymentRequest + if let period = proof.billingPeriod { + guard let subscription = PaykitSubscription(record: record), subscription.isPayer, + subscription.isActive(at: now), !subscription.hasPaymentDeadline, + subscription.recurrence.unit.isSupported, subscription.recurrence.canMaterializePeriods, + period.startsAt <= now, subscription.recurrence.contains(period) + else { throw PaykitPaymentRequestError.requestUnavailable } + request = PaykitPaymentRequest(subscription: subscription, billingPeriod: period, lifecycleState: .activeRecurring) + } else { + guard let original = PaykitPaymentRequest(record: record, now: now) else { + throw PaykitPaymentRequestError.requestUnavailable + } + request = original + } + guard request.id == requestId, request.amountSats == attempt.amountSats, !request.requiresAcceptance, + request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier), + !request.isExpired(at: Date()), record.state != .proofSubmitted, + !record.paymentProofs.contains(where: { Self.billingPeriod($0.billingPeriod, matches: proof.billingPeriod) }) else { throw PaykitPaymentRequestError.requestUnavailable } try await requireRecoveryPayer(payer) if restoreStartedProof && !proof.paymentStarted { diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 079f5e276..bf7f71c39 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -134,6 +134,65 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } + func testRecurringRecoveryKeepsOriginalUnpaidBillingPeriod() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "2026-10-01T08:00:00.123456789Z", + anchor: "2026-10-01T08:00:00.123456789Z", endsAt: nil + ) + let record = try paymentRequestRecord(endpoints: [endpoint], state: .activeRecurring, recurrence: recurrence) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = try XCTUnwrap(subscription.requests(through: Date(), acceptedAt: PaykitPreciseInstant(date: Date())).first) + let period = try XCTUnwrap(request.billingPeriod) + let attemptsStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptsStore) + let sender = PreparedAttemptNodeMock() + sender.amount = request.amountSats + _ = try await attempts.send(using: sender, address: onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, + requestId: request.id, paymentIdentity: identity) + let original = try XCTUnwrap(attemptsStore.snapshot().first) + let proof = PendingPaykitPaymentProof( + identity: identity, requestId: request.id, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, + kind: .onchain, billingPeriod: period, paymentStarted: true, + paymentIdentifier: original.txid, proofData: nil, onchainAddress: onchainAddress, onchainAmountSats: request.amountSats + ) + let store = PaymentProofMemoryStore() + await store.seed([proof]) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts) + let authorized = try await service.authorizeOnchainRecovery(original, restoreStartedProof: false) + XCTAssertEqual(authorized.id, request.id) + XCTAssertEqual(authorized.billingPeriod, period) + XCTAssertEqual(authorized.amountSats, original.amountSats) + let saved = await store.snapshot() + let submissions = await sdk.submissionCount() + XCTAssertEqual(saved, [proof]) + XCTAssertEqual(submissions, 0) + XCTAssertEqual(sender.broadcasts, 1) + let settledProof = try paymentProofRecord(endpoint: endpoint, kind: .onchain, + data: try XCTUnwrap(original.txid), billingPeriod: period.sdkValue) + let invalidRecords = [ + try paymentRequestRecord(endpoints: [endpoint], paymentProofs: [settledProof], state: .activeRecurring, recurrence: recurrence), + try paymentRequestRecord(endpoints: [endpoint], state: .canceled, recurrence: recurrence), + try paymentRequestRecord(endpoints: [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], + state: .activeRecurring, recurrence: recurrence), + ] + for invalid in invalidRecords { + let invalidSdk = PaymentProofSdkMock(identity: identity, records: [invalid]) + let invalidService = paymentProofService(sdk: invalidSdk, store: store, attemptService: attempts) + do { + _ = try await invalidService.authorizeOnchainRecovery(original, restoreStartedProof: false) + XCTFail("Authorized a settled, canceled or changed-endpoint recurring payment") + } catch {} + let unchanged = await store.snapshot() + let invalidSubmissions = await invalidSdk.submissionCount() + XCTAssertEqual(unchanged, [proof]) + XCTAssertEqual(invalidSubmissions, 0) + } + XCTAssertEqual(sender.broadcasts, 1) + } + func testHardwarePredispatchReleaseMatchesOnlyOriginalProofAndFailsClosedOnSave() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -1190,10 +1249,10 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await sdk.waitForSubmissionStart() await sdk.setIdentityAvailable(false) try await attempts.acknowledgeLocalFollowup(txid: txid) - _ = try await attempts.admit( - walletId: "node-0", requestId: nil, orderId: nil, - address: "bcrt1qnew", amountSats: 9000, isMaxAmount: false - ) + let laterSender = PreparedAttemptNodeMock() + laterSender.amount = 9000 + _ = try await attempts.send(using: laterSender, address: "bcrt1qnew", amountSats: 9000, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false) let countBefore = await sdk.submissionCount() await sdk.setIdentityAvailable(true) await sdk.setSubmissionFailure(false) @@ -1205,7 +1264,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let submitted = await sdk.lastSubmission() XCTAssertGreaterThan(countAfter, countBefore) XCTAssertEqual(try proofValues(XCTUnwrap(submitted).proof.exportText()), ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue]) - XCTAssertEqual(attemptStore.snapshot().first?.status, .pending, "Earlier paid proof mutated the later send guard") + XCTAssertEqual(attemptStore.snapshot().first?.status, .unknown, "Earlier paid proof mutated the later send guard") } func testOnchainProofWriterRequiresExactPositiveAttemptEvidence() async throws { From 40d6fb84e796f95f5048ff79ce20242b06fed03d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:00:25 +0200 Subject: [PATCH 60/77] fix: release definitely unsent expired payments --- .../Services/OnchainSendAttemptService.swift | 28 +++++++++-- Bitkit/ViewModels/WalletViewModel.swift | 6 ++- .../Wallets/Send/SendConfirmationView.swift | 8 ++- .../OnchainSendAttemptServiceTests.swift | 27 +++++++++- .../PaykitPaymentProofServiceTests.swift | 50 +++++++++++++++++++ 5 files changed, 111 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 4bdad80d5..ed1b8cf90 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -42,10 +42,14 @@ extension LightningService: OnchainSending { txid: txid, inputs: inputs, recipientAmountSats: recipientAmountSats, miningFeeSats: miningFeeSats, broadcast: { try await ServiceQueue.background(.ldk, wrapErrors: false) { - guard self.currentWalletIndex == expectedWalletIndex, self.onchainDispatchNode === node, expectedNode === node else { - throw NodeError.NotRunning(message: "Wallet or node changed before on-chain dispatch") + do { + guard self.currentWalletIndex == expectedWalletIndex, self.onchainDispatchNode === node, expectedNode === node else { + throw NodeError.NotRunning(message: "Wallet or node changed before on-chain dispatch") + } + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) + } catch { + throw PreparedOnchainSendNotSubmitted(underlying: error) } - try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) return try prepared.broadcast() } } @@ -62,6 +66,11 @@ struct OnchainSendInput: Codable, Equatable, Hashable { } } +// Produced only by the adapter before entering the native broadcast call. +struct PreparedOnchainSendNotSubmitted: Error { + let underlying: Error +} + struct PreparedOnchainSendDispatch { let txid: String let inputs: [OnchainSendInput] @@ -377,7 +386,8 @@ actor OnchainSendAttemptService { followupContext: OnchainSendFollowupContext? = nil, transferContext: OnchainSendTransferContext? = nil, paymentDeadline: PaykitPreciseInstant? = nil, - beforeBroadcastAttempt: () async throws -> Void = {} + beforeBroadcastAttempt: () async throws -> Void = {}, + onPreDispatchFailure: (Error) async throws -> Void = { _ in throw OnchainSendAttemptError.preDispatchCleanupFailed } ) async throws -> OnchainSendResult { guard nativeDispatchInProgress == nil else { throw OnchainSendAttemptError.unresolved } let walletIndex = lightningService.currentWalletIndex @@ -452,6 +462,16 @@ actor OnchainSendAttemptService { let result: OnchainSendResult // Once broadcast starts, any thrown error is ambiguous. Never release its receipt. do { result = try await normalized(prepared.broadcast(), candidate: prepared.txid) } + catch let failure as PreparedOnchainSendNotSubmitted { + // Remove the exact durable proof before releasing its matching wallet guard. + do { + if requestId != nil { try await onPreDispatchFailure(failure.underlying) } + try clearBeforeDispatch(attemptId: attemptId) + } catch { + throw OnchainSendAttemptError.unresolved + } + throw OnchainSendAttemptError.preDispatch(failure.underlying) + } catch { result = .unknown(txid: prepared.txid) } do { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index edc147c92..99f0bc2fb 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -632,7 +632,8 @@ class WalletViewModel: ObservableObject { paymentIdentity: String? = nil, followupContext: OnchainSendFollowupContext? = nil, paymentDeadline: PaykitPreciseInstant? = nil, - beforeBroadcastAttempt: () async throws -> Void = {} + beforeBroadcastAttempt: () async throws -> Void = {}, + onPreDispatchFailure: (Error) async throws -> Void = { _ in throw OnchainSendAttemptError.preDispatchCleanupFailed } ) async throws -> OnchainSendResult { guard let selectedFeeRateSatsPerVByte else { throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.") @@ -655,7 +656,8 @@ class WalletViewModel: ObservableObject { paymentIdentity: paymentIdentity, followupContext: followupContext, paymentDeadline: paymentDeadline, - beforeBroadcastAttempt: beforeBroadcastAttempt + beforeBroadcastAttempt: beforeBroadcastAttempt, + onPreDispatchFailure: onPreDispatchFailure ) if case .accepted = result { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 1b7fc6e44..c757d0c20 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1128,7 +1128,13 @@ struct SendConfirmationView: View { createdAt: UInt64(Date().timeIntervalSince1970) ), paymentDeadline: incomingPaymentRequest?.paymentDeadline, - beforeBroadcastAttempt: beforeBroadcastAttempt + beforeBroadcastAttempt: beforeBroadcastAttempt, + onPreDispatchFailure: { _ in + guard let incomingPaymentRequest else { return } + guard await PaykitPaymentProofService.shared.failOnchainPayment( + incomingPaymentRequest, paymentIdentity: originalPaymentIdentity + ) else { throw OnchainSendAttemptError.preDispatchCleanupFailed } + } ) } ) diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index aaab8efba..dd456dcc4 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -117,6 +117,30 @@ final class OnchainSendAttemptServiceTests: XCTestCase { XCTAssertTrue(restored.canRetrySamePayment) } + func testInitialQueuedExpiryReleasesDefinitelyUnsentAttempt() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + let deadlineDate = Date(timeIntervalSince1970: 1_800_000_000) + sender.deadlineClock = { deadlineDate.addingTimeInterval(-1) } + sender.onNativeQueueDispatch = { sender.deadlineClock = { deadlineDate.addingTimeInterval(1) } } + do { + _ = try await service.send( + using: sender, address: "original", amountSats: sender.amount, satsPerVbyte: 1, + utxosToSpend: nil, isMaxAmount: false, + paymentDeadline: PaykitPreciseInstant(date: deadlineDate) + ) + XCTFail("Queued expiry must surface a definite pre-submission failure") + } catch OnchainSendAttemptError.preDispatch {} + XCTAssertEqual(sender.broadcasts, 0) + XCTAssertTrue(store.snapshot().isEmpty) + let restarted = OnchainSendAttemptService(store: store) + sender.onNativeQueueDispatch = nil + _ = try await restarted.send(using: sender, address: "next", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + XCTAssertEqual(sender.broadcasts, 1) + } + func testOriginalRetryChecksDeadlineAfterAuthorizationBeforeNativeDispatch() async throws { for expired in [false, true] { let store = MemoryAttemptStore() @@ -1313,7 +1337,8 @@ final class PreparedAttemptNodeMock: OnchainSending { let candidateId = txid return PreparedOnchainSendDispatch(txid: candidateId, inputs: inputs, recipientAmountSats: amount, miningFeeSats: miningFee) { self.onNativeQueueDispatch?() - try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: self.deadlineClock()) + do { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: self.deadlineClock()) } + catch { throw PreparedOnchainSendNotSubmitted(underlying: error) } self.broadcasts += 1 try await self.onBroadcast?() return self.result ?? .unknown(txid: candidateId) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index bf7f71c39..350cde519 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -2160,6 +2160,56 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(remainingProofs, [onchainProof]) } + func testInitialQueuedExpiryRollsBackProofBeforeReleasingAttempt() async throws { + for failCleanup in [false, true] { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let attemptsStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptsStore) + let sender = PreparedAttemptNodeMock() + sender.amount = request.amountSats + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store, attemptService: attempts + ) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + let deadline = Date(timeIntervalSince1970: 1_800_000_000) + sender.deadlineClock = { deadline.addingTimeInterval(-1) } + sender.onNativeQueueDispatch = { sender.deadlineClock = { deadline.addingTimeInterval(1) } } + do { + _ = try await attempts.send( + using: sender, address: onchainAddress, amountSats: request.amountSats, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, + requestId: request.id, paymentIdentity: identity, + paymentDeadline: PaykitPreciseInstant(date: deadline), + beforeBroadcastAttempt: { + try await service.markOnchainPaymentStarted(request, address: self.onchainAddress, paymentIdentity: self.identity) + if failCleanup { await store.failNextSave() } + }, + onPreDispatchFailure: { _ in + XCTAssertEqual(attemptsStore.snapshot().first?.requestId, request.id) + guard await service.failOnchainPayment(request, paymentIdentity: self.identity) else { + throw OnchainSendAttemptError.preDispatchCleanupFailed + } + } + ) + XCTFail("Definitely unsent expiry must not become Unknown") + } catch OnchainSendAttemptError.preDispatch { + XCTAssertFalse(failCleanup) + } catch OnchainSendAttemptError.unresolved { + XCTAssertTrue(failCleanup) + } + let proofs = await store.snapshot() + XCTAssertEqual(sender.broadcasts, 0) + XCTAssertEqual(proofs.isEmpty, !failCleanup) + XCTAssertEqual(attemptsStore.snapshot().isEmpty, !failCleanup) + let restarted = OnchainSendAttemptService(store: attemptsStore) + let stillBlocked = try await restarted.hasAttempt(for: request.id) + XCTAssertEqual(stillBlocked, failCleanup) + } + } + func testAuthorizationRollbackFailureKeepsMatchingAttemptWithoutBroadcast() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) From 8dc5f9351c243132b19201e21886ac4c0655f65c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:09:14 +0200 Subject: [PATCH 61/77] fix: authorize recovery of the saved original payment --- .../PaykitPaymentRequestService.swift | 17 ++++++++ .../Wallets/Send/SendPendingScreen.swift | 3 +- .../PaykitPaymentRequestServiceTests.swift | 42 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index daddeb40a..8d5b4f8b7 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1744,6 +1744,23 @@ final class PaykitPaymentRequestManager { } } + func ensureOnchainRecoveryAllowed( + _ attempt: OnchainSendAttempt, proofService: PaykitPaymentProofService + ) async throws -> PaykitPaymentRequest { + let generation = stateGeneration + guard let identity = activeIdentity, + PubkyPublicKeyFormat.matches(identity, attempt.recoveryContext?.paymentIdentity) + else { throw PaykitPaymentRequestError.requestUnavailable } + // Durable original-payment authorization replaces only the lost process-local approval. + let request = try await proofService.authorizeOnchainRecovery(attempt) + try await service.ensurePaymentAllowed(request) + guard generation == stateGeneration, PubkyPublicKeyFormat.matches(activeIdentity, identity), + !request.isPaymentDeadlineExpired(at: now()) + else { throw PaykitPaymentRequestError.requestUnavailable } + try await proofService.requireRecoveryPayer(identity) + return request + } + func prepareForPayment( _ request: PaykitPaymentRequest, consumePrivatePaymentList: () async throws -> Void = {} diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 1ddb41401..2830db6ee 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -273,9 +273,8 @@ struct SendPendingScreen: View { } } if attempt.requestId != nil { - let request = try await proofService.authorizeOnchainRecovery(attempt) + let request = try await paymentRequests.ensureOnchainRecoveryAllowed(attempt, proofService: proofService) guard request.paymentDeadline == paymentDeadline else { throw PaykitPaymentRequestError.requestUnavailable } - try await paymentRequests.ensurePaymentAllowed(request) guard let payer = attempt.recoveryContext?.paymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } try await proofService.requireRecoveryPayer(payer) } else if let orderId = attempt.orderId { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 4930a96fd..d8750f513 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -7378,6 +7378,48 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestsForPresentation().isEmpty) } + func testReopenedRecoveryUsesDurableOriginalWithoutNewApproval() async throws { + let identity = "pubky" + String(repeating: "z", count: 52) + let onchainAddress = "bcrt1qoriginal" + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(counterparty: "pubky" + String(repeating: "y", count: 52), state: .accepted, endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let attemptsStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptsStore) + let sender = PreparedAttemptNodeMock() + sender.amount = request.amountSats + let service = PaykitPaymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store, attemptService: attempts, logInfo: { _ in }, logWarning: { _ in } + ) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + _ = try await attempts.send( + using: sender, address: onchainAddress, amountSats: request.amountSats, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: false, requestId: request.id, paymentIdentity: identity, + beforeBroadcastAttempt: { + try await service.markOnchainPaymentStarted(request, address: onchainAddress, paymentIdentity: identity) + } + ) + let attempt = try XCTUnwrap(attemptsStore.snapshot().first) + let manager = PaykitPaymentRequestManager(service: PaykitPaymentRequestService(sdk: PaymentRequestSdkMock(records: [record]))) + manager.activate(identity: identity) + XCTAssertFalse(manager.isApprovedForPayment(request)) + let beforeProofs = await store.snapshot() + let authorized = try await manager.ensureOnchainRecoveryAllowed(attempt, proofService: service) + XCTAssertEqual(authorized.id, request.id) + XCTAssertEqual(authorized.amountSats, attempt.amountSats) + let afterProofs = await store.snapshot() + XCTAssertEqual(afterProofs, beforeProofs) + XCTAssertFalse(manager.isApprovedForPayment(request), "Recovery must not grant approval for a new payment") + manager.activate(identity: "pubky" + String(repeating: "y", count: 52)) + do { + _ = try await manager.ensureOnchainRecoveryAllowed(attempt, proofService: service) + XCTFail("Foreign payer must not recover the original payment") + } catch {} + XCTAssertEqual(sender.broadcasts, 1) + XCTAssertEqual(attemptsStore.snapshot().first, attempt) + } + private func paymentRequestManager( sdk: PaymentRequestSdkMock, scheduleAcceptedRequestDelivery: @escaping @MainActor (String) async -> Void = { _ in }, From a69b107aad136d613c9525248efcaaece0f7b82b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:20:01 +0200 Subject: [PATCH 62/77] fix: defer backups during prepared retry authorization --- Bitkit/Services/OnchainSendAttemptService.swift | 1 + BitkitTests/OnchainSendAttemptServiceTests.swift | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index ed1b8cf90..45366e8d1 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -347,6 +347,7 @@ actor OnchainSendAttemptService { func backupSnapshot(wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet, proofs: [PendingPaykitPaymentProof]) throws -> PaykitPaymentStateBackup.ActiveOnchainAttempt? { + guard nativeDispatchInProgress == nil else { throw OnchainSendAttemptError.unresolved } guard let attempt = try currentAttempt(), attempt.blocksNewSend else { return nil } if attempt.status == .pending, attempt.txid == nil, attempt.recoveryContext?.inputs.isEmpty != false, diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index dd456dcc4..1830c16c1 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -570,15 +570,24 @@ final class OnchainSendAttemptServiceTests: XCTestCase { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) let sender = PreparedAttemptNodeMock() - _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + _ = try await service.send(using: sender, address: "bcrt1qoriginal", amountSats: sender.amount, satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) let original = try XCTUnwrap(store.snapshot().first) let originalTxid = try XCTUnwrap(original.txid) + let wallet = PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet( + kind: "software", network: "regtest", binding: String(repeating: "12", count: 32), sourceIndex: "0" + ) + let beforeRetry = try await service.backupSnapshot(wallet: wallet, proofs: []) + XCTAssertNotNil(beforeRetry) sender.txid = String(repeating: "cd", count: 32) do { _ = try await service.retrySamePayment( using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), satsPerVbyte: 2, authorize: { _, _ in + do { + _ = try await service.backupSnapshot(wallet: wallet, proofs: []) + XCTFail("Authorization must not upload the undispatched retry candidate") + } catch {} if walletChanges { sender.currentWalletIndex = 1 } else { throw CancellationError() } } From afa93df4dc6d7be4d8cc0861e83d3642cc80f22d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 22:42:42 +0200 Subject: [PATCH 63/77] fix: preserve hardware completion until its payer returns --- Bitkit/AppScene.swift | 2 +- .../Services/PaykitPaymentProofService.swift | 54 ++++++++++++++----- .../Views/Wallets/Send/HwSendSignView.swift | 2 +- .../Wallets/Send/SendPendingScreen.swift | 2 +- .../PaykitPaymentProofServiceTests.swift | 20 +++++-- .../TransferServiceActivityTests.swift | 2 +- 6 files changed, 63 insertions(+), 19 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 4f8bac600..c6b7ff7e3 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1339,7 +1339,7 @@ struct AppScene: View { private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async { await Self.associateResolvedPaykitOnchainPayment(resolution, activeIdentity: pubkyProfile.publicKey, activity: activity) - await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) + await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution, activeIdentity: pubkyProfile.publicKey) } static func associateResolvedPaykitOnchainPayment( diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 4994fb802..05d6ef3e2 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -68,6 +68,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var onchainAcceptanceVerified: Bool? /// Device-local acknowledgement: backup restore reruns local activity proof. var onchainLocalFollowupComplete: Bool? + /// Retain the bounded original proof until its payer consumes the local resolution. + var onchainResolutionAwaitingConsumption: Bool? = false var hasUnsupportedOnchainWallet: Bool { kind == .onchain && onchainWalletId != nil && onchainWalletId != WalletScope.default @@ -230,7 +232,6 @@ actor PaykitPaymentProofService { } private var hardwareCompletionsInProgress: Set = [] - private var hardwareResolutionsAwaitingIdentity: Set = [] private var hardwarePaymentsInProgress: Set = [] @@ -770,6 +771,7 @@ actor PaykitPaymentProofService { var proofs = try await loadProofs() if let index = proofs.firstIndex(of: completed) { proofs[index].onchainLocalFollowupComplete = true + proofs[index].onchainResolutionAwaitingConsumption = true try await persist(proofs) return proofs[index] } @@ -784,16 +786,13 @@ actor PaykitPaymentProofService { } else { _ = await submit(completed) } - let currentIdentity = (try? await sdk.identityStatus())?.publicKey - let identityIsActive = PubkyPublicKeyFormat.matches(currentIdentity, identity) - if didCompleteLocalFollowup, !identityIsActive { - hardwareResolutionsAwaitingIdentity.insert(key) - } - let replayForReturningPayer = identityIsActive && hardwareResolutionsAwaitingIdentity.remove(key) != nil - if didCompleteLocalFollowup || replayForReturningPayer { - Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + if didCompleteLocalFollowup || completed.onchainResolutionAwaitingConsumption == true { + let resolution = PaykitOnchainPaymentResolution( identity: completed.identity, requestId: requestId, transactionId: txid.lowercased(), walletId: walletId - )) + ) + if didCompleteLocalFollowup || Self.onchainPaymentResolutionSubject.value != resolution { + Self.onchainPaymentResolutionSubject.send(resolution) + } } return true } catch { @@ -1325,7 +1324,37 @@ actor PaykitPaymentProofService { } } - func consumeOnchainPaymentResolution(_ resolution: PaykitOnchainPaymentResolution) { + func consumeOnchainPaymentResolution(_ resolution: PaykitOnchainPaymentResolution, activeIdentity: String?) async { + if resolution.walletId != WalletScope.default { + guard PubkyPublicKeyFormat.matches(activeIdentity, resolution.identity), + PubkyPublicKeyFormat.matches((try? await sdk.identityStatus())?.publicKey, resolution.identity) + else { + if Self.onchainPaymentResolutionSubject.value == resolution { + Self.onchainPaymentResolutionSubject.send(nil) + } + return + } + do { + let consumed: PendingPaykitPaymentProof? = try await mutationLock.withLock { + var proofs = try await loadProofs() + guard let index = proofs.firstIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, resolution.identity) && $0.requestId == resolution.requestId && + $0.onchainWalletId == resolution.walletId && $0.kind == .onchain && + $0.paymentIdentifier?.caseInsensitiveCompare(resolution.transactionId) == .orderedSame && + $0.proofData?.caseInsensitiveCompare(resolution.transactionId) == .orderedSame && + $0.onchainAcceptanceVerified == true && $0.onchainLocalFollowupComplete == true && + $0.onchainResolutionAwaitingConsumption == true + }) else { return nil } + proofs[index].onchainResolutionAwaitingConsumption = false + try await persist(proofs) + return proofs[index] + } + if let consumed { submitInBackground(consumed) } + } catch { + logWarning("Failed to acknowledge the original hardware payment resolution: \(error)") + return + } + } guard Self.onchainPaymentResolutionSubject.value == resolution else { return } Self.onchainPaymentResolutionSubject.send(nil) } @@ -1409,7 +1438,8 @@ actor PaykitPaymentProofService { await removeProofs { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == proof.requestId && - (!$0.hasUnsupportedOnchainWallet || $0 == proof) + (!$0.hasUnsupportedOnchainWallet || $0 == proof) && + (!$0.hasUnsupportedOnchainWallet || $0.onchainResolutionAwaitingConsumption != true) } } diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 913d95adb..33dce7e99 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -138,7 +138,7 @@ struct HwSendSignView: View { app.prepareResolvedOnchainContactContext(resolution, isHardware: true) app.consumePaykitOnchainPaymentResolution(resolution) navigationPath.append(route) - Task { await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) } + Task { await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution, activeIdentity: pubkyProfile.publicKey) } } private func startSigning() { diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 15f087c5b..1289c0533 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -345,7 +345,7 @@ struct SendPendingScreen: View { ordinarySendResolved = true app.prepareResolvedOnchainContactContext(resolution, isHardware: pendingHardwareWalletId != nil) Task { - await proofService.consumeOnchainPaymentResolution(resolution) + await proofService.consumeOnchainPaymentResolution(resolution, activeIdentity: pubkyProfile.publicKey) guard PubkyPublicKeyFormat.matches(resolution.identity, pubkyProfile.publicKey) else { return } navigationPath.append(.success(paymentId: resolution.transactionId, walletId: resolution.walletId)) } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 350cde519..7579acf5d 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -278,15 +278,22 @@ final class PaykitPaymentProofServiceTests: XCTestCase { identity: identity, requestId: request.id, transactionId: txid, walletId: hardwareWalletId ) XCTAssertEqual(resolutions, [originalResolution]) - await service.consumeOnchainPaymentResolution(originalResolution) + await service.consumeOnchainPaymentResolution(originalResolution, activeIdentity: (try? await sdk.identityStatus())?.publicKey) + // Recreate the service to discard every process-local replay marker. + let reopened = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) await sdk.setIdentity(identity) - await service.reconcile() + await sdk.setSubmissionFailure(false) + await reopened.reconcile() XCTAssertEqual( resolutions, [originalResolution, originalResolution], "Returning to the original payer must resume its already verified local result" ) - await service.reconcile() + let awaitingConsumption = await store.snapshot().first { PubkyPublicKeyFormat.matches($0.identity, identity) } + XCTAssertEqual(awaitingConsumption?.onchainResolutionAwaitingConsumption, true, + "Proof delivery must retain the original local result until its payer consumes it") + await reopened.consumeOnchainPaymentResolution(originalResolution, activeIdentity: (try? await sdk.identityStatus())?.publicKey) + await reopened.reconcile() XCTAssertEqual(resolutions, [originalResolution, originalResolution], "Returning-payer replay occurs once") let lookups = await lookup.calls() XCTAssertEqual(lookups.count, 1, "Already verified original follow-up must not repeat payment or observation") @@ -370,6 +377,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await sdk.setSubmissionFailure(false) let restarted = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup) await restarted.reconcile() + let deliveredProofs = await store.snapshot() + XCTAssertEqual(deliveredProofs.first?.onchainResolutionAwaitingConsumption, true) + await restarted.consumeOnchainPaymentResolution( + .init(identity: identity, requestId: request.id, transactionId: txid, walletId: hardwareWalletId), + activeIdentity: identity + ) + await restarted.reconcile() let remainingProofs = await store.snapshot() XCTAssertTrue(remainingProofs.isEmpty) let submitted = await sdk.lastSubmission() diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 9422cbe4c..d70abf303 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -405,7 +405,7 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(sent?.contact, PubkyPublicKeyFormat.normalized(requestId.counterparty)) let tags = try await activity.tags(forActivity: txid, walletId: walletId) XCTAssertEqual(tags, ["original tag"]) - await service.consumeOnchainPaymentResolution(.init(identity: identity, requestId: requestId, transactionId: txid, walletId: walletId)) + await service.consumeOnchainPaymentResolution(.init(identity: identity, requestId: requestId, transactionId: txid, walletId: walletId), activeIdentity: identity) let confirmedCallback = Bitkit.LightningService.shared.onchainTransactionConfirmed let receivedCallback = Bitkit.LightningService.shared.onchainTransactionReceived defer { From 436adad0e246840165c7074a10b43255651277b5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 22:49:14 +0200 Subject: [PATCH 64/77] fix: approve exact recovery fee before authentication --- .../Services/OnchainSendAttemptService.swift | 5 +- .../Wallets/Send/SendPendingScreen.swift | 56 ++++++++++++++++++- .../OnchainSendAttemptServiceTests.swift | 55 ++++++++++++++++++ journeys/README.md | 12 ++++ .../hardware-wallet/shop-onchain-proof.xml | 44 +++++++-------- journeys/send/onchain-accepted-result.xml | 45 +++++---------- .../send/onchain-original-payment-retry.xml | 49 ++++++++-------- 7 files changed, 183 insertions(+), 83 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 45366e8d1..a0955594f 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -486,6 +486,7 @@ actor OnchainSendAttemptService { func retrySamePayment( using sender: any OnchainSending, context: OnchainSendPendingContext, satsPerVbyte: UInt32? = nil, paymentDeadline: PaykitPreciseInstant? = nil, + approvePrepared: (PreparedOnchainSendDispatch) async throws -> Void = { _ in }, authorize: (OnchainSendAttempt, UInt32) async throws -> Void ) async throws -> OnchainSendResult { guard nativeDispatchInProgress == nil, let original = try currentAttempt(), @@ -499,7 +500,7 @@ actor OnchainSendAttemptService { nativeDispatchInProgress = original.id defer { nativeDispatchInProgress = nil } let authorizedFeeRate = satsPerVbyte ?? recovery.satsPerVbyte - guard authorizedFeeRate > 0 else { throw OnchainSendAttemptError.unresolved } + guard authorizedFeeRate > 0, authorizedFeeRate <= 999 else { throw OnchainSendAttemptError.unresolved } // Send-all already spends the original inputs minus its fee. A bump cannot // preserve both the exact input set and the original recipient amount. guard !original.isMaxAmount || authorizedFeeRate == recovery.satsPerVbyte else { @@ -558,6 +559,8 @@ actor OnchainSendAttemptService { // It validates the original payer/request/order and the chosen fee policy without // repeating initial proof-start/consume side effects. do { + try Task.checkCancellation() + try await approvePrepared(prepared) try Task.checkCancellation() try await authorize(attempt, authorizedFeeRate) try Task.checkCancellation() diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 1289c0533..469f9b164 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -41,6 +41,7 @@ struct SendPendingScreen: View { @EnvironmentObject private var activityList: ActivityListViewModel @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var pubkyProfile: PubkyProfileManager @EnvironmentObject private var settings: SettingsViewModel @@ -58,6 +59,8 @@ struct SendPendingScreen: View { @State private var retryFeeRate = "" @State private var retryingOnchain = false @State private var pendingOnchainProof: PendingPaykitPaymentProof? + @State private var retryApprovalMessage: String? + @State private var retryApprovalContinuation: CheckedContinuation? private var pendingHardwareWalletId: String? { hardwareWalletId ?? pendingOnchainProof?.onchainWalletId.flatMap { $0 == WalletScope.default ? nil : $0 } @@ -133,14 +136,14 @@ struct SendPendingScreen: View { TextField(t("wallet__onchain_retry_fee"), text: $retryFeeRate).keyboardType(.numberPad) } Button(t("common__cancel"), role: .cancel) {} - Button(t("common__retry")) { - guard let rate = UInt32(retryFeeRate), rate > 0 else { + Button(t("common__continue")) { + guard let rate = UInt32(retryFeeRate), rate > 0, rate <= 999 else { app.toast(type: .warning, title: t("wallet__onchain_retry_invalid_fee")) return } Task { await retryOriginalPayment(feeRate: rate) } } - .disabled(UInt32(retryFeeRate).map { $0 > 0 } != true) + .disabled(UInt32(retryFeeRate).map { $0 > 0 && $0 <= 999 } != true) } message: { Text(t(onchainAttempt?.isMaxAmount == true ? "wallet__onchain_retry_max_note" : "wallet__onchain_retry_note", variables: [ "amount": CurrencyFormatter.formatSats(onchainAttempt?.amountSats ?? 0), @@ -148,6 +151,16 @@ struct SendPendingScreen: View { "fee": String(onchainAttempt?.recoveryContext?.satsPerVbyte ?? 1), ])) } + .alert(t("wallet__onchain_retry_original"), isPresented: Binding( + get: { retryApprovalMessage != nil }, + set: { if !$0 { finishRetryApproval(false) } } + )) { + Button(t("common__cancel"), role: .cancel) { finishRetryApproval(false) } + Button(t("wallet__send_yes")) { finishRetryApproval(true) } + } message: { + Text(retryApprovalMessage ?? "") + } + .onDisappear { finishRetryApproval(false) } .navigationBarHidden(true) .allowSwipeBack(false) .padding(.horizontal, 16) @@ -256,6 +269,11 @@ struct SendPendingScreen: View { defer { retryingOnchain = false } let context = OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: original.txid) do { + let feeLimits = await wallet.getFeeLimits() + guard feeRate > 0, feeRate <= feeLimits.maxFee else { + app.toast(type: .warning, title: t("wallet__onchain_retry_invalid_fee")) + return + } // Capture the original request deadline without mutating its proof before authentication. let paymentDeadline: PaykitPreciseInstant? = if original.requestId != nil { try await proofService.authorizeOnchainRecovery(original, restoreStartedProof: false).paymentDeadline @@ -264,6 +282,18 @@ struct SendPendingScreen: View { } let result = try await attemptService.retrySamePayment( using: LightningService.shared, context: context, satsPerVbyte: feeRate, paymentDeadline: paymentDeadline, + approvePrepared: { prepared in + guard let fee = prepared.miningFeeSats else { throw OnchainSendAttemptError.retryUnavailable } + let message = original.address + "\n" + CurrencyFormatter.formatSats(original.amountSats) + "\n" + + t("wallet__send_fee_total", variables: ["feeSats": String(fee)]) + guard await requestRetryApproval(message) else { throw CancellationError() } + if let usd = currency.convert(sats: fee, to: "USD"), usd.value > 10 { + guard await requestRetryApproval(t("wallet__send_dialog4")) else { throw CancellationError() } + } + if fee > original.amountSats / 2 { + guard await requestRetryApproval(t("wallet__send_dialog3")) else { throw CancellationError() } + } + }, authorize: { attempt, _ in if settings.requirePinForPayments && settings.pinEnabled { if settings.useBiometrics && BiometricAuth.isAvailable { @@ -303,6 +333,26 @@ struct SendPendingScreen: View { } } + @MainActor + private func requestRetryApproval(_ message: String) async -> Bool { + guard !Task.isCancelled else { return false } + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + retryApprovalContinuation = continuation + retryApprovalMessage = message + } + } onCancel: { + Task { @MainActor in finishRetryApproval(false) } + } + } + + private func finishRetryApproval(_ approved: Bool) { + let continuation = retryApprovalContinuation + retryApprovalContinuation = nil + retryApprovalMessage = nil + continuation?.resume(returning: approved) + } + @MainActor private func refreshOriginalOutcome(context: OnchainSendPendingContext) async throws { onchainAttempt = try await attemptService.pendingAttempt(context: context) diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 1830c16c1..8d6175e26 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -396,6 +396,61 @@ final class OnchainSendAttemptServiceTests: XCTestCase { XCTAssertEqual(store.snapshot().first?.blocksNewSend, true) } + func testRetryApprovesExactPreparedFeeBeforeAuthenticationAndDispatch() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + sender.miningFee = 800 + var approved = false + _ = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 2, approvePrepared: { prepared in + XCTAssertEqual(prepared.miningFeeSats, 800) + XCTAssertEqual(prepared.recipientAmountSats, original.amountSats) + XCTAssertEqual(prepared.txid, sender.txid) + XCTAssertEqual(sender.broadcasts, 1) + approved = true + }, authorize: { _, _ in XCTAssertTrue(approved) } + ) + XCTAssertEqual(sender.preparations, 2) + XCTAssertEqual(sender.broadcasts, 2) + XCTAssertEqual(store.snapshot().first?.txid, sender.txid) + } + + func testCancelledFeeApprovalDoesNotSubmitOrRetainNewCandidate() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + var authenticated = false + do { + _ = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 2, approvePrepared: { _ in throw CancellationError() }, + authorize: { _, _ in authenticated = true } + ) + XCTFail("Cancelled fee approval must stop the retry") + } catch is CancellationError {} + XCTAssertFalse(authenticated) + XCTAssertEqual(sender.broadcasts, 1) + XCTAssertEqual(store.snapshot().first, original) + do { + _ = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 1000, authorize: { _, _ in authenticated = true } + ) + XCTFail("Recovery must share the normal fee ceiling") + } catch {} + XCTAssertEqual(sender.preparations, 2) + } + func testMaxRetryRejectsFeeIncreaseBeforePreparationOrAuthorization() async throws { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) diff --git a/journeys/README.md b/journeys/README.md index 2e28c2d6a..3d04ab682 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -115,6 +115,18 @@ iOS uses `accessibilityIdentifier`; Android uses Compose `testTag`. The vocabula journey usually names the same string on both platforms. Where a container needs to be queryable, iOS pairs the identifier with `.accessibilityElement(children: .contain)`. +### On-chain payment safety adaptations + +The shared names and action prose in `send/onchain-original-payment-retry.xml`, +`send/onchain-accepted-result.xml` and `hardware-wallet/shop-onchain-proof.xml` follow Android. +Their descriptions record extra iOS manual checks and necessary platform mechanics: + +| Journey | iOS adaptation | +| --- | --- | +| Original payment retry | Max retains its original rate; custom fees use `getFeeLimits()`. Additional channel funding, payer/wallet race and backup checks are in the description. | +| Accepted result | `xcrun simctl openurl`; confirm change before the Max leg, use Manual coin selection and restore Autopilot afterward. The existing Autopilot Max limitation is not claimed fixed. | +| Hardware Shop proof | iOS identifiers and Bridge setup; extra durable returning-payer and later tag-edit replay checks are in the description. | + Known naming differences: | Concept | Android testTag | iOS accessibilityIdentifier | diff --git a/journeys/hardware-wallet/shop-onchain-proof.xml b/journeys/hardware-wallet/shop-onchain-proof.xml index b9354f20c..ef7846395 100644 --- a/journeys/hardware-wallet/shop-onchain-proof.xml +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -1,28 +1,26 @@ - + - Verifies a hardware-funded Shop Payment Request against the original payer, request and - wallet, using fresh exact transaction observation before proof, Sent activity or Success. - Requires the paired, funded regtest Bridge Trezor emulator from hardware-wallet/README.md - and the saved, linked issuer from payment-requests/README.md, with Paykit UI enabled. - Record the original payer public key and hardware wallet identity before signing. Use - the canonical lowercase btc and btc-regtest-p2wpkh issuer shape on bitkit/server; a Bitkit - issuer uses its negotiated bitkit/wallet path. If real observation initially lags, inspect - Pending without another payment and let existing reconciliation finish. Do not manufacture - a refusal or missing response: controlled native faults and physical transports are manual - tests. This spec has not been run as part of the rc68 software-wallet fixture validation. + Pays a linked issuer's one-time on-chain request with the paired, funded Bridge Trezor emulator. + Requires the hardware-wallet Bridge setup and payment-requests issuer fixture; the hardware + native-segwit account must cover 100,000 sats plus fees. Observe the exact returned transaction + in the original hardware account and match the issuer's delivered proof to that transaction. + If Core fails after payment has started and returns no transaction ID, dismissing and reopening + must keep that original request pending; this journey cannot inject that response-loss condition. + This journey does not inject native broadcast faults or prove physical USB/BLE behavior. + iOS adaptations: identifiers replace Android testTags; use the issuer fixture and Bridge emulator described in the iOS hardware-wallet README. Reopening after consumed local verification must use the durable original completion; repeat proof reconciliation after editing activity tags and verify the later edits are preserved. Switch the active payer, complete the original payment, restart, then return to the original payer and verify only that payer receives the saved original result. These are additional manual checks on the common hardware journey. Native faults and physical USB/BLE acceptance remain unrun. - Have the fixture issuer publish its current regtest receive address as btc-regtest-p2wpkh with JSON payload {"value":"<current address>"}, then send proposed one-time request "71300000-0000-4000-8000-000000000009" for asset "btc" and amount "0.001" - Open that original request through the Payment Requests bell (id "PaymentRequestsBell") and Pay (id "PaymentRequestPay-71300000-0000-4000-8000-000000000009"), or use its automatic confirmation - Verify the Payment Request confirmation (id "PaymentRequestConfirm") shows 100,000 sats; tap Show Details (id "SendConfirmToggleDetails") and verify the saved issuer contact (id "ReviewContactRecipient") - If necessary, cycle the funding source (id "SendConfirmAssetButton") until it shows the original funded hardware wallet, then verify that wallet and the original request amount before signing - Swipe the send handle (id "GRAB") fully right and verify the hardware signing screen (id "HardwareSendSign") shows the original request amount and issuer address - Tap Open Trezor Connect (id "HardwareSendOpenTrezorConnect") and approve the transaction prompts on the deterministic emulator as described in hardware-wallet/README.md - If fresh observation initially lags and Pending appears, verify it retains 100,000 sats and the original candidate txid, offers no repayment, and has not shown Bitcoin Sent or created Sent activity solely from the bare broadcast return; an existing watcher may independently show a transaction it has actually observed. Record the candidate without initiating another payment - If Pending appears, close and reopen that same original request after verification has been consumed by the app listener; verify reopening resolves the durable exact original payment without another send, and Sent activity retains the original contact and any original tags - Wait for exact positive observation and the sent screen (id "SendSuccess"); on the issuer, verify the onchain proof for the original payer and request contains that exact observed txid, rather than accepting a bare broadcast-returned txid as proof - Tap Details (id "Details"), then Explore (id "ActivityTxDetails"); record the exact transaction ID (id "TXID") and verify Details belongs to the original hardware wallet and request amount - Query the exact recorded txid on the configured regtest backend and verify the original hardware wallet inputs pay 100,000 sats to the issuer; if Pending supplied a candidate, verify the final proof and Details use that same txid - Return to the Payment Requests list and verify the original paid request cannot initiate a second payment; stop recording and shut down only the task-owned simulator + Launch the E2E Bitkit app on regtest with Paykit UI enabled, a Pubky identity authenticated, the fixture issuer saved and linked on receiver path "bitkit/server", and the funded Bridge hardware wallet paired + Have the issuer publish a current regtest P2WPKH destination under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} + Have the issuer send a new proposed one-time Payment Request for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh"; record its exact request ID and payer identity for the backend proof check + Verify Payment Request confirmation (id "PaymentRequestConfirm") shows 100,000 sats and the saved issuer contact under Show details (id "SendConfirmToggleDetails") + Tap the funding-source button (id "SendConfirmAssetButton"), waiting for balance loading between taps, until FROM shows the paired hardware wallet + Swipe to confirm; verify hardware sign shows the original issuer destination (id "HardwareSendAddress") and 100,000 sats (id "HardwareSendAmount") + Tap Open Trezor Connect (id "HardwareSendOpenTrezorConnect") once and approve the Bridge emulator's transaction prompts + While signing or local proof observation is running, verify another tap cannot start another payment; wait for Success (id "SendSuccess") or the existing Payment Pending screen + If Pending is shown, verify there is no resend action or Sent activity from the returned Core transaction ID alone; close the sheet and reopen the same request from Payment Requests and verify it cannot start a fresh hardware payment + Once the original outgoing transaction is visible to Electrum, resume Bitkit to allow existing proof reconciliation; verify the same original payment creates Sent activity in its original hardware wallet and finishes or its request leaves the pending list, without another signing prompt + Open the original hardware wallet's activity and record the exact new transaction ID; independently read that transaction from the regtest backend and verify it spends that hardware account's inputs + Inspect the fixture issuer's proof for the original request and payer identity; verify type "bitcoin-onchain-txid" contains exactly the recorded original transaction ID, and the backend contains only the original payment diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 13682ef1a..e8406837f 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -1,40 +1,25 @@ - Verifies that fixed-amount and send-all sends show success after the configured backend - acknowledges the transaction, and that an accepted send finishes its local activity before a - new send is admitted. Precondition: an onboarded E2E wallet on regtest with confirmed Savings - funds sufficient for both sends and fees, a synced running node, and a second test wallet's - regtest receiving address. Start with Autopilot coin selection. The wallet, recipient and - backend must be test fixtures. The Max leg uses the existing Manual coin-selection route: - an amount-less payment link followed by Max currently fails in Autopilot's Branch and Bound - selection with CoinSelectionFailed before Confirm or native dispatch, even with confirmed - funds. This journey verifies actual send-all acceptance through Manual; it does not claim - that the Autopilot limitation is fixed or that fixture confirmation proves acceptance. - Rejected or missing-result cases need controlled broadcast-refusal/response-loss fixtures, + Verifies that fixed-amount and send-all sends show success only after the configured backend + acknowledges the transaction, and that an accepted send finishes its local activity before a new + send is admitted. Precondition: an onboarded dev wallet with confirmed savings funds sufficient + for both sends and fees, a running synced node, and a second regtest wallet's savings address. + The receiving wallet and backend must be test fixtures. + Rejected or missing-result cases need a controlled broadcast-refusal/response-loss fixture, which the Capabilities table does not provide, and belong in Manual Tests. + iOS adaptations: accept an Open payment-link prompt if it appears. After the fixed send, mine one block and wait for that exact transaction to confirm. Before the Max leg, choose Settings > Advanced > Coin Selection > Manual. After Continue on Send Amount, select all spendable fixture outputs on Coin Selection and Continue before Confirm. Restore Autopilot afterward. The Manual route is necessary because the amount-less link plus Max currently fails Autopilot Branch and Bound selection with CoinSelectionFailed before native dispatch; this journey does not claim that limitation is fixed. Independently query both exact transaction IDs to verify their recipient amounts and actual input sets. Hand the receiving test wallet address to the app with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>?amount=0.00001" - If iOS asks whether to open the payment link in Bitkit, choose Open - Verify the Send Amount screen (id "SendAmount") is visible with 1 000 sats and Savings selected - Tap Continue (id "ContinueAmount"), tap Show Details (id "SendConfirmToggleDetails"), and verify the Confirm screen shows the receiving test wallet address (id "ReviewUri") + Verify the Send Amount screen is visible with 1 000 sats and Savings selected + Tap Continue (id "ContinueAmount"), expand Show Details (id "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (id "ReviewUri") Swipe the send handle (id "GRAB") fully to the right - Wait for the sent screen (id "SendSuccess") and verify it shows the accepted payment amount - Tap Details (id "Details"), tap Explore (id "ActivityTxDetails"), and record the exact transaction ID (id "TXID") without selecting a different transaction by address or amount - Use the configured regtest backend to query that exact recorded txid and verify the acknowledged transaction pays 1 000 sats to the receiving test wallet - Navigate back from Transaction Details, then back from the activity to the wallet home screen - If the Wallet Backup introduction appears, choose Later (id "BackupIntroViewCancel") before continuing with the fixture send - Mine one block with the configured regtest fixture backend and wait for the fixed transaction's exact recorded txid to confirm before sending the change output - Open the menu (id "HeaderMenu"), Settings (id "DrawerSettings"), Advanced (id "Tab-advanced"), Coin Selection (id "CoinSelectPreference"), and choose Manual; return to Wallet from the menu (id "DrawerWallet") + Wait for the Bitcoin Sent success screen (id "SendSuccess") and verify it shows the accepted payment amount + Tap Details (id "Details"), then Explore (id "ActivityTxDetails"), and verify the outbound transaction shows a transaction ID (id "TXID"); record that exact ID without inferring a different transaction from address or amount + Close the transaction details and return to the wallet home screen Hand the receiving test wallet address to the app again with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>" - If iOS asks whether to open the payment link in Bitkit, choose Open - Tap the available balance (id "AvailableAmount") on Send Amount to choose Max, then tap Continue (id "ContinueAmount") - On Coin Selection, select all the fixture's spendable outputs with their switches, verify Total Selected covers Total Required, and tap Continue - Tap Show Details (id "SendConfirmToggleDetails") and verify the Confirm screen shows the receiving test wallet address and the drain amount and fee - Swipe the send handle (id "GRAB") fully to the right - If the Are You Sure? alert warns that the payment exceeds half the fixture balance, choose Yes, Send - Wait for the sent screen (id "SendSuccess"), tap Details (id "Details"), tap Explore (id "ActivityTxDetails"), and verify this send-all transaction has its own exact transaction ID (id "TXID") - Query this exact txid on the configured regtest backend and verify it spends the selected fixture outputs and pays the remaining balance minus the actual fee to the recipient - Return to Settings, Advanced, Coin Selection and restore Autopilot; stop recording and shut down only the task-owned simulator + Tap the available balance (id "AvailableAmount") on Send Amount to choose Max, tap Continue (id "ContinueAmount"), expand Show Details (id "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (id "ReviewUri") + Swipe the send handle (id "GRAB") fully to the right, then tap Yes, Send (id "DialogConfirm") if the over-50%-of-balance confirmation appears + Wait for the Bitcoin Sent success screen (id "SendSuccess"), tap Details (id "Details"), then Explore (id "ActivityTxDetails"), and verify this send-all transaction shows its own exact transaction ID (id "TXID") diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 1c12c13b4..7b4d8fd7b 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -1,31 +1,28 @@ - + - Manual Test: controlled broadcast refusal or response loss is not a listed Capability. - Use only a funded regtest wallet, a test recipient and an owned backend fault fixture. - Record the initial prepared txid, exact input set, recipient amount, wallet and any original - Shop request/payer or channel order. A missing backend lookup never authorizes a new payment. - This spec does not claim that a controlled UI fixture reproduced a real backend failure. + Verifies explicit fee approval and normal spend authentication for retrying one original software + on-chain payment. Precondition: an onboarded funded dev wallet, PIN enabled, a synced node, and a + retained unresolved prepared-send receipt with its exact original recipient, amount, inputs and + candidate transaction IDs. Creating a controlled refusal or response-loss fixture is not provided + by the Capabilities table and remains in Manual Tests. A seeded Pending fixture must be labelled + as state injection, never as proof of a native fault. Manual Tests must verify the exact backend + winning transaction retains the complete original input set and recipient amount, and any original + Shop request or Blocktank order completes once without a new request or order. Local activity alone + cannot prove these checks. The funded native fault journey remains unrun. + iOS adaptations: Max recovery keeps the original fee rate without an editable field because the original drain has no higher-fee headroom. The UI also checks wallet.getFeeLimits().maxFee (at most 999). For channel funding, manually verify reopening the original Pending after expiry or failed local completion creates no new order or broadcast, and that accepted original funding resumes its own order watcher. During retry preparation/authentication, check original-winner races and wallet/payer changes; backup must retain the original guarded amount, inputs and candidates. These extra channel-funding and backup checks extend the common actions rather than replace their wording. - Enable payment PIN authorization in the test wallet, with biometrics disabled for deterministic PIN entry - For ordinary sends and channel funding, pause the owned signing fixture after admission but before a prepared transaction exists; request wallet backup and verify it defers without replacing the remote wallet snapshot. Release signing and verify the prepared receipt retains the exact original operation for backup and recovery - Send a fixed amount to the fixture recipient; for the Max variant use the initial native send-all route, retaining reserves - Make the owned fault fixture refuse this broadcast or lose its response after dispatch, and verify Payment Pending retains the original amount and transaction ID without Bitcoin Sent or a Shop proof - For a channel-funding variant, begin Transfer To Spending and withhold the funding acknowledgement after dispatch; verify confirmation yields directly to Payment Pending with the original order/wallet/transaction context, not a generic toast or Lightning Setting Up. Retry must recover this original funding operation only - For iOS channel funding, dismiss Pending and swipe the original confirmation again, including after the original order expires and when broadcast was accepted but local transfer/activity persistence failed; verify the exact original Pending reopens without creating an order, broadcasting or showing a generic failure toast - Close and reopen Send; verify the original Pending context is restored, rather than a fresh payment or selected wallet amount - Tap Retry original payment (id "RetryOriginalOnchainPayment"); verify the confirmation shows the original recipient and actual amount, and for fixed sends requires an explicit fee rate in sat/vB; for Max shows the original fee rate without any editable fee field - For fixed sends, clear the fee, enter zero, and enter a value above 4294967295; verify confirmation is disabled or gives explicit invalid fee feedback, never silently dismissing or sending - For fixed sends enter a valid fee rate; for Max confirm the displayed original fee. Confirm; authorize exactly once with the original payment PIN. Cancel once and verify the original durable guard remains, with no broadcast - Confirm a valid retry again; the successor must pay the original exact amount using the original exact inputs. A Max recovery is fixed amount, not another drain. If the original coins cannot pay the chosen fee, verify the fee-headroom explanation and unchanged recipient amount/guard - Restore the fixture backend and authorize the original payment retry. Record every attempted candidate txid; query exact backend transactions and verify all candidates conflict on the original input set and preserve the original recipient amount - Allow the original candidate to be observed while successor preparation or authorization awaits; verify no new broadcast once the original wins and no later Unknown or Rejected result replaces that winner - Keep Payment Pending open while restoring observation of the original candidate after a rejected successor; verify its uncertainty warning and Retry button disappear and Details becomes enabled without closing or reopening the sheet - Verify the exact positively accepted or original-wallet observed winner creates one local Sent activity and enables its original Details, with durable local completion; verify Details displays the exact winning transaction fee and its own authorized fee rate, including when an earlier candidate wins - For iOS channel funding, keep Payment Pending open while the exact funding transaction becomes accepted and local transfer completion is saved; verify Unknown and Retry disappear and Details is enabled without reopening, including when the completion event is missed - For Shop, verify proof uses only that exact winner and the original payer/request, with no fresh request acceptance or private-list consume. For a channel order, verify original order tracking and balance metadata are retained - For iOS channel funding, observe the original accepted funding candidate while the confirmation flow remains open; verify the same original order resumes Lightning Setting Up and order watching without app restart, and a paid order proceeds to channel opening. An unrelated payment resolution must not advance this flow - Change the selected wallet or payer during preparation or authorization; verify no mismatched wallet broadcast or proof, and no success for a different operation - Restore all owned fault/PIN fixture settings, stop recording and shut down only the task-owned device and helpers + Open the retained original payment's Pending screen and record its displayed transaction ID + Verify Pending describes the unresolved outcome and offers Retry original payment + Tap Retry original payment + Verify the dialog shows the original recipient and amount and prefills the original fee rate + Enter an invalid fee rate of 1000 sat/vB and verify valid positive fee feedback appears and Continue is disabled + Enter a valid authorized fee rate that the funded fixture can support and tap Continue + Verify the exact prepared mining fee in sats is shown beside the original recipient amount; tap Authorize retry, accept any normal fee-over-half or fee-over-10-USD warnings, and verify the normal payment PIN screen appears before dispatch + Go Back without entering the PIN and verify the original Pending payment remains retained + Tap Retry original payment again, choose the fixture-supported fee, tap Continue, approve the exact prepared fee and any normal fee warnings + Enter the fixture's normal payment PIN without recording entered digits + Wait for Bitcoin Sent only after exact positive evidence, or verify Pending remains with a readable error if fee headroom is insufficient + If successful, tap Details then Explore and record the exact winning transaction ID shown by TXID From 2992a46370b46b1021797ede6b6daa846fd76041 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 23:12:25 +0200 Subject: [PATCH 65/77] fix: resume original payments after unsent retries --- .../Services/OnchainSendAttemptService.swift | 14 +++++++- Bitkit/ViewModels/TransferViewModel.swift | 13 +++++--- .../OnchainSendAttemptServiceTests.swift | 32 ++++++++++++++----- .../TransferServiceActivityTests.swift | 20 ++++++++++++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index a0955594f..5ae1eea57 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -578,7 +578,19 @@ actor OnchainSendAttemptService { } let result: OnchainSendResult do { result = try await normalized(prepared.broadcast(), candidate: prepared.txid) } - catch { result = .unknown(txid: prepared.txid) } + catch let failure as PreparedOnchainSendNotSubmitted { + do { + try discardUnsubmittedRetryCandidate(prepared.txid, attemptId: original.id, wasAdded: addedCandidate) + } catch { + throw OnchainSendAttemptError.unresolved + } + if let winner = try winningResult(attemptId: original.id) { + return winner + } + throw OnchainSendAttemptError.preDispatch(failure.underlying) + } catch { + result = .unknown(txid: prepared.txid) + } do { try record(result, attemptId: original.id) } catch { Logger.warn("Could not persist the known recovery outcome; original inputs remain guarded", context: "OnchainSendAttempt") } return (try? winningResult(attemptId: original.id)) ?? result diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 96afc8a9d..62ea09134 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -379,10 +379,6 @@ class TransferViewModel: ObservableObject { func orderForSwipe( createOrder: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> IBtOrder ) async throws -> IBtOrder { - if let order = uiState.order, let context = try await onchainAttemptService.orderPendingContext(orderId: order.id) { - await showFundingPending(context) - throw OnchainFundingPendingError() - } guard let order = try await currentOrder(createOrder: createOrder, isCurrent: { true }) else { throw CancellationError() } return order } @@ -392,6 +388,13 @@ class TransferViewModel: ObservableObject { isCurrent: () -> Bool ) async throws -> IBtOrder? { guard isCurrent() else { return nil } + if let retained = try await onchainAttemptService.blockingAttempt(), + retained.requestId == nil, retained.orderId != nil + { + let context = OnchainSendPendingContext(attemptId: retained.id, walletId: retained.walletId, txid: retained.txid) + await showFundingPending(context) + throw OnchainFundingPendingError() + } if let order = uiState.order { if pendingHwFundingBroadcast?.orderId == order.id { return order @@ -833,6 +836,8 @@ class TransferViewModel: ObservableObject { ) else { return } guard hwOrderCreationToken == token else { return } onTransferToSpendingHwConfirm(order: order, walletId: walletId) + } catch is OnchainFundingPendingError { + return } catch { hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription) } diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 8d6175e26..733083ca7 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -155,15 +155,28 @@ final class OnchainSendAttemptServiceTests: XCTestCase { var dispatchTime = deadlineDate.addingTimeInterval(-1) sender.deadlineClock = { dispatchTime } sender.txid = String(repeating: "cd", count: 32) - let result = try await service.retrySamePayment( - using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), - paymentDeadline: deadline, authorize: { _, _ in - // Deadline crosses after request authorization while native dispatch awaits its queue. - sender.onNativeQueueDispatch = { dispatchTime = deadlineDate.addingTimeInterval(expired ? 0.001 : 0) } + do { + let result = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), + paymentDeadline: deadline, authorize: { _, _ in + sender.onNativeQueueDispatch = { dispatchTime = deadlineDate.addingTimeInterval(expired ? 0.001 : 0) } + } + ) + if expired { + XCTFail("Definitely unsent retry must report pre-dispatch failure") + } else { + XCTAssertEqual(result, .unknown(txid: sender.txid)) } - ) + } catch OnchainSendAttemptError.preDispatch { + XCTAssertTrue(expired) + } XCTAssertEqual(sender.broadcasts, expired ? 1 : 2, "An expired successor must never reach native broadcast") - XCTAssertEqual(result, .unknown(txid: sender.txid)) + if expired { + XCTAssertEqual(store.snapshot().first?.recoveryContext?.candidateTxids, [originalTxid]) + XCTAssertNil(store.snapshot().first?.recoveryContext?.candidateFeeRates?[sender.txid]) + let observed = try await service.observeTransaction(txid: originalTxid, walletId: original.walletId) + XCTAssertTrue(observed, "The original must remain observable without an unsent competitor") + } XCTAssertEqual(store.snapshot().first?.id, original.id) XCTAssertEqual(store.snapshot().first?.address, original.address) XCTAssertEqual(store.snapshot().first?.amountSats, original.amountSats) @@ -1326,6 +1339,8 @@ final class AttemptNodeMock: OnchainSending { self.result = result } + var preparedMiningFeeSats: UInt64? + var preparedRecipientAmountSats: UInt64? var preparationError: Error? var onPrepare: (() async throws -> Void)? @@ -1342,7 +1357,8 @@ final class AttemptNodeMock: OnchainSending { case let .accepted(txid), let .rejected(txid, _), let .unknown(txid): txid } return PreparedOnchainSendDispatch(txid: candidate, - inputs: [OnchainSendInput(txid: String(repeating: "ef", count: 32), vout: 0)], recipientAmountSats: sats) + inputs: [OnchainSendInput(txid: String(repeating: "ef", count: 32), vout: 0)], + recipientAmountSats: preparedRecipientAmountSats ?? sats, miningFeeSats: preparedMiningFeeSats) { try await self.send(address: address, sats: sats, satsPerVbyte: satsPerVbyte, utxosToSpend: utxosToSpend, isMaxAmount: isMaxAmount, expectedWalletIndex: expectedWalletIndex, expectedNode: expectedNode) diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index d70abf303..912847ac3 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -921,6 +921,16 @@ final class TransferServiceActivityTests: XCTestCase { do { try await vm.payOrder(order: order, speed: .normal, txFee: 123, satsPerVbyte: 2) } catch {} let original = try XCTUnwrap(store.snapshot().first) sheets.hideSheet() + vm.onEstimateReady(clientBalance: 20000, lspBalance: 10000, feeSat: 21000) + XCTAssertNil(vm.uiState.order, "A new estimate drops the screen order, not the global payment guard") + do { + _ = try await vm.orderForConfirmation { _, _ in + XCTFail("Confirmation must route retained funding before creating another order") + return IBtOrder.mock(id: "replacement-confirmation-order") + } + XCTFail("A new confirmation must yield to original Pending") + } catch is OnchainFundingPendingError {} catch { XCTFail("Wrong confirmation re-entry error: \(error)") } + sheets.hideSheet() do { _ = try await vm.orderForSwipe { _, _ in XCTFail("An unresolved funding operation must not create a new order") @@ -928,6 +938,12 @@ final class TransferServiceActivityTests: XCTestCase { } XCTFail("The reset swipe must yield to original Pending") } catch is OnchainFundingPendingError {} catch { XCTFail("Wrong funding re-entry error: \(error)") } + sheets.hideSheet() + await vm.onTransferToSpendingHwConfirm(walletId: "hardware-wallet") { _, _ in + XCTFail("Hardware confirmation must not create a replacement order") + return IBtOrder.mock(id: "replacement-hardware-order") + } + XCTAssertNil(vm.hwTransferError, "Routing Pending must not add a generic hardware error") let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? SendConfig) guard case let .onchainPending(context) = config.initialRoute else { return XCTFail("Missing Pending") } XCTAssertEqual(context.attemptId, original.id) @@ -977,6 +993,10 @@ final class TransferServiceActivityTests: XCTestCase { let attempts = OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }) let order = IBtOrder.mock() let initialTotal = isMax ? UInt64(20000) : order.feeSat + 123 + if isMax { + node.preparedMiningFeeSats = 123 + node.preparedRecipientAmountSats = initialTotal - 123 + } let vm = TransferViewModel( transferService: makeService(), sheetViewModel: SheetViewModel(), onchainAttemptService: attempts, onchainSender: node, onchainBalanceProvider: { 50000 } From 8c16c3bec5c5f231314b52b51a7182e3151c1c8b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 04:37:43 +0200 Subject: [PATCH 66/77] fix: allow leaving refused hardware payments safely --- .../BroadcastError+Connectivity.swift | 13 ++++ Bitkit/ViewModels/HwFundingSigner.swift | 16 ++-- BitkitTests/HwFundingSignerTests.swift | 78 ++++++++++++++----- .../OnchainSendAttemptServiceTests.swift | 58 +++++++++++--- .../hardware-wallet/shop-onchain-proof.xml | 2 +- 5 files changed, 131 insertions(+), 36 deletions(-) diff --git a/Bitkit/Extensions/BroadcastError+Connectivity.swift b/Bitkit/Extensions/BroadcastError+Connectivity.swift index 5f34ade09..e16628410 100644 --- a/Bitkit/Extensions/BroadcastError+Connectivity.swift +++ b/Bitkit/Extensions/BroadcastError+Connectivity.swift @@ -52,6 +52,19 @@ extension Error { return false } + func isHardwareBroadcastRefusal() -> Bool { + if isDefiniteHardwarePreBroadcastFailure() { + return true + } + if let error = self as? BroadcastError, case let .ElectrumError(details) = error { + return details.lowercased().hasPrefix("broadcast failed:") + } + if let error = self as? AppError, let underlyingError = error.underlyingError { + return underlyingError.isHardwareBroadcastRefusal() + } + return false + } + func isBroadcastConnectivityFailure() -> Bool { if let broadcastError = self as? BroadcastError { return broadcastError.isConnectivityFailure diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 158b032ff..c48061f1a 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -386,8 +386,8 @@ final class HwSendCoordinator { } /// Whether the sign screen may be left. Reaching the device (a Jade may wait minutes for its PIN) - /// can be abandoned, and leaving cancels it; once the device is asked to sign, or a broadcast may - /// have gone out, it cannot. + /// can be abandoned, and leaving cancels it. An uncertain broadcast keeps navigation locked; + /// a definite refusal permits leaving while the original signed Shop payment stays retained. var canLeave: Bool { (!isSigning || isConnectingDevice) && !isBroadcastUnresolved } @@ -526,7 +526,9 @@ final class HwSendCoordinator { throw AppError(message: "Unknown hardware wallet", debugMessage: "The send flow has no wallet id") } let request = PaymentRequest(address: address, sats: sats, satsPerVByte: satsPerVByte) - if isBroadcastUnresolved, let pendingPayment { + if let pendingPayment, + isBroadcastUnresolved || (pendingPayment.paymentRequestId != nil && pendingPayment.hasBroadcastAttempted) + { guard pendingPayment.request == request, pendingPayment.paymentRequestId == paymentRequestId else { throw PaykitPaymentRequestError.operationInProgress } @@ -632,7 +634,8 @@ final class HwSendCoordinator { await afterBroadcast(result) return result } catch { - isBroadcastUnresolved = paymentRequestId != nil + // Navigation may unlock after a definite refusal; the receipt still guards funds. + isBroadcastUnresolved = paymentRequestId != nil && !error.isHardwareBroadcastRefusal() let underlyingError = (error as? AppError)?.underlyingError ?? error if underlyingError as? PaykitPaymentRequestError == .requestExpired { // A queued retry can expire without changing the uncertainty of an earlier attempt. @@ -745,6 +748,7 @@ final class HwSendCoordinator { isVerifyingPassphrase = false isPassphraseRequired = false guard !isBroadcastUnresolved else { return } + let retainsSubmittedShopPayment = pendingPayment?.paymentRequestId != nil && pendingPayment?.hasBroadcastAttempted == true let abandonedSession = operationSession operationTask?.cancel() // Receipt persistence/cleanup is suspended work. Keep the cancelled operation registered @@ -754,7 +758,9 @@ final class HwSendCoordinator { operationTask = nil operationRequest = nil operationSession = nil - pendingPayment = nil + if !retainsSubmittedShopPayment { + pendingPayment = nil + } isSigning = false isConnectingDevice = false // A Swift cancel never reaches the device, which would otherwise keep connecting (or wait for diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index ee7682cbb..cf10eb0d9 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -844,33 +844,71 @@ final class HwFundingSignerTests: XCTestCase { } func testShopBroadcastFailurePreservesSignedPaymentAcrossCancel() async throws { - let funding = MockHwFunding() - let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) - let manager = HwWalletManager() - let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") - funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") - await assertThrowsAsync { + for details in ["offline", "unrecognized backend error"] { + let funding = MockHwFunding() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let manager = HwWalletManager() + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: details) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId + ) + } + XCTAssertTrue(coordinator.isBroadcastUnresolved) + XCTAssertFalse(coordinator.canLeave) + coordinator.cancel() + XCTAssertTrue(coordinator.hasPendingBroadcast) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentRequestId: .init(paymentRequestId: "other-request", counterparty: "original-merchant") + ) + } + XCTAssertEqual(funding.broadcastCalls, 1) + funding.broadcastError = nil _ = try await coordinator.signAndBroadcast( manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId ) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 2) } - XCTAssertTrue(coordinator.isBroadcastUnresolved) - XCTAssertFalse(coordinator.canLeave) - coordinator.cancel() - XCTAssertTrue(coordinator.hasPendingBroadcast) - await assertThrowsAsync { + } + + func testShopDefiniteRefusalAllowsLeavingAndRetainsOriginalSignedPayment() async throws { + for error in [ + BroadcastError.InvalidHex(errorDetails: "invalid hex"), + BroadcastError.InvalidTransaction(errorDetails: "invalid transaction"), + BroadcastError.ElectrumError(errorDetails: "broadcast failed: min relay fee not met"), + ] { + let funding = MockHwFunding() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let manager = HwWalletManager() + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + funding.broadcastError = error + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId + ) + } + XCTAssertFalse(coordinator.isBroadcastUnresolved) + XCTAssertTrue(coordinator.canLeave) + coordinator.cancel() + XCTAssertTrue(coordinator.hasPendingBroadcast) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentRequestId: .init(paymentRequestId: "other-request", counterparty: "original-merchant") + ) + } + XCTAssertEqual(funding.broadcastCalls, 1) + funding.broadcastError = nil _ = try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - paymentRequestId: .init(paymentRequestId: "other-request", counterparty: "original-merchant") + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId ) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 2) } - XCTAssertEqual(funding.broadcastCalls, 1) - funding.broadcastError = nil - _ = try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId - ) - XCTAssertEqual(funding.signCalls, 1) - XCTAssertEqual(funding.broadcastCalls, 2) } func testRestoredShopReceiptRequiresAuthorizationAndNeverSignsAgain() async throws { diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 733083ca7..1918abc06 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -1143,12 +1143,23 @@ final class OnchainSendAttemptServiceTests: XCTestCase { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) let node = AttemptNodeMock(result: .unknown(txid: txid)) - node.onSend = { - XCTAssertEqual(store.snapshot().first?.status, .pending) - await Task.yield() + node.preparedMiningFeeSats = 100 + let preparation = SuspendedOnchainPreparation() + node.onPrepare = { await preparation.suspend() } + node.onSend = { XCTAssertEqual(store.snapshot().first?.status, .pending) } + let first = Task { + try? await service.send( + using: node, address: "bcrt1qexample", amountSats: 1000, satsPerVbyte: 1, + utxosToSpend: nil, isMaxAmount: true, orderId: "order", + transferContext: OnchainSendTransferContext( + clientBalanceSats: 900, txTotalSats: 1100, + preTransferOnchainSats: 1100, originalOrderFeeSats: 1000 + ) + ) } - let results = await withTaskGroup(of: Bool.self) { group in - for index in 0 ..< 20 { + await preparation.waitForStart() + let competingSends = await withTaskGroup(of: Bool.self) { group in + for index in 1 ..< 20 { group.addTask { do { _ = try await service.send( @@ -1164,14 +1175,16 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } var count = 0 - for await sent in group { - if sent { - count += 1 - } + for await sent in group where sent { + count += 1 } return count } - XCTAssertEqual(results, 1) + XCTAssertEqual(competingSends, 0) + XCTAssertEqual(node.calls, 0, "Competing sends must be blocked before native dispatch") + await preparation.release() + let result = await first.value + XCTAssertEqual(result, .unknown(txid: txid)) XCTAssertEqual(node.calls, 1) XCTAssertEqual(store.snapshot().count, 1) } @@ -1468,3 +1481,28 @@ private actor SuspendedOrdinaryFollowup: OnchainSendLocalFollowupHandling { func callCount() -> Int { calls } } + +private actor SuspendedOnchainPreparation { + private var hasStarted = false + private var started: CheckedContinuation? + private var suspended: CheckedContinuation? + + func waitForStart() async { + if hasStarted { + return + } + await withCheckedContinuation { started = $0 } + } + + func suspend() async { + hasStarted = true + started?.resume() + started = nil + await withCheckedContinuation { suspended = $0 } + } + + func release() { + suspended?.resume() + suspended = nil + } +} diff --git a/journeys/hardware-wallet/shop-onchain-proof.xml b/journeys/hardware-wallet/shop-onchain-proof.xml index ef7846395..135e1f4b6 100644 --- a/journeys/hardware-wallet/shop-onchain-proof.xml +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -7,7 +7,7 @@ If Core fails after payment has started and returns no transaction ID, dismissing and reopening must keep that original request pending; this journey cannot inject that response-loss condition. This journey does not inject native broadcast faults or prove physical USB/BLE behavior. - iOS adaptations: identifiers replace Android testTags; use the issuer fixture and Bridge emulator described in the iOS hardware-wallet README. Reopening after consumed local verification must use the durable original completion; repeat proof reconciliation after editing activity tags and verify the later edits are preserved. Switch the active payer, complete the original payment, restart, then return to the original payer and verify only that payer receives the saved original result. These are additional manual checks on the common hardware journey. Native faults and physical USB/BLE acceptance remain unrun. + iOS adaptations: identifiers replace Android testTags; use the issuer fixture and Bridge emulator described in the iOS hardware-wallet README. Reopening after consumed local verification must use the durable original completion; repeat proof reconciliation after editing activity tags and verify the later edits are preserved. Switch the active payer, complete the original payment, restart, then return to the original payer and verify only that payer receives the saved original result. These are additional manual checks on the common hardware journey. For an explicit backend broadcast refusal or InvalidHex/InvalidTransaction refusal, leaving the sign sheet retains the original signed payment and the durable wallet guard; reopening may retry only that payment without signing again. An uncertain transport failure remains non-dismissible. Native faults and physical USB/BLE acceptance remain unrun. Launch the E2E Bitkit app on regtest with Paykit UI enabled, a Pubky identity authenticated, the fixture issuer saved and linked on receiver path "bitkit/server", and the funded Bridge hardware wallet paired From 24dc1be6ea40c0bfdcd4cbf8dde863ea0f2b5f9f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 04:45:55 +0200 Subject: [PATCH 67/77] fix: preserve hardware navigation guards for unknown errors --- Bitkit/Extensions/BroadcastError+Connectivity.swift | 12 +++++++++++- BitkitTests/HwFundingSignerTests.swift | 5 ++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Bitkit/Extensions/BroadcastError+Connectivity.swift b/Bitkit/Extensions/BroadcastError+Connectivity.swift index e16628410..93a29e9fb 100644 --- a/Bitkit/Extensions/BroadcastError+Connectivity.swift +++ b/Bitkit/Extensions/BroadcastError+Connectivity.swift @@ -57,7 +57,17 @@ extension Error { return true } if let error = self as? BroadcastError, case let .ElectrumError(details) = error { - return details.lowercased().hasPrefix("broadcast failed:") + let prefix = "broadcast failed: " + let details = details.lowercased() + guard details.hasPrefix(prefix) else { return false } + let reason = String(details.dropFirst(prefix.count)) + return [ + "min relay fee not met", + "mempool min fee not met", + "bad-txns-inputs-missingorspent", + "txn-mempool-conflict", + "non-final", + ].contains(reason) } if let error = self as? AppError, let underlyingError = error.underlyingError { return underlyingError.isHardwareBroadcastRefusal() diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index cf10eb0d9..3b19b413f 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -844,7 +844,10 @@ final class HwFundingSignerTests: XCTestCase { } func testShopBroadcastFailurePreservesSignedPaymentAcrossCancel() async throws { - for details in ["offline", "unrecognized backend error"] { + for details in [ + "offline", "unrecognized backend error", + "broadcast failed: disconnected", "broadcast failed: unknown refusal", + ] { let funding = MockHwFunding() let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) let manager = HwWalletManager() From 0a9d82d807d87396c35b7db94789e47766ead926 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 11:46:17 +0200 Subject: [PATCH 68/77] fix: recover unfinished shop payments and preserve signed fees --- .../BroadcastError+Connectivity.swift | 15 ++++- .../Services/OnchainSendAttemptService.swift | 7 +- Bitkit/ViewModels/HwFundingSigner.swift | 13 ++-- BitkitTests/HwFundingSignerTests.swift | 65 ++++++++++++++++++- .../OnchainSendAttemptServiceTests.swift | 55 +++++++++++++++- 5 files changed, 140 insertions(+), 15 deletions(-) diff --git a/Bitkit/Extensions/BroadcastError+Connectivity.swift b/Bitkit/Extensions/BroadcastError+Connectivity.swift index 93a29e9fb..3f7e245bd 100644 --- a/Bitkit/Extensions/BroadcastError+Connectivity.swift +++ b/Bitkit/Extensions/BroadcastError+Connectivity.swift @@ -60,14 +60,25 @@ extension Error { let prefix = "broadcast failed: " let details = details.lowercased() guard details.hasPrefix(prefix) else { return false } - let reason = String(details.dropFirst(prefix.count)) + var reason = String(details.dropFirst(prefix.count)) + for envelope in ["electrum server error: ", "sendrawtransaction rpc error: "] where reason.hasPrefix(envelope) { + guard let data = String(reason.dropFirst(envelope.count)).data(using: .utf8), + let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = response["message"] as? String + else { return false } + reason = message + } + let rejectionPrefix = "the transaction was rejected by network rules.\n\n" + if reason.hasPrefix(rejectionPrefix) { + reason = String(reason.dropFirst(rejectionPrefix.count).split(separator: "\n", maxSplits: 1).first ?? "") + } return [ "min relay fee not met", "mempool min fee not met", "bad-txns-inputs-missingorspent", "txn-mempool-conflict", "non-final", - ].contains(reason) + ].contains { reason == $0 || reason.hasPrefix($0 + ", ") } } if let error = self as? AppError, let underlyingError = error.underlyingError { return underlyingError.isHardwareBroadcastRefusal() diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 5ae1eea57..105515465 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -438,6 +438,9 @@ actor OnchainSendAttemptService { } attempt.amountSats = prepared.recipientAmountSats attempt.txid = prepared.txid + if let miningFee = prepared.miningFeeSats { + attempt.followupContext?.feeSats = miningFee + } attempt.recoveryContext = OnchainSendRecoveryContext( inputs: prepared.inputs, satsPerVbyte: satsPerVbyte, paymentIdentity: paymentIdentity, candidateTxids: [prepared.txid], candidateFeeRates: [prepared.txid.lowercased(): satsPerVbyte] @@ -720,7 +723,7 @@ actor OnchainSendAttemptService { guard attempts.count <= 1 else { throw OnchainSendAttemptError.unresolved } guard let saved = attempts.first else { return nil } if knownAttempt?.id != saved.id, nativeDispatchInProgress == nil, - saved.status == .pending, saved.txid == nil, saved.recoveryContext == nil, saved.requestId == nil + saved.status == .pending, saved.txid == nil, saved.recoveryContext == nil { try store.save([]) return nil @@ -792,7 +795,7 @@ actor OnchainSendAttemptService { throw OnchainSendAttemptError.localFollowupNotSaved } let isOriginal = recovery.candidateTxids.first?.caseInsensitiveCompare(txid) == .orderedSame - let observed = isOriginal ? context.feeSats : try await winningFee(original) + let observed = isOriginal && context.feeSats > 0 ? context.feeSats : try await winningFee(original) guard let fee = observed, !original.amountSats.addingReportingOverflow(fee).overflow, var attempt = try currentAttempt(), attempt.id == original.id, diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index c48061f1a..bfa4c0724 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -586,6 +586,7 @@ final class HwSendCoordinator { } let hadPriorBroadcastAttempt = pendingPayment?.hasBroadcastAttempted == true + let priorBroadcastWasUnresolved = isBroadcastUnresolved var broadcastWasAttempted = hadPriorBroadcastAttempt isAuthorizingPayment = true defer { isAuthorizingPayment = false } @@ -599,8 +600,6 @@ final class HwSendCoordinator { guard signingAttempt == attempt else { throw error } if !broadcastWasAttempted { pendingPayment = nil - } else if ((error as? AppError)?.underlyingError ?? error) as? PaykitPaymentRequestError == .requestExpired { - isBroadcastUnresolved = true } throw error } @@ -645,11 +644,11 @@ final class HwSendCoordinator { } else { cleared = await clearSignedPaymentBeforeDispatch(signed) } - let unresolved = hadPriorBroadcastAttempt || !cleared - isBroadcastUnresolved = unresolved - broadcastWasAttempted = unresolved - pendingPayment?.hasBroadcastAttempted = unresolved - if !unresolved { + let retainsBroadcastAttempt = hadPriorBroadcastAttempt || !cleared + isBroadcastUnresolved = hadPriorBroadcastAttempt ? priorBroadcastWasUnresolved : !cleared + broadcastWasAttempted = retainsBroadcastAttempt + pendingPayment?.hasBroadcastAttempted = retainsBroadcastAttempt + if !retainsBroadcastAttempt { pendingPayment = nil } throw error diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 3b19b413f..e78cc9229 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -618,6 +618,7 @@ final class HwFundingSignerTests: XCTestCase { } funding.broadcastError = nil } + let priorNavigationWasUnresolved = coordinator.isBroadcastUnresolved let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(expiresInQueue ? 60 : -60)) funding.broadcastNow = { deadline.date.addingTimeInterval(1) } var outcomes: [PrivatePaymentListSendOutcome] = [] @@ -633,7 +634,7 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastCalls, hadPriorAttempt ? 1 : 0) XCTAssertEqual(outcomes, [hadPriorAttempt ? .uncertain : .definitePreBroadcastFailure]) XCTAssertEqual(coordinator.hasPendingBroadcast, hadPriorAttempt) - XCTAssertEqual(coordinator.isBroadcastUnresolved, hadPriorAttempt) + XCTAssertEqual(coordinator.isBroadcastUnresolved, priorNavigationWasUnresolved) } } } @@ -847,6 +848,10 @@ final class HwFundingSignerTests: XCTestCase { for details in [ "offline", "unrecognized backend error", "broadcast failed: disconnected", "broadcast failed: unknown refusal", + "Broadcast failed: Electrum server error: {\"code\":-26,\"message\":\"unknown refusal\"}", + "Broadcast failed: Electrum server error: {\"code\":-26,\"message\":\"disconnected\",\"data\":\"non-final\"}", + "Broadcast failed: Electrum server error: {\"code\":1,\"message\":\"the transaction was rejected by network rules.\\n\\ndisconnected\\n[non-final]\"}", + "Broadcast failed: Electrum server error: {\"code\":2,\"message\":\"sendrawtransaction RPC error: {\\\"message\\\":\\\"unknown refusal\\\",\\\"data\\\":\\\"non-final\\\"}\"}", ] { let funding = MockHwFunding() let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) @@ -879,11 +884,22 @@ final class HwFundingSignerTests: XCTestCase { } func testShopDefiniteRefusalAllowsLeavingAndRetainsOriginalSignedPayment() async throws { + let messages = [ + "min relay fee not met, 110 < 123", + "mempool min fee not met, 110 < 1000", + "bad-txns-inputs-missingorspent", "txn-mempool-conflict", "non-final", + "the transaction was rejected by network rules.\n\nmin relay fee not met, 100 < 110\n[0200000001...]", + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"bad-txns-inputs-missingorspent\"}", + ] + let serverErrors = try messages.map { message in + let json = try JSONSerialization.data(withJSONObject: ["code": 1, "message": message]) + return BroadcastError.ElectrumError(errorDetails: "Broadcast failed: Electrum server error: " + String(decoding: json, as: UTF8.self)) + } for error in [ BroadcastError.InvalidHex(errorDetails: "invalid hex"), BroadcastError.InvalidTransaction(errorDetails: "invalid transaction"), BroadcastError.ElectrumError(errorDetails: "broadcast failed: min relay fee not met"), - ] { + ] + serverErrors { let funding = MockHwFunding() let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) let manager = HwWalletManager() @@ -914,6 +930,51 @@ final class HwFundingSignerTests: XCTestCase { } } + func testExpiredShopRetryPreservesPriorNavigationStateAndSignedPayment() async throws { + for details in ["broadcast failed: min relay fee not met", "offline"] { + for expiresInQueue in [false, true] { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: details) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId + ) + } + let couldLeave = coordinator.canLeave + funding.broadcastError = nil + let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(60)) + funding.broadcastNow = { deadline.date.addingTimeInterval(1) } + var outcomes: [PrivatePaymentListSendOutcome] = [] + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentDeadline: deadline, paymentRequestId: requestId, + beforeBroadcastAttempt: { + if !expiresInQueue { + throw PaykitPaymentRequestError.requestExpired + } + }, + afterFailure: { outcomes.append($0) } + ) + } + XCTAssertEqual(coordinator.canLeave, couldLeave, "A never-submitted retry must not change prior navigation uncertainty") + XCTAssertTrue(coordinator.hasPendingBroadcast) + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertEqual(outcomes, [.uncertain], "The earlier submitted bytes remain guarded") + coordinator.cancel() + funding.broadcastNow = Date.init + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, paymentRequestId: requestId + ) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastTransactions, [funding.signedTx.serializedTx, funding.signedTx.serializedTx]) + } + } + } + func testRestoredShopReceiptRequiresAuthorizationAndNeverSignsAgain() async throws { let funding = MockHwFunding() let manager = HwWalletManager() diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 1918abc06..6444913cd 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -276,6 +276,48 @@ final class OnchainSendAttemptServiceTests: XCTestCase { XCTAssertEqual(retriedCalls, calls + 1, "A failed owner must allow a later retry") } + func testOriginalSignedFeeIsPersistedBeforeDispatchInsteadOfUnreadyEstimate() async throws { + for estimate in [UInt64(0), 143] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + sender.result = .accepted(txid: sender.txid) + _ = try await service.send( + using: sender, address: "original", amountSats: sender.amount, satsPerVbyte: 1, + utxosToSpend: nil, isMaxAmount: false, + followupContext: .init(feeSats: estimate, feeRate: 1, tags: [], contact: nil, createdAt: 123), + beforeBroadcastAttempt: { + XCTAssertEqual(store.snapshot().first?.followupContext?.feeSats, sender.miningFee) + XCTAssertEqual(sender.broadcasts, 0) + } + ) + XCTAssertEqual(store.snapshot().first?.followupContext?.feeSats, sender.miningFee) + } + } + + func testOriginalWinnerWithZeroStoredFeeUsesObservedFeeOrKeepsFollowupPending() async throws { + for observed in [UInt64(281), nil] as [UInt64?] { + let store = MemoryAttemptStore() + let followup = CapturingWinningFeeFollowup() + let service = OnchainSendAttemptService(store: store, localFollowup: followup, winningFee: { _ in observed }) + let sender = PreparedAttemptNodeMock() + sender.miningFee = nil + sender.result = .accepted(txid: sender.txid) + _ = try await service.send( + using: sender, address: "original", amountSats: sender.amount, satsPerVbyte: 1, + utxosToSpend: nil, isMaxAmount: false, + followupContext: .init(feeSats: 0, feeRate: 1, tags: [], contact: nil, createdAt: 123) + ) + do { + _ = try await service.resumeAcceptedOrdinarySend(walletId: try XCTUnwrap(store.snapshot().first).walletId) + XCTFail("The capturing local follow-up does not acknowledge completion") + } catch {} + XCTAssertEqual(followup.attempt?.followupContext?.feeSats, observed) + XCTAssertEqual(store.snapshot().first?.followupContext?.feeSats, observed ?? 0) + XCTAssertTrue(store.snapshot().first?.blocksNewSend == true) + } + } + func testSuccessorFollowupRetainsWinningFeeInsteadOfOriginalFee() async throws { let store = MemoryAttemptStore() let followup = CapturingWinningFeeFollowup() @@ -371,6 +413,7 @@ final class OnchainSendAttemptServiceTests: XCTestCase { let followup = CapturingWinningFeeFollowup() let service = OnchainSendAttemptService(store: store, localFollowup: followup, winningFee: { _ in 143 }) let sender = PreparedAttemptNodeMock() + sender.miningFee = 143 _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, followupContext: .init(feeSats: 143, feeRate: 1, tags: [], contact: nil, createdAt: 123)) @@ -850,7 +893,7 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } - func testUnsignedShopAttemptStillRequiresProofCleanupAfterRestart() async throws { + func testUnsignedShopAttemptDoesNotBlockAfterServiceRestart() async throws { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) _ = try await service.admit( @@ -858,9 +901,17 @@ final class OnchainSendAttemptServiceTests: XCTestCase { requestId: .init(paymentRequestId: "original-request", counterparty: "payer", billingPeriodStartsAt: nil), orderId: nil, address: "bcrt1qexample", amountSats: 1000, isMaxAmount: false ) + let live = try await service.unresolvedAttempt(walletId: WalletScope.default) + XCTAssertNotNil(live, "Current-process Shop preparation must remain guarded") let restarted = OnchainSendAttemptService(store: store) + let wallet = PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet( + kind: "software", network: "regtest", binding: String(repeating: "12", count: 32), sourceIndex: "0" + ) + let backup = try await restarted.backupSnapshot(wallet: wallet, proofs: []) + XCTAssertNil(backup, "An unsigned abandoned Shop admission must not stall wallet backup") let pending = try await restarted.unresolvedAttempt(walletId: WalletScope.default) - XCTAssertNotNil(pending, "Shop proof cleanup must precede releasing its guard") + XCTAssertNil(pending, "Proof-start cannot precede persistence of the signed receipt") + _ = try await admit(restarted) } func testRejectedAndUnknownRetainTxidAndBlockAnotherSend() async throws { From ace62523866ea0f3ab07d4ad5fd26b14480a643e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 13:34:34 +0200 Subject: [PATCH 69/77] fix: retain hardware refusal navigation after restart --- Bitkit/Managers/HwWalletManager.swift | 9 +- Bitkit/Services/OnChainHwService.swift | 9 +- .../Services/PaykitPaymentProofService.swift | 30 ++++++- Bitkit/ViewModels/HwFundingSigner.swift | 38 +++++++-- Bitkit/ViewModels/TransferViewModel.swift | 6 +- .../Views/Wallets/Send/HwSendSignView.swift | 8 ++ BitkitTests/HwFundingSignerTests.swift | 82 +++++++++++++++++++ BitkitTests/HwTransferMocks.swift | 7 +- .../PaykitPaymentProofServiceTests.swift | 39 +++++++++ 9 files changed, 213 insertions(+), 15 deletions(-) diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index dca44525a..74fee3acf 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -1549,11 +1549,16 @@ final class HwWalletManager { } /// Broadcast a signed funding transaction and return its txid. Does not require a connected device. - func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { + func broadcastFunding( + serializedTx: String, + paymentDeadline: PaykitPreciseInstant? = nil, + beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void = {} + ) async throws -> String { try await OnChainHwService.shared.broadcastRawTx( serializedTx: serializedTx, electrumUrl: electrumUrlProvider(), - paymentDeadline: paymentDeadline + paymentDeadline: paymentDeadline, + beforeDispatch: beforeDispatch ) } diff --git a/Bitkit/Services/OnChainHwService.swift b/Bitkit/Services/OnChainHwService.swift index 52c1f6546..dfc59c5a2 100644 --- a/Bitkit/Services/OnChainHwService.swift +++ b/Bitkit/Services/OnChainHwService.swift @@ -117,9 +117,16 @@ class OnChainHwService { /// Broadcast a signed raw transaction via Electrum. /// - Returns: The transaction ID (txid) - func broadcastRawTx(serializedTx: String, electrumUrl: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { + func broadcastRawTx( + serializedTx: String, + electrumUrl: String, + paymentDeadline: PaykitPreciseInstant? = nil, + beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void = {} + ) async throws -> String { try await ServiceQueue.background(.core) { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) + try Task.checkCancellation() + try await beforeDispatch() return try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) } } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 05d6ef3e2..8dcb07f29 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -62,6 +62,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var hardwareFeeRate: UInt64? var hardwareTotalSpent: UInt64? var hardwareDispatchAttempted: Bool? + /// Device-local navigation hint only; wallet backups intentionally omit it. + var hardwareRefusedForNavigation: Bool? = false var privatePaymentListVersion: UInt64? var previousPrivatePaymentListVersion: UInt64? var onchainMatchingTransactionIdsBeforeAttempt: Set? @@ -645,7 +647,8 @@ actor PaykitPaymentProofService { } return RetainedHardwareOnchainPayment( signedTx: HwFundingSignedTx(serializedTx: raw, miningFeeSats: fee, feeRate: Float(rate), totalSpent: spent), - hasAttemptedBroadcast: proof.hardwareDispatchAttempted != false + hasAttemptedBroadcast: proof.hardwareDispatchAttempted != false, + isRefusedForNavigation: proof.hardwareRefusedForNavigation == true ) } } @@ -677,6 +680,31 @@ actor PaykitPaymentProofService { } } + func markHardwareCandidateRefused( + requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, serializedTx: String, + refused: Bool + ) async throws { + let identity = try await currentIdentity() + let txid = try SignedTransactionId.fromHex(serializedTx) + guard PubkyPublicKeyFormat.matches(identity, paymentIdentity), walletId != WalletScope.default, + hardwareTransactionLookup.hasWallet(walletId: walletId) + else { throw PaykitPaymentRequestError.requestUnavailable } + try await mutationLock.withLock { + guard try await PubkyPublicKeyFormat.matches(currentIdentity(), identity) else { + throw PaykitPaymentRequestError.requestUnavailable + } + var proofs = try await loadProofs() + guard let index = proofs.firstIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.onchainWalletId == walletId && $0.kind == .onchain && $0.paymentStarted && + $0.paymentIdentifier == txid && $0.hardwareSignedTransaction == serializedTx && + $0.hardwareDispatchAttempted == true && $0.proofData == nil && $0.onchainAcceptanceVerified != true + }) else { throw PaykitPaymentRequestError.operationInProgress } + proofs[index].hardwareRefusedForNavigation = refused + try await persist(proofs) + } + } + /// Only the coordinator's definite first queued-dispatch expiry may remove this exact candidate. func clearHardwareCandidateBeforeDispatch( requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, serializedTx: String, diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index bfa4c0724..d93248b34 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -4,6 +4,7 @@ import Observation struct RetainedHardwareOnchainPayment { let signedTx: HwFundingSignedTx let hasAttemptedBroadcast: Bool + var isRefusedForNavigation: Bool = false } /// Orchestrates an on-chain payment from a hardware wallet: reconnect the device, compose the exact @@ -113,8 +114,12 @@ struct HwFundingSigner { } /// Broadcasts a signed funding transaction without requiring the hardware device. - func broadcastSignedFunding(_ signed: HwFundingSignedTx, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> HwFundingBroadcastResult { - let txId = try await broadcastStep(serializedTx: signed.serializedTx, paymentDeadline: paymentDeadline) + func broadcastSignedFunding( + _ signed: HwFundingSignedTx, + paymentDeadline: PaykitPreciseInstant? = nil, + beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void = {} + ) async throws -> HwFundingBroadcastResult { + let txId = try await broadcastStep(serializedTx: signed.serializedTx, paymentDeadline: paymentDeadline, beforeDispatch: beforeDispatch) return HwFundingBroadcastResult( txId: txId, miningFeeSats: signed.miningFeeSats, @@ -260,10 +265,14 @@ struct HwFundingSigner { /// already been handed to the network must never be reported as a signing timeout, so a timeout /// here surfaces `.broadcastUncertain` (the funding tx may still confirm) without tearing down the /// device session. - private func broadcastStep(serializedTx: String, paymentDeadline: PaykitPreciseInstant?) async throws -> String { + private func broadcastStep( + serializedTx: String, + paymentDeadline: PaykitPreciseInstant?, + beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void + ) async throws -> String { do { return try await withTimeout(timeouts.broadcast) { - try await funding.broadcastFunding(serializedTx: serializedTx, paymentDeadline: paymentDeadline) + try await funding.broadcastFunding(serializedTx: serializedTx, paymentDeadline: paymentDeadline, beforeDispatch: beforeDispatch) } } catch is CancellationError { throw CancellationError() @@ -518,6 +527,7 @@ final class HwSendCoordinator { beforeFirstBroadcast: @escaping (HwFundingSignedTx) async throws -> Void = { _ in }, beforeBroadcastAttempt: @escaping () async throws -> Void = {}, retainSignedPayment: @escaping (HwFundingSignedTx) async throws -> Void = { _ in }, + markSignedPaymentRefused: @escaping (HwFundingSignedTx, Bool) async throws -> Void = { _, _ in }, clearSignedPaymentBeforeDispatch: @escaping (HwFundingSignedTx) async -> Bool = { _ in true }, afterBroadcast: @escaping (HwFundingBroadcastResult) async -> Void = { _ in }, afterFailure: @escaping (PrivatePaymentListSendOutcome) async -> Void = { _ in } @@ -555,7 +565,7 @@ final class HwSendCoordinator { pendingPayment = PendingPayment(request: request, signedTx: restored.signedTx, paymentRequestId: paymentRequestId) pendingPayment?.isPreparedForBroadcast = true pendingPayment?.hasBroadcastAttempted = restored.hasAttemptedBroadcast - isBroadcastUnresolved = restored.hasAttemptedBroadcast + isBroadcastUnresolved = restored.hasAttemptedBroadcast && !restored.isRefusedForNavigation } let signed: HwFundingSignedTx if let pendingPayment, pendingPayment.request == request { @@ -628,15 +638,25 @@ final class HwSendCoordinator { isBroadcastUnresolved = true broadcastWasAttempted = true pendingPayment?.hasBroadcastAttempted = true + var enteredDispatch = false do { - let result = try await signer.broadcastSignedFunding(signed, paymentDeadline: paymentDeadline) + let result = try await signer.broadcastSignedFunding(signed, paymentDeadline: paymentDeadline) { [self] in + try Task.checkCancellation() + guard signingAttempt == attempt else { throw CancellationError() } + try await markSignedPaymentRefused(signed, false) + enteredDispatch = true + } await afterBroadcast(result) return result } catch { // Navigation may unlock after a definite refusal; the receipt still guards funds. - isBroadcastUnresolved = paymentRequestId != nil && !error.isHardwareBroadcastRefusal() - let underlyingError = (error as? AppError)?.underlyingError ?? error - if underlyingError as? PaykitPaymentRequestError == .requestExpired { + let refused = error.isHardwareBroadcastRefusal() + if enteredDispatch, paymentRequestId != nil, refused { + try? await markSignedPaymentRefused(signed, true) + } + let dispatchIsUncertain = enteredDispatch || (error as? HwTransferError) == .broadcastUncertain + isBroadcastUnresolved = dispatchIsUncertain ? paymentRequestId != nil && !refused : priorBroadcastWasUnresolved + if !enteredDispatch, (error as? HwTransferError) != .broadcastUncertain { // A queued retry can expire without changing the uncertainty of an earlier attempt. let cleared: Bool if hadPriorBroadcastAttempt { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 62ea09134..67a69ac8c 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -107,7 +107,11 @@ protocol HwTransferFunding: Sendable { addressType: AddressScriptType ) async throws -> UInt64 func signFunding(walletId: String, funding: HwFundingTransaction) async throws -> HwFundingSignedTx - func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant?) async throws -> String + func broadcastFunding( + serializedTx: String, + paymentDeadline: PaykitPreciseInstant?, + beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void + ) async throws -> String } /// The device-session capability the transfer flow needs for on-device signing, addressed by wallet diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 33dce7e99..6d6dbc946 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -189,6 +189,14 @@ struct HwSendSignView: View { address: invoice.address, amountSats: amount, serializedTx: signed.serializedTx ) }, + markSignedPaymentRefused: { signed, refused in + guard let requestId else { return } + guard let identity = contactPaymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + try await PaykitPaymentProofService.shared.markHardwareCandidateRefused( + requestId: requestId, paymentIdentity: identity, walletId: walletId, + serializedTx: signed.serializedTx, refused: refused + ) + }, clearSignedPaymentBeforeDispatch: { signed in guard let requestId else { return true } guard let identity = contactPaymentIdentity else { return false } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index e78cc9229..425a5be32 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -930,6 +930,88 @@ final class HwFundingSignerTests: XCTestCase { } } + func testRefusedShopRestoreAndFeeRefreshRemainDismissibleBeforeRetryDispatch() async throws { + for relaunch in [false, true] { + for (denial, expiresInQueue) in [ + (PaykitPaymentRequestError.requestExpired, false), + (.requestUnavailable, false), + (.operationInProgress, false), + (.requestExpired, true), + ] { + let funding = MockHwFunding() + let manager = HwWalletManager() + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + var stored: RetainedHardwareOnchainPayment? + let retain: (HwFundingSignedTx) async throws -> Void = { + stored = RetainedHardwareOnchainPayment( + signedTx: $0, hasAttemptedBroadcast: true, isRefusedForNavigation: stored?.isRefusedForNavigation == true + ) + } + let markRefused: (HwFundingSignedTx, Bool) async throws -> Void = { signed, refused in + XCTAssertEqual(stored?.signedTx, signed) + stored?.isRefusedForNavigation = refused + } + let original = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "broadcast failed: min relay fee not met") + await assertThrowsAsync { + _ = try await original.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentRequestId: requestId, retainSignedPayment: retain, markSignedPaymentRefused: markRefused + ) + } + XCTAssertTrue(original.canLeave) + let retry = relaunch ? makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) : original + if !relaunch { + _ = try await retry.preparePreview(manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 3) + } + funding.broadcastError = nil + let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(60)) + if expiresInQueue { + funding.broadcastNow = { deadline.date.addingTimeInterval(1) } + } + await assertThrowsAsync { + _ = try await retry.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: relaunch ? 2 : 3, + paymentDeadline: expiresInQueue ? deadline : nil, paymentRequestId: requestId, loadSignedPayment: { stored }, + beforeBroadcastAttempt: { + if !expiresInQueue, denial != .operationInProgress { + throw denial + } + }, + retainSignedPayment: { + signed in if expiresInQueue { + try await retain(signed) + } else { + throw denial + } + }, + markSignedPaymentRefused: markRefused + ) + } + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertTrue(retry.hasPendingBroadcast) + XCTAssertTrue(retry.canLeave, "Restore must preserve refusal navigation before another dispatch") + retry.cancel() + XCTAssertTrue(retry.hasPendingBroadcast) + XCTAssertEqual(stored?.isRefusedForNavigation, true) + funding.broadcastNow = Date.init + funding.broadcastError = HwTransferError.broadcastUncertain + await assertThrowsAsync { + _ = try await retry.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: relaunch ? 2 : 3, + paymentRequestId: requestId, loadSignedPayment: { stored }, + retainSignedPayment: retain, markSignedPaymentRefused: markRefused + ) + } + XCTAssertEqual(stored?.isRefusedForNavigation, false, "A real new dispatch consumes the navigation hint") + XCTAssertFalse(retry.canLeave) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastTransactions, [funding.signedTx.serializedTx, funding.signedTx.serializedTx]) + } + } + } + func testExpiredShopRetryPreservesPriorNavigationStateAndSignedPayment() async throws { for details in ["broadcast failed: min relay fee not met", "offline"] { for expiresInQueue in [false, true] { diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index 6f8ab6305..cdeb99023 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -115,8 +115,13 @@ final class MockHwFunding: HwTransferFunding { return signedTx } - func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { + func broadcastFunding( + serializedTx: String, + paymentDeadline: PaykitPreciseInstant? = nil, + beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void = {} + ) async throws -> String { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: broadcastNow()) + try await beforeDispatch() broadcastCalls += 1 broadcastTransactions.append(serializedTx) if let broadcastGate { diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 7579acf5d..8dbb84a09 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -548,6 +548,45 @@ final class PaykitPaymentProofServiceTests: XCTestCase { address: onchainAddress, amountSats: request.amountSats ) XCTAssertEqual(attempted?.hasAttemptedBroadcast, true) + try await reopened.markHardwareCandidateRefused( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + serializedTx: serializedTx, refused: true + ) + let localSnapshot = await store.snapshot() + let localProof = try XCTUnwrap(localSnapshot.first) + let localRoundtrip = try JSONDecoder().decode(PendingPaykitPaymentProof.self, from: JSONEncoder().encode(localProof)) + XCTAssertEqual(localRoundtrip.hardwareRefusedForNavigation, true) + let localReopened = paymentProofService(sdk: sdk, store: store) + let refused = try await localReopened.retainedHardwareOnchainPayment( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats + ) + XCTAssertEqual(refused?.isRefusedForNavigation, true) + XCTAssertEqual(refused?.signedTx, receipt) + try await localReopened.retainHardwareOnchainCandidate( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx + ) + let retainedSnapshot = await store.snapshot() + XCTAssertEqual(retainedSnapshot.first?.hardwareRefusedForNavigation, true, "Retention is not dispatch") + let refusedBackup = try await localReopened.backupSnapshot() + let refusedEncoded = try JSONEncoder().encode(refusedBackup) + XCTAssertFalse(String(decoding: refusedEncoded, as: UTF8.self).contains("hardwareRefusedForNavigation")) + try await localReopened.markHardwareCandidateRefused( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + serializedTx: serializedTx, refused: false + ) + let dispatchedSnapshot = await store.snapshot() + XCTAssertEqual(dispatchedSnapshot.first?.hardwareRefusedForNavigation, false) + await store.clear() + try await localReopened.restoreBackup(JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: refusedEncoded)) + let conservative = try await localReopened.retainedHardwareOnchainPayment( + requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, + address: onchainAddress, amountSats: request.amountSats + ) + XCTAssertEqual(conservative?.hasAttemptedBroadcast, true) + XCTAssertEqual(conservative?.isRefusedForNavigation, false) + await reopened.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: hardwareWalletId) let guarded = await store.snapshot() XCTAssertEqual(guarded.count, 1) From cbe332e2ce0b46065fb9ef24763ec2fe7566f928 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 14:37:27 +0200 Subject: [PATCH 70/77] fix: prevent expired hardware payment dispatch --- Bitkit/Services/OnChainHwService.swift | 19 +++++- Bitkit/ViewModels/HwFundingSigner.swift | 26 ++++--- BitkitTests/HwFundingSignerTests.swift | 90 ++++++++++++++++++++++++- BitkitTests/HwTransferMocks.swift | 11 ++- 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/Bitkit/Services/OnChainHwService.swift b/Bitkit/Services/OnChainHwService.swift index dfc59c5a2..5c419d648 100644 --- a/Bitkit/Services/OnChainHwService.swift +++ b/Bitkit/Services/OnChainHwService.swift @@ -124,11 +124,28 @@ class OnChainHwService { beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void = {} ) async throws -> String { try await ServiceQueue.background(.core) { + try await Self.broadcastAtBoundary(paymentDeadline: paymentDeadline, beforeDispatch: beforeDispatch) { + try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) + } + } + } + + /// Recheck after awaited preparation; errors here prove the native call was not entered. + static func broadcastAtBoundary( + paymentDeadline: PaykitPreciseInstant?, + beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void, + nativeBroadcast: () async throws -> String + ) async throws -> String { + do { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) try Task.checkCancellation() try await beforeDispatch() - return try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) + try Task.checkCancellation() + } catch { + throw PreparedOnchainSendNotSubmitted(underlying: error) } + return try await nativeBroadcast() } // MARK: - Event Watcher (No Device Required) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index d93248b34..94fe6f5af 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -638,25 +638,31 @@ final class HwSendCoordinator { isBroadcastUnresolved = true broadcastWasAttempted = true pendingPayment?.hasBroadcastAttempted = true - var enteredDispatch = false do { let result = try await signer.broadcastSignedFunding(signed, paymentDeadline: paymentDeadline) { [self] in try Task.checkCancellation() guard signingAttempt == attempt else { throw CancellationError() } try await markSignedPaymentRefused(signed, false) - enteredDispatch = true + try Task.checkCancellation() + guard signingAttempt == attempt else { throw CancellationError() } } await afterBroadcast(result) return result } catch { // Navigation may unlock after a definite refusal; the receipt still guards funds. - let refused = error.isHardwareBroadcastRefusal() - if enteredDispatch, paymentRequestId != nil, refused { + let underlying = (error as? AppError)?.underlyingError ?? error + let notSubmitted = underlying as? PreparedOnchainSendNotSubmitted + let failure = notSubmitted?.underlying ?? error + let refused = failure.isHardwareBroadcastRefusal() + if notSubmitted == nil, paymentRequestId != nil, refused { try? await markSignedPaymentRefused(signed, true) } - let dispatchIsUncertain = enteredDispatch || (error as? HwTransferError) == .broadcastUncertain + let dispatchIsUncertain = notSubmitted == nil isBroadcastUnresolved = dispatchIsUncertain ? paymentRequestId != nil && !refused : priorBroadcastWasUnresolved - if !enteredDispatch, (error as? HwTransferError) != .broadcastUncertain { + if notSubmitted != nil { + if hadPriorBroadcastAttempt, !priorBroadcastWasUnresolved, paymentRequestId != nil { + try? await markSignedPaymentRefused(signed, true) + } // A queued retry can expire without changing the uncertainty of an earlier attempt. let cleared: Bool if hadPriorBroadcastAttempt { @@ -671,13 +677,13 @@ final class HwSendCoordinator { if !retainsBroadcastAttempt { pendingPayment = nil } - throw error + throw failure } - let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain - if paymentRequestId == nil, !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { + let outcomeIsUncertain = (failure as? HwTransferError) == .broadcastUncertain + if paymentRequestId == nil, !outcomeIsUncertain, !failure.isBroadcastConnectivityFailure() { pendingPayment = nil } - throw error + throw failure } } catch { guard signingAttempt == attempt else { throw error } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 425a5be32..a0f12915b 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -639,6 +639,92 @@ final class HwFundingSignerTests: XCTestCase { } } + func testHardwareSubmissionRechecksDeadlineAfterAwaitedPreparation() async throws { + let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(1)) + let gate = AsyncGate() + var callbackEnteredBeforeDeadline = false + var nativeCalls = 0 + let task = Task { @MainActor in + do { + _ = try await OnChainHwService.broadcastAtBoundary( + paymentDeadline: deadline, + beforeDispatch: { + callbackEnteredBeforeDeadline = Date() < deadline.date + await gate.wait() + }, + nativeBroadcast: { nativeCalls += 1; return "unexpected" } + ) + XCTFail("Expiry during preparation must not enter native broadcast") + } catch { + XCTAssertEqual((error as? PreparedOnchainSendNotSubmitted)?.underlying as? PaykitPaymentRequestError, .requestExpired) + } + } + await waitUntil { callbackEnteredBeforeDeadline } + XCTAssertTrue(callbackEnteredBeforeDeadline, "The callback must start while authorization remains valid") + try await Task.sleep(nanoseconds: UInt64(max(0, deadline.date.timeIntervalSinceNow + 0.02) * 1_000_000_000)) + gate.open() + await task.value + XCTAssertEqual(nativeCalls, 0) + } + + func testHardwareSubmissionRechecksCancellationAfterAwaitedPreparation() async { + var prepared = false + var nativeCalls = 0 + let task = Task { @MainActor in + do { + _ = try await OnChainHwService.broadcastAtBoundary( + paymentDeadline: nil, + beforeDispatch: { prepared = true; withUnsafeCurrentTask { $0?.cancel() } }, + nativeBroadcast: { nativeCalls += 1; return "unexpected" } + ) + XCTFail("Cancellation during preparation must not enter native broadcast") + } catch { + XCTAssertTrue((error as? PreparedOnchainSendNotSubmitted)?.underlying is CancellationError) + } + } + await task.value + XCTAssertTrue(prepared) + XCTAssertEqual(nativeCalls, 0) + } + + func testRefusedShopRetryExpiryDuringDispatchPreparationKeepsHintAndOriginalBytes() async { + let funding = MockHwFunding() + let manager = HwWalletManager() + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + let receipt = funding.signedTx + var stored = RetainedHardwareOnchainPayment(signedTx: receipt, hasAttemptedBroadcast: true, isRefusedForNavigation: true) + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(60)) + let gate = AsyncGate() + var preparing = false + let task = Task { @MainActor in + do { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentDeadline: deadline, paymentRequestId: requestId, loadSignedPayment: { stored }, + markSignedPaymentRefused: { _, refused in + stored.isRefusedForNavigation = refused + if !refused { + preparing = true; await gate.wait() + } + } + ) + XCTFail("An expiry after awaited preparation must not dispatch") + } catch { XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired) } + } + await waitUntil { preparing } + XCTAssertTrue(preparing) + funding.broadcastNow = { deadline.date.addingTimeInterval(1) } + gate.open() + await task.value + XCTAssertEqual(funding.broadcastCalls, 0) + XCTAssertEqual(funding.signCalls, 0) + XCTAssertEqual(stored.signedTx, receipt) + XCTAssertTrue(stored.isRefusedForNavigation) + XCTAssertTrue(coordinator.hasPendingBroadcast) + XCTAssertTrue(coordinator.canLeave) + } + func testHardwareSubmissionRejectsExpiredDeadlineBeforeCallingElectrum() async throws { do { _ = try await OnChainHwService.shared.broadcastRawTx( @@ -647,7 +733,9 @@ final class HwFundingSignerTests: XCTestCase { ) XCTFail("Expired payment must not reach Electrum") } catch { - XCTAssertEqual((error as? Bitkit.AppError)?.underlyingError as? PaykitPaymentRequestError, .requestExpired) + let underlying = (error as? Bitkit.AppError)?.underlyingError ?? error + let failure = (underlying as? PreparedOnchainSendNotSubmitted)?.underlying ?? underlying + XCTAssertEqual(failure as? PaykitPaymentRequestError, .requestExpired) } } diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index cdeb99023..d0fa59cc9 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -120,8 +120,15 @@ final class MockHwFunding: HwTransferFunding { paymentDeadline: PaykitPreciseInstant? = nil, beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void = {} ) async throws -> String { - try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: broadcastNow()) - try await beforeDispatch() + do { + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: broadcastNow()) + try Task.checkCancellation() + try await beforeDispatch() + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: broadcastNow()) + try Task.checkCancellation() + } catch { + throw PreparedOnchainSendNotSubmitted(underlying: error) + } broadcastCalls += 1 broadcastTransactions.append(serializedTx) if let broadcastGate { From ec99bd011629015cd116dd1bd45e4c3896a10509 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 14:56:17 +0200 Subject: [PATCH 71/77] fix: recognize hardware payment refusals --- .../BroadcastError+Connectivity.swift | 19 ++++++++++++++++--- BitkitTests/HwFundingSignerTests.swift | 14 +++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Bitkit/Extensions/BroadcastError+Connectivity.swift b/Bitkit/Extensions/BroadcastError+Connectivity.swift index 3f7e245bd..c545cf79e 100644 --- a/Bitkit/Extensions/BroadcastError+Connectivity.swift +++ b/Bitkit/Extensions/BroadcastError+Connectivity.swift @@ -63,10 +63,23 @@ extension Error { var reason = String(details.dropFirst(prefix.count)) for envelope in ["electrum server error: ", "sendrawtransaction rpc error: "] where reason.hasPrefix(envelope) { guard let data = String(reason.dropFirst(envelope.count)).data(using: .utf8), - let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let message = response["message"] as? String + let response = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) else { return false } - reason = message + if let message = response as? String { + reason = message + } else if let response = response as? [String: Any], let message = response["message"] as? String { + reason = message + } else { + return false + } + } + for prefix in ["sendrawtransaction rpc error -25: ", "sendrawtransaction rpc error -26: "] where reason.hasPrefix(prefix) { + reason = String(reason.dropFirst(prefix.count)) + } + // This exact Bitcoin Core replacement refusal only releases navigation, never the receipt. + let replacementRefusal = "\\Ainsufficient fee, rejecting replacement [0-9a-f]{64}; new feerate [0-9]+\\.[0-9]{8} btc/kvb <= old feerate [0-9]+\\.[0-9]{8} btc/kvb\\z" + if reason.range(of: replacementRefusal, options: .regularExpression) != nil { + return true } let rejectionPrefix = "the transaction was rejected by network rules.\n\n" if reason.hasPrefix(rejectionPrefix) { diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index a0f12915b..38eeff0eb 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -935,6 +935,14 @@ final class HwFundingSignerTests: XCTestCase { func testShopBroadcastFailurePreservesSignedPaymentAcrossCancel() async throws { for details in [ "offline", "unrecognized backend error", + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -25: disconnected\"", + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -26: unknown refusal\"", + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -25x: bad-txns-inputs-missingorspent\"", + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -25: bad-txns-inputs-missingorspent-ish\"", + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -26: insufficient fee, rejecting replacement-ish\"", + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -26: insufficient fee, rejecting replacement disconnected\"", + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -25: bad-txns-inputs-missingorspent", + "Broadcast failed: Electrum server error: [\"sendrawtransaction RPC error -25: bad-txns-inputs-missingorspent\"]", "broadcast failed: disconnected", "broadcast failed: unknown refusal", "Broadcast failed: Electrum server error: {\"code\":-26,\"message\":\"unknown refusal\"}", "Broadcast failed: Electrum server error: {\"code\":-26,\"message\":\"disconnected\",\"data\":\"non-final\"}", @@ -983,11 +991,15 @@ final class HwFundingSignerTests: XCTestCase { let json = try JSONSerialization.data(withJSONObject: ["code": 1, "message": message]) return BroadcastError.ElectrumError(errorDetails: "Broadcast failed: Electrum server error: " + String(decoding: json, as: UTF8.self)) } + let stringErrors = [ + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -25: bad-txns-inputs-missingorspent\"", + "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -26: insufficient fee, rejecting replacement b3f63e62aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; new feerate 0.00001000 BTC/kvB <= old feerate 0.00001018 BTC/kvB\"", + ].map { BroadcastError.ElectrumError(errorDetails: $0) } for error in [ BroadcastError.InvalidHex(errorDetails: "invalid hex"), BroadcastError.InvalidTransaction(errorDetails: "invalid transaction"), BroadcastError.ElectrumError(errorDetails: "broadcast failed: min relay fee not met"), - ] + serverErrors { + ] + serverErrors + stringErrors { let funding = MockHwFunding() let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) let manager = HwWalletManager() From 1067305ab72946d2b702ed87666144e04085984e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 15:25:06 +0200 Subject: [PATCH 72/77] fix: reopen retained hardware payments --- Bitkit/AppScene.swift | 33 ++++--- .../Services/PaykitPaymentProofService.swift | 42 ++++++++ .../PaykitPaymentRequestService.swift | 27 ++++- .../PrivatePaykitService+Payments.swift | 24 +++++ Bitkit/ViewModels/AppViewModel.swift | 12 +++ Bitkit/ViewModels/HwFundingSigner.swift | 7 +- .../Views/Wallets/Send/HwSendSignView.swift | 7 ++ .../Wallets/Send/SendConfirmationView.swift | 25 ++++- Bitkit/Views/Wallets/Send/SendSheet.swift | 30 ++++++ .../PaykitPaymentProofServiceTests.swift | 8 ++ .../PaykitPaymentRequestServiceTests.swift | 99 +++++++++++++++++++ 11 files changed, 296 insertions(+), 18 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index c6b7ff7e3..c29151975 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1452,14 +1452,19 @@ struct AppScene: View { } else { preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: pubkyProfile.currentSession) incomingPaymentRequestPreparation = preparation - sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { - if preparation.resolvedRoute == nil, scenePhase == .active, - preparation.matchesSession(pubkyProfile.currentSession), let request = preparation.request - { - paykitPaymentRequestManager.dismissPreparingRequest(request) + sheets.showSheet(.send, data: SendConfig( + view: .confirm, + hardwareWalletId: paykitPaymentRequestManager.retainedHardwareRetries[request.id]?.onchainWalletId, + preparation: preparation, + onDismiss: { + if preparation.resolvedRoute == nil, scenePhase == .active, + preparation.matchesSession(pubkyProfile.currentSession), let request = preparation.request + { + paykitPaymentRequestManager.dismissPreparingRequest(request) + } + preparation.clear() } - preparation.clear() - })) + )) } defer { if preparation.resolvedRoute == nil { @@ -1510,11 +1515,15 @@ struct AppScene: View { do { try await preparation.whilePreparing { - try await app.handleScannedData( - paymentTarget, - claimedContactPaymentContext: contactPaymentContext, - alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats - ) + if let proof = paykitPaymentRequestManager.retainedHardwareRetries[request.id] { + try app.handleRetainedHardwarePayment(proof, context: contactPaymentContext) + } else { + try await app.handleScannedData( + paymentTarget, + claimedContactPaymentContext: contactPaymentContext, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + ) + } } guard isCurrentIncomingPaymentRequestPreparation(preparation), app.ownsContactPaymentContext(contactPaymentContext) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 8dcb07f29..503706866 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -77,6 +77,22 @@ struct PendingPaykitPaymentProof: Codable, Equatable { kind == .onchain && onchainWalletId != nil && onchainWalletId != WalletScope.default } + var retainedHardwareRetry: RetainedHardwareOnchainPayment? { + guard kind == .onchain, paymentStarted, hasUnsupportedOnchainWallet, + hardwareRefusedForNavigation == true, hardwareDispatchAttempted == true, + proofData == nil, onchainAcceptanceVerified != true, + let raw = hardwareSignedTransaction, let txid = try? SignedTransactionId.fromHex(raw), + txid == paymentIdentifier, let address = onchainAddress, !address.isEmpty, + let amount = onchainAmountSats, amount > 0, + let fee = hardwareMiningFeeSats, let rate = hardwareFeeRate, let spent = hardwareTotalSpent, + spent >= amount, spent - amount == fee + else { return nil } + return RetainedHardwareOnchainPayment( + signedTx: HwFundingSignedTx(serializedTx: raw, miningFeeSats: fee, feeRate: Float(rate), totalSpent: spent), + hasAttemptedBroadcast: true, isRefusedForNavigation: true + ) + } + init( identity: String, requestId: PaykitPaymentRequest.ID, @@ -1318,6 +1334,32 @@ actor PaykitPaymentProofService { } } + func retainedHardwarePaymentForRetry(request: PaykitPaymentRequest) async throws -> PendingPaykitPaymentProof? { + let identity = try await currentIdentity() + let proof = await retainedHardwarePaymentsForRetry(identity: identity)[request.id] + guard let proof, proof.onchainAmountSats == request.amountSats, proof.billingPeriod == request.billingPeriod, + request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier) + else { return nil } + return proof + } + + /// Presentation-only access to the original refused hardware payment; in-flight guards stay intact. + func retainedHardwarePaymentsForRetry(identity: String) async -> [PaykitPaymentRequest.ID: PendingPaykitPaymentProof] { + do { + var result: [PaykitPaymentRequest.ID: PendingPaykitPaymentProof] = [:] + for proof in try await loadProofs() { + guard PubkyPublicKeyFormat.matches(proof.identity, identity), proof.retainedHardwareRetry != nil, + let walletId = proof.onchainWalletId, hardwareTransactionLookup.hasWallet(walletId: walletId) + else { continue } + result[proof.requestId] = proof + } + return result + } catch { + logWarning("Failed to inspect retained hardware retries: \(error)") + return [:] + } + } + func pendingOnchainPayment(requestId: PaykitPaymentRequest.ID, identity: String) async throws -> PendingPaykitPaymentProof? { try await loadProofs().last { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && $0.paymentStarted diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 8d5b4f8b7..0381a980f 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1235,6 +1235,7 @@ final class PaykitPaymentRequestManager { private static let recentEligibilityCheckInterval = TimeInterval(30) private(set) var pendingRequests: [PaykitPaymentRequest] = [] + private(set) var retainedHardwareRetries: [PaykitPaymentRequest.ID: PendingPaykitPaymentProof] = [:] private(set) var historyRequests: [PaykitPaymentRequest] = [] private(set) var subscriptions: [PaykitSubscription] = [] private(set) var receivedPaymentContacts = PaykitReceivedPaymentContacts() @@ -1253,6 +1254,7 @@ final class PaykitPaymentRequestManager { private let subscriptionStateStore: any PaykitSubscriptionStateStoring private let subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler private let completedPaymentProofKinds: @Sendable (String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] + private let retainedHardwarePaymentsForRetry: @Sendable (String) async -> [PaykitPaymentRequest.ID: PendingPaykitPaymentProof] private let inFlightPaymentRequestIds: @Sendable (String) async -> Set private let protectedRequestIdsForSubscriptionCancellation: @Sendable ( String, @@ -1324,6 +1326,9 @@ final class PaykitPaymentRequestManager { inFlightPaymentRequestIds: @escaping @Sendable (String) async -> Set = { identity in await PaykitPaymentProofService.shared.inFlightRequestIds(identity: identity) }, + retainedHardwarePaymentsForRetry: @escaping @Sendable (String) async -> [PaykitPaymentRequest.ID: PendingPaykitPaymentProof] = { identity in + await PaykitPaymentProofService.shared.retainedHardwarePaymentsForRetry(identity: identity) + }, protectedRequestIdsForSubscriptionCancellation: @escaping @Sendable ( String, PaykitSubscription.ID @@ -1349,6 +1354,7 @@ final class PaykitPaymentRequestManager { self.subscriptionNotificationScheduler = subscriptionNotificationScheduler self.completedPaymentProofKinds = completedPaymentProofKinds self.inFlightPaymentRequestIds = inFlightPaymentRequestIds + self.retainedHardwarePaymentsForRetry = retainedHardwarePaymentsForRetry self.protectedRequestIdsForSubscriptionCancellation = protectedRequestIdsForSubscriptionCancellation self.now = now self.subscriptionNow = subscriptionNow @@ -2043,6 +2049,7 @@ final class PaykitPaymentRequestManager { presentationRetryTask?.cancel() presentationRetryTask = nil pendingRequests = [] + retainedHardwareRetries = [:] historyRequests = [] subscriptions = [] receivedPaymentContacts = PaykitReceivedPaymentContacts() @@ -2089,7 +2096,8 @@ final class PaykitPaymentRequestManager { } return pendingRequests.filter { - !presentedRequestIds.contains($0.id) && + retainedHardwareRetries[$0.id] == nil && + !presentedRequestIds.contains($0.id) && !dismissedPreparingRequestIds.contains($0.id) && !processingRequestIds.contains($0.id) && (presentationRetryDeadlines[$0.id].map { $0 <= date } ?? true) @@ -2322,10 +2330,23 @@ final class PaykitPaymentRequestManager { async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) let (locallyCompletedProofKinds, locallyInFlightRequestIds) = await (completedProofKinds, inFlightRequestIds) + let retainedRetries = await retainedHardwarePaymentsForRetry(activeIdentity) let locallyCompletedRequestIds = Set(locallyCompletedProofKinds.keys) guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) else { return false } + retainedHardwareRetries = retainedRetries.filter { id, proof in + guard id == proof.requestId, PubkyPublicKeyFormat.matches(proof.identity, activeIdentity), + proof.retainedHardwareRetry != nil, + let request = (snapshot.incoming + snapshot.history).first(where: { $0.id == id }), + request.direction == .incoming, + [.accepted, .recoveryRequired].contains(request.lifecycleState), + request.amountSats == proof.onchainAmountSats, + request.billingPeriod == proof.billingPeriod, + request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier) + else { return false } + return true + } pruneAcceptedRequestIds(snapshot.history, identity: activeIdentity) let refreshDate = now() let subscriptionDate = subscriptionNow() @@ -2365,7 +2386,7 @@ final class PaykitPaymentRequestManager { requests.filter { $0.lifecycleState != .proofSubmitted && !locallyCompletedRequestIds.contains($0.id) && - !locallyInFlightRequestIds.contains($0.id) && + (!locallyInFlightRequestIds.contains($0.id) || retainedHardwareRetries[$0.id] != nil) && !dismissedSubscriptionPaymentIds.contains($0.id) } } @@ -2385,7 +2406,7 @@ final class PaykitPaymentRequestManager { let oneTimePending = snapshot.incoming.filter { ($0.requiresAcceptance || acceptedRequestIds.contains($0.id)) && !locallyCompletedRequestIds.contains($0.id) && - !locallyInFlightRequestIds.contains($0.id) && + (!locallyInFlightRequestIds.contains($0.id) || retainedHardwareRetries[$0.id] != nil) && !approvedPaymentRequestIds.contains($0.id) } let oneTimeHistory = snapshot.history.map { request in diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index f6995d082..b4a24f005 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -74,12 +74,36 @@ extension PrivatePaykitService { throw PrivatePaykitError.invalidPublicKey } + if let proof = try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request), + let result = Self.retainedHardwarePaymentLaunchResult(proof: proof, request: request) + { + return result + } + return try await beginContactPayment( to: publicKey, paymentRequest: request ) } + static func retainedHardwarePaymentLaunchResult( + proof: PendingPaykitPaymentProof, request: PaykitPaymentRequest + ) -> PublicPaykitPaymentLaunchResult? { + guard proof.requestId == request.id, proof.retainedHardwareRetry != nil, + proof.onchainAmountSats == request.amountSats, proof.billingPeriod == request.billingPeriod, + let address = proof.onchainAddress, + request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier) + else { return nil } + // Reopen the original receipt, never resolve or consume a replacement merchant endpoint. + return .opened( + paymentRequest: "bitcoin:" + address, + privatePaymentContext: PrivatePaykitPaymentContext( + paymentAppsByEndpoint: [proof.paymentEndpointIdentifier: proof.paymentAppId], + paymentListVersion: proof.privatePaymentListVersion + ) + ) + } + func beginPaymentRequestWaitingForUpdatedList(_ request: PaykitPaymentRequest) async throws -> PublicPaykitPaymentLaunchResult { var result = try await beginPaymentRequest(request) for delay in Self.privatePaymentResolutionRetryDelays { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1da95d4eb..0d0e907e9 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -901,6 +901,18 @@ extension AppViewModel { } } + func handleRetainedHardwarePayment(_ proof: PendingPaykitPaymentProof, context: ContactPaymentContext) throws { + guard ownsContactPaymentContext(context), let request = context.incomingPaymentRequest, + context.publicKey == request.counterparty, + PrivatePaykitService.retainedHardwarePaymentLaunchResult(proof: proof, request: request) != nil, + let address = proof.onchainAddress + else { throw PaykitPaymentRequestError.requestUnavailable } + // This route submits an existing transaction, so it never admits fresh inputs against a balance. + handleScannedOnchainInvoice(OnChainInvoice( + address: address, amountSatoshis: request.amountSats, label: nil, message: nil, params: nil + )) + } + private func handleScannedOnchainInvoice(_ invoice: OnChainInvoice) { selectedWalletToPayFrom = .onchain scannedOnchainInvoice = invoice diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 94fe6f5af..873faf26b 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -415,9 +415,14 @@ final class HwSendCoordinator { self.signerFactory = signerFactory } - func seedAvailable(walletId: String, availableSats: UInt64) { + func seedAvailable(walletId: String, availableSats: UInt64, retainedFeeSats: UInt64? = nil) { guard self.walletId == walletId else { return } self.availableSats = availableSats + if let retainedFeeSats { + previewFeeSats = retainedFeeSats + isFundingSourceLoading = false + isPreviewLoading = false + } } func selectWallet( diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 6d6dbc946..414b52821 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -174,6 +174,13 @@ struct HwSendSignView: View { loadSignedPayment: { guard let requestId else { return nil } guard let identity = contactPaymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + if let request = app.contactPaymentContext?.incomingPaymentRequest, + let proof = try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request) + { + guard proof.onchainWalletId == walletId, proof.onchainAddress == invoice.address, + proof.onchainAmountSats == amount + else { throw PaykitPaymentRequestError.requestUnavailable } + } return try await PaykitPaymentProofService.shared.retainedHardwareOnchainPayment( requestId: requestId, paymentIdentity: identity, walletId: walletId, address: invoice.address, amountSats: amount diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index c757d0c20..df473e6b6 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -66,8 +66,13 @@ struct SendConfirmationView: View { return app.selectedWalletToPayFrom == .lightning ? .spending : .savings } + private var retainedHardwareRetry: PendingPaykitPaymentProof? { + guard let request = app.contactPaymentContext?.incomingPaymentRequest else { return nil } + return paykitPaymentRequestManager.retainedHardwareRetries[request.id] + } + private var canSwitchFundingSource: Bool { - fundingSources.count > 1 + retainedHardwareRetry == nil && fundingSources.count > 1 } var canSwitchWallet: Bool { @@ -159,7 +164,8 @@ struct SendConfirmationView: View { } private var shouldAutomaticallyPay: Bool { - preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && app.selectedWalletToPayFrom == .lightning && + retainedHardwareRetry == nil && preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && app + .selectedWalletToPayFrom == .lightning && !hwSend.isActive && !requiresPaymentConfirmation } @@ -1591,6 +1597,21 @@ struct SendConfirmationView: View { if transactionFee == 0, hwSend.previewFeeSats > 0 { apply(hwSend.previewFeeSats) } + if let request = app.contactPaymentContext?.incomingPaymentRequest, + let proof = try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request), + let receipt = proof.retainedHardwareRetry + { + guard proof.onchainWalletId == hwSend.walletId, proof.onchainAddress == address, + proof.onchainAmountSats == amountSats + else { throw PaykitPaymentRequestError.requestUnavailable } + hwSend.seedAvailable( + walletId: proof.onchainWalletId ?? "", + availableSats: hwWalletManager.fundingBalance(walletId: proof.onchainWalletId ?? ""), + retainedFeeSats: receipt.signedTx.miningFeeSats + ) + apply(receipt.signedTx.miningFeeSats) + return + } guard let fee = try await hwSend.preparePreview( manager: hwWalletManager, address: address, diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 635dd214b..e6c79bd2f 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -538,6 +538,15 @@ struct SendSheet: View { } private func performPaymentValidationAfterSync(ignoreChannelWait: Bool) -> PaymentValidationResult { + if let request = incomingPaymentRequest, + let proof = paykitPaymentRequestManager.retainedHardwareRetries[request.id], + proof.retainedHardwareRetry != nil, proof.onchainWalletId == hwSend.walletId, + proof.onchainAddress == app.scannedOnchainInvoice?.address, + proof.onchainAmountSats == wallet.sendAmountSats + { + // Original signed bytes only; new-payment balance admission does not apply. + return .ready + } let requestedAmount = app.contactPaymentContext?.incomingPaymentRequest?.amountSats if let lnurlPayData = app.lnurlPayData, let requestedAmount { @@ -689,6 +698,21 @@ struct SendSheet: View { let satsPerVByte = wallet.selectedFeeRateSatsPerVByte else { return true } + if let request = incomingPaymentRequest, + let proof = try? await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request), + let walletId = proof.onchainWalletId + { + guard isCurrentIncomingRequest(requestId), proof.onchainAddress == invoice.address, + (try? hwWalletManager.getFundingAccount(walletId: walletId)) != nil + else { return false } + hwSend.selectWallet(walletId, initialAvailableSats: hwWalletManager.fundingBalance(walletId: walletId)) + hwSend.seedAvailable( + walletId: walletId, availableSats: hwWalletManager.fundingBalance(walletId: walletId), + retainedFeeSats: proof.hardwareMiningFeeSats + ) + return true + } + let savingsAvailable: UInt64? do { savingsAvailable = try await wallet.calculateMaxSendableAmount( @@ -964,6 +988,12 @@ struct SendSheet: View { guard let request = context?.incomingPaymentRequest else { return } guard PubkyPublicKeyFormat.matches(pubkyProfile.publicKey, paymentIdentity) else { throw PaykitPaymentRequestError.requestUnavailable } + if try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request) != nil, + !paykitPaymentRequestManager.isApprovedForPayment(request) + { + // Fresh authorization for the original signed payment; its list version is already retained. + try await paykitPaymentRequestManager.prepareForPayment(request) + } try await paykitPaymentRequestManager.ensurePaymentAllowed(request) } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 8dbb84a09..2e78e4ba7 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -563,6 +563,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ) XCTAssertEqual(refused?.isRefusedForNavigation, true) XCTAssertEqual(refused?.signedTx, receipt) + let retryable = await localReopened.retainedHardwarePaymentsForRetry(identity: identity) + XCTAssertEqual(retryable[request.id]?.hardwareSignedTransaction, serializedTx) + let stillGuarded = await localReopened.inFlightRequestIds(identity: identity) + XCTAssertTrue(stillGuarded.contains(request.id), "Manual reopening must not release the in-flight guard") + let foreignRetries = await localReopened.retainedHardwarePaymentsForRetry(identity: "pubky" + String(repeating: "y", count: 52)) + XCTAssertTrue(foreignRetries.isEmpty) try await localReopened.retainHardwareOnchainCandidate( requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId, address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx @@ -578,6 +584,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ) let dispatchedSnapshot = await store.snapshot() XCTAssertEqual(dispatchedSnapshot.first?.hardwareRefusedForNavigation, false) + let dispatchedRetries = await localReopened.retainedHardwarePaymentsForRetry(identity: identity) + XCTAssertTrue(dispatchedRetries.isEmpty, "Unknown dispatch must not be offered as a refused retry") await store.clear() try await localReopened.restoreBackup(JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: refusedEncoded)) let conservative = try await localReopened.retainedHardwareOnchainPayment( diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index e553ea949..a3e2ddf94 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3812,6 +3812,103 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(restoredManager.requestsForPresentation(), [restoredRequest]) } + func testRefusedHardwareReceiptReopensAcceptedRequestFromListAfterRestart() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(state: .accepted, endpoints: [endpoint], acceptedEventId: "accepted") + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let raw = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" + var proof = try PendingPaykitPaymentProof( + identity: "pubky" + String(repeating: "z", count: 52), requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, + paymentStarted: true, paymentIdentifier: SignedTransactionId.fromHex(raw), proofData: nil, + onchainAddress: "original-address", onchainAmountSats: request.amountSats, onchainWalletId: "jade:wallet" + ) + proof.hardwareSignedTransaction = raw + proof.hardwareMiningFeeSats = 120 + proof.hardwareFeeRate = 2 + proof.hardwareTotalSpent = request.amountSats + 120 + proof.hardwareDispatchAttempted = true + proof.hardwareRefusedForNavigation = true + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), inFlightPaymentRequestIds: [request.id], + retainedHardwareRetries: [request.id: proof], acceptedRecords: [record] + ) + await manager.refresh() + XCTAssertEqual(manager.historyRequests.first?.lifecycleState, .accepted, "Accepted remains SDK lifecycle, not backend acceptance") + XCTAssertEqual(manager.pendingRequests, [request], "List/details must expose the original retained retry") + XCTAssertTrue(manager.requestsForPresentation().isEmpty, "Retained retry requires explicit reopening") + XCTAssertTrue(manager.requestPresentation(request), "Details Pay must reopen the original request") + XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertEqual(manager.retainedHardwareRetries[request.id]?.hardwareSignedTransaction, raw) + let preview = HwSendCoordinator(walletId: proof.onchainWalletId) + preview.seedAvailable(walletId: "jade:wallet", availableSats: 0, retainedFeeSats: 120) + XCTAssertEqual(preview.walletId, proof.onchainWalletId) + XCTAssertEqual(preview.availableSats, 0, "Spent balance stays zero; this is an original transaction route") + XCTAssertEqual(preview.previewFeeSats, 120) + XCTAssertFalse(preview.isFundingSourceLoading) + let app = AppViewModel() + let contact = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertTrue(app.claimContactPaymentContext(contact)) + try app.handleRetainedHardwarePayment(proof, context: contact) + XCTAssertEqual(app.scannedOnchainInvoice?.address, proof.onchainAddress) + XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, request.amountSats) + XCTAssertEqual(app.selectedWalletToPayFrom, .onchain) + let launch = try XCTUnwrap(PrivatePaykitService.retainedHardwarePaymentLaunchResult(proof: proof, request: request)) + guard case let .opened(target, context) = launch else { return XCTFail("Original receipt must reopen") } + XCTAssertEqual(target, "bitcoin:original-address") + XCTAssertEqual(try XCTUnwrap(context).paymentAppId(for: endpoint), proof.paymentAppId) + try await manager.prepareForPayment(request) + try await manager.ensurePaymentAllowed(request) + + for invalid in ["request", "amount", "billing", "endpoint", "identity", "unknown", "completed"] { + var changed = PendingPaykitPaymentProof( + identity: invalid == "identity" ? "pubky" + String(repeating: "y", count: 52) : proof.identity, + requestId: invalid == "request" ? .init(paymentRequestId: "another-request", counterparty: request.counterparty) : proof.requestId, + paymentAppId: proof.paymentAppId, + paymentEndpointIdentifier: invalid == "endpoint" ? "different-endpoint" : proof.paymentEndpointIdentifier, + kind: proof.kind, + billingPeriod: invalid == "billing" ? PaykitBillingPeriod(sdkPeriod: BillingPeriod( + startsAt: "2027-01-01T00:00:00Z", endsAt: "2027-02-01T00:00:00Z" + )) : proof.billingPeriod, + paymentStarted: true, paymentIdentifier: proof.paymentIdentifier, proofData: nil, + onchainAddress: proof.onchainAddress, onchainAmountSats: proof.onchainAmountSats, + onchainWalletId: proof.onchainWalletId + ) + changed.hardwareSignedTransaction = raw + changed.hardwareMiningFeeSats = 120 + changed.hardwareFeeRate = 2 + changed.hardwareTotalSpent = proof.hardwareTotalSpent + changed.hardwareDispatchAttempted = true + changed.hardwareRefusedForNavigation = true + if invalid == "amount" { + changed.onchainAmountSats = request.amountSats + 1 + changed.hardwareTotalSpent = request.amountSats + 121 + } else if invalid == "unknown" { + changed.hardwareRefusedForNavigation = false + } else if invalid == "completed" { + changed.proofData = changed.paymentIdentifier + changed.onchainAcceptanceVerified = true + } + let guarded = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), inFlightPaymentRequestIds: [request.id], + retainedHardwareRetries: [request.id: changed], acceptedRecords: [record] + ) + await guarded.refresh() + XCTAssertTrue(guarded.pendingRequests.isEmpty, invalid) + XCTAssertTrue(guarded.retainedHardwareRetries.isEmpty, invalid) + XCTAssertFalse(guarded.requestPresentation(request), invalid) + } + let revised = try paymentRequestRecord(state: .accepted, amount: "0.002", endpoints: [endpoint], acceptedEventId: "accepted") + let revisedRequest = try XCTUnwrap(PaykitPaymentRequest(record: revised, now: Date())) + let revisedManager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [revised]), inFlightPaymentRequestIds: [request.id], + retainedHardwareRetries: [request.id: proof], acceptedRecords: [revised] + ) + await revisedManager.refresh() + XCTAssertTrue(revisedManager.pendingRequests.isEmpty, "Changed SDK terms cannot reopen the original receipt as a new payment") + XCTAssertNil(PrivatePaykitService.retainedHardwarePaymentLaunchResult(proof: proof, request: revisedRequest)) + } + func testRecoveryRequiredIncomingRequestHonorsLocalPaymentProtection() async throws { let record = try paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted") let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -7455,6 +7552,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { isPrivatePaymentPublishingEnabled: Bool = true, completedPaymentProofKinds: [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = [:], inFlightPaymentRequestIds: Set = [], + retainedHardwareRetries: [PaykitPaymentRequest.ID: PendingPaykitPaymentProof] = [:], protectedRequestIdsForSubscriptionCancellation: Set = [], presentationStore: PaymentRequestPresentationMemoryStore = PaymentRequestPresentationMemoryStore(), acceptanceStore: PaymentRequestPresentationMemoryStore? = nil, @@ -7481,6 +7579,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { subscriptionNotificationScheduler: subscriptionNotificationScheduler, completedPaymentProofKinds: { _ in completedPaymentProofKinds }, inFlightPaymentRequestIds: { _ in inFlightPaymentRequestIds }, + retainedHardwarePaymentsForRetry: { _ in retainedHardwareRetries }, protectedRequestIdsForSubscriptionCancellation: { _, _ in protectedRequestIdsForSubscriptionCancellation }, now: now, subscriptionNow: subscriptionNow, From 72814b0c90c0a091506d287e2664ac21383d7c1c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 16:20:15 +0200 Subject: [PATCH 73/77] fix: preserve retained recurring payment access --- .../PaykitPaymentRequestService.swift | 44 ++++-- .../PaymentRequests/PaymentRequestsView.swift | 60 ++++---- .../PaykitPaymentRequestServiceTests.swift | 135 +++++++++++++++++- 3 files changed, 196 insertions(+), 43 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 0381a980f..cfa2bfe6b 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1847,7 +1847,21 @@ final class PaykitPaymentRequestManager { } } + func canDismiss(_ request: PaykitPaymentRequest) -> Bool { + retainedHardwareRetries[request.id] == nil + } + func dismiss(_ request: PaykitPaymentRequest) async throws { + guard canDismiss(request) else { throw PaykitPaymentRequestError.operationInProgress } + guard let identity = activeIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + let generation = stateGeneration + let inFlightIds = await inFlightPaymentRequestIds(identity) + guard generation == stateGeneration, PubkyPublicKeyFormat.matches(activeIdentity, identity) else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard !inFlightIds.contains(request.id), canDismiss(request) else { + throw PaykitPaymentRequestError.operationInProgress + } if request.billingPeriod != nil { guard dismissSubscriptionPayment(request) else { throw PaykitPaymentRequestError.requestUnavailable @@ -1910,7 +1924,7 @@ final class PaykitPaymentRequestManager { @discardableResult func dismissSubscriptionPayment(_ request: PaykitPaymentRequest) -> Bool { - guard request.billingPeriod != nil, + guard request.billingPeriod != nil, canDismiss(request), pendingRequests.contains(where: { $0.id == request.id }) else { return false } @@ -2335,18 +2349,6 @@ final class PaykitPaymentRequestManager { guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) else { return false } - retainedHardwareRetries = retainedRetries.filter { id, proof in - guard id == proof.requestId, PubkyPublicKeyFormat.matches(proof.identity, activeIdentity), - proof.retainedHardwareRetry != nil, - let request = (snapshot.incoming + snapshot.history).first(where: { $0.id == id }), - request.direction == .incoming, - [.accepted, .recoveryRequired].contains(request.lifecycleState), - request.amountSats == proof.onchainAmountSats, - request.billingPeriod == proof.billingPeriod, - request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier) - else { return false } - return true - } pruneAcceptedRequestIds(snapshot.history, identity: activeIdentity) let refreshDate = now() let subscriptionDate = subscriptionNow() @@ -2375,6 +2377,22 @@ final class PaykitPaymentRequestManager { } return (subscription, requests) } + let retainedRetryRequests = snapshot.incoming + snapshot.history + recurringRequestsBySubscription + .filter { $0.0.lifecycleState == .activeRecurring && !$0.0.hasPaymentDeadline } + .flatMap { $0.1.filter { $0.lifecycleState == .activeRecurring } } + retainedHardwareRetries = retainedRetries.filter { id, proof in + guard id == proof.requestId, !locallyCompletedRequestIds.contains(id), + PubkyPublicKeyFormat.matches(proof.identity, activeIdentity), + proof.retainedHardwareRetry != nil, + let request = retainedRetryRequests.first(where: { $0.id == id }), + request.direction == .incoming, + [.accepted, .recoveryRequired, .activeRecurring].contains(request.lifecycleState), + request.amountSats == proof.onchainAmountSats, + request.billingPeriod == proof.billingPeriod, + request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier) + else { return false } + return true + } let activeRecurringRequestIds = Set(recurringRequestsBySubscription .filter { $0.0.lifecycleState == .activeRecurring } .flatMap { $0.1.map(\.id) }) diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index 110080ba2..78b78be56 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -115,21 +115,23 @@ struct PaymentRequestCard: View { .padding(16) } else if onPay != nil || onReject != nil { HStack(spacing: 16) { - CustomButton( - title: t("wallet__payment_request_dismiss"), - variant: .secondary, - size: .small, - icon: Image("x-mark").resizable().frame(width: 16, height: 16), - isDisabled: isActionDisabled, - isLoading: isRejecting, - shouldExpand: true - ) { - guard !isRejecting else { return } - isRejecting = true - await onReject?() - isRejecting = false + if let onReject { + CustomButton( + title: t("wallet__payment_request_dismiss"), + variant: .secondary, + size: .small, + icon: Image("x-mark").resizable().frame(width: 16, height: 16), + isDisabled: isActionDisabled, + isLoading: isRejecting, + shouldExpand: true + ) { + guard !isRejecting else { return } + isRejecting = true + await onReject() + isRejecting = false + } + .accessibilityIdentifier("PaymentRequestDismiss-\(request.paymentRequestId)") } - .accessibilityIdentifier("PaymentRequestDismiss-\(request.paymentRequestId)") CustomButton( title: t("common__pay"), @@ -300,9 +302,9 @@ struct PaymentRequestsSheet: View { _ = paymentRequests.requestPresentation(request) } }, - onReject: { + onReject: paymentRequests.canDismiss(request) ? { await dismiss(request) - } + } : nil ) } @@ -458,7 +460,7 @@ struct PaymentRequestsView: View { isActionDisabled: paymentRequests.requestedPresentationId == request.id, onOpen: { navigation.navigate(.paymentRequestDetail(request.id)) }, onPay: { _ = paymentRequests.requestPresentation(request) }, - onReject: { await dismiss(request) } + onReject: paymentRequests.canDismiss(request) ? { await dismiss(request) } : nil ) } else if request.direction == .outgoing { PaymentRequestCard( @@ -668,17 +670,19 @@ struct PaymentRequestDetailView: View { private func actions(_ request: PaykitPaymentRequest) -> some View { HStack(spacing: 16) { - CustomButton( - title: t("wallet__payment_request_dismiss"), - variant: .secondary, - icon: Image("x-mark").resizable().frame(width: 16, height: 16), - isDisabled: paymentRequests.requestedPresentationId == request.id - ) { - do { - try await paymentRequests.dismiss(request) - navigation.navigateBack() - } catch { - app.toast(error) + if paymentRequests.canDismiss(request) { + CustomButton( + title: t("wallet__payment_request_dismiss"), + variant: .secondary, + icon: Image("x-mark").resizable().frame(width: 16, height: 16), + isDisabled: paymentRequests.requestedPresentationId == request.id + ) { + do { + try await paymentRequests.dismiss(request) + navigation.navigateBack() + } catch { + app.toast(error) + } } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index a3e2ddf94..773003b41 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3829,8 +3829,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { proof.hardwareTotalSpent = request.amountSats + 120 proof.hardwareDispatchAttempted = true proof.hardwareRefusedForNavigation = true + let sdk = PaymentRequestSdkMock(records: [record]) let manager = paymentRequestManager( - sdk: PaymentRequestSdkMock(records: [record]), inFlightPaymentRequestIds: [request.id], + sdk: sdk, inFlightPaymentRequestIds: [request.id], retainedHardwareRetries: [request.id: proof], acceptedRecords: [record] ) await manager.refresh() @@ -3840,6 +3841,22 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestPresentation(request), "Details Pay must reopen the original request") XCTAssertEqual(manager.requestsForPresentation(), [request]) XCTAssertEqual(manager.retainedHardwareRetries[request.id]?.hardwareSignedTransaction, raw) + XCTAssertFalse(manager.canDismiss(request), "Cards/details must omit Dismiss for this retained payment") + var dismissError: Error? + do { try await manager.dismiss(request) } catch { dismissError = error } + let canceled = await sdk.canceledPaymentRequestIds() + XCTAssertTrue(canceled.isEmpty, "Dismiss must not cancel the retained signed transaction's request") + XCTAssertEqual(dismissError as? PaykitPaymentRequestError, .operationInProgress) + XCTAssertEqual(manager.pendingRequests, [request], "Original retry must remain reachable") + XCTAssertEqual(manager.retainedHardwareRetries[request.id]?.hardwareSignedTransaction, raw) + let unknownSdk = try PaymentRequestSdkMock(records: [record]) + let unknown = paymentRequestManager(sdk: unknownSdk, inFlightPaymentRequestIds: [request.id], acceptedRecords: [record]) + await unknown.refresh() + var unknownDismissError: Error? + do { try await unknown.dismiss(request) } catch { unknownDismissError = error } + let unknownCanceled = await unknownSdk.canceledPaymentRequestIds() + XCTAssertTrue(unknownCanceled.isEmpty, "An alternate/stale dismissal must not cancel a durable unresolved payment") + XCTAssertEqual(unknownDismissError as? PaykitPaymentRequestError, .operationInProgress) let preview = HwSendCoordinator(walletId: proof.onchainWalletId) preview.seedAvailable(walletId: "jade:wallet", availableSats: 0, retainedFeeSats: 120) XCTAssertEqual(preview.walletId, proof.onchainWalletId) @@ -3909,6 +3926,113 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertNil(PrivatePaykitService.retainedHardwarePaymentLaunchResult(proof: proof, request: revisedRequest)) } + func testRefusedHardwareReceiptReopensOriginalUnpaidRecurringPeriodAfterRestart() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let date = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-16T08:00:00Z")) + let clock = PaymentRequestTestClock(date) + let recurrence = PaymentRequestRecurrence( + every: 1, unit: "week", startsAt: "2027-01-01T08:00:00Z", anchor: "2027-01-01T08:00:00Z", endsAt: nil + ) + let record = try paymentRequestRecord( + state: .activeRecurring, recurrence: recurrence, endpoints: [endpoint], acceptedEventId: "accepted" + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = try XCTUnwrap(subscription.requests(through: date, acceptedAt: PaykitPreciseInstant(date: date)).first) + let period = try XCTUnwrap(request.billingPeriod) + let raw = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300" + func receipt(_ invalid: String = "") throws -> PendingPaykitPaymentProof { + let alteredPeriod = invalid == "period" || invalid == "request" + ? PaykitBillingPeriod(sdkPeriod: BillingPeriod(startsAt: "2027-01-22T08:00:00Z", endsAt: "2027-01-29T08:00:00Z")) + : period + var proof = try PendingPaykitPaymentProof( + identity: "pubky" + String(repeating: invalid == "identity" ? "y" : "z", count: 52), + requestId: invalid == "request" ? .init(paymentRequestId: "other", counterparty: request.counterparty) : request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: invalid == "endpoint" ? "other" : endpoint, + kind: .onchain, billingPeriod: alteredPeriod, paymentStarted: true, + paymentIdentifier: SignedTransactionId.fromHex(raw), proofData: invalid == "completed" ? "proof" : nil, + onchainAddress: "original-recurring-address", onchainAmountSats: request.amountSats + (invalid == "amount" ? 1 : 0), + onchainWalletId: "jade:wallet" + ) + proof.hardwareSignedTransaction = raw + proof.hardwareMiningFeeSats = 120 + proof.hardwareFeeRate = 2 + proof.hardwareTotalSpent = try XCTUnwrap(proof.onchainAmountSats) + 120 + proof.hardwareDispatchAttempted = true + proof.hardwareRefusedForNavigation = invalid != "unknown" + return proof + } + func manager(_ record: PaymentRequestRecord, proof: PendingPaykitPaymentProof, + completed: Bool = false) throws -> PaykitPaymentRequestManager + { + let store = PaymentRequestSubscriptionStateMemoryStore() + try store.save(PaykitSubscriptionState(acceptedAt: [subscription.id: PaykitPreciseInstant(date: date)]), identity: proof.identity) + return paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), clock: clock, subscriptionStateStore: store, + completedPaymentProofKinds: completed ? [request.id: .onchain] : [:], + inFlightPaymentRequestIds: [request.id], retainedHardwareRetries: [request.id: proof] + ) + } + let proof = try receipt() + let restored = try manager(record, proof: proof) + await restored.refresh() + XCTAssertEqual(restored.pendingRequests, [request], "Original unpaid recurring period must remain reachable after restart") + XCTAssertEqual(restored.retainedHardwareRetries[request.id], proof) + XCTAssertTrue(restored.requestsForPresentation().isEmpty, "Retained recurring receipt must not auto-popup") + XCTAssertTrue(restored.requestPresentation(request), "Details Pay must reopen this exact period") + XCTAssertEqual(restored.requestsForPresentation(), [request]) + XCTAssertFalse(restored.isApprovedForPayment(request), "Reopening does not authorize a new payment") + XCTAssertFalse(restored.canDismiss(request), "Recurring retained payment cannot expose Dismiss") + var recurringDismissError: Error? + do { try await restored.dismiss(request) } catch { recurringDismissError = error } + XCTAssertEqual(recurringDismissError as? PaykitPaymentRequestError, .operationInProgress) + XCTAssertFalse(restored.dismissSubscriptionPayment(request), "Alternate recurring dismissal must retain the original retry") + XCTAssertEqual(restored.pendingRequests, [request]) + let launch = try XCTUnwrap(PrivatePaykitService.retainedHardwarePaymentLaunchResult(proof: proof, request: request)) + guard case let .opened(target, context) = launch else { return XCTFail("Original retained receipt must reopen") } + XCTAssertEqual(target, "bitcoin:original-recurring-address") + XCTAssertEqual(try XCTUnwrap(context).paymentAppId(for: endpoint), proof.paymentAppId) + XCTAssertEqual(restored.retainedHardwareRetries[request.id]?.onchainWalletId, "jade:wallet") + XCTAssertEqual(restored.retainedHardwareRetries[request.id]?.hardwareSignedTransaction, raw) + XCTAssertEqual(restored.retainedHardwareRetries[request.id]?.billingPeriod, period) + + for invalid in [ + "request", + "period", + "amount", + "endpoint", + "identity", + "unknown", + "completed", + "local-paid", + "sdk-paid", + "changed-amount", + "changed-endpoint", + "inactive", + ] { + let changed = try receipt(invalid) + let revised: PaymentRequestRecord = switch invalid { + case "sdk-paid": + try paymentRequestRecord( + state: .activeRecurring, recurrence: recurrence, endpoints: [endpoint], acceptedEventId: "accepted", + paymentProofs: [paymentProofRecord(endpoint: endpoint, kind: .onchain, billingPeriod: period.sdkValue)] + ) + case "changed-amount": + try paymentRequestRecord(state: .activeRecurring, amount: "0.002", recurrence: recurrence, endpoints: [endpoint]) + case "changed-endpoint": + try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence, endpoints: ["other"]) + case "inactive": + try paymentRequestRecord(state: .canceled, recurrence: recurrence, endpoints: [endpoint]) + default: + record + } + let guarded = try manager(revised, proof: changed, completed: invalid == "local-paid") + await guarded.refresh() + XCTAssertFalse(guarded.pendingRequests.contains { $0.id == request.id }, invalid) + XCTAssertNil(guarded.retainedHardwareRetries[request.id], invalid) + XCTAssertFalse(guarded.requestPresentation(request), invalid) + } + } + func testRecoveryRequiredIncomingRequestHonorsLocalPaymentProtection() async throws { let record = try paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted") let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -8093,12 +8217,19 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym return record } + private var canceledRequests: [String] = [] + + func canceledPaymentRequestIds() -> [String] { + canceledRequests + } + func cancelPaymentRequest( counterparty: String, paymentRequestId: String, reason _: String? ) throws -> PaymentRequestRecord { - try removeRecord( + canceledRequests.append(paymentRequestId) + return try removeRecord( counterparty: counterparty, id: paymentRequestId ) From b24a470f52a4080f84d18dfe35e2dfaf2fab731c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 18:30:33 +0200 Subject: [PATCH 74/77] fix: allow maximum sends and lock hardware retry fees --- Bitkit/ViewModels/WalletViewModel.swift | 24 +++++--- .../Views/Wallets/Send/HwSendSignView.swift | 8 ++- .../Wallets/Send/SendConfirmationView.swift | 60 ++++++++++++++----- BitkitTests/HwFundingSignerTests.swift | 30 ++++++++++ BitkitTests/SendConfirmationViewTests.swift | 11 ++++ BitkitTests/WalletViewModelSendTests.swift | 44 ++++++++++++++ 6 files changed, 155 insertions(+), 22 deletions(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1d964720e..677bada77 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -747,7 +747,11 @@ class WalletViewModel: ObservableObject { } /// Sets the UTXO selection for the send flow using the specified coin selection algorithm.based on chosen fee and target amount - func setUtxoSelection(coinSelectionAlgorythm: CoinSelectionAlgorithm) async throws { + func setUtxoSelection( + coinSelectionAlgorythm: CoinSelectionAlgorithm, + listSpendable: (() async throws -> [SpendableUtxo])? = nil, + selectForAmount: ((UInt64, UInt32, CoinSelectionAlgorithm) async throws -> [SpendableUtxo])? = nil + ) async throws { guard let selectedFeeRateSatsPerVByte else { throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.") } @@ -760,12 +764,18 @@ class WalletViewModel: ObservableObject { "Selecting UTXOs with algorithm: \(coinSelectionAlgorythm), target amount: \(sendAmountSats) sats, fee rate: \(selectedFeeRateSatsPerVByte) sats/vbyte" ) - selectedUtxos = try await lightningService.selectUtxosWithAlgorithm( - targetAmountSats: sendAmountSats, - satsPerVbyte: selectedFeeRateSatsPerVByte, - coinSelectionAlgorythm: coinSelectionAlgorythm, - utxos: nil - ) + let select = selectForAmount ?? { amount, rate, algorithm in + try await self.lightningService.selectUtxosWithAlgorithm( + targetAmountSats: amount, satsPerVbyte: rate, coinSelectionAlgorythm: algorithm, utxos: nil + ) + } + if isMaxAmountSend { + // A drain amount already accounts for its exact fee. The fixed-payment selector + // adds conservative recipient overhead and can reject an otherwise valid drain. + selectedUtxos = try await (listSpendable ?? { try await self.lightningService.listSpendableOutputs() })() + } else { + selectedUtxos = try await select(sendAmountSats, selectedFeeRateSatsPerVByte, coinSelectionAlgorythm) + } Logger.info("Selected UTXOs: \(String(describing: selectedUtxos))") } diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 414b52821..613f3f16a 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -151,7 +151,6 @@ struct HwSendSignView: View { } guard let invoice = app.scannedOnchainInvoice, let amount = wallet.sendAmountSats, - let feeRate = wallet.selectedFeeRateSatsPerVByte, let walletId = hwSend.walletId else { app.toast(type: .error, title: t("common__error"), description: t("other__try_again")) @@ -162,6 +161,13 @@ struct HwSendSignView: View { let tags = tagManager.selectedTagsArray do { + var retained: PendingPaykitPaymentProof? + if let request = app.contactPaymentContext?.incomingPaymentRequest { + retained = try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request) + } + guard let feeRate = SendConfirmationView.confirmationFeeRate( + retained: retained?.retainedHardwareRetry?.signedTx, selected: wallet.selectedFeeRateSatsPerVByte + ) else { throw PaykitPaymentRequestError.requestUnavailable } var proofVerified = requestId == nil let result = try await PaykitPaymentProofService.shared.withHardwarePaymentOwnership(walletId: walletId) { try await hwSend.signAndBroadcast( diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index df473e6b6..8084109f2 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -71,6 +71,30 @@ struct SendConfirmationView: View { return paykitPaymentRequestManager.retainedHardwareRetries[request.id] } + static func confirmationFeeRate(retained: HwFundingSignedTx?, selected: UInt32?) -> UInt32? { + guard let retained else { return selected } + return UInt32(exactly: retained.feeRate.rounded(.up)) + } + + static func canEditConfirmationFee(retained: HwFundingSignedTx?, isLoading: Bool) -> Bool { + retained == nil && !isLoading + } + + private var confirmationFeeRate: UInt32? { + Self.confirmationFeeRate(retained: retainedHardwareRetry?.retainedHardwareRetry?.signedTx, selected: wallet.selectedFeeRateSatsPerVByte) + } + + private var confirmationSpeed: TransactionSpeed { + if retainedHardwareRetry != nil, let confirmationFeeRate { + return .custom(satsPerVByte: confirmationFeeRate) + } + return wallet.selectedSpeed + } + + private var canEditFee: Bool { + Self.canEditConfirmationFee(retained: retainedHardwareRetry?.retainedHardwareRetry?.signedTx, isLoading: isHardwarePreparationLoading) + } + private var canSwitchFundingSource: Bool { retainedHardwareRetry == nil && fundingSources.count > 1 } @@ -449,25 +473,26 @@ struct SendConfirmationView: View { HStack(alignment: .top, spacing: 16) { Button(action: { + guard canEditFee else { return } navigationPath.append(.feeRate) }) { SendSectionView(t("wallet__send_fee_and_speed")) { HStack(spacing: 0) { Group { if hwSend.isPreviewLoading { - ActivityIndicator(size: 10, tint: wallet.selectedSpeed.iconColor) + ActivityIndicator(size: 10, tint: confirmationSpeed.iconColor) } else { - Image(wallet.selectedSpeed.iconName) + Image(confirmationSpeed.iconName) .resizable() .aspectRatio(contentMode: .fit) - .foregroundColor(wallet.selectedSpeed.iconColor) + .foregroundColor(confirmationSpeed.iconColor) } } .frame(width: 16, height: 16) .padding(.trailing, 4) HStack(spacing: 0) { - BodySSBText(wallet.selectedSpeed.title) + BodySSBText(confirmationSpeed.customSetSpeed ?? confirmationSpeed.title) if displayedTransactionFee > 0 { BodySSBText(" (") MoneyText( @@ -480,14 +505,16 @@ struct SendConfirmationView: View { } } - Image("pencil") - .foregroundColor(.textPrimary) - .frame(width: 12, height: 12) - .padding(.leading, 6) + if canEditFee { + Image("pencil") + .foregroundColor(.textPrimary) + .frame(width: 12, height: 12) + .padding(.leading, 6) + } } } } - .disabled(isHardwarePreparationLoading) + .disabled(!canEditFee) SendSectionView(t("wallet__send_confirming_in")) { HStack(spacing: 0) { @@ -498,7 +525,7 @@ struct SendConfirmationView: View { BodySSBText( TransactionSpeed.getFeeTierLocalized( - feeRate: UInt64(wallet.selectedFeeRateSatsPerVByte ?? 0), + feeRate: UInt64(confirmationFeeRate ?? 0), feeEstimates: feeEstimatesManager.estimates, variant: .range ) @@ -894,7 +921,9 @@ struct SendConfirmationView: View { } private var oneOffPaymentRequest: PaykitPaymentRequest? { - if let preparingRequest { return preparingRequest } + if let preparingRequest { + return preparingRequest + } guard let request = app.contactPaymentContext?.incomingPaymentRequest, request.billingPeriod == nil else { return nil } @@ -1232,7 +1261,9 @@ struct SendConfirmationView: View { incomingPaymentRequest, paymentIdentity: originalPaymentIdentity ) onchainPaymentStarted = !rolledBack - if !rolledBack { privatePaymentListOutcome = .uncertain } + if !rolledBack { + privatePaymentListOutcome = .uncertain + } } else if onchainPaymentStarted { shouldCancelPaymentProof = false await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) @@ -1249,7 +1280,8 @@ struct SendConfirmationView: View { } if let attemptError = error as? OnchainSendAttemptError { switch attemptError { - case .unresolved, .duplicate, .outcomeNotSaved, .localFollowupNotSaved, .retryConstruction, .retryUnavailable, .preDispatchCleanupFailed: + case .unresolved, .duplicate, .outcomeNotSaved, .localFollowupNotSaved, .retryConstruction, .retryUnavailable, + .preDispatchCleanupFailed: await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) shouldCancelPaymentProof = false app.toast(attemptError) @@ -1581,7 +1613,7 @@ struct SendConfirmationView: View { return } - guard let feeRate = wallet.selectedFeeRateSatsPerVByte else { + guard let feeRate = confirmationFeeRate else { if hwSend.isActive { await hwSend.refreshAvailable( manager: hwWalletManager, diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 38eeff0eb..7ad8840e6 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -979,6 +979,36 @@ final class HwFundingSignerTests: XCTestCase { } } + func testRetainedConfirmationRepeatSwipeIgnoresPresetAndKeepsOriginalBytes() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "original-request", counterparty: "original-merchant") + let signed = funding.signedTx + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "broadcast failed: min relay fee not met") + var authorized = 0 + for preset: UInt32 in [17, 99] { + let rate = try XCTUnwrap(SendConfirmationView.confirmationFeeRate(retained: signed, selected: preset)) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: UInt64(rate), paymentRequestId: requestId, + loadSignedPayment: { RetainedHardwareOnchainPayment(signedTx: signed, hasAttemptedBroadcast: true, isRefusedForNavigation: true) + }, + beforeFirstBroadcast: { _ in XCTFail("Must not prepare a new payment") }, + beforeBroadcastAttempt: { authorized += 1 } + ) + } _: { error in + XCTAssertTrue(error is BroadcastError, "Expected the original native refusal, not another request: \(error)") + } + XCTAssertTrue(coordinator.canLeave) + } + XCTAssertEqual(authorized, 2) + XCTAssertEqual(funding.broadcastCalls, 2) + XCTAssertEqual(funding.broadcastTransactions, [signed.serializedTx, signed.serializedTx]) + XCTAssertTrue(funding.composeCalls.isEmpty) + XCTAssertEqual(funding.signCalls, 0) + } + func testShopDefiniteRefusalAllowsLeavingAndRetainsOriginalSignedPayment() async throws { let messages = [ "min relay fee not met, 110 < 123", diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 11a621872..54876bdab 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -3,6 +3,17 @@ import LDKNode import XCTest final class SendConfirmationViewTests: XCTestCase { + func testRetainedHardwareConfirmationLocksSignedFeeRateDespitePresetEdits() { + let signed = HwFundingSignedTx(serializedTx: "original", miningFeeSats: 181, feeRate: 2.25, totalSpent: 42181) + for preset: UInt32? in [1, 17, 99, nil] { + XCTAssertEqual(SendConfirmationView.confirmationFeeRate(retained: signed, selected: preset), 3) + XCTAssertFalse(SendConfirmationView.canEditConfirmationFee(retained: signed, isLoading: false)) + } + XCTAssertEqual(SendConfirmationView.confirmationFeeRate(retained: nil, selected: 17), 17) + XCTAssertTrue(SendConfirmationView.canEditConfirmationFee(retained: nil, isLoading: false)) + XCTAssertFalse(SendConfirmationView.canEditConfirmationFee(retained: nil, isLoading: true)) + } + func testAutomaticPaymentRequiresManualConfirmationForNonLightningFunding() { for walletType in [WalletType.lightning, .onchain] { for isHardwarePayment in [false, true] { diff --git a/BitkitTests/WalletViewModelSendTests.swift b/BitkitTests/WalletViewModelSendTests.swift index 19b32410f..4a1714987 100644 --- a/BitkitTests/WalletViewModelSendTests.swift +++ b/BitkitTests/WalletViewModelSendTests.swift @@ -1,10 +1,54 @@ @testable import Bitkit +import LDKNode import XCTest @MainActor final class WalletViewModelSendTests: XCTestCase { private struct FeeFetchError: Error {} + func testSoftwareMaxUsesSpendableOutputsInsteadOfFixedAmountSelection() async throws { + let wallet = WalletViewModel() + wallet.sendAmountSats = 99819 + wallet.selectedFeeRateSatsPerVByte = 1 + wallet.isMaxAmountSend = true + let coins = [60000, 40000].enumerated().map { index, value in + SpendableUtxo(outpoint: OutPoint(txid: String(repeating: index == 0 ? "a" : "b", count: 64), vout: 0), valueSats: UInt64(value)) + } + var fixedSelections = 0 + var listCalls = 0 + do { + try await wallet.setUtxoSelection( + coinSelectionAlgorythm: .branchAndBound, + listSpendable: { listCalls += 1; return coins }, + selectForAmount: { amount, rate, _ in + fixedSelections += 1 + XCTAssertEqual(amount, 99819) + XCTAssertEqual(rate, 1) + throw FeeFetchError() + } + ) + } catch { + XCTFail("Max must use the drain inputs without fixed-payment selection: \(error)") + } + XCTAssertEqual(wallet.selectedUtxos, coins) + XCTAssertEqual(fixedSelections, 0) + XCTAssertEqual(listCalls, 1) + wallet.isMaxAmountSend = false + wallet.sendAmountSats = 10000 + try await wallet.setUtxoSelection( + coinSelectionAlgorythm: .branchAndBound, + listSpendable: { XCTFail("Fixed payment must retain its selected algorithm"); return coins }, + selectForAmount: { amount, rate, _ in + fixedSelections += 1 + XCTAssertEqual(amount, 10000) + XCTAssertEqual(rate, 1) + return [coins[0]] + } + ) + XCTAssertEqual(wallet.selectedUtxos, [coins[0]]) + XCTAssertEqual(fixedSelections, 1) + } + func testFeeRateLoadRetriesAFailedFetchAndRecovers() async throws { let wallet = WalletViewModel() var attempts = 0 From bb02e835d5b59af26869b8dab52da6dbe8d6ebf2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 10 Oct 2026 02:39:43 +0200 Subject: [PATCH 75/77] docs: make fallback payment checks reproducible --- journeys/pubky-marketplace/README.md | 7 + .../local-paykit-fallback.md | 135 ++++++++++++++++++ .../local-paykit-fallback.xml | 23 +++ 3 files changed, 165 insertions(+) create mode 100644 journeys/pubky-marketplace/local-paykit-fallback.md create mode 100644 journeys/pubky-marketplace/local-paykit-fallback.xml diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index d22255ff3..9490e2686 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -5,6 +5,13 @@ Paykit access and a watch-only account, a linked buyer receives the Payment Requ the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. +## Local-only fallback + +Remote Shop staging remains the default. After an actual staging Paykit pairing failure, see +[`local-paykit-fallback.md`](local-paykit-fallback.md) and `local-paykit-fallback.xml` for the +conditional standalone fixture. It is unrun and does not establish Shop order-paid acceptance. +The companion journey is shared with Android; only platform build and device instructions differ. + ## Required integration fixture The journey needs a controlled integration fixture outside this repository. The test operator owns diff --git a/journeys/pubky-marketplace/local-paykit-fallback.md b/journeys/pubky-marketplace/local-paykit-fallback.md new file mode 100644 index 000000000..ef47adfbb --- /dev/null +++ b/journeys/pubky-marketplace/local-paykit-fallback.md @@ -0,0 +1,135 @@ +# Local-only Paykit fallback + +Status: **unrun**. The default is a purchase on `https://shop.staging.pubky.app` with its deployed +Paykit service. Run this companion only after an actual staging Paykit pairing attempt fails and +its URL, timestamp, HTTP outcome and wallet error have been recorded. A pending signup prerequisite, +unknown server version, invoice-delivery failure or unpaid Shop order does not trigger fallback. + +This exercises an operator-owned Paykit server with staging identities and staging regtest Bitcoin. +It does not replace the deployed Shop backend. Standalone pairing, request delivery, accepted payment +and server observation are separate results; none proves that the original Shop order is paid or +that Locks released its content. Follow `local-paykit-fallback.xml` after this setup. + +## Prerequisites + +- Confirm the homeserver fix PR is open and signup has been rerun successfully against + `ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy` before starting this fixture. +- Use two isolated wallets, fresh seller/buyer staging invites and Bitkit-owned identities registered + on that homeserver. Preserve existing identities. Use `../pubky-auth/grant-signup.xml` for a fresh + `pubkyauth://signup_grant` URL naming that homeserver; invites and authorization URLs are secrets. +- Record the app commit, built app executable hash and installed bundle identifier. iOS uses + `Debug`, `to.bitkit`, Paykit `0.1.0-rc72` and native LDK `0.7.0-rc.71`. Use normal Debug with + `E2E` unset and without `E2E_BUILD`, or explicitly network-backed E2E with + `E2E_BACKEND=network E2E_NETWORK=regtest`. The local E2E default selects a different chain endpoint. +- Both wallets and the server must use `ssl://electrs.bitkit.stag0.blocktank.to:9999`, the regtest + endpoint in `Bitkit/Constants/Env.swift`. Verify this before funding or creating a request. +- Provide an owned disposable PostgreSQL database, an operator-controlled secret store, and a fixture + service signer with its exact public key allowlisted. Never point migrations at staging/shared + databases. The server automatically applies migrations at startup. +- A signed fixture driver must create and observe one request using the pinned server's documented + routes and signature contract. Record its source revision. Without that driver, stop at pairing; + do not invent unsigned invoice endpoints or borrow deployed service signing secrets. + +For the normal Debug route, use the repository's XcodeBuildMCP setup on each isolated simulator: + +```sh +xcodebuildmcp simulator build-and-run --project-path Bitkit.xcodeproj --scheme Bitkit \ + --configuration Debug --simulator-id +``` + +Verify the built configuration and any stored Electrum override before funding. With the network +E2E route, `E2E_HOMESERVER_PUBKY` is ignored; it only applies to local E2E. Select the hosted +homeserver through the real signup flow above, not through a build flag. Use XcodeBuildMCP to +inspect and drive the wallet. Deliver the generated Pubky Auth URL through Bitkit Scan as in the +signup journey; do not copy its secret-bearing value into evidence. + +## Pinned server and configuration + +Use [`pubky/paykit-server` tag `v0.1.0-rc11`](https://github.com/pubky/paykit-server/tree/v0.1.0-rc11), +commit `662dca0619a9aa2962bcd677bd5ddd4563cd2784`. Its `Cargo.toml` pins Paykit to +`ad3c72248d18587bb5b6ef3c99b063fa9bf31551`, matching client rc72. A matching pin is not pairing evidence. +Verify `git rev-parse HEAD` in the server checkout before running it. + +Copy `config/paykit-server.example.toml` from that revision to an operator-controlled file and set: + +| Key | Value | +| --- | --- | +| `http.listen_addr` | `127.0.0.1:8080` on the fixture host | +| `http.trusted_proxy_hops` | `0`; do not trust forwarded headers without verifying the proxy chain | +| `signed_services.trusted_public_keys` | Nonempty list containing only the fixture service's canonical public key | +| `setup.allowed_origins` | Exact HTTPS origin of the fixture's requesting page; no wildcard | +| `setup.log_authorization_url` | `false` | +| `paykit.client_id` | `app.paykit.server` | +| `paykit.app_id` | `paykit-server` | +| `paykit.network` | `mainnet` | +| `bitcoin.network` | `regtest` | +| `electrum.endpoint` | `ssl://electrs.bitkit.stag0.blocktank.to:9999` | + +Retain the example's remaining supported keys. `paykit.network = "mainnet"` means normal Pkarr and +homeserver resolution, including the staging identities above; it does not select Bitcoin mainnet. +`"testnet"` instead selects fixed localhost Pubky services and is wrong for this fixture. The server +accepts no arbitrary homeserver/relay config keys: select the homeserver during identity signup. + +Supply `PAYKIT_CONFIG` (file path), `PAYKIT_DATABASE_URL` (owned database) and `PAYKIT_MASTER_KEY` +(exactly 32 bytes encoded as unpadded base64url) through the secret store, not command arguments, +source files or captured logs. From the pinned server checkout, the documented commands are: + +```sh +cargo run --locked -p paykit-server -- --check-config +cargo run --locked -p paykit-server +``` + +The first command must print `configuration valid`; it does not connect to the database or prove +runtime readiness. In a separate owned process, expose only this listener: + +```sh +cloudflared tunnel --url http://127.0.0.1:8080 +``` + +Record the assigned HTTPS tunnel origin and exact process identities. Require successful +`GET /health/live` and `GET /health/ready` through the tunnel before pairing. A changed tunnel URL +requires updating the fixture URL; do not restart the server or reset identities to retry a payment. + +Open the tunnel's `/setup?return_to=&state=` in the +fixture page. Upstream rc11 initial setup accepts only `return_to` and `state`, not `creator`. +The page supplies the real Pubky Auth QR/deep link and polls `POST /setup/{flow_id}/complete`. +Approve its Paykit and watch-only account claims in the seller wallet. Existing stored bindings use +`/setup/reconnect` with `creator`, `return_to` and `state`; do not replace or reset the binding. +Do not change the deployed Shop configuration to point at this tunnel. + +## Payment and evidence + +Enable contact payments and save both contacts as in `wallet-leg.xml`. Fund only the fresh buyer's +verified staging-regtest savings address. The sibling Android checkout's staging-default `lsp` helper supports: + +```sh +../bitkit-android/lsp POST /regtest/chain/deposit '{"address":"","amountSat":100000}' +../bitkit-android/lsp POST /regtest/chain/mine '{"count":1}' +``` + +Wait for the confirmed balance, then create one small request within it. Freeze the original buyer, +seller, request ID, fixture invoice/bundle ID, amount, network, derived recipient and deadline before +authentication. Keep the failed remote Shop order separately identified; a fixture invoice is not a +replacement order. Capture the original selected inputs, signed transaction ID and retained-receipt +hash where available, without logging raw secrets. On uncertainty, retain the original request and +payment guard; any authenticated retry must preserve the original amount, inputs and retained bytes. +Never substitute payer, endpoint, order, request or inputs, or use absence of evidence to unlock. + +Record wallet acceptance, the exact transaction/output on staging regtest, server delivery/payment +status and confirmation height separately. `connected` alone is not payment completion. Preserve +sanitized evidence of each boundary; omit invites, auth URLs, private keys, sessions and signatures. + +## Failure classification and teardown + +Report the first failed boundary: `signup prerequisite`, `config/startup`, `tunnel/transport`, +`pairing/claim`, `contact/link`, `request delivery`, `wallet pre-dispatch`, `broadcast rejected`, +`broadcast unknown`, `proof delivery`, `server observation`, or `Shop order completion`. +Capture the timestamp, original IDs, HTTP status/request ID and typed error where available. +Do not relabel a transport/storage error as revoked authority or a transaction ID as acceptance. +If no Shop order was exercised, report Shop acceptance **unrun**, not failed or passed. + +Save evidence before stopping owned server/tunnel processes and test devices. Verify their exact +process/device identities and shutdown; never stop a shared database, Simulator session or another fixture. +Retain the owned database and original recovery state while a payment outcome is unresolved, with +its location and owner recorded privately. Remove disposable binaries/browser profiles and temporary +files only after evidence and required recovery state are secured. Record every incomplete step. diff --git a/journeys/pubky-marketplace/local-paykit-fallback.xml b/journeys/pubky-marketplace/local-paykit-fallback.xml new file mode 100644 index 000000000..ec49230a6 --- /dev/null +++ b/journeys/pubky-marketplace/local-paykit-fallback.xml @@ -0,0 +1,23 @@ + + + LOCAL-ONLY and unrun. Use the setup recipe in local-paykit-fallback.md only after actual + remote Shop staging Paykit pairing fails and the staging homeserver signup prerequisite passes. + This verifies a standalone Paykit fixture, not Shop order-paid or Locks content acceptance. + + + Record the failed remote staging pairing evidence and successful homeserver signup prerequisite + Verify the pinned server revision, app binary, staging homeserver identities and matching regtest Electrum endpoint + Validate the owned server configuration, start the server and Cloudflare tunnel, and verify both health endpoints + Open the fixture setup page in a browser, send its generated Pubky Auth URL to the seller wallet, and verify Paykit access and watch-only account consent + Authorize the claims and verify setup completes for the original seller without sharing root identity or spending keys + Enable contact payments in both wallets, save both contacts and verify their linked state + Fund the fresh buyer staging-regtest savings address and verify the confirmed balance + Create exactly one fixture request and record its original payer, seller, request, invoice, amount, recipient, network and deadline + Verify the received request and payment review match those original values before authenticating + Authenticate the payment once and record the retained transaction identity and backend outcome + If the outcome is uncertain, retain the original guard and request without creating a replacement payment + Verify the exact transaction output and server payment observation, then mine and record confirmation + Report standalone pairing, delivery and payment results separately from the original Shop order, marking unexercised Shop acceptance unrun + Save sanitized evidence, classify incomplete boundaries and verify teardown of only owned resources while preserving unresolved recovery state + + From d89e0469f1f419203c6bab77e1c2dcf82bb4c86c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 10 Oct 2026 11:01:11 +0200 Subject: [PATCH 76/77] fix: preserve contact edits during send recovery --- Bitkit/Services/CoreService.swift | 5 +- .../Services/OnchainSendAttemptService.swift | 4 +- .../TransferServiceActivityTests.swift | 122 ++++++++++-------- journeys/send/onchain-accepted-result.xml | 4 + 4 files changed, 80 insertions(+), 55 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index cf72e0569..c7d2132b8 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1328,7 +1328,8 @@ class ActivityService { isTransfer: Bool = false, contact: String? = nil, walletId: String = WalletScope.default, - feeIsExact: Bool = false + feeIsExact: Bool = false, + preserveExistingContact: Bool = false ) async -> Bool { let normalizedContact = contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } do { @@ -1342,7 +1343,7 @@ class ActivityService { if isTransfer { updated.isTransfer = true } - if let normalizedContact { + if let normalizedContact, !(preserveExistingContact && existing.txType == .sent) { updated.contact = normalizedContact } if updated != existing { diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 105515465..d2c6a3e82 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -265,7 +265,9 @@ struct OnchainSendLocalFollowup: OnchainSendLocalFollowupHandling { guard await activity.createSentOnchainActivityFromSendResult( txid: txid, address: attempt.address, amount: attempt.amountSats, fee: context.feeSats, feeRate: context.feeRate, contact: context.contact, - feeIsExact: attempt.recoveryContext.map { $0.candidateTxids.first?.caseInsensitiveCompare(txid) != .orderedSame } ?? false + feeIsExact: attempt.recoveryContext.map { $0.candidateTxids.first?.caseInsensitiveCompare(txid) != .orderedSame } ?? false, + // Retrying an unacknowledged save must preserve later contact edits, including removal. + preserveExistingContact: true ) else { throw OnchainSendAttemptError.localFollowupNotSaved } } else { guard let metadata = try await activity.getPreActivityMetadata(searchKey: txid), diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 912847ac3..17ded4091 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -1132,63 +1132,81 @@ final class TransferServiceActivityTests: XCTestCase { } func testAcceptedOrdinaryRestartResumesStoredContextAndDurablyAcknowledgesActivity() async throws { - let store = MemoryAttemptStore() - let txid = String(repeating: "ab", count: 32) - let node = AttemptNodeMock(result: .accepted(txid: txid)) - let original = OnchainSendAttemptService(store: store) - let context = OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: ["saved-tag"], contact: nil, createdAt: 100) - _ = try await original.send( - using: node, address: "bcrt1qoriginal", amountSats: 4321, satsPerVbyte: 2, - utxosToSpend: nil, isMaxAmount: true, followupContext: context - ) - let walletId = OnchainSendAttemptService.walletId(index: 0) - let restarted = OnchainSendAttemptService(store: store) - let unrelatedTxid = String(repeating: "cd", count: 32) - let unrelatedSaved = await activity.createSentOnchainActivityFromSendResult( - txid: unrelatedTxid, address: "bcrt1qoriginal", amount: 4321, fee: 999, feeRate: 99 - ) - XCTAssertTrue(unrelatedSaved) - store.failSave = true - do { - _ = try await restarted.resumeAcceptedOrdinarySend(walletId: walletId) - XCTFail("Failed acknowledgement released the accepted guard") - } catch {} - XCTAssertEqual(store.snapshot().first?.status, .accepted) - XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) - let exactSaved = try await activity.getOnchainActivityByTxId(txid: txid) - XCTAssertEqual(exactSaved?.value, 4321) - XCTAssertEqual(exactSaved?.address, "bcrt1qoriginal") - XCTAssertEqual(exactSaved?.fee, 123) - store.failSave = false - let restartedAgain = OnchainSendAttemptService(store: store) - do { + let originalContact = "pubky" + String(repeating: "y", count: 52) + let reassigned = "pubky" + String(repeating: "x", count: 52) + for (index, editedContact) in [String?.none, Optional(reassigned)].enumerated() { + let store = MemoryAttemptStore() + let txid = String(repeating: index == 0 ? "ab" : "ef", count: 32) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let original = OnchainSendAttemptService(store: store) + let context = OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: ["saved-tag"], contact: originalContact, createdAt: 100) + _ = try await original.send( + using: node, address: "bcrt1qoriginal", amountSats: 4321, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: true, followupContext: context + ) + var expectedAttempt = try XCTUnwrap(store.snapshot().first) + let walletId = OnchainSendAttemptService.walletId(index: 0) + let restarted = OnchainSendAttemptService(store: store) + let unrelatedTxid = String(repeating: index == 0 ? "cd" : "ac", count: 32) + let unrelatedSaved = await activity.createSentOnchainActivityFromSendResult( + txid: unrelatedTxid, address: "bcrt1qoriginal", amount: 4321, fee: 999, feeRate: 99 + ) + XCTAssertTrue(unrelatedSaved) + store.failSave = true + do { + _ = try await restarted.resumeAcceptedOrdinarySend(walletId: walletId) + XCTFail("Failed acknowledgement released the accepted guard") + } catch {} + XCTAssertEqual(store.snapshot().first?.status, .accepted) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, false) + let exactSaved = try await activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual(exactSaved?.value, 4321) + XCTAssertEqual(exactSaved?.address, "bcrt1qoriginal") + XCTAssertEqual(exactSaved?.fee, 123) + XCTAssertEqual(exactSaved?.contact, PubkyPublicKeyFormat.normalized(originalContact)) + try await activity.setContact(editedContact, forActivity: txid) + store.failSave = false + let restartedAgain = OnchainSendAttemptService(store: store) + do { + _ = try await restartedAgain.send( + using: node, + address: "bcrt1qfresh-ui", + amountSats: 9999, + satsPerVbyte: 99, + utxosToSpend: nil, + isMaxAmount: false + ) + XCTFail("Fresh UI dispatched before the original local follow-up was acknowledged") + } catch {} + let firstResolution = try await restartedAgain.resumeAcceptedOrdinarySend(walletId: walletId) + let repeatedResolution = try await restartedAgain.resumeAcceptedOrdinarySend(walletId: walletId) + XCTAssertEqual(firstResolution?.txid, txid) + XCTAssertEqual(firstResolution?.amountSats, 4321) + XCTAssertEqual(firstResolution?.contact, editedContact.flatMap(PubkyPublicKeyFormat.normalized)) + XCTAssertEqual(repeatedResolution?.contact, editedContact.flatMap(PubkyPublicKeyFormat.normalized)) + XCTAssertEqual(repeatedResolution?.txid, txid) + XCTAssertEqual(node.calls, 1, "Local resume invoked node dispatch") + let durableActivity = try await activity.getOnchainActivityByTxId(txid: txid) + let durableTags = try await activity.tags(forActivity: txid) + XCTAssertEqual(durableActivity?.address, "bcrt1qoriginal") + XCTAssertEqual(durableActivity?.contact, editedContact.flatMap(PubkyPublicKeyFormat.normalized)) + XCTAssertEqual(durableTags, ["saved-tag"]) + XCTAssertEqual(durableActivity?.feeRate, 2) + let activities = try await activity.get(filter: .onchain, limit: 50, sortDirection: .desc) + XCTAssertEqual(activities.count, (index + 1) * 2, "Repeated follow-up duplicated a durable activity") + expectedAttempt.localFollowupComplete = true + XCTAssertEqual(store.snapshot().first, expectedAttempt, "Recovery changed the original accepted payment context") _ = try await restartedAgain.send( using: node, - address: "bcrt1qfresh-ui", - amountSats: 9999, - satsPerVbyte: 99, + address: "bcrt1qnew", + amountSats: 1000, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false ) - XCTFail("Fresh UI dispatched before the original local follow-up was acknowledged") - } catch {} - let firstResolution = try await restartedAgain.resumeAcceptedOrdinarySend(walletId: walletId) - let repeatedResolution = try await restartedAgain.resumeAcceptedOrdinarySend(walletId: walletId) - XCTAssertEqual(firstResolution?.txid, txid) - XCTAssertEqual(firstResolution?.amountSats, 4321) - XCTAssertEqual(repeatedResolution?.txid, txid) - XCTAssertEqual(node.calls, 1, "Local resume invoked node dispatch") - let durableActivity = try await activity.getOnchainActivityByTxId(txid: txid) - let durableTags = try await activity.tags(forActivity: txid) - XCTAssertEqual(durableActivity?.address, "bcrt1qoriginal") - XCTAssertEqual(durableTags, ["saved-tag"]) - XCTAssertEqual(durableActivity?.feeRate, 2) - let activities = try await activity.get(filter: .onchain, limit: 50, sortDirection: .desc) - XCTAssertEqual(activities.count, 2, "Repeated follow-up duplicated a durable activity") - XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) - _ = try await restartedAgain.send(using: node, address: "bcrt1qnew", amountSats: 1000, satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false) - XCTAssertEqual(node.calls, 2) - XCTAssertEqual(store.snapshot().count, 1) + XCTAssertEqual(node.calls, 2) + XCTAssertEqual(store.snapshot().count, 1) + } } func testExactConfirmationCanFinishAcceptedIncompleteOrdinaryFollowup() async throws { diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index e8406837f..624fe19a5 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -7,6 +7,10 @@ The receiving wallet and backend must be test fixtures. Rejected or missing-result cases need a controlled broadcast-refusal/response-loss fixture, which the Capabilities table does not provide, and belong in Manual Tests. + A failed local-follow-up acknowledgement also needs a controlled persistence-failure fixture: + after Sent activity is saved, remove or reassign its original contact in Details, restart and + resume the original send, and verify the edited contact survives without another dispatch. + This failure-window case belongs in Manual Tests and focused persistence regression tests. iOS adaptations: accept an Open payment-link prompt if it appears. After the fixed send, mine one block and wait for that exact transaction to confirm. Before the Max leg, choose Settings > Advanced > Coin Selection > Manual. After Continue on Send Amount, select all spendable fixture outputs on Coin Selection and Continue before Confirm. Restore Autopilot afterward. The Manual route is necessary because the amount-less link plus Max currently fails Autopilot Branch and Bound selection with CoinSelectionFailed before native dispatch; this journey does not claim that limitation is fixed. Independently query both exact transaction IDs to verify their recipient amounts and actual input sets. From 0c95852e96b47b2200b1a5ca18dd0550d3ad1641 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 10 Oct 2026 11:54:49 +0200 Subject: [PATCH 77/77] fix: restore contacts on recovered sent activity --- Bitkit/Services/CoreService.swift | 4 +- .../TransferServiceActivityTests.swift | 44 +++++++++++++++++++ journeys/send/onchain-accepted-result.xml | 5 ++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index c7d2132b8..a211134ca 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1343,7 +1343,9 @@ class ActivityService { if isTransfer { updated.isTransfer = true } - if let normalizedContact, !(preserveExistingContact && existing.txType == .sent) { + let preserveContact = preserveExistingContact && existing.txType == .sent + && (existing.contact != nil || self.isContactDetached(activityId: existing.id, walletId: existing.walletId)) + if let normalizedContact, !preserveContact { updated.contact = normalizedContact } if updated != existing { diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 17ded4091..a8369829f 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -27,6 +27,7 @@ final class TransferServiceActivityTests: XCTestCase { try await super.setUp() transferDefaults = try makeIsolatedDefaults() guardAppDefaults("transfers") + snapshotAppDefaults("activityDetachedContacts") await drainCoreServiceQueue() try FileManager.default.createDirectory(atPath: testDbPath, withIntermediateDirectories: true) _ = try initDb(basePath: testDbPath) @@ -1131,6 +1132,47 @@ final class TransferServiceActivityTests: XCTestCase { withExtendedLifetime(wallet) {} } + func testAcceptedOrdinaryRecoveryAssociatesSyncCreatedSentRowWithoutDetachedContact() async throws { + let store = MemoryAttemptStore() + let txid = String(repeating: "19", count: 32) + let contact = "pubky" + String(repeating: "y", count: 52) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + let original = OnchainSendAttemptService(store: store) + _ = try await original.send( + using: node, address: "bcrt1qoriginal", amountSats: 4321, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: contact, createdAt: 100) + ) + var expectedAttempt = try XCTUnwrap(store.snapshot().first) + let walletId = expectedAttempt.walletId + let inserted = await activity.createSentOnchainActivityFromSendResult( + txid: txid, address: "bcrt1qoriginal", amount: 4321, fee: 123, feeRate: 2 + ) + XCTAssertTrue(inserted) + let syncedRow = try await activity.getOnchainActivityByTxId(txid: txid) + let synced = try XCTUnwrap(syncedRow) + let activityWalletId = synced.walletId + XCTAssertEqual(activityWalletId, Bitkit.WalletScope.default) + XCTAssertNil(synced.contact) + XCTAssertFalse(activity.isContactDetached(activityId: synced.id, walletId: activityWalletId)) + // An unrelated wallet's removal must not suppress the original wallet's attribution. + activity.setContactDetached(true, activityId: synced.id, walletId: "trezor:other-wallet") + activity.setContactDetached(true, activityId: "other-activity", walletId: activityWalletId) + let restarted = OnchainSendAttemptService(store: store) + let resolution = try await restarted.resumeAcceptedOrdinarySend(walletId: walletId) + let repeated = try await restarted.resumeAcceptedOrdinarySend(walletId: walletId) + let saved = try await activity.getOnchainActivityByTxId(txid: txid) + XCTAssertEqual(saved?.contact, PubkyPublicKeyFormat.normalized(contact)) + XCTAssertEqual(resolution?.contact, PubkyPublicKeyFormat.normalized(contact)) + XCTAssertEqual(repeated?.contact, PubkyPublicKeyFormat.normalized(contact)) + XCTAssertEqual(saved?.id, synced.id) + XCTAssertFalse(activity.isContactDetached(activityId: synced.id, walletId: activityWalletId)) + XCTAssertTrue(activity.isContactDetached(activityId: synced.id, walletId: "trezor:other-wallet")) + expectedAttempt.localFollowupComplete = true + XCTAssertEqual(store.snapshot().first, expectedAttempt) + XCTAssertEqual(node.calls, 1, "Restoring contact attribution dispatched another payment") + } + func testAcceptedOrdinaryRestartResumesStoredContextAndDurablyAcknowledgesActivity() async throws { let originalContact = "pubky" + String(repeating: "y", count: 52) let reassigned = "pubky" + String(repeating: "x", count: 52) @@ -1165,6 +1207,8 @@ final class TransferServiceActivityTests: XCTestCase { XCTAssertEqual(exactSaved?.fee, 123) XCTAssertEqual(exactSaved?.contact, PubkyPublicKeyFormat.normalized(originalContact)) try await activity.setContact(editedContact, forActivity: txid) + XCTAssertEqual(try activity.isContactDetached(activityId: XCTUnwrap(exactSaved).id, + walletId: XCTUnwrap(exactSaved).walletId), editedContact == nil) store.failSave = false let restartedAgain = OnchainSendAttemptService(store: store) do { diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 624fe19a5..0cf24d518 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -10,7 +10,10 @@ A failed local-follow-up acknowledgement also needs a controlled persistence-failure fixture: after Sent activity is saved, remove or reassign its original contact in Details, restart and resume the original send, and verify the edited contact survives without another dispatch. - This failure-window case belongs in Manual Tests and focused persistence regression tests. + Also stop before the first local activity write and let wallet sync create the Sent row with no + contact. Resuming that retained send must restore the originally selected contact, while an + explicitly removed contact remains detached. These failure-window cases belong in Manual Tests + and focused persistence regression tests. iOS adaptations: accept an Open payment-link prompt if it appears. After the fixed send, mine one block and wait for that exact transaction to confirm. Before the Max leg, choose Settings > Advanced > Coin Selection > Manual. After Continue on Send Amount, select all spendable fixture outputs on Coin Selection and Continue before Confirm. Restore Autopilot afterward. The Manual route is necessary because the amount-less link plus Max currently fails Autopilot Branch and Bound selection with CoinSelectionFailed before native dispatch; this journey does not claim that limitation is fixed. Independently query both exact transaction IDs to verify their recipient amounts and actual input sets.