diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 4995cde25..8b54b9a39 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.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 765933a6d..cfda66d0a 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" : "daeee4d2cb7164680ea686c0c7623cf866636310", + "version" : "0.7.0-rc.71" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 65fc0b5ce..667cb2494 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1357,7 +1357,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, activeIdentity: pubkyProfile.publicKey) + } + + 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 { @@ -1384,7 +1396,6 @@ struct AppScene: View { ) } } - await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) } private func pollIncomingPaykitPaymentRequests() async { @@ -1460,14 +1471,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 { @@ -1518,11 +1534,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/Extensions/BroadcastError+Connectivity.swift b/Bitkit/Extensions/BroadcastError+Connectivity.swift index 5f34ade09..c545cf79e 100644 --- a/Bitkit/Extensions/BroadcastError+Connectivity.swift +++ b/Bitkit/Extensions/BroadcastError+Connectivity.swift @@ -52,6 +52,53 @@ extension Error { return false } + func isHardwareBroadcastRefusal() -> Bool { + if isDefiniteHardwarePreBroadcastFailure() { + return true + } + if let error = self as? BroadcastError, case let .ElectrumError(details) = error { + let prefix = "broadcast failed: " + let details = details.lowercased() + guard details.hasPrefix(prefix) else { return false } + 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, options: .fragmentsAllowed) + else { return false } + 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) { + 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 == $0 || reason.hasPrefix($0 + ", ") } + } + 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/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 0066d4a7e..74fee3acf 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -1235,6 +1235,14 @@ final class HwWalletManager { ) } + nonisolated static func persistedFundingAccount(walletId: String) throws -> HwFundingAccount { + let entries = HwKnownDeviceStorage.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 @@ -1541,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/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 e262098c8..3bfd51b43 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 var acceptedOneTimeRequests: [String: [RequestID]]? = nil struct RequestID: Codable { @@ -14,7 +16,7 @@ struct PaykitPaymentStateBackup: Codable { init(_ id: PaykitPaymentRequest.ID, billingPeriod: PaykitBillingPeriod?) { paymentRequestId = id.paymentRequestId counterparty = id.counterparty - billingPeriodStartsAt = billingPeriod?.sdkValue.startsAt + billingPeriodStartsAt = billingPeriod?.sdkValue.startsAt ?? id.billingPeriodStartsAt.map { PaykitPreciseInstant(date: $0).timestamp } } func restored(billingPeriod: PaykitBillingPeriod?) -> PaykitPaymentRequest.ID { @@ -70,7 +72,15 @@ struct PaykitPaymentStateBackup: Codable { let onchainAddress: String? let onchainAmountSats: UInt64? let onchainWalletId: String? + let hardwareSignedTransaction: String? + let hardwareMiningFeeSats: UInt64? + let hardwareFeeRate: UInt64? + let hardwareTotalSpent: UInt64? + let hardwareDispatchAttempted: Bool? + let privatePaymentListVersion: UInt64? + let previousPrivatePaymentListVersion: UInt64? let onchainMatchingTransactionIdsBeforeAttempt: Set + let onchainAcceptanceVerified: Bool? init(_ proof: PendingPaykitPaymentProof) { identity = proof.identity @@ -85,7 +95,15 @@ struct PaykitPaymentStateBackup: Codable { onchainAddress = proof.onchainAddress onchainAmountSats = proof.onchainAmountSats onchainWalletId = proof.onchainWalletId + hardwareSignedTransaction = proof.hardwareSignedTransaction + hardwareMiningFeeSats = proof.hardwareMiningFeeSats + hardwareFeeRate = proof.hardwareFeeRate + hardwareTotalSpent = proof.hardwareTotalSpent + hardwareDispatchAttempted = proof.hardwareDispatchAttempted + privatePaymentListVersion = proof.privatePaymentListVersion + previousPrivatePaymentListVersion = proof.previousPrivatePaymentListVersion onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] + onchainAcceptanceVerified = proof.onchainAcceptanceVerified } func restored() throws -> PendingPaykitPaymentProof { @@ -95,7 +113,7 @@ struct PaykitPaymentStateBackup: Codable { } return period } - return PendingPaykitPaymentProof( + var proof = PendingPaykitPaymentProof( identity: identity, requestId: requestId.restored(billingPeriod: period), paymentAppId: paymentAppId, @@ -108,8 +126,232 @@ struct PaykitPaymentStateBackup: Codable { onchainAddress: onchainAddress, onchainAmountSats: onchainAmountSats, onchainWalletId: onchainWalletId, - onchainMatchingTransactionIdsBeforeAttempt: onchainMatchingTransactionIdsBeforeAttempt + onchainMatchingTransactionIdsBeforeAttempt: onchainMatchingTransactionIdsBeforeAttempt, + onchainAcceptanceVerified: onchainAcceptanceVerified ) + 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 + proof.hardwareMiningFeeSats = hardwareMiningFeeSats + proof.hardwareFeeRate = hardwareFeeRate + 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 + } + } + + /// 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 candidateFeeRates: [String: String]? + let followup: Followup? + let transfer: Transfer? + + 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: billingPeriod) } + 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() } ?? [] + 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") + } + 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 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") + } + 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 } + return PaykitPaymentRequest.ID(paymentRequestId: value.paymentRequestId, counterparty: value.counterparty, + 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 + let originalFee: UInt64 = try number(value.originalOrderFeeSats) + let transactionTotal: UInt64 = try number(value.txTotalSats) + 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), + 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, candidateFeeRates: rates)) + 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 } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 03e5c931e..507a5ae6c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1772,3 +1772,35 @@ "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 start a separate payment. {reason}"; +"wallet__onchain_unknown_pending" = "This transaction may have been sent. Its outcome is unknown. Do not start a separate payment."; +"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."; + +"wallet__onchain_retry_original" = "Retry original payment"; +"wallet__onchain_retry_fee" = "Fee rate (sat/vB)"; +"wallet__onchain_retry_note" = "Retry {amount} sats to {address} using the same original coins. The recipient amount stays unchanged. Enter the network fee rate in sat/vB. This cannot create a separate payment."; +"wallet__onchain_retry_construction" = "This fee cannot be paid from the original coins while keeping the recipient amount unchanged, or those coins are no longer available. Choose a lower fee rate or wait for the original payment to be recognized. No separate payment was sent."; +"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/BackupService.swift b/Bitkit/Services/BackupService.swift index 864d5e70c..3998eba25 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -336,10 +336,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) - try PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) - .restoreBackup(paymentState.acceptedOneTimeRequests ?? [:]) + // 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) @@ -530,6 +529,7 @@ class BackupService { PaykitSubscriptionStateStore.walletBackupDataChangedPublisher .merge(with: PaykitPaymentRequestIdStore.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 } @@ -899,10 +899,8 @@ class BackupService { paykitSdkBackupState: paykitSdkBackupState, watchOnlyAccounts: watchOnlyAccountSnapshot.accounts, watchOnlyAccountAllocationState: watchOnlyAccountSnapshot.allocationState, - paykitPaymentState: PaykitPaymentStateBackup( - subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), - pendingProofs: PaykitPaymentProofService.shared.backupSnapshot(), - acceptedOneTimeRequests: PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests).backupSnapshot() + paykitPaymentState: PaykitPaymentProofService.shared.backupSnapshot( + wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0) ) ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index a3cf80b79..a211134ca 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1318,6 +1318,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, @@ -1326,17 +1327,25 @@ class ActivityService { feeRate: UInt32, isTransfer: Bool = false, contact: String? = nil, - walletId: String = WalletScope.default - ) async { + walletId: String = WalletScope.default, + feeIsExact: Bool = false, + preserveExistingContact: Bool = false + ) 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 feeIsExact { + updated.fee = fee + updated.feeRate = UInt64(feeRate) + } if isTransfer { updated.isTransfer = true } - if let normalizedContact { + 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 { @@ -1344,7 +1353,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( @@ -1374,9 +1383,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 2e29ffefa..0bfae088a 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,8 @@ class LightningService { @MainActor private var cachedChannels: [ChannelDetails]? private var storedEventCallback: ((Event) -> Void)? + var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? + var onchainTransactionReceived: (@Sendable (String) async -> Void)? var syncStatusChangedPublisher: AnyPublisher { syncStatusChangedSubject.eraseToAnyPublisher() @@ -778,7 +784,7 @@ class LightningService { private static func convertVByteToKwu(satsPerVByte: UInt32) -> FeeRate { // 1 vbyte = 4 weight units, so 1 sats/vbyte = 250 sats/kwu - let satPerKwu = UInt64(satsPerVByte * 250) + let satPerKwu = UInt64(satsPerVByte) * 250 // Ensure we're above the minimum relay fee return .fromSatPerKwu(satKwu: max(satPerKwu, 253)) // FEERATE_FLOOR_SATS_PER_KW is 253 in LDK } @@ -789,26 +795,32 @@ class LightningService { satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]? = nil, isMaxAmount: Bool = false, + expectedWalletIndex: Int? = nil, + expectedNode: AnyObject? = nil, beforeSubmission: @escaping () throws -> Void = {} - ) async throws -> Txid { + ) 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 Self.submitPayment(beforeSubmission: beforeSubmission) { + 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") + } + } + try beforeSubmission() 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), @@ -1489,6 +1501,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 { @@ -1498,6 +1511,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/OnChainHwService.swift b/Bitkit/Services/OnChainHwService.swift index 52c1f6546..5c419d648 100644 --- a/Bitkit/Services/OnChainHwService.swift +++ b/Bitkit/Services/OnChainHwService.swift @@ -117,11 +117,35 @@ 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 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() try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) - return try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) + try Task.checkCancellation() + } catch { + throw PreparedOnchainSendNotSubmitted(underlying: error) } + return try await nativeBroadcast() } // MARK: - Event Watcher (No Device Required) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift new file mode 100644 index 000000000..d2c6a3e82 --- /dev/null +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -0,0 +1,1010 @@ +import BitkitCore +import Combine +import Foundation +import LDKNode + +protocol OnchainSending { + var currentWalletIndex: Int { get } + 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 +} + +extension LightningService: OnchainSending { + func prepareOnchainSend( + address: String, sats: UInt64, satsPerVbyte: UInt32, + utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, + 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, 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") + } + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) + let prepared: PreparedOnchainSend = if isMaxAmount { + try node.onchainPayment().prepareSendAllToAddress( + address: address, retainReserves: true, + feeRate: .fromSatPerKwu(satKwu: max(UInt64(satsPerVbyte) * 250, 253)) + ) + } else { + try node.onchainPayment().prepareSendToAddress( + address: address, amountSats: sats, + feeRate: .fromSatPerKwu(satKwu: max(UInt64(satsPerVbyte) * 250, 253)), utxosToSpend: utxosToSpend + ) + } + return (prepared, prepared.txid(), prepared.inputs().map { OnchainSendInput(txid: $0.txid, vout: $0.vout) }, + prepared.recipientAmountSats(), prepared.miningFeeSats()) + } + return PreparedOnchainSendDispatch( + txid: txid, inputs: inputs, recipientAmountSats: recipientAmountSats, miningFeeSats: miningFeeSats, + broadcast: { + try await ServiceQueue.background(.ldk, wrapErrors: false) { + 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) + } + return try prepared.broadcast() + } + } + ) + } +} + +struct OnchainSendInput: Codable, Equatable, Hashable { + let txid: String + let vout: UInt32 + + var utxo: SpendableUtxo { + SpendableUtxo(outpoint: OutPoint(txid: Txid(txid), vout: vout), valueSats: 0) + } +} + +// 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] + let recipientAmountSats: UInt64 + var miningFeeSats: UInt64? = nil + let broadcast: () async throws -> OnchainSendResult +} + +struct OnchainSendRecoveryContext: Codable, Equatable { + let inputs: [OnchainSendInput] + 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 { + case unresolved + case duplicate + case outcomeNotSaved + case localFollowupNotSaved + case retryConstruction + case retryUnavailable + case preDispatch(Error) + case preDispatchCleanupFailed + + var errorDescription: String? { + switch self { + case .unresolved: + t("wallet__onchain_send_unresolved") + case .duplicate: + t("wallet__onchain_send_duplicate") + case .outcomeNotSaved: + t("wallet__onchain_outcome_save_failed") + case .localFollowupNotSaved, .preDispatchCleanupFailed: + t("wallet__onchain_followup_failed") + case .retryConstruction: + t("wallet__onchain_retry_construction") + case .retryUnavailable: + t("wallet__onchain_retry_unavailable") + 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 + var amountSats: UInt64 + let isMaxAmount: Bool + var status: Status + var txid: String? = nil + var rejectionReason: String? = nil + var localFollowupComplete = false + var followupContext: OnchainSendFollowupContext? = nil + var transferContext: OnchainSendTransferContext? = nil + var recoveryContext: OnchainSendRecoveryContext? = nil + + var blocksNewSend: Bool { + status.blocksNewSend || !localFollowupComplete + } + + func containsCandidate(_ txid: String?) -> Bool { + guard let txid else { return self.txid == nil } + return self.txid?.caseInsensitiveCompare(txid) == .orderedSame || + recoveryContext?.candidateTxids.contains(where: { $0.caseInsensitiveCompare(txid) == .orderedSame }) == true + } + + func storedCandidate(_ txid: String) -> String? { + if self.txid?.caseInsensitiveCompare(txid) == .orderedSame { + return self.txid + } + return recoveryContext?.candidateTxids.first { $0.caseInsensitiveCompare(txid) == .orderedSame } + } + + var canRetrySamePayment: Bool { + guard status == .pending || status == .unknown || status == .rejected, let recovery = recoveryContext, + !recovery.inputs.isEmpty, let txid, recovery.candidateTxids.contains(txid) + else { return false } + return requestId == nil || recovery.paymentIdentity != nil + } +} + +struct OnchainSendFollowupContext: Codable, Equatable { + var feeSats: UInt64 + var feeRate: UInt32 + 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 { + let attemptId: UUID + let walletId: String + let txid: String + let amountSats: UInt64 + let contact: String? + let activity: OnchainActivity +} + +protocol OnchainSendLocalFollowupHandling { + func save(_ attempt: OnchainSendAttempt) async throws -> OnchainActivity +} + +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 + 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, + 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), + 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 { + 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) + } + + func save(_ attempts: [OnchainSendAttempt]) throws { + try Keychain.upsert(key: .onchainSendAttempts, data: JSONEncoder().encode(attempts)) + Self.backupDataChanged.send() + } +} + +struct OnchainSendPendingContext: Hashable { + let attemptId: UUID + let walletId: String + let txid: String? +} + +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 winningFee: (OnchainSendAttempt) async throws -> UInt64? + private let store: any OnchainSendAttemptStoring + private let hasPaidOrder: (String) throws -> Bool + private var knownAttempt: OnchainSendAttempt? + private var nativeDispatchInProgress: UUID? + private var requestFollowupInProgress: UUID? + private var ordinaryFollowupInProgress: UUID? + + 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 + } + + 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, + attempt.recoveryContext?.candidateTxids.isEmpty != false + { + throw OnchainSendAttemptError.unresolved + } + 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 + } + + 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, + amountSats: UInt64, + satsPerVbyte: UInt32, + utxosToSpend: [SpendableUtxo]?, + isMaxAmount: Bool, + requestId: PaykitPaymentRequest.ID? = nil, + orderId: String? = nil, + paymentIdentity: String? = nil, + followupContext: OnchainSendFollowupContext? = nil, + transferContext: OnchainSendTransferContext? = nil, + paymentDeadline: PaykitPreciseInstant? = nil, + 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 + let dispatchNode = lightningService.onchainDispatchNode + if let prior = try currentAttempt(), prior.status == .accepted, let txid = prior.txid, + prior.walletId == Self.walletId(index: walletIndex), + prior.recoveryContext?.paymentIdentity == nil || prior.recoveryContext?.paymentIdentity == paymentIdentity, + (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, + transferContext: transferContext + ) + nativeDispatchInProgress = attemptId + defer { nativeDispatchInProgress = nil } + let prepared: PreparedOnchainSendDispatch + do { + prepared = try await lightningService.prepareOnchainSend( + address: address, sats: amountSats, satsPerVbyte: satsPerVbyte, + utxosToSpend: utxosToSpend, isMaxAmount: isMaxAmount, + expectedWalletIndex: walletIndex, expectedNode: dispatchNode, paymentDeadline: paymentDeadline + ) + do { + try validateReceipt(prepared, amount: isMaxAmount && requestId == nil ? nil : amountSats, + 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, + let miningFee = prepared.miningFeeSats, + miningFee <= context.txTotalSats, + prepared.recipientAmountSats >= originalFee, + prepared.recipientAmountSats <= context.txTotalSats - miningFee + else { throw OnchainSendAttemptError.unresolved } + } + 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] + ) + try store.save([attempt]) + knownAttempt = attempt + } + 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 } + throw OnchainSendAttemptError.preDispatch(preDispatchError) + } + + if let winner = try winningResult(attemptId: attemptId) { + return winner + } + 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 { + 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 (try? winningResult(attemptId: attemptId)) ?? result + } + + 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(), + original.id == context.attemptId, original.walletId == context.walletId, + original.containsCandidate(context.txid), original.canRetrySamePayment, + let recovery = original.recoveryContext + else { throw OnchainSendAttemptError.unresolved } + let index = sender.currentWalletIndex + let node = sender.onchainDispatchNode + guard original.walletId == Self.walletId(index: index) else { throw OnchainSendAttemptError.unresolved } + nativeDispatchInProgress = original.id + defer { nativeDispatchInProgress = nil } + let authorizedFeeRate = satsPerVbyte ?? recovery.satsPerVbyte + 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 { + throw OnchainSendAttemptError.retryConstruction + } + try checkWallet(sender, index: index, node: node) + if let winner = try winningResult(attemptId: original.id) { + return winner + } + let prepared: PreparedOnchainSendDispatch + do { + 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: paymentDeadline + ) + prepared = receipt + do { try validateReceipt(prepared, amount: original.amountSats, inputs: recovery.inputs) } + catch { throw OnchainSendAttemptError.retryConstruction } + } catch let error as NodeError { + switch error { + case .InsufficientFunds, .OnchainTxCreationFailed, .InvalidAmount, .WalletOperationFailed: + throw OnchainSendAttemptError.retryConstruction + default: throw OnchainSendAttemptError.retryUnavailable + } + } catch let error as OnchainSendAttemptError { throw error } + catch { throw OnchainSendAttemptError.retryUnavailable } + try checkWallet(sender, index: index, node: node) + if let winner = try winningResult(attemptId: original.id) { + return winner + } + guard var attempt = try currentAttempt(), attempt.id == original.id, + attempt.recoveryContext == recovery + else { throw OnchainSendAttemptError.unresolved } + let addedCandidate = !attempt.containsCandidate(prepared.txid) + if addedCandidate { + 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]) + knownAttempt = attempt + // 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. + do { + try Task.checkCancellation() + try await approvePrepared(prepared) + 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) } + 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 + } + + 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") + } + } + + private func validateReceipt(_ prepared: PreparedOnchainSendDispatch, amount: UInt64?, inputs: [OnchainSendInput]?) throws { + guard prepared.txid.count == 64, prepared.txid.allSatisfy(\.isHexDigit), + prepared.recipientAmountSats > 0, amount == nil || amount == prepared.recipientAmountSats, + !prepared.inputs.isEmpty, Set(prepared.inputs).count == prepared.inputs.count, + prepared.inputs.allSatisfy({ $0.txid.count == 64 && $0.txid.allSatisfy(\.isHexDigit) }), + inputs == nil || (Set(inputs ?? []) == Set(prepared.inputs) && inputs?.count == prepared.inputs.count) + else { throw OnchainSendAttemptError.unresolved } + } + + private func normalized(_ result: OnchainSendResult, candidate: String) -> OnchainSendResult { + switch result { + case let .accepted(txid) where txid == candidate: return result + case let .rejected(txid, _) where txid == candidate: return result + case let .unknown(txid) where txid == candidate: return result + default: return .unknown(txid: candidate) + } + } + + private func winningResult(attemptId: UUID) throws -> OnchainSendResult? { + guard let attempt = try currentAttempt(), attempt.id == attemptId, attempt.status == .accepted, let txid = attempt.txid + else { return nil } + return .accepted(txid: txid) + } + + func admit( + walletId: String, + requestId: PaykitPaymentRequest.ID?, + orderId: String?, + address: String, + amountSats: UInt64, + isMaxAmount: Bool, + followupContext: OnchainSendFollowupContext? = nil, + transferContext: OnchainSendTransferContext? = nil + ) throws -> UUID { + guard nativeDispatchInProgress == nil else { throw OnchainSendAttemptError.unresolved } + 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, + transferContext: transferContext + ) + 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 else { + throw OnchainSendAttemptError.outcomeNotSaved + } + if attempt.status == .accepted { + return + } + if attempt.recoveryContext == nil { + guard attempt.status == .pending else { throw OnchainSendAttemptError.outcomeNotSaved } + } else { + let txid: String = switch result { + case let .accepted(id), let .unknown(id), let .rejected(id, _): id + } + guard attempt.containsCandidate(txid) 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 } + if knownAttempt?.id != saved.id, nativeDispatchInProgress == nil, + saved.status == .pending, saved.txid == nil, saved.recoveryContext == nil + { + try store.save([]) + return nil + } + return knownAttempt?.id == saved.id ? knownAttempt : saved + } + + func acknowledgeLocalFollowup(txid: String) throws { + guard nativeDispatchInProgress == nil else { throw OnchainSendAttemptError.unresolved } + 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, 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 } + if let pendingContext { + guard attempt.id == pendingContext.attemptId, attempt.walletId == pendingContext.walletId, + attempt.containsCandidate(pendingContext.txid) else { return nil } + } + if let observedTxid { + guard attempt.containsCandidate(observedTxid) else { return nil } + if attempt.status == .accepted, txid.caseInsensitiveCompare(observedTxid) != .orderedSame { + return nil + } + if attempt.status != .accepted { + attempt.status = .accepted + attempt.txid = attempt.storedCandidate(observedTxid) + knownAttempt = attempt + try store.save([attempt]) + } + } + guard attempt.status == .accepted, nativeDispatchInProgress == nil else { return nil } + guard let txid = attempt.txid 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 + ) + } + ordinaryFollowupInProgress = attempt.id + defer { ordinaryFollowupInProgress = nil } + attempt = try await winningFollowupAttempt(attempt) + 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, activity: activity + ) + Self.localResolutionSubject.send(resolution) + 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 > 0 ? 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 } + } + + @discardableResult + 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 { 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 + } + + @discardableResult + func resumeAcceptedTransfer(walletId: String, using transferService: TransferService) async throws -> Bool { + guard nativeDispatchInProgress == nil, 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 nativeDispatchInProgress == nil, 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 } + let attempt = try await winningFollowupAttempt(attempt) + 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 + )]) + 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: 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, + 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 } + 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 } + guard knownAttempt?.id != attemptId || knownAttempt?.status == .pending else { return } + attempts.remove(at: index) + try store.save(attempts) + knownAttempt = nil + } + + func acceptedRequestContext(requestId: PaykitPaymentRequest.ID, paymentIdentity: String? = nil) throws -> OnchainSendAttempt? { + guard nativeDispatchInProgress == nil, let attempt = try currentAttempt(), + attempt.requestId == requestId, attempt.status == .accepted, + attempt.recoveryContext?.paymentIdentity == nil || + PubkyPublicKeyFormat.matches(attempt.recoveryContext?.paymentIdentity, paymentIdentity) + else { return nil } + return attempt + } + + func acceptedTransactionId(for requestId: PaykitPaymentRequest.ID, paymentIdentity: String? = nil) throws -> String? { + try acceptedRequestContext(requestId: requestId, paymentIdentity: paymentIdentity)?.txid + } + + func hasAttempt(for requestId: PaykitPaymentRequest.ID) throws -> Bool { + 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 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) + } + + func orderPendingContext(orderId: String) throws -> OnchainSendPendingContext? { + guard let attempt = try currentAttemptForPending(), attempt.requestId == nil, + attempt.orderId == orderId, 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 + } + } + + func ordinaryPendingContext(txid: String? = nil) throws -> OnchainSendPendingContext? { + 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 { + // 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.containsCandidate(context.txid) && + $0.requestId == nil && $0.orderId == nil ? $0 : nil + } + } + + 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 } + } + + @discardableResult + 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) + } + + @discardableResult + func observeConfirmedTransaction(txid: String) throws -> Bool { + guard var attempt = try currentAttempt(), + attempt.containsCandidate(txid), + attempt.blocksNewSend + else { return false } + if attempt.status == .accepted { + return attempt.txid?.caseInsensitiveCompare(txid) == .orderedSame + } + attempt.status = .accepted + attempt.txid = attempt.storedCandidate(txid) + knownAttempt = attempt + try store.save([attempt]) + return true + } +} diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 95a05415f..503706866 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1,3 +1,4 @@ +import BitkitCore import Combine import CryptoKit import Foundation @@ -18,13 +19,27 @@ struct PaykitOnchainPaymentResolution: Equatable { let identity: String let requestId: PaykitPaymentRequest.ID let transactionId: String - let walletId: String + var walletId: String = WalletScope.default +} - init(identity: String, requestId: PaykitPaymentRequest.ID, transactionId: String, walletId: String = WalletScope.default) { - self.identity = identity - self.requestId = requestId - self.transactionId = transactionId - self.walletId = walletId +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 + ) } } @@ -40,11 +55,42 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var proofData: String? var onchainAddress: String? var onchainAmountSats: UInt64? - let onchainWalletId: String? + 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 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? + 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 + } - var effectiveOnchainWalletId: String { - 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( @@ -60,7 +106,9 @@ struct PendingPaykitPaymentProof: Codable, Equatable { onchainAddress: String? = nil, onchainAmountSats: UInt64? = nil, onchainWalletId: String? = nil, - onchainMatchingTransactionIdsBeforeAttempt: Set? = nil + onchainMatchingTransactionIdsBeforeAttempt: Set? = nil, + onchainAcceptanceVerified: Bool? = false, + onchainLocalFollowupComplete: Bool? = false ) { self.identity = identity self.requestId = requestId @@ -75,6 +123,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { self.onchainAmountSats = onchainAmountSats self.onchainWalletId = onchainWalletId self.onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt + self.onchainAcceptanceVerified = onchainAcceptanceVerified + self.onchainLocalFollowupComplete = onchainLocalFollowupComplete } } @@ -149,64 +199,24 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { } } -protocol PaykitOnchainPaymentProofLookingUp: Sendable { - func supportsWallet(_ walletId: String) -> Bool - func existingTransactionIds(address: String, amountSats: UInt64, walletId: String) async throws -> Set - func transactionId(address: String, amountSats: UInt64, walletId: String, excluding transactionIds: Set) async throws -> String? -} - -extension PaykitOnchainPaymentProofLookingUp { - func supportsWallet(_ walletId: String) -> Bool { - walletId == WalletScope.default - } -} - -struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp { - func supportsWallet(_ walletId: String) -> Bool { - walletId == WalletScope.default || !HwKnownDeviceStorage.loadAll(walletId: walletId).isEmpty - } +private actor PaykitProofMutationLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] - func existingTransactionIds(address: String, amountSats: UInt64, walletId: String) async throws -> Set { - try await Set(matchingTransactionIds(address: address, amountSats: amountSats, walletId: walletId).map { $0.lowercased() }) - } - - func transactionId(address: String, amountSats: UInt64, walletId: String, excluding transactionIds: Set) async throws -> String? { - try await matchingTransactionIds(address: address, amountSats: amountSats, walletId: walletId) - .reversed() - .first { !transactionIds.contains($0.lowercased()) } - } - - private func matchingTransactionIds(address: String, amountSats: UInt64, walletId: String) async throws -> [String] { - guard supportsWallet(walletId) else { throw PaykitPaymentRequestError.requestUnavailable } - if walletId != WalletScope.default { - let activities = try await CoreService.shared.activity.get(filter: .onchain, txType: .sent, walletId: walletId) - var transactionIds: [String] = [] - for case let .onchain(activity) in activities { - guard activity.walletId == walletId, activity.doesExist, - let details = try await CoreService.shared.activity.getTransactionDetails(txid: activity.txId, walletId: walletId), - details.walletId == walletId, - details.outputs.contains(where: { $0.scriptpubkeyAddress == address && $0.value == amountSats }) - else { continue } - transactionIds.append(activity.txId) - } - return transactionIds - } - 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() } } @@ -227,28 +237,87 @@ actor PaykitPaymentProofService { private let sdk: any PaykitPaymentProofSdkHandling private let store: any PaykitPaymentProofStoring private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp - private let onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp + 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 + private let mutationLock = PaykitProofMutationLock() + private struct HardwareCompletionKey: Hashable { + let identity: String + let requestId: PaykitPaymentRequest.ID + let walletId: String + } + + private var hardwareCompletionsInProgress: 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) } + func backupSnapshot(wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet) async throws -> PaykitPaymentStateBackup { + 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 { + 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) + try PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests).restoreBackup(state.acceptedOneTimeRequests ?? [:]) + } + 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") } - try await persist(restoredProofs) + try await mutationLock.withLock { try await persist(restoredProofs) } } init( sdk: any PaykitPaymentProofSdkHandling = PaykitSdkService.shared, store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(), lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), - onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp = PaykitOnchainPaymentProofLookup(), + 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") }, @@ -259,7 +328,9 @@ actor PaykitPaymentProofService { self.sdk = sdk self.store = store self.lightningPaymentLookup = lightningPaymentLookup - self.onchainPaymentLookup = onchainPaymentLookup + self.hardwareTransactionLookup = hardwareTransactionLookup + self.hardwareFollowup = hardwareFollowup + self.attemptService = attemptService self.logInfo = logInfo self.logWarning = logWarning } @@ -268,30 +339,45 @@ actor PaykitPaymentProofService { request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, - kind: PaykitPaymentProofKind, - walletId: String = WalletScope.default + kind: PaykitPaymentProofKind ) async throws { + if try await attemptService.hasAttempt(for: request.id) { + if kind == .onchain, + try await attemptService.acceptedTransactionId(for: request.id, paymentIdentity: currentIdentity()) != nil + { + return + } + throw PaykitPaymentRequestError.operationInProgress + } let proof = try await pendingProof( request: request, paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, - kind: kind, - walletId: walletId + kind: kind ) + try await mutationLock.withLock { try await prepareLocked(request: request, proof: proof) } + } + private func prepareLocked(request: PaykitPaymentRequest, proof: PendingPaykitPaymentProof) async throws { + if try await attemptService.hasAttempt(for: request.id) { + if proof.kind == .onchain, try await attemptService.acceptedTransactionId(for: request.id, paymentIdentity: proof.identity) != nil { + return + } + throw PaykitPaymentRequestError.operationInProgress + } 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 } pendingProofs.removeAll { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == request.id && - $0.effectiveOnchainWalletId == proof.effectiveOnchainWalletId && - !hasUnsupportedOnchainWallet($0) && + !$0.hasUnsupportedOnchainWallet && !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil @@ -304,12 +390,10 @@ actor PaykitPaymentProofService { request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, - kind: PaykitPaymentProofKind, - walletId: String = WalletScope.default + kind: PaykitPaymentProofKind ) async throws -> PendingPaykitPaymentProof { guard request.acceptedPaymentEndpointIdentifiers.contains(paymentEndpointIdentifier), Self.endpoint(paymentEndpointIdentifier, supports: kind), - kind != .onchain || onchainPaymentLookup.supportsWallet(walletId), let identityStatus = try await sdk.identityStatus(), identityStatus.capability == .privateLinkCapable, let publicKey = identityStatus.publicKey, @@ -318,6 +402,19 @@ 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) + 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, @@ -326,17 +423,25 @@ actor PaykitPaymentProofService { kind: kind, billingPeriod: request.billingPeriod, paymentIdentifier: nil, - proofData: nil, - onchainWalletId: kind == .onchain ? walletId : nil + proofData: nil ) } func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + let identity = try await currentIdentity() + try await mutationLock.withLock { + try await associateLightningPaymentLocked(request, paymentHash: paymentHash, identity: identity) + } + } + + private func associateLightningPaymentLocked(_ request: PaykitPaymentRequest, paymentHash: String, identity: String) async throws { + if try await attemptService.hasAttempt(for: request.id) { + throw PaykitPaymentRequestError.operationInProgress + } guard Self.isHex(paymentHash, byteCount: 32) else { throw PaykitPaymentRequestError.requestUnavailable } - let identity = try await currentIdentity() var pendingProofs = try await loadProofs() guard let index = pendingProofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && @@ -353,20 +458,161 @@ actor PaykitPaymentProofService { try await persist(pendingProofs) } - func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String, walletId: String = WalletScope.default) async throws { + func onchainPaymentIdentity(requestId: PaykitPaymentRequest.ID) async throws -> String { let identity = try await currentIdentity() - let existingTransactionIds = try await onchainPaymentLookup.existingTransactionIds( - address: address, - amountSats: request.amountSats, - walletId: walletId - ) + guard try await loadProofs().contains(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && + $0.kind == .onchain && !$0.hasUnsupportedOnchainWallet + }) else { throw PaykitPaymentRequestError.requestUnavailable } + return identity + } + + func authorizeOnchainRecovery(_ attempt: OnchainSendAttempt, restoreStartedProof: Bool = true) async throws -> PaykitPaymentRequest { + guard let requestId = attempt.requestId, let payer = attempt.recoveryContext?.paymentIdentity else { + throw PaykitPaymentRequestError.requestUnavailable + } + try await requireRecoveryPayer(payer) + guard let proof = try await loadProofs().last(where: { + PubkyPublicKeyFormat.matches($0.identity, payer) && $0.requestId == requestId && $0.kind == .onchain + }), + !proof.hasUnsupportedOnchainWallet, proof.onchainAcceptanceVerified != true, proof.proofData == nil, + proof.onchainAddress == nil || proof.onchainAddress == attempt.address, + proof.onchainAmountSats == nil || proof.onchainAmountSats == attempt.amountSats, + proof.paymentIdentifier == nil || attempt.containsCandidate(proof.paymentIdentifier) + else { throw PaykitPaymentRequestError.operationInProgress } + let records = try await sdk.paymentRequests() + guard let record = records.first(where: { + $0.localRole == .payer && $0.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches($0.counterparty, requestId.counterparty) + }) 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 { + // 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 { + var proofs = try await loadProofs() + guard let index = proofs.lastIndex(of: proof) else { throw PaykitPaymentRequestError.operationInProgress } + proofs[index].paymentStarted = true + proofs[index].onchainAddress = attempt.address + proofs[index].onchainAmountSats = attempt.amountSats + try await persist(proofs) + } + } + return request + } + + func resolvedOnchainPayment(requestId: PaykitPaymentRequest.ID, identity: String, + context: OnchainSendPendingContext?) async -> PaykitOnchainPaymentResolution? + { + do { + try await requireRecoveryPayer(identity) + let proof = try await pendingOnchainPayment(requestId: requestId, identity: identity) + let attempt: OnchainSendAttempt? = if let context { + try await attemptService.pendingAttempt(context: context) + } else { + nil + } + let txid: String? + if let attempt, attempt.requestId == requestId, attempt.status == .accepted, + PubkyPublicKeyFormat.matches(attempt.recoveryContext?.paymentIdentity ?? identity, identity) + { + txid = attempt.txid + } else if let proof, !proof.hasUnsupportedOnchainWallet, proof.onchainAcceptanceVerified == true, + proof.paymentIdentifier == proof.proofData + { + txid = proof.paymentIdentifier + } else { + return nil + } + guard let txid, await completeOnchainPayment(requestId: requestId, identity: identity, txid: txid) else { return nil } + return PaykitOnchainPaymentResolution(identity: identity, requestId: requestId, transactionId: txid) + } catch { return nil } + } + + func requireRecoveryPayer(_ payer: String) async throws { + guard try await PubkyPublicKeyFormat.matches(currentIdentity(), payer) else { + throw PaykitPaymentRequestError.requestUnavailable + } + } + + func markOnchainPaymentStarted( + _ request: PaykitPaymentRequest, + address: String, + hardwareWalletId: String? = nil, + paymentIdentity: String? = nil, + signedTx: HwFundingSignedTx? = nil, + privatePaymentListVersion: UInt64? = nil, + previousPrivatePaymentListVersion: UInt64? = nil + ) async throws { + let identity = try await currentIdentity() + try await mutationLock.withLock { + try await markOnchainPaymentStartedLocked( + request, + address: address, + hardwareWalletId: hardwareWalletId, + paymentIdentity: paymentIdentity, + identity: identity, + serializedTx: signedTx?.serializedTx, + signedTx: signedTx, + privatePaymentListVersion: privatePaymentListVersion, + previousPrivatePaymentListVersion: previousPrivatePaymentListVersion + ) + } + } + + private func markOnchainPaymentStartedLocked( + _ request: PaykitPaymentRequest, + address: String, + hardwareWalletId: String?, + paymentIdentity: String?, + identity: String, + serializedTx: String?, + signedTx: HwFundingSignedTx?, + privatePaymentListVersion: UInt64?, + previousPrivatePaymentListVersion: UInt64? + ) async throws { + if let hardwareWalletId { + guard hardwareWalletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: hardwareWalletId) + else { throw PaykitPaymentRequestError.requestUnavailable } + } + 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) && $0.requestId == request.id && $0.kind == .onchain && - $0.effectiveOnchainWalletId == walletId && - !hasUnsupportedOnchainWallet($0) && + !$0.hasUnsupportedOnchainWallet && !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil @@ -376,10 +622,291 @@ actor PaykitPaymentProofService { pendingProofs[index].paymentStarted = true pendingProofs[index].onchainAddress = address pendingProofs[index].onchainAmountSats = request.amountSats - pendingProofs[index].onchainMatchingTransactionIdsBeforeAttempt = existingTransactionIds + 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 + pendingProofs[index].hardwareDispatchAttempted = signedTx.map { _ in false } + pendingProofs[index].privatePaymentListVersion = privatePaymentListVersion + pendingProofs[index].previousPrivatePaymentListVersion = privatePaymentListVersion == nil ? nil : previousPrivatePaymentListVersion try await persist(pendingProofs) } + func retainedHardwareOnchainPayment( + requestId: PaykitPaymentRequest.ID, paymentIdentity: String, walletId: String, + address: String, amountSats: UInt64, + privatePaykitService: PrivatePaykitService = .shared + ) async throws -> RetainedHardwareOnchainPayment? { + 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 } + 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, + isRefusedForNavigation: proof.hardwareRefusedForNavigation == true + ) + } + } + + 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 + proofs[index].hardwareDispatchAttempted = true + try await persist(proofs) + } + } + + 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, + privatePaykitService: PrivatePaykitService = .shared + ) 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 } + if let version = original.privatePaymentListVersion { + try await privatePaykitService.releaseUnsentPaymentListVersion( + publicKey: original.requestId.counterparty, + version: version, + previousVersion: original.previousPrivatePaymentListVersion + ) + } + 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 } + } + + @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, + 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() + 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 nil } + if proofs[index].paymentIdentifier == nil { + proofs[index].paymentIdentifier = txid.lowercased() + try await persist(proofs) + } + return proofs[index] + } + 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 } + 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 + } + 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 + proofs[index].onchainResolutionAwaitingConsumption = true + try await persist(proofs) + return proofs[index] + } + return nil + } + guard let followedUp else { return false } + completed = followedUp + didCompleteLocalFollowup = true + } + if deliverInBackground { + submitInBackground(completed) + } else { + _ = await submit(completed) + } + 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 { + 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), + // 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) + } + 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 { guard let preimage, Self.preimage(preimage, matchesPaymentHash: paymentHash) @@ -391,114 +918,118 @@ 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 func completeOnchainPayment( _ request: PaykitPaymentRequest, txid: String, paymentAppId: String, - paymentEndpointIdentifier: String, - walletId: String = WalletScope.default - ) async { - let prepared = try? await loadProofs().filter { - $0.requestId == request.id && $0.paymentAppId == paymentAppId && - $0.paymentEndpointIdentifier == paymentEndpointIdentifier && - $0.kind == .onchain && $0.paymentStarted && $0.effectiveOnchainWalletId == walletId && - $0.paymentIdentifier == nil && $0.proofData == nil - } - if let prepared, prepared.count == 1, let proof = prepared.first { - await completeOnchainPayment(requestId: request.id, identity: proof.identity, txid: txid, walletId: walletId, fallbackProof: proof) - return - } - guard let identity = try? await currentIdentity() else { return } - let fallbackProof = try? await pendingProof( - request: request, - paymentAppId: paymentAppId, - paymentEndpointIdentifier: paymentEndpointIdentifier, - kind: .onchain, - walletId: walletId - ) - await completeOnchainPayment( + paymentEndpointIdentifier: String + ) async -> Bool { + guard let identity = try? await currentIdentity() else { return false } + return await completeOnchainPayment( requestId: request.id, identity: identity, - txid: txid, - walletId: walletId, - fallbackProof: fallbackProof + txid: txid ) } private func completeOnchainPayment( requestId: PaykitPaymentRequest.ID, identity: String, - txid: String, - walletId: String, - fallbackProof: PendingPaykitPaymentProof? = nil - ) async { - guard Self.isHex(txid, byteCount: 32) else { - logWarning("Ignored a Paykit on-chain proof with an invalid transaction id") - return - } - + txid: String + ) async -> Bool { + guard Self.isHex(txid, byteCount: 32) else { return false } do { - var pendingProofs = try await loadProofs() - guard let index = pendingProofs.lastIndex(where: { - PubkyPublicKeyFormat.matches($0.identity, identity) && - $0.requestId == requestId && - $0.kind == .onchain && - $0.effectiveOnchainWalletId == walletId && - $0.paymentStarted && - $0.paymentIdentifier == nil && - $0.proofData == nil - }) else { return } - pendingProofs[index].paymentIdentifier = txid.lowercased() - pendingProofs[index].proofData = txid.lowercased() - 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)") + // 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.paymentStarted } - if didPersist { - submitInBackground(completedProof) - } else { - persistAndSubmitInBackground(completedProof, allProofs: pendingProofs) - } - Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( - identity: completedProof.identity, - requestId: requestId, - transactionId: txid.lowercased(), - walletId: completedProof.effectiveOnchainWalletId - )) + 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 let accepted = try await attemptService.acceptedRequestContext(requestId: requestId, paymentIdentity: identity), + accepted.txid?.caseInsensitiveCompare(txid) == .orderedSame + else { return (false, nil) } + guard let index = proofs.lastIndex(where: { + guard PubkyPublicKeyFormat.matches($0.identity, identity), $0.requestId == requestId, + $0.kind == .onchain, !$0.hasUnsupportedOnchainWallet, $0.paymentStarted + else { return false } + if accepted.recoveryContext != nil { + // Only this original operation can associate its pending candidate with the winner. + // Never overwrite a proof already verified/submitted for another candidate. + guard $0.onchainAddress == accepted.address, $0.onchainAmountSats == accepted.amountSats, + $0.onchainAcceptanceVerified != true + else { return false } + return ($0.paymentIdentifier == nil && $0.proofData == nil) || + ($0.proofData == nil && accepted.containsCandidate($0.paymentIdentifier)) || + ($0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame && + $0.proofData?.caseInsensitiveCompare(txid) == .orderedSame) + } + return ($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]) + } + guard result.0 else { return false } + 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() + )) + } + 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(), - walletId: fallbackProof.effectiveOnchainWalletId - )) + logWarning("Failed to retain a completed Paykit on-chain proof for retry: \(error)") + return false } } @@ -528,19 +1059,71 @@ actor PaykitPaymentProofService { return true } - func failOnchainPayment(_ request: PaykitPaymentRequest, walletId: String = WalletScope.default) async { - await removeRequestProofs(request) { - $0.kind == .onchain && - $0.effectiveOnchainWalletId == walletId && - $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 + } } } - func cancelPreparation(_ request: PaykitPaymentRequest, walletId: String = WalletScope.default) 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 } + 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, previousVersion: original.previousPrivatePaymentListVersion) + } + 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)") + } + } + + func cancelPreparation(_ request: PaykitPaymentRequest) async { await removeRequestProofs(request) { - $0.effectiveOnchainWalletId == walletId && + !$0.hasUnsupportedOnchainWallet && !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil @@ -550,38 +1133,73 @@ 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.capability == .privateLinkCapable, let publicKey = identityStatus.publicKey, let identity = PubkyPublicKeyFormat.normalized(publicKey) else { return } + if let acceptedRequest, let requestId = acceptedRequest.requestId, let txid = acceptedRequest.txid, + acceptedRequest.recoveryContext?.paymentIdentity == nil || + PubkyPublicKeyFormat.matches(acceptedRequest.recoveryContext?.paymentIdentity, identity) + { + 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, onlyIfIncomplete: true) { + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: identity, requestId: requestId, transactionId: txid.lowercased() + )) + } + } pendingProofs = await removingSettledUnsupportedWalletProofs(from: pendingProofs, identity: identity) let identityProofs = pendingProofs.filter { PubkyPublicKeyFormat.matches($0.identity, identity) } for proof in identityProofs { do { - guard !hasUnsupportedOnchainWallet(proof) else { continue } + if proof.kind == .onchain, proof.paymentStarted, + let walletId = proof.onchainWalletId, walletId != WalletScope.default, + let txid = proof.paymentIdentifier + { + guard hardwareTransactionLookup.hasWallet(walletId: walletId) else { continue } + _ = await completeHardwareOnchainPayment( + requestId: proof.requestId, identity: proof.identity, walletId: walletId, txid: txid, + deliverInBackground: false + ) + continue + } + guard !proof.hasUnsupportedOnchainWallet else { continue } if proof.proofData != nil { + if proof.kind == .onchain, proof.onchainAcceptanceVerified != true { + if proof.paymentStarted, + let txid = try await attemptService.acceptedTransactionId(for: proof.requestId, paymentIdentity: identity), + proof.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame, + proof.proofData?.caseInsensitiveCompare(txid) == .orderedSame + { + _ = await completeOnchainPayment(requestId: proof.requestId, identity: proof.identity, txid: txid) + } + 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, - walletId: proof.effectiveOnchainWalletId, - excluding: proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] - ) + let txid = try await attemptService.acceptedTransactionId(for: proof.requestId, paymentIdentity: identity) { - await completeOnchainPayment( - requestId: proof.requestId, identity: proof.identity, txid: txid, walletId: proof.effectiveOnchainWalletId - ) + _ = await completeOnchainPayment(requestId: proof.requestId, identity: proof.identity, txid: txid) continue } guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue } @@ -602,12 +1220,36 @@ actor PaykitPaymentProofService { } } + private func resumeAcceptedRequestFollowup(requestId: PaykitPaymentRequest.ID, txid: String, + onlyIfIncomplete: Bool = false) async -> Bool + { + do { + return try await attemptService.resumeAcceptedRequestSend(requestId: requestId, txid: txid, onlyIfIncomplete: onlyIfIncomplete) + } catch { + logWarning("Accepted Paykit payment local follow-up remains guarded: \(error)") + return false + } + } + + 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.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 ) async -> [PendingPaykitPaymentProof] { let candidates = pendingProofs.filter { - hasUnsupportedOnchainWallet($0) && 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 } @@ -618,13 +1260,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 @@ -666,7 +1311,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.onchainAcceptanceVerified == true + else { return } result[proof.requestId] = proof.kind } } catch { @@ -687,32 +1334,97 @@ 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 + } + } + func protectedRequestIdsForSubscriptionCancellation( 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 - } - 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) || hasUnsupportedOnchainWallet($0) || $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 + } + 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) { + 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) } @@ -727,7 +1439,10 @@ actor PaykitPaymentProofService { @discardableResult private func submit(_ pendingProof: PendingPaykitPaymentProof) async -> Bool { - guard !hasUnsupportedOnchainWallet(pendingProof), 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(), identityStatus.capability == .privateLinkCapable, @@ -780,77 +1495,43 @@ actor PaykitPaymentProofService { try await store.load() } - private func hasUnsupportedOnchainWallet(_ proof: PendingPaykitPaymentProof) -> Bool { - proof.kind == .onchain && !onchainPaymentLookup.supportsWallet(proof.effectiveOnchainWalletId) - } - private func persist(_ proofs: [PendingPaykitPaymentProof]) async throws { try await store.save(proofs) 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 { await removeProofs { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == proof.requestId && - $0.effectiveOnchainWalletId == proof.effectiveOnchainWalletId + (!$0.hasUnsupportedOnchainWallet || $0 == proof) && + (!$0.hasUnsupportedOnchainWallet || $0.onchainResolutionAwaitingConsumption != true) } } private func removeRequestProofs( _ request: PaykitPaymentRequest, where shouldRemove: (PendingPaykitPaymentProof) -> Bool + ) async { + let identity = try? await currentIdentity() + await mutationLock.withLock { + 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 { @@ -866,6 +1547,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) } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 453b6c9c0..fb51d067a 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1245,6 +1245,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() @@ -1263,6 +1264,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, @@ -1334,6 +1336,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 @@ -1359,6 +1364,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 @@ -1754,6 +1760,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 = {} @@ -1834,7 +1857,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 @@ -1897,7 +1934,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 } @@ -2036,6 +2073,7 @@ final class PaykitPaymentRequestManager { presentationRetryTask?.cancel() presentationRetryTask = nil pendingRequests = [] + retainedHardwareRetries = [:] historyRequests = [] subscriptions = [] receivedPaymentContacts = PaykitReceivedPaymentContacts() @@ -2082,7 +2120,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) @@ -2315,6 +2354,7 @@ 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) @@ -2347,6 +2387,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) }) @@ -2358,7 +2414,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) } } @@ -2378,7 +2434,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 c3843766e..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 { @@ -318,6 +342,47 @@ 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 + } + + /// 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 } + guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version else { return } + let previous = contact + guard previousVersion.map({ $0 < version }) ?? true else { throw PaykitPaymentRequestError.requestUnavailable } + contact.consumedPrivatePaymentListVersion = previousVersion + 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/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index 3f24ffbe5..b51c36aa2 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -4,17 +4,21 @@ 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 + 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 @@ -38,9 +42,25 @@ 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 + } // 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/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 054abe1d6..67a7701fa 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 paykitAcceptedPaymentRequests case paykitSubscriptionState @@ -24,6 +25,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 .paykitAcceptedPaymentRequests: "paykit_accepted_payment_requests" case .paykitSubscriptionState: "paykit_subscription_state" diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1a54209b8..8cd7cc522 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -503,6 +503,17 @@ extension AppViewModel { pendingContactPaymentContexts[hash] = context } + func prepareResolvedOnchainContactContext(_ resolution: PaykitOnchainPaymentResolution, isHardware: Bool) { + if isHardware { + // Verified hardware activity already owns its contact, including later user edits. + consumeContactPaymentContext(forPendingPaymentHash: resolution.transactionId) + } else { + addPendingContactPaymentContext( + resolution.transactionId, context: ContactPaymentContext(publicKey: resolution.requestId.counterparty) + ) + } + } + func contactPaymentContext(forPendingPaymentHash hash: String) -> ContactPaymentContext? { pendingContactPaymentContexts[hash] } @@ -890,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 ad65836ba..873faf26b 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -1,6 +1,12 @@ import BitkitCore 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 /// payment, sign it on-device, and broadcast. Owns the per-phase timeouts and fee-reserve math. /// @@ -108,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, @@ -255,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() @@ -363,6 +377,8 @@ final class HwSendCoordinator { private var operationTask: Task? private var operationRequest: PaymentRequest? private var operationSession: OperationSession? + private var isRetainingSignedPayment = false + private var isAuthorizingPayment = 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 @@ -379,8 +395,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; signing, an active broadcast and completion of a - /// verified payment must finish first. + /// 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 } @@ -399,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( @@ -506,10 +527,13 @@ final class HwSendCoordinator { sats: UInt64, satsPerVByte: UInt64, paymentDeadline: PaykitPreciseInstant? = nil, - paykitRequestId: PaykitPaymentRequest.ID? = nil, - paykitIdentity: String? = nil, - beforeFirstBroadcast: @escaping () async throws -> Void = {}, + paymentRequestId: PaykitPaymentRequest.ID? = 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 }, + 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 } ) async throws -> HwFundingBroadcastResult { @@ -517,14 +541,12 @@ 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) - let identity = paykitIdentity.flatMap(PubkyPublicKeyFormat.normalized) - guard pendingPayment?.resolvedTransactionId == nil else { throw PaykitPaymentRequestError.requestUnavailable } if let pendingPayment, - pendingPayment.request != request || pendingPayment.walletId != walletId || - pendingPayment.paykitRequestId != paykitRequestId || pendingPayment.paykitIdentity != identity + isBroadcastUnresolved || (pendingPayment.paymentRequestId != nil && pendingPayment.hasBroadcastAttempted) { - guard !pendingPayment.hasBroadcastAttempted else { throw PaykitPaymentRequestError.requestUnavailable } - self.pendingPayment = nil + 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)) } @@ -534,16 +556,9 @@ final class HwSendCoordinator { signingAttempt += 1 let attempt = signingAttempt let signer = signerFactory(manager, address, satsPerVByte) - let wasBroadcastUnresolved = isBroadcastUnresolved isSigning = true let task = Task { @MainActor in - @MainActor - func checkAttempt() throws { - try Task.checkCancellation() - guard signingAttempt == attempt else { throw CancellationError() } - } - defer { if signingAttempt == attempt { isSigning = false @@ -551,6 +566,12 @@ final class HwSendCoordinator { } } + if pendingPayment == nil, let restored = try await loadSignedPayment() { + pendingPayment = PendingPayment(request: request, signedTx: restored.signedTx, paymentRequestId: paymentRequestId) + pendingPayment?.isPreparedForBroadcast = true + pendingPayment?.hasBroadcastAttempted = restored.hasAttemptedBroadcast + isBroadcastUnresolved = restored.hasAttemptedBroadcast && !restored.isRefusedForNavigation + } let signed: HwFundingSignedTx if let pendingPayment, pendingPayment.request == request { signed = pendingPayment.signedTx @@ -569,66 +590,112 @@ final class HwSendCoordinator { isConnectingDevice = isConnecting } ) - try checkAttempt() - pendingPayment = PendingPayment( - request: request, signedTx: signed, walletId: walletId, - paykitRequestId: paykitRequestId, paykitIdentity: identity - ) + try Task.checkCancellation() + pendingPayment = PendingPayment(request: request, signedTx: signed, paymentRequestId: paymentRequestId) } if pendingPayment?.isPreparedForBroadcast != true { - try await beforeFirstBroadcast() - try checkAttempt() + try await beforeFirstBroadcast(signed) + try Task.checkCancellation() pendingPayment?.isPreparedForBroadcast = true } let hadPriorBroadcastAttempt = pendingPayment?.hasBroadcastAttempted == true + let priorBroadcastWasUnresolved = isBroadcastUnresolved var broadcastWasAttempted = hadPriorBroadcastAttempt + isAuthorizingPayment = true + defer { isAuthorizingPayment = false } do { do { try await beforeBroadcastAttempt() - try checkAttempt() + try Task.checkCancellation() + guard signingAttempt == attempt else { throw CancellationError() } try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } catch { - try checkAttempt() + guard signingAttempt == attempt else { throw error } if !broadcastWasAttempted { pendingPayment = nil } throw error } + do { + // 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 + } + } isBroadcastUnresolved = true broadcastWasAttempted = true pendingPayment?.hasBroadcastAttempted = true do { - let result = try await signer.broadcastSignedFunding(signed, paymentDeadline: paymentDeadline) - try checkAttempt() - pendingPayment?.resolvedTransactionId = result.txId + 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) + try Task.checkCancellation() + guard signingAttempt == attempt else { throw CancellationError() } + } await afterBroadcast(result) - try checkAttempt() return result } catch { - try checkAttempt() - isBroadcastUnresolved = false - let underlyingError = (error as? AppError)?.underlyingError ?? error - if underlyingError as? PaykitPaymentRequestError == .requestExpired { + // Navigation may unlock after a definite refusal; the receipt still guards funds. + 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 = notSubmitted == nil + isBroadcastUnresolved = dispatchIsUncertain ? paymentRequestId != nil && !refused : priorBroadcastWasUnresolved + 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. - isBroadcastUnresolved = wasBroadcastUnresolved - broadcastWasAttempted = hadPriorBroadcastAttempt - pendingPayment?.hasBroadcastAttempted = hadPriorBroadcastAttempt - if !hadPriorBroadcastAttempt { pendingPayment = nil } - throw error + let cleared: Bool + if hadPriorBroadcastAttempt { + cleared = false + } else { + cleared = await clearSignedPaymentBeforeDispatch(signed) + } + let retainsBroadcastAttempt = hadPriorBroadcastAttempt || !cleared + isBroadcastUnresolved = hadPriorBroadcastAttempt ? priorBroadcastWasUnresolved : !cleared + broadcastWasAttempted = retainsBroadcastAttempt + pendingPayment?.hasBroadcastAttempted = retainsBroadcastAttempt + if !retainsBroadcastAttempt { + pendingPayment = nil + } + throw failure } - if error.isDefiniteHardwarePreBroadcastFailure(), !hadPriorBroadcastAttempt { - broadcastWasAttempted = false + let outcomeIsUncertain = (failure as? HwTransferError) == .broadcastUncertain + if paymentRequestId == nil, !outcomeIsUncertain, !failure.isBroadcastConnectivityFailure() { pendingPayment = nil } - throw error + throw failure } } catch { - try checkAttempt() + guard signingAttempt == attempt else { throw error } + if !broadcastWasAttempted { + pendingPayment = nil + } await afterFailure(broadcastWasAttempted ? .uncertain : .definitePreBroadcastFailure) - try checkAttempt() throw error } } @@ -642,40 +709,7 @@ final class HwSendCoordinator { operationSession = nil } } - let result = try await task.value - guard signingAttempt == attempt else { throw CancellationError() } - return result - } - - func resolvePayment( - _ resolution: PaykitOnchainPaymentResolution, - identity: String?, - walletId: String - ) -> HwFundingBroadcastResult? { - guard let pendingPayment, pendingPayment.hasBroadcastAttempted, - self.walletId == walletId, pendingPayment.walletId == walletId, resolution.walletId == walletId, - pendingPayment.paykitRequestId == resolution.requestId, - PubkyPublicKeyFormat.matches(pendingPayment.paykitIdentity, resolution.identity), - PubkyPublicKeyFormat.matches(identity, resolution.identity), - pendingPayment.resolvedTransactionId == nil || pendingPayment.resolvedTransactionId == resolution.transactionId - else { return nil } - - // A verified payment supersedes any retry, including callbacks from uncancellable transports. - operationTask?.cancel() - signingAttempt += 1 - operationTask = nil - operationRequest = nil - operationSession = nil - isSigning = false - isConnectingDevice = false - isBroadcastUnresolved = true - self.pendingPayment?.resolvedTransactionId = resolution.transactionId - return HwFundingBroadcastResult( - txId: resolution.transactionId, - miningFeeSats: pendingPayment.signedTx.miningFeeSats, - feeRate: UInt64(pendingPayment.signedTx.feeRate.rounded(.up)), - totalSpent: pendingPayment.signedTx.totalSpent - ) + return try await task.value } func reconnectWithPassphrase( @@ -698,6 +732,39 @@ 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 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 @@ -711,13 +778,19 @@ 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 + // until it finishes, so a concurrent send joins it instead of adopting its receipt. + guard !isRetainingSignedPayment, !isAuthorizingPayment else { return } signingAttempt += 1 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 @@ -750,12 +823,9 @@ final class HwSendCoordinator { private struct PendingPayment { let request: PaymentRequest let signedTx: HwFundingSignedTx - let walletId: String - let paykitRequestId: PaykitPaymentRequest.ID? - let paykitIdentity: String? + let paymentRequestId: PaykitPaymentRequest.ID? var isPreparedForBroadcast = false var hasBroadcastAttempted = false - var resolvedTransactionId: String? } private struct OperationSession { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 4b898c2e0..67a69ac8c 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -1,7 +1,11 @@ import BitkitCore +import Combine 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 @@ -103,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 @@ -134,6 +142,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() @@ -159,6 +170,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 @@ -208,10 +222,16 @@ class TransferViewModel: ObservableObject { hwFeeRateProvider: (() async -> UInt64?)? = nil, hwAddressProvider: (() async throws -> String)? = nil, hwTimeouts: (compose: Double, sign: Double, broadcast: Double) = (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 @@ -229,6 +249,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 @@ -365,6 +392,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 @@ -431,11 +465,16 @@ class TransferViewModel: ObservableObject { isMaxAmount: Bool = false, maxSendableAmount: UInt64? = nil ) 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 { 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 { @@ -445,16 +484,6 @@ class TransferViewModel: ObservableObject { ) } - // For sendAll (change would be dust), send entire balance - // Otherwise, send exact order.feeSat amount - let txid = try await lightningService.send( - address: address, - sats: order.feeSat, - satsPerVbyte: satsPerVbyte, - utxosToSpend: utxosToSpend, - isMaxAmount: isMaxAmount - ) - let txTotalSats = SpendingConfirmTotal.leavingAmount( orderFeeSat: order.feeSat, networkFeeSat: txFee, @@ -462,31 +491,104 @@ class TransferViewModel: ObservableObject { 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, + // For sendAll (change would be dust), send entire balance + // Otherwise, send exact order.feeSat amount + let result = try await onchainAttemptService.send( + using: onchainSender, address: address, - isReceive: false, - feeRate: UInt64(satsPerVbyte), - isTransfer: true, - channelId: nil, - createdAt: currentTime + amountSats: order.feeSat, + satsPerVbyte: satsPerVbyte, + utxosToSpend: utxosToSpend, + isMaxAmount: isMaxAmount, + 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, + originalOrderFeeSats: order.feeSat + ) ) - try? await coreService.activity.addPreActivityMetadata(preActivityMetadata) + let txid: String + switch result { + case let .accepted(acceptedTxid): + txid = acceptedTxid + case let .rejected(rejectedTxid, reason): + if let context = try await onchainAttemptService.pendingContext(txid: rejectedTxid) { + await showFundingPending(context) + throw OnchainFundingPendingError() + } + throw AppError( + message: t("wallet__onchain_funding_rejected"), + debugMessage: "Broadcast rejected for \(rejectedTxid): \(reason)" + ) + case let .unknown(unknownTxid): + if let context = try await onchainAttemptService.pendingContext(txid: unknownTxid) { + await showFundingPending(context) + throw OnchainFundingPendingError() + } + throw AppError( + message: t("wallet__onchain_funding_unknown"), + debugMessage: "Broadcast outcome unknown for \(unknownTxid)" + ) + } - await fundPaidOrder( - order: order, - txId: txid, - txTotalSats: txTotalSats, - preTransferOnchainSats: preTransferOnchainSats - ) + do { + guard try await onchainAttemptService.resumeAcceptedTransfer( + orderId: order.id, txid: txid, using: transferService + ) else { throw OnchainSendAttemptError.localFollowupNotSaved } + } catch { + Logger.warn("Accepted funding local follow-up remains guarded: \(error)", context: "TransferViewModel") + throw AppError( + message: t("wallet__onchain_funding_followup_failed"), + debugMessage: "Accepted funding transaction \(txid) for order \(order.id)" + ) + } + fundedOrderId = order.id + lightningSetupStep = 0 + await onBalanceRefresh?() + watchOrder(orderId: order.id) } + 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) + } + + 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, txId: String, @@ -496,8 +598,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, @@ -508,6 +611,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) } @@ -525,6 +629,7 @@ class TransferViewModel: ObservableObject { lightningSetupStep = 0 await onBalanceRefresh?() watchOrder(orderId: order.id) + return trackingSaved } func startWatchingOrderFromRestart(_ order: IBtOrder) async { @@ -735,6 +840,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/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 5d51ea7f4..41a401efc 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -103,7 +103,8 @@ class WalletViewModel: ObservableObject { rgsConfigService: RgsConfigService = RgsConfigService(), transferService: TransferService, sheetViewModel: SheetViewModel, - feeEstimatesManager: FeeEstimatesManager + feeEstimatesManager: FeeEstimatesManager, + onchainAttemptService: OnchainSendAttemptService = .shared ) { self.lightningService = lightningService self.coreService = coreService @@ -117,6 +118,28 @@ 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") + } + } + 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, isConfirmed: isConfirmed) else { return } + _ = try await onchainAttemptService.resumeAcceptedOrdinarySend(walletId: walletId, observedTxid: txid) + _ = try await onchainAttemptService.resumeAcceptedTransfer(walletId: walletId, using: transferService) + await PaykitPaymentProofService.shared.reconcile() + } catch { + Logger.warn("Observed payment local follow-up remains guarded: \(error)", context: "WalletViewModel") + } + } + lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } + lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } } /// Convenience initializer for previews and testing @@ -599,15 +622,19 @@ 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, + paymentIdentity: String? = nil, + followupContext: OnchainSendFollowupContext? = nil, paymentDeadline: PaykitPreciseInstant? = nil, - beforeBroadcastAttempt: () async throws -> Void = {} - ) async throws -> Txid { + 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.") } @@ -618,22 +645,38 @@ 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, - beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } + requestId: requestId, + paymentIdentity: paymentIdentity, + followupContext: followupContext, + paymentDeadline: paymentDeadline, + beforeBroadcastAttempt: beforeBroadcastAttempt, + onPreDispatchFailure: onPreDispatchFailure ) - 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 + } + + func resumeAcceptedOnchainTransfer(walletId: String, attempts: OnchainSendAttemptService) async throws -> Bool { + 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 @@ -708,7 +751,12 @@ 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, isCurrentSend: () -> Bool = { true }) async throws { + func setUtxoSelection( + coinSelectionAlgorythm: CoinSelectionAlgorithm, + isCurrentSend: () -> Bool = { true }, + 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.") } @@ -721,12 +769,18 @@ class WalletViewModel: ObservableObject { "Selecting UTXOs with algorithm: \(coinSelectionAlgorythm), target amount: \(sendAmountSats) sats, fee rate: \(selectedFeeRateSatsPerVByte) sats/vbyte" ) - let utxos = 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 + ) + } + let utxos: [SpendableUtxo] = 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. + try await (listSpendable ?? { try await self.lightningService.listSpendableOutputs() })() + } else { + try await select(sendAmountSats, selectedFeeRateSatsPerVByte, coinSelectionAlgorythm) + } guard !Task.isCancelled, isCurrentSend() else { throw CancellationError() } selectedUtxos = utxos 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/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 6347b4c31..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() } @@ -237,6 +241,9 @@ struct SpendingConfirm: View { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { hideSwipeButton = true } + } 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) diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 7b5703696..613f3f16a 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -3,21 +3,24 @@ import SwiftUI struct HwSendSignView: View { @EnvironmentObject private var app: AppViewModel - @EnvironmentObject private var pubkyProfile: PubkyProfileManager @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 prepareContactPayment: (ContactPaymentContext?) async throws -> Void - let authorizeContactPayment: (ContactPaymentContext?) async throws -> Void - let completeContactPayment: (ContactPaymentContext?, String) async -> Void - let cancelContactPayment: (ContactPaymentContext?, PrivatePaymentListSendOutcome) async -> Void + let contactPaymentRequestId: PaykitPaymentRequest.ID? + let contactPaymentIdentity: String? + let contactPaymentDeadline: PaykitPreciseInstant? + let prepareContactPayment: (HwFundingSignedTx) async throws -> Void + let authorizeContactPayment: () async throws -> Void + let completeContactPayment: (String) async -> Bool + let cancelContactPayment: (PrivatePaymentListSendOutcome) async -> Void @State private var signingTask: Task? - @State private var signingAttempt = 0 - @State private var isCompletingPayment = false @State private var passphraseTask: Task? + @State private var observedResolution: PaykitOnchainPaymentResolution? + @State private var appliedResolution = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -57,8 +60,8 @@ struct HwSendSignView: View { CustomButton( title: hwSend.hasPendingBroadcast ? t("common__retry") : vendor.sendSignButtonTitle, - isDisabled: hwSend.isSigning || isCompletingPayment, - isLoading: hwSend.isSigning || isCompletingPayment + isDisabled: hwSend.isSigning, + isLoading: hwSend.isSigning ) { startSigning() } @@ -76,8 +79,27 @@ 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: app.paykitOnchainPaymentResolution, initial: true) { _, resolution in - resolvePayment(resolution) + guard let resolution, + 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 } @@ -106,113 +128,139 @@ 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.prepareResolvedOnchainContactContext(resolution, isHardware: true) + app.consumePaykitOnchainPaymentResolution(resolution) + navigationPath.append(route) + Task { await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution, activeIdentity: pubkyProfile.publicKey) } + } + private func startSigning() { - guard signingTask == nil, !isCompletingPayment else { return } - signingAttempt += 1 - let attempt = signingAttempt + guard signingTask == nil else { return } signingTask = Task { @MainActor in - defer { if signingAttempt == attempt { signingTask = nil } } + let paymentActivity = PaykitPaymentActivity.shared.begin() + defer { + PaykitPaymentActivity.shared.end(paymentActivity) + signingTask = nil + } 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")) return } - let contactPaymentContext = app.contactPaymentContext - let paymentActivity = PaykitPaymentActivity.shared.begin() - defer { PaykitPaymentActivity.shared.end(paymentActivity) } + let contactPublicKey = app.contactPaymentContext?.publicKey + let requestId = contactPaymentRequestId + let tags = tagManager.selectedTagsArray do { - let result = try await hwSend.signAndBroadcast( - manager: hwWalletManager, - address: invoice.address, - sats: amount, - satsPerVByte: UInt64(feeRate), - paymentDeadline: contactPaymentContext?.incomingPaymentRequest?.paymentDeadline, - paykitRequestId: contactPaymentContext?.incomingPaymentRequest?.id, - paykitIdentity: pubkyProfile.publicKey, - beforeFirstBroadcast: { try await prepareContactPayment(contactPaymentContext) }, - beforeBroadcastAttempt: { try await authorizeContactPayment(contactPaymentContext) }, - afterBroadcast: { result in - await completeContactPayment(contactPaymentContext, result.txId) - }, - afterFailure: { outcome in - await cancelContactPayment(contactPaymentContext, outcome) - } - ) - guard signingAttempt == attempt, !Task.isCancelled else { return } - await finishPayment( + 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( + 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 } + 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 + ) + }, + 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 + ) + }, + 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 } + 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, address: invoice.address, amount: amount, - context: contactPaymentContext + contactPublicKey: contactPublicKey, + tags: tags, + requestId: requestId, + proofVerified: proofVerified + ) + guard !appliedResolution else { return } + hwSend.completeBroadcast() + 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 { return + } catch is HwPassphraseError { + hwSend.requestPassphrase() + } catch let error as HwTransferError { + app.toast(error) } catch { - guard signingAttempt == attempt, !Task.isCancelled else { return } - if error is HwPassphraseError { - hwSend.requestPassphrase() - } else if let error = error as? HwTransferError { - app.toast(error) - } else { - showHardwareError(error) - } - } - } - } - - private func resolvePayment(_ resolution: PaykitOnchainPaymentResolution?) { - guard !isCompletingPayment, let resolution, - let context = app.contactPaymentContext, - context.incomingPaymentRequest?.id == resolution.requestId, - let invoice = app.scannedOnchainInvoice, - let amount = wallet.sendAmountSats, - let walletId = hwSend.walletId, - let result = hwSend.resolvePayment(resolution, identity: pubkyProfile.publicKey, walletId: walletId) - else { return } - - signingTask?.cancel() - signingAttempt += 1 - let attempt = signingAttempt - isCompletingPayment = true - signingTask = Task { @MainActor in - defer { - if signingAttempt == attempt { - signingTask = nil - isCompletingPayment = false - } + showHardwareError(error) } - await completeContactPayment(context, result.txId) - guard !Task.isCancelled else { return } - await finishPayment(result, walletId: walletId, address: invoice.address, amount: amount, context: context) - } - } - - private func finishPayment( - _ result: HwFundingBroadcastResult, - walletId: String, - address: String, - amount: UInt64, - context: ContactPaymentContext? - ) async { - isCompletingPayment = true - defer { isCompletingPayment = false } - await recordSentPayment(result, walletId: walletId, address: address, amount: amount, contactPublicKey: context?.publicKey) - guard !Task.isCancelled else { return } - hwSend.completeBroadcast() - if let resolution = app.paykitOnchainPaymentResolution, - resolution.requestId == context?.incomingPaymentRequest?.id, - resolution.transactionId == result.txId, - resolution.walletId == walletId, - PubkyPublicKeyFormat.matches(resolution.identity, pubkyProfile.publicKey) - { - app.consumePaykitOnchainPaymentResolution(resolution) } - navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) } private func reconnectWithPassphrase(_ passphrase: String) { @@ -255,17 +303,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, @@ -277,6 +328,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, @@ -286,10 +340,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/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index f4303b233..cfdec8cfd 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -106,8 +106,37 @@ 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] + } + + 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 { - fundingSources.count > 1 + retainedHardwareRetry == nil && fundingSources.count > 1 } var canSwitchWallet: Bool { @@ -190,10 +219,35 @@ 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 } + 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 + ) async -> SendRoute { + 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) + } + var body: some View { ZStack { confirmationContent @@ -469,25 +523,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( @@ -500,14 +555,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) { @@ -518,7 +575,7 @@ struct SendConfirmationView: View { BodySSBText( TransactionSpeed.getFeeTierLocalized( - feeRate: UInt64(wallet.selectedFeeRateSatsPerVByte ?? 0), + feeRate: UInt64(confirmationFeeRate ?? 0), feeEstimates: feeEstimatesManager.estimates, variant: .range ) @@ -806,7 +863,10 @@ struct SendConfirmationView: View { ) -> PrivatePaymentListSendOutcome { guard walletType == .onchain else { return currentOutcome } guard onchainPaymentStarted else { return .definitePreBroadcastFailure } - return PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error) ? .definitePreBroadcastFailure : .uncertain + if let attemptError = error as? OnchainSendAttemptError, case .preDispatch = attemptError { + return .definitePreBroadcastFailure + } + return .uncertain } static func sendLightningPayment( @@ -830,7 +890,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 { @@ -840,7 +900,7 @@ struct SendConfirmationView: View { do { try await authorize(request) } catch { - await onAuthorizationFailure(error) + try await onAuthorizationFailure(error) throw error } await onAuthorized(request) @@ -952,7 +1012,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 } @@ -1041,9 +1103,10 @@ struct SendConfirmationView: View { let contactPublicKey = contactPaymentContext?.publicKey let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest var shouldCancelPaymentProof = false - var preparedPaymentProof: (endpointIdentifier: String, kind: PaykitPaymentProofKind)? + var preparedPaymentProof: (endpointIdentifier: String, kind: PaykitPaymentProofKind, appId: String)? var onchainPaymentStarted = false var lightningPaymentSubmitted = false + var originalPaymentIdentity: String? var privatePaymentListOutcome = PrivatePaymentListSendOutcome.definitePreBroadcastFailure do { @@ -1054,13 +1117,17 @@ struct SendConfirmationView: View { guard let privateContext = contactPaymentContext?.privatePaymentContext else { throw PaykitPaymentRequestError.requestUnavailable } + let paymentAppId = try privateContext.paymentAppId(for: proof.endpointIdentifier) try await PaykitPaymentProofService.shared.prepare( request: incomingPaymentRequest, - paymentAppId: privateContext.paymentAppId(for: proof.endpointIdentifier), + paymentAppId: paymentAppId, paymentEndpointIdentifier: proof.endpointIdentifier, kind: proof.kind ) - preparedPaymentProof = proof + preparedPaymentProof = (proof.endpointIdentifier, proof.kind, paymentAppId) + if proof.kind == .onchain { + originalPaymentIdentity = try await PaykitPaymentProofService.shared.onchainPaymentIdentity(requestId: incomingPaymentRequest.id) + } shouldCancelPaymentProof = true } try await prepareIncomingPaymentRequest() @@ -1156,18 +1223,20 @@ 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 Self.sendOnchainPayment( + let result = try await Self.sendOnchainPayment( request: incomingPaymentRequest, prepareBroadcast: { try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( $0, - address: invoice.address + address: invoice.address, paymentIdentity: originalPaymentIdentity ) }, authorize: { try await paykitPaymentRequestManager.ensurePaymentAllowed($0) }, onAuthorizationFailure: { _ in guard let incomingPaymentRequest else { return } - await PaykitPaymentProofService.shared.failOnchainPayment(incomingPaymentRequest) + guard await PaykitPaymentProofService.shared.failOnchainPayment( + incomingPaymentRequest, paymentIdentity: originalPaymentIdentity + ) else { throw OnchainSendAttemptError.preDispatchCleanupFailed } }, onAuthorized: { _ in onchainPaymentStarted = true @@ -1178,45 +1247,89 @@ struct SendConfirmationView: View { address: invoice.address, sats: amount, isMaxAmount: useMaxAmount, + requestId: incomingPaymentRequest?.id, paymentIdentity: originalPaymentIdentity, + followupContext: OnchainSendFollowupContext( + feeSats: UInt64(transactionFee), feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1, + tags: tagManager.selectedTagsArray, contact: contactPublicKey, + 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 } + } ) } ) shouldCancelPaymentProof = false + let txid: String + switch result { + case let .accepted(acceptedTxid): + txid = acceptedTxid + case let .rejected(rejectedTxid, reason): + await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) + Logger.warn("On-chain broadcast rejected for \(rejectedTxid): \(reason)", context: "SendConfirmation") + app.toast( + type: .warning, + title: t("wallet__onchain_broadcast_rejected"), + description: t("wallet__onchain_broadcast_rejected_note", variables: ["reason": reason]) + ) + 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: 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 await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) + var proofSaved = true if let incomingPaymentRequest, let preparedPaymentProof { - guard let paymentAppId = try contactPaymentContext?.privatePaymentContext?.paymentAppId( - for: preparedPaymentProof.endpointIdentifier - ) else { throw PaykitPaymentRequestError.requestUnavailable } - await PaykitPaymentProofService.shared.completeOnchainPayment( + proofSaved = await PaykitPaymentProofService.shared.completeOnchainPayment( incomingPaymentRequest, txid: txid, - paymentAppId: paymentAppId, + paymentAppId: preparedPaymentProof.appId, paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier ) } - // Create pre-activity metadata for tags and activity address - 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( - txid: txid, - address: invoice.address, - amount: amount, - fee: UInt64(transactionFee), - feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1, - contact: contactPublicKey - ) + var savedActivity: OnchainActivity? + if proofSaved { + do { + 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 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)") - 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"] @@ -1231,14 +1344,17 @@ struct SendConfirmationView: View { } catch { if let incomingPaymentRequest { privatePaymentListOutcome = Self.privatePaymentListOutcomeAfterFailure( - currentOutcome: privatePaymentListOutcome, - walletType: app.selectedWalletToPayFrom, - onchainPaymentStarted: onchainPaymentStarted, - error: error + currentOutcome: privatePaymentListOutcome, walletType: app.selectedWalletToPayFrom, + onchainPaymentStarted: onchainPaymentStarted, error: error ) if onchainPaymentStarted, privatePaymentListOutcome == .definitePreBroadcastFailure { - await PaykitPaymentProofService.shared.failOnchainPayment(incomingPaymentRequest) - 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) @@ -1253,10 +1369,23 @@ struct SendConfirmationView: View { return } } - await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) + if let attemptError = error as? OnchainSendAttemptError { + switch attemptError { + case .unresolved, .duplicate, .outcomeNotSaved, .localFollowupNotSaved, .retryConstruction, .retryUnavailable, + .preDispatchCleanupFailed: + await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) + shouldCancelPaymentProof = false + app.toast(attemptError) + await navigationPath.append(Self.onchainPendingRoute(requestId: incomingPaymentRequest?.id)) + return + case .preDispatch: + break + } + } if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } + await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) Logger.error("Payment failed: \(error)") if let paymentId = createdMetadataPaymentId { @@ -1441,13 +1570,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, @@ -1462,7 +1592,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) { @@ -1585,7 +1721,7 @@ struct SendConfirmationView: View { return } - guard let feeRate = wallet.selectedFeeRateSatsPerVByte else { + guard let feeRate = confirmationFeeRate else { if hwSend.isActive { await hwSend.refreshAvailable( manager: hwWalletManager, @@ -1601,6 +1737,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/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 996ab12fd..469f9b164 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -1,4 +1,5 @@ import BitkitCore +import LDKNode import SwiftUI struct HourglassLoadingView: View { @@ -29,27 +30,74 @@ struct SendPendingScreen: View { let paymentRequest: String? let paykitPaymentRequestId: PaykitPaymentRequest.ID? let routingCacheResetAttempted: Bool + var attemptService: OnchainSendAttemptService = .shared + var ordinaryPendingContext: OnchainSendPendingContext? + var hardwareWalletId: String? + var hardwareTransactionId: String? + var hardwarePaymentIdentity: String? + var proofService: PaykitPaymentProofService = .shared + var requestPinCheck: () async -> Bool = { false } @Binding var navigationPath: [SendRoute] @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 + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests @EnvironmentObject private var sheets: SheetViewModel @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 + @State private var refreshingOrdinaryOutcome = false + @State private var showingRetryConfirmation = false + @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 } + } + + private var pendingAmountSats: UInt64? { + pendingHardwareWalletId == nil ? onchainAttempt?.amountSats ?? pendingOnchainProof?.onchainAmountSats ?? wallet + .sendAmountSats : pendingOnchainProof?.onchainAmountSats + } + + private var pendingTransactionId: String? { + pendingHardwareWalletId == nil ? onchainAttempt?.txid ?? pendingOnchainProof?.paymentIdentifier : pendingOnchainProof? + .paymentIdentifier ?? hardwareTransactionId + } var body: some View { VStack(alignment: .leading, spacing: 0) { SheetHeader(title: t("wallet__send_pending"), showBackButton: false) - if let sendAmountSats = wallet.sendAmountSats { + if let sendAmountSats = pendingAmountSats { + if onchainAttempt?.requestId == nil, onchainAttempt != nil { + BodySSBText(t("wallet__onchain_earlier_payment")) + .accessibilityIdentifier("EarlierOnchainPayment") + } MoneyStack(sats: Int(sendAmountSats), showSymbol: true) .padding(.bottom, 32) } - BodyMText(t("wallet__send_pending_note")) + if paymentHash == nil { + BodyMText(onchainPendingMessage) + if let txid = pendingTransactionId { + BodySSBText(t("wallet__onchain_transaction_id", variables: ["txid": txid])) + .textSelection(.enabled) + } + } else { + BodyMText(t("wallet__send_pending_note")) + } Spacer() @@ -57,6 +105,15 @@ struct SendPendingScreen: View { Spacer() + 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 + } + .accessibilityIdentifier("RetryOriginalOnchainPayment") + .padding(.bottom, 16) + } + HStack(spacing: 16) { CustomButton( title: t("wallet__send_details"), @@ -74,33 +131,363 @@ struct SendPendingScreen: View { } } } + .alert(t("wallet__onchain_retry_original"), isPresented: $showingRetryConfirmation) { + if onchainAttempt?.isMaxAmount != true { + TextField(t("wallet__onchain_retry_fee"), text: $retryFeeRate).keyboardType(.numberPad) + } + Button(t("common__cancel"), role: .cancel) {} + 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 && $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), + "address": onchainAttempt?.address ?? "", + "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) .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .task { + if paymentHash == nil { + 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 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) + } + 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 { + let loaded = try await Self.loadOrdinaryPending( + using: attemptService, context: ordinaryPendingContext, + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex), + isOrdinary: paykitPaymentRequestId == nil + ) + onchainAttempt = loaded.attempt + if onchainAttempt?.requestId != paykitPaymentRequestId { + onchainAttempt = nil + onchainStateUnavailable = true + } + 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( + requestId: requestId, identity: identity, context: ordinaryPendingContext + ) + { + applyOnchainPaymentResolution(resolution) + } + } catch { onchainStateUnavailable = true } + } + } 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) } + .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 - guard resolution.requestId == paykitPaymentRequestId, - resolution.walletId == WalletScope.default, - let identity = pubkyProfile.publicKey, - PubkyPublicKeyFormat.matches(resolution.identity, identity) - else { return } - app.addPendingContactPaymentContext( - resolution.transactionId, - context: ContactPaymentContext(publicKey: resolution.requestId.counterparty) + applyOnchainPaymentResolution(resolution) + } + } + + @MainActor + private func retryOriginalPayment(feeRate: UInt32) async { + 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) + 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 + } else { + nil + } + 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 { + guard case .success = await BiometricAuth.authenticate() else { throw CancellationError() } + } else { + guard await requestPinCheck() else { throw CancellationError() } + } + } + if attempt.requestId != nil { + let request = try await paymentRequests.ensureOnchainRecoveryAllowed(attempt, proofService: proofService) + guard request.paymentDeadline == paymentDeadline else { throw PaykitPaymentRequestError.requestUnavailable } + guard let payer = attempt.recoveryContext?.paymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + try await proofService.requireRecoveryPayer(payer) + } else if let orderId = attempt.orderId { + let orders = try await CoreService.shared.blocktank.orders(orderIds: [orderId], refresh: true) + guard let order = orders.first(where: { $0.id == orderId }), order.state2 == .created, + order.payment?.onchain?.address == attempt.address, + order.clientBalanceSat == attempt.transferContext?.clientBalanceSats, + order.feeSat <= attempt.amountSats + else { throw OnchainSendAttemptError.unresolved } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let expiry = formatter.date(from: order.orderExpiresAt) ?? ISO8601DateFormatter().date(from: order.orderExpiresAt), + expiry > Date() + else { throw OnchainSendAttemptError.unresolved } + } + } ) - Task { - await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) - navigationPath.append(.success(paymentId: resolution.transactionId)) + _ = result + try await refreshOriginalOutcome(context: context) + } catch is CancellationError { + // A positive original observation may have won while authentication awaited. + try? await refreshOriginalOutcome(context: context) + } catch { + try? await refreshOriginalOutcome(context: context) + app.toast(error is NodeError ? OnchainSendAttemptError.retryUnavailable : error) + } + } + + @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) + guard let original = onchainAttempt, original.status == .accepted, let txid = original.txid else { return } + if let requestId = original.requestId { + await proofService.reconcile() + if let payer = original.recoveryContext?.paymentIdentity, + let resolution = await proofService.resolvedOnchainPayment(requestId: requestId, identity: payer, context: context) + { + applyOnchainPaymentResolution(resolution) + } + } else if original.orderId != nil { + 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) + } + } + + 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 pendingHardwareWalletId == nil, let attempt = onchainAttempt { + guard attempt.requestId == resolution.requestId, attempt.containsCandidate(resolution.transactionId), + attempt.recoveryContext?.paymentIdentity == nil || + PubkyPublicKeyFormat.matches(attempt.recoveryContext?.paymentIdentity, resolution.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 } + } + ordinarySendResolved = true + app.prepareResolvedOnchainContactContext(resolution, isHardware: pendingHardwareWalletId != nil) + Task { + 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)) + } + } + + private var onchainPendingMessage: String { + if onchainStateUnavailable { + return t("wallet__onchain_state_unavailable") + } + 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 localFollowupUnavailable { + return t("wallet__onchain_pending_followup_failed") + } + 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 let context { + try await service.pendingAttempt(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) + } + + @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, + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex)) + else { return } + ordinarySendResolved = true + foundActivity = .onchain(resolution.activity) + onchainAttempt?.status = .accepted + 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?) { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index dc23eaa66..e6c79bd2f 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -66,6 +66,9 @@ enum SendRoute: Hashable { paymentRequest: String?, paykitPaymentRequestId: PaykitPaymentRequest.ID? = nil ) + case onchainPending(OnchainSendPendingContext) + case onchainOperationPending(OnchainSendPendingContext, requestId: PaykitPaymentRequest.ID?) + case hardwarePending(requestId: PaykitPaymentRequest.ID, walletId: String, transactionId: String, paymentIdentity: String?) case success(paymentId: String, walletId: String = WalletScope.default) case failure(SendFailureContext) case lnurlPayAmount @@ -115,6 +118,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 @@ -133,6 +137,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? @@ -181,7 +186,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 isPreparingRequest || hwSend.isActive { + if isPreparingRequest || hwSend.isActive || isResumingAcceptedOrdinarySend { return false } @@ -238,7 +243,7 @@ struct SendSheet: View { .sheet(isPresented: reconnectPairingBinding) { HardwarePairingSheet(config: HardwarePairingSheetItem()) } - .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut, isEnabled: !isPreparingRequest) + .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut, isEnabled: !isPreparingRequest && !isResumingAcceptedOrdinarySend) .onChange(of: shouldDismissUnavailableRequest) { _, shouldDismiss in guard shouldDismiss else { return } sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") @@ -377,6 +382,29 @@ 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) + ) { + let context = OnchainSendPendingContext(attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid) + if attempt.requestId != nil || attempt.orderId != nil || attempt.status != .accepted { + hasValidatedAfterSync = true + replaceRootRoute(with: .onchainOperationPending(context, requestId: attempt.requestId)) + return + } + isResumingAcceptedOrdinarySend = true + hasValidatedAfterSync = true + app.selectedWalletToPayFrom = .onchain + replaceRootRoute(with: Self.acceptedOrdinaryStartupRoute(attempt: attempt)) + return + } + } catch { + Logger.warn("Could not inspect retained on-chain follow-up", context: "SendSheet") + } + } do { try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) } catch is CancellationError { @@ -440,6 +468,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 @@ -490,6 +522,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 } @@ -505,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 { @@ -656,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( @@ -739,13 +796,39 @@ struct SendSheet: View { preparingRequest: isPreparingRequest ? config.preparation?.request : nil ) 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, - authorizeContactPayment: authorizeHardwareContactPayment, - completeContactPayment: completeHardwareContactPayment, - cancelContactPayment: cancelHardwareContactPayment + contactPaymentRequestId: contactContext?.incomingPaymentRequest?.id, + contactPaymentIdentity: paymentIdentity, + contactPaymentDeadline: contactContext?.incomingPaymentRequest?.paymentDeadline, + prepareContactPayment: { signedTx in + try await prepareHardwareContactPayment( + context: contactContext, + address: address, + walletId: walletId, + paymentIdentity: paymentIdentity, + signedTx: signedTx + ) + }, + authorizeContactPayment: { + try await authorizeHardwareContactPayment(context: contactContext, paymentIdentity: paymentIdentity) + }, + completeContactPayment: { txid in + await completeHardwareContactPayment(context: contactContext, walletId: walletId, paymentIdentity: paymentIdentity, txid: txid) + }, + cancelContactPayment: { outcome in + await cancelHardwareContactPayment( + context: contactContext, + walletId: walletId, + paymentIdentity: paymentIdentity, + outcome: outcome + ) + } ) case .feeRate: SendFeeRate(navigationPath: $navigationPath, hwSend: hwSend) @@ -771,6 +854,26 @@ struct SendSheet: View { routingCacheResetAttempted: routingCacheResetAttempted, navigationPath: $navigationPath ) + case let .onchainPending(context): + SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: nil, + routingCacheResetAttempted: routingCacheResetAttempted, ordinaryPendingContext: context, + requestPinCheck: requestPinCheck, navigationPath: $navigationPath + ) + case let .onchainOperationPending(context, requestId): + SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId, + routingCacheResetAttempted: routingCacheResetAttempted, ordinaryPendingContext: context, + requestPinCheck: requestPinCheck, 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, @@ -839,63 +942,84 @@ struct SendSheet: View { } } - private func prepareHardwareContactPayment(_ context: ContactPaymentContext?) async throws { - guard let request = context?.incomingPaymentRequest, - let address = app.scannedOnchainInvoice?.address - else { + private func prepareHardwareContactPayment( + context: ContactPaymentContext?, + address: String?, + walletId: String?, + paymentIdentity: String?, + signedTx: HwFundingSignedTx + ) 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 - guard let privateContext = context?.privatePaymentContext, let walletId = hwSend.walletId else { + guard let privateContext = context?.privatePaymentContext else { throw PaykitPaymentRequestError.requestUnavailable } try await PaykitPaymentProofService.shared.prepare( request: request, paymentAppId: privateContext.paymentAppId(for: endpointIdentifier), paymentEndpointIdentifier: endpointIdentifier, - kind: .onchain, - walletId: walletId + 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, walletId: walletId) + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( + request, address: address, hardwareWalletId: walletId, paymentIdentity: paymentIdentity, + signedTx: signedTx, privatePaymentListVersion: privateContext.paymentListVersion, + previousPrivatePaymentListVersion: previousVersion + ) } catch { _ = await paykitPaymentRequestManager.paymentRequestForRetry(request.id) await context?.resolvePrivatePaymentListConsumption(.definitePreBroadcastFailure) - await PaykitPaymentProofService.shared.cancelPreparation(request, walletId: walletId) + await PaykitPaymentProofService.shared.cancelPreparation(request) throw error } } - private func authorizeHardwareContactPayment(_ context: ContactPaymentContext?) async throws { + private func authorizeHardwareContactPayment(context: ContactPaymentContext?, paymentIdentity: String?) async throws { 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) } - private func completeHardwareContactPayment(_ context: ContactPaymentContext?, txid: String) async { - guard let request = context?.incomingPaymentRequest, - let walletId = hwSend.walletId, - let address = app.scannedOnchainInvoice?.address, - let paymentAppId = try? context?.privatePaymentContext?.paymentAppId( - for: PublicPaykitService.onchainMethodId(for: address).rawValue - ) - 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 context?.resolvePrivatePaymentListConsumption(.succeeded) - await PaykitPaymentProofService.shared.completeOnchainPayment( + let verified = await PaykitPaymentProofService.shared.completeHardwareOnchainPayment( request, - txid: txid, - paymentAppId: paymentAppId, - paymentEndpointIdentifier: PublicPaykitService.onchainMethodId(for: address).rawValue, - walletId: walletId + paymentIdentity: paymentIdentity, + walletId: walletId, + txid: txid ) + await context?.resolvePrivatePaymentListConsumption(verified ? .succeeded : .uncertain) + return verified } - private func cancelHardwareContactPayment(_ context: ContactPaymentContext?, outcome: PrivatePaymentListSendOutcome) async { - guard let walletId = hwSend.walletId else { return } - await Self.cancelHardwareContactPayment(context, outcome: outcome, app: app, manager: paykitPaymentRequestManager, walletId: walletId) + private func cancelHardwareContactPayment(context: ContactPaymentContext?, walletId: String?, paymentIdentity: String?, + outcome: PrivatePaymentListSendOutcome) async + { + await Self.cancelHardwareContactPayment( + context, outcome: outcome, app: app, manager: paykitPaymentRequestManager, + hardwareWalletId: walletId, paymentIdentity: paymentIdentity + ) } @MainActor @@ -904,15 +1028,24 @@ struct SendSheet: View { outcome: PrivatePaymentListSendOutcome, app: AppViewModel, manager: PaykitPaymentRequestManager, - walletId: String = WalletScope.default, privatePaykitService: PrivatePaykitService = .shared, - paymentProofService: PaykitPaymentProofService = .shared + paymentProofService: PaykitPaymentProofService = .shared, + hardwareWalletId: String? = nil, + paymentIdentity: String? = nil ) async { guard let request = context?.incomingPaymentRequest else { return } await context?.resolvePrivatePaymentListConsumption(outcome, service: privatePaykitService) guard outcome == .definitePreBroadcastFailure else { return } - await paymentProofService.failOnchainPayment(request, walletId: walletId) - await paymentProofService.cancelPreparation(request, walletId: walletId) + if let hardwareWalletId { + guard let paymentIdentity else { return } + await paymentProofService.cancelHardwarePaymentBeforeDispatch( + request, paymentIdentity: paymentIdentity, walletId: hardwareWalletId, + privatePaykitService: privatePaykitService + ) + } else { + await paymentProofService.failOnchainPayment(request) + } + await paymentProofService.cancelPreparation(request) if let context { await restoreHardwareContactPaymentForRetry(context, app: app, manager: manager) } 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/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index c5af4bfae..7ad8840e6 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -1,11 +1,59 @@ @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), + paymentRequestId: paymentRequestId, + localRole: .payer, + state: state, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), + paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: recurrence, + acceptedPaymentEndpointIdentifiers: endpoints, + paymentEndpoints: nil, + requiredAppId: "bitkit", + 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, @@ -187,6 +235,49 @@ final class HwFundingSignerTests: XCTestCase { try await assertCoordinatorRetryReusesSignedPayment(error: HwTransferError.broadcastUncertain) } + func testCoordinatorRoutesOnlyUnverifiedShopBroadcastToPending() async throws { + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: UUID().uuidString, counterparty: "merchant", 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") @@ -204,7 +295,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, @@ -214,11 +305,30 @@ 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 failureOutcomes: [PrivatePaymentListSendOutcome] = [] + 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 failureOutcomes: [PrivatePaymentListSendOutcome] = [] var isPaymentAllowed = true - let preparePayment: () async throws -> Void = { preparationCalls += 1 } + let preparePayment: (HwFundingSignedTx) async throws -> Void = { _ in + preparationCalls += 1 + await store.seed([PendingPaykitPaymentProof( + identity: identity, requestId: request.id, paymentAppId: "bitkit", + 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 { @@ -235,7 +345,12 @@ final class HwFundingSignerTests: XCTestCase { satsPerVByte: 2, beforeFirstBroadcast: preparePayment, beforeBroadcastAttempt: authorizePayment, - afterFailure: { failureOutcomes.append($0) } + afterFailure: { outcome in + failureOutcomes.append(outcome) + if outcome == .definitePreBroadcastFailure { + await releaseBeforeDispatch() + } + } ) } @@ -249,14 +364,23 @@ final class HwFundingSignerTests: XCTestCase { satsPerVByte: 2, beforeFirstBroadcast: preparePayment, beforeBroadcastAttempt: authorizePayment, - afterFailure: { failureOutcomes.append($0) } + afterFailure: { outcome in + failureOutcomes.append(outcome) + if outcome == .definitePreBroadcastFailure { + await releaseBeforeDispatch() + } + } ) } XCTAssertTrue(coordinator.hasPendingBroadcast) XCTAssertEqual(funding.broadcastCalls, 1) - XCTAssertEqual(failureOutcomes, [.uncertain, .uncertain]) + let durableProofs = await store.snapshot() + XCTAssertEqual(durableProofs.count, 1) + XCTAssertEqual(durableProofs.first?.paymentStarted, true, "Denied attempted retry must retain its durable original proof") + XCTAssertEqual(failureOutcomes, [.uncertain, .uncertain]) + XCTAssertEqual(releases, 0, "Attempted retry incorrectly invoked pre-dispatch proof release") isPaymentAllowed = true _ = try await coordinator.signAndBroadcast( manager: manager, @@ -264,7 +388,13 @@ final class HwFundingSignerTests: XCTestCase { sats: 42000, satsPerVByte: 2, beforeFirstBroadcast: preparePayment, - beforeBroadcastAttempt: authorizePayment + beforeBroadcastAttempt: authorizePayment, + afterFailure: { outcome in + failureOutcomes.append(outcome) + if outcome == .definitePreBroadcastFailure { + await releaseBeforeDispatch() + } + } ) XCTAssertEqual(preparationCalls, 1) @@ -275,6 +405,153 @@ final class HwFundingSignerTests: XCTestCase { } } + func testConfirmedBroadcastCompletesProofBeforeReturningToCancelledCaller() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) + let completion = AsyncGate() + var completionStarted = false + var completedTransactionId: String? + let payment = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + afterBroadcast: { result in + completionStarted = true + await completion.wait() + completedTransactionId = result.txId + } + ) + } + await waitUntil { completionStarted } + XCTAssertTrue(completionStarted) + let wasPaused = await PrivatePaykitService.shared.isBackgroundWorkPaused + addTeardownBlock { await PrivatePaykitService.shared.setBackgroundWorkPaused(wasPaused) } + await PrivatePaykitService.shared.setBackgroundWorkPaused(true) + manager.onAppBackgrounded() + payment.cancel() + coordinator.cancel() + completion.open() + + let result = try await payment.value + XCTAssertEqual(completedTransactionId, result.txId) + XCTAssertEqual(result.txId, funding.broadcastTxId) + XCTAssertEqual(funding.broadcastCalls, 1) + coordinator.completeBroadcast() + } + + func testCoordinatorDeniedFirstAttemptDropsPreparedPayment() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + walletId: "trezor:original-ios-wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: MockHwConnecting(), + feeRate: satsPerVByte, + address: address + ) + } + ) + + 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, paymentAppId: "bitkit", + 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, + beforeFirstBroadcast: { _ in + try await proofService.prepare( + request: request, paymentAppId: "bitkit", + paymentEndpointIdentifier: originalProof.paymentEndpointIdentifier, + kind: .onchain + ) + try await proofService.markOnchainPaymentStarted( + request, + address: "bc1qtest", + hardwareWalletId: "trezor:original-ios-wallet", + paymentIdentity: identity + ) + }, + beforeBroadcastAttempt: { throw MockHwFunding.TestError() }, + afterFailure: { outcome in + if outcome == .definitePreBroadcastFailure { + 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 { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: connecting, + feeRate: satsPerVByte, + address: address + ) + } + ) + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") + + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2 + ) + } + + XCTAssertTrue(coordinator.hasPendingBroadcast) + + coordinator.cancel() + + XCTAssertFalse(coordinator.hasPendingBroadcast) + + funding.broadcastError = nil + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2 + ) + + XCTAssertEqual(funding.signCalls, 2) + XCTAssertEqual(funding.broadcastCalls, 2) + } + func testCoordinatorDeniedFirstAttemptReleasesPreparedPaymentForRetry() async throws { for authorizationError in [MockHwFunding.TestError() as Error, CancellationError()] { let funding = MockHwFunding() @@ -299,7 +576,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) } ) @@ -315,7 +592,7 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { preparationCalls += 1 } + beforeFirstBroadcast: { _ in preparationCalls += 1 } ) XCTAssertEqual(preparationCalls, 2) @@ -323,10 +600,9 @@ final class HwFundingSignerTests: XCTestCase { } } - func testPaymentDeadlineRejectionRetainsPriorAttemptWithoutBlockingDismissal() async throws { - enum ExpiryPhase { case beforeRetry, authorization, queuedBroadcast } + func testPaymentDeadlineRejectionPreservesOnlyPriorBroadcastUncertainty() async throws { for hadPriorAttempt in [false, true] { - for phase in [ExpiryPhase.beforeRetry, .authorization, .queuedBroadcast] { + for expiresInQueue in [false, true] { let funding = MockHwFunding() let manager = HwWalletManager() let coordinator = HwSendCoordinator( @@ -342,17 +618,14 @@ final class HwFundingSignerTests: XCTestCase { } funding.broadcastError = nil } - let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(phase == .beforeRetry ? -60 : 60)) + let priorNavigationWasUnresolved = coordinator.isBroadcastUnresolved + let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(expiresInQueue ? 60 : -60)) funding.broadcastNow = { deadline.date.addingTimeInterval(1) } var outcomes: [PrivatePaymentListSendOutcome] = [] do { _ = try await coordinator.signAndBroadcast( manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - paymentDeadline: deadline, - beforeBroadcastAttempt: { - if phase == .authorization { throw PaykitPaymentRequestError.requestExpired } - }, - afterFailure: { outcomes.append($0) } + paymentDeadline: deadline, afterFailure: { outcomes.append($0) } ) XCTFail("Expired payment must not broadcast") } catch { @@ -361,15 +634,95 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastCalls, hadPriorAttempt ? 1 : 0) XCTAssertEqual(outcomes, [hadPriorAttempt ? .uncertain : .definitePreBroadcastFailure]) XCTAssertEqual(coordinator.hasPendingBroadcast, hadPriorAttempt) - XCTAssertFalse(coordinator.isBroadcastUnresolved) - XCTAssertTrue(coordinator.canLeave) - XCTAssertEqual(funding.signCalls, 1) - coordinator.cancel() - XCTAssertFalse(coordinator.hasPendingBroadcast) - XCTAssertTrue(coordinator.canLeave) - XCTAssertEqual(outcomes, [hadPriorAttempt ? .uncertain : .definitePreBroadcastFailure]) + XCTAssertEqual(coordinator.isBroadcastUnresolved, priorNavigationWasUnresolved) + } + } + } + + 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 { @@ -380,401 +733,97 @@ 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) } } - func testResolvedHardwarePaymentFinishesExpiredRetryWithoutRebroadcasting() async throws { + func testCoordinatorBroadcastFailureRemainsUncertainAfterPendingPaymentIsCleared() async { let funding = MockHwFunding() - funding.broadcastError = HwTransferError.broadcastUncertain let manager = HwWalletManager() - let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) - let identity = "pubky" + String(repeating: "y", count: 52) - let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer") - var failures: [PrivatePaymentListSendOutcome] = [] - - for deadline in [nil, PaykitPreciseInstant(date: Date().addingTimeInterval(-60))] { - await assertThrowsAsync { - _ = try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - paymentDeadline: deadline, paykitRequestId: requestId, paykitIdentity: identity, - afterFailure: { failures.append($0) } + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: MockHwConnecting(), + feeRate: satsPerVByte, + address: address ) } - } - - XCTAssertFalse(coordinator.isBroadcastUnresolved) - XCTAssertTrue(coordinator.hasPendingBroadcast) - XCTAssertTrue(coordinator.canLeave) - XCTAssertEqual(failures, [.uncertain, .uncertain]) - let resolution = PaykitOnchainPaymentResolution( - identity: identity, requestId: requestId, transactionId: "resolved-tx", walletId: "trezor:wallet" ) - let result = try XCTUnwrap(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) + var failureOutcomes: [PrivatePaymentListSendOutcome] = [] + funding.broadcastError = MockHwFunding.TestError() - XCTAssertEqual(result.txId, resolution.transactionId) - XCTAssertEqual(result.miningFeeSats, funding.signedTx.miningFeeSats) - XCTAssertEqual(result.feeRate, UInt64(funding.signedTx.feeRate.rounded(.up))) - XCTAssertEqual(result.totalSpent, funding.signedTx.totalSpent) - XCTAssertEqual(funding.signCalls, 1) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + afterFailure: { failureOutcomes.append($0) } + ) + } + + XCTAssertFalse(coordinator.hasPendingBroadcast) XCTAssertEqual(funding.broadcastCalls, 1) - XCTAssertTrue(coordinator.hasPendingBroadcast) - XCTAssertFalse(coordinator.canLeave) - XCTAssertEqual(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet"), result) + XCTAssertEqual(failureOutcomes, [.uncertain]) + + funding.broadcastError = nil await assertThrowsAsync { _ = try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - paykitRequestId: requestId, paykitIdentity: identity + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + beforeFirstBroadcast: { _ in throw PaykitPaymentRequestError.operationInProgress }, + afterFailure: { failureOutcomes.append($0) } ) } + XCTAssertEqual(funding.broadcastCalls, 1) - coordinator.completeBroadcast() - XCTAssertFalse(coordinator.hasPendingBroadcast) - XCTAssertTrue(coordinator.canLeave) - XCTAssertNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) + XCTAssertEqual(failureOutcomes, [.uncertain]) } - func testHardwareResolutionRequiresAttemptedRequestIdentityAndWallet() async { + func testCoordinatorRetriesPreparationFailureBeforeBroadcast() async throws { let funding = MockHwFunding() - funding.broadcastError = HwTransferError.broadcastUncertain let manager = HwWalletManager() - let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) - let identity = "pubky" + String(repeating: "y", count: 52) - let otherIdentity = "pubky" + String(repeating: "b", count: 52) - let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer") - let resolution = PaykitOnchainPaymentResolution( - identity: identity, requestId: requestId, transactionId: "resolved-tx", walletId: "trezor:wallet" + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: MockHwConnecting(), + feeRate: satsPerVByte, + address: address + ) + } ) - var preparationStarted = false - let preparation = AsyncGate() - let payment = Task { - try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - paykitRequestId: requestId, paykitIdentity: identity, - beforeFirstBroadcast: { - preparationStarted = true - await preparation.wait() + var beforeFirstBroadcastCalls = 0 + + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + beforeFirstBroadcast: { _ in + beforeFirstBroadcastCalls += 1 + throw MockHwFunding.TestError() } ) } - await waitUntil { preparationStarted } - XCTAssertTrue(preparationStarted) - XCTAssertNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) - preparation.open() - await assertThrowsAsync { _ = try await payment.value } - let unrelatedIds = [ - PaykitPaymentRequest.ID(paymentRequestId: "other", counterparty: "peer"), - PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "other-peer"), - PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer", billingPeriodStartsAt: Date()), - ] - for id in unrelatedIds { - let unrelated = PaykitOnchainPaymentResolution( - identity: identity, requestId: id, transactionId: "other-tx", walletId: "trezor:wallet" - ) - XCTAssertNil(coordinator.resolvePayment(unrelated, identity: identity, walletId: "trezor:wallet")) - } - let unrelated = PaykitOnchainPaymentResolution( - identity: otherIdentity, requestId: requestId, transactionId: "other-tx", walletId: "trezor:wallet" - ) - XCTAssertNil(coordinator.resolvePayment(unrelated, identity: otherIdentity, walletId: "trezor:wallet")) - let unrelatedWallet = PaykitOnchainPaymentResolution( - identity: identity, requestId: requestId, transactionId: "other-tx", walletId: "trezor:other" - ) - XCTAssertNil(coordinator.resolvePayment(unrelatedWallet, identity: identity, walletId: "trezor:wallet")) - XCTAssertNil(coordinator.resolvePayment(resolution, identity: otherIdentity, walletId: "trezor:wallet")) - XCTAssertNil(coordinator.resolvePayment(resolution, identity: nil, walletId: "trezor:wallet")) - XCTAssertNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "other-wallet")) XCTAssertTrue(coordinator.hasPendingBroadcast) - XCTAssertEqual(funding.broadcastCalls, 1) - XCTAssertNotNil(coordinator.resolvePayment(resolution, identity: String(identity.dropFirst(5)), walletId: "trezor:wallet")) - } + XCTAssertEqual(funding.broadcastCalls, 0) - func testHardwareResolutionCancelsRetryBeforeLateAuthorizationCanBroadcast() async { - for authorizationFails in [false, true] { - let funding = MockHwFunding() - funding.broadcastError = HwTransferError.broadcastUncertain - let manager = HwWalletManager() - let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) - let identity = "pubky" + String(repeating: "y", count: 52) - let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer") - await assertThrowsAsync { - _ = try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - paykitRequestId: requestId, paykitIdentity: identity - ) - } - - let authorization = AsyncGate() - var authorizationStarted = false - var failureCalled = false - let retry = Task { - try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - paykitRequestId: requestId, paykitIdentity: identity, - beforeBroadcastAttempt: { - authorizationStarted = true - await authorization.wait() - if authorizationFails { throw PaykitPaymentRequestError.requestExpired } - }, - afterFailure: { _ in failureCalled = true } - ) - } - await waitUntil { authorizationStarted } - XCTAssertTrue(authorizationStarted) - let resolution = PaykitOnchainPaymentResolution( - identity: identity, requestId: requestId, transactionId: "resolved-tx", walletId: "trezor:wallet" - ) - XCTAssertNotNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) - authorization.open() - await assertThrowsAsync { _ = try await retry.value } _: { XCTAssertTrue($0 is CancellationError) } - - XCTAssertEqual(funding.broadcastCalls, 1) - XCTAssertFalse(failureCalled) - XCTAssertFalse(coordinator.isSigning) - XCTAssertTrue(coordinator.hasPendingBroadcast) - coordinator.completeBroadcast() - XCTAssertFalse(coordinator.hasPendingBroadcast) - XCTAssertTrue(coordinator.canLeave) - } - } - - func testHardwareResolutionIgnoresLateBroadcastSuccessAndFailure() async { - for broadcastFails in [false, true] { - let funding = MockHwFunding() - let broadcast = AsyncGate() - funding.broadcastGate = broadcast - let manager = HwWalletManager() - let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) - let identity = "pubky" + String(repeating: "y", count: 52) - let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer") - var completionCalled = false - var failureCalled = false - let payment = Task { - try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - paykitRequestId: requestId, paykitIdentity: identity, - afterBroadcast: { _ in completionCalled = true }, - afterFailure: { _ in failureCalled = true } - ) - } - await waitUntil { funding.broadcastCalls == 1 } - XCTAssertEqual(funding.broadcastCalls, 1) - let resolution = PaykitOnchainPaymentResolution( - identity: identity, requestId: requestId, transactionId: "resolved-tx", walletId: "trezor:wallet" - ) - XCTAssertNotNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) - funding.broadcastError = broadcastFails ? MockHwFunding.TestError() : nil - broadcast.open() - await assertThrowsAsync { _ = try await payment.value } _: { XCTAssertTrue($0 is CancellationError) } - - XCTAssertFalse(completionCalled) - XCTAssertFalse(failureCalled) - XCTAssertFalse(coordinator.isSigning) - XCTAssertTrue(coordinator.hasPendingBroadcast) - coordinator.completeBroadcast() - XCTAssertFalse(coordinator.hasPendingBroadcast) - XCTAssertTrue(coordinator.canLeave) - } - } - - func testConfirmedBroadcastCompletesProofBeforeReturningToCancelledCaller() async throws { - let funding = MockHwFunding() - let manager = HwWalletManager() - let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) - let completion = AsyncGate() - var completionStarted = false - var completedTransactionId: String? - let payment = Task { - try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - afterBroadcast: { result in - completionStarted = true - await completion.wait() - completedTransactionId = result.txId - } - ) - } - await waitUntil { completionStarted } - XCTAssertTrue(completionStarted) - let wasPaused = await PrivatePaykitService.shared.isBackgroundWorkPaused - addTeardownBlock { await PrivatePaykitService.shared.setBackgroundWorkPaused(wasPaused) } - await PrivatePaykitService.shared.setBackgroundWorkPaused(true) - manager.onAppBackgrounded() - payment.cancel() - coordinator.cancel() - completion.open() - - let result = try await payment.value - XCTAssertEqual(completedTransactionId, result.txId) - XCTAssertEqual(result.txId, funding.broadcastTxId) - XCTAssertEqual(funding.broadcastCalls, 1) - coordinator.completeBroadcast() - } - - func testCoordinatorUnclassifiedBroadcastFailureRetainsSignedPaymentForRetry() async throws { - let funding = MockHwFunding() - let manager = HwWalletManager() - let coordinator = HwSendCoordinator( - walletId: "trezor:wallet", - signerFactory: { [self] _, address, satsPerVByte in - makeSigner( - funding: funding, - connecting: MockHwConnecting(), - feeRate: satsPerVByte, - address: address - ) - } - ) - var failureOutcomes: [PrivatePaymentListSendOutcome] = [] - funding.broadcastError = MockHwFunding.TestError() - - await assertThrowsAsync { - _ = try await coordinator.signAndBroadcast( - manager: manager, - address: "bc1qtest", - sats: 42000, - satsPerVByte: 2, - afterFailure: { failureOutcomes.append($0) } - ) - } - - XCTAssertTrue(coordinator.hasPendingBroadcast) - XCTAssertEqual(funding.broadcastCalls, 1) - XCTAssertEqual(failureOutcomes, [.uncertain]) - - funding.broadcastError = nil _ = try await coordinator.signAndBroadcast( manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeFirstBroadcast: { XCTFail("An uncertain attempt must not prepare another payment") }, - afterFailure: { failureOutcomes.append($0) } - ) - - XCTAssertEqual(funding.broadcastCalls, 2) - XCTAssertEqual(funding.signCalls, 1) - XCTAssertEqual(failureOutcomes, [.uncertain]) - } - - func testCoordinatorInvalidTransactionPreservesOnlyEarlierUncertainAttempts() async { - for hadPriorAttempt in [false, true] { - let funding = MockHwFunding() - let manager = HwWalletManager() - let coordinator = HwSendCoordinator( - walletId: "trezor:wallet", - signerFactory: { [self] _, address, satsPerVByte in - makeSigner( - funding: funding, connecting: MockHwConnecting(), - feeRate: satsPerVByte, address: address - ) - } - ) - var outcomes: [PrivatePaymentListSendOutcome] = [] - if hadPriorAttempt { - funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") - await assertThrowsAsync { - _ = try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2 - ) - } - } - funding.broadcastError = BroadcastError.InvalidTransaction(errorDetails: "invalid transaction") - await assertThrowsAsync { - _ = try await coordinator.signAndBroadcast( - manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, - afterFailure: { outcomes.append($0) } - ) - } - XCTAssertEqual(outcomes, [hadPriorAttempt ? .uncertain : .definitePreBroadcastFailure]) - XCTAssertEqual(coordinator.hasPendingBroadcast, hadPriorAttempt) - XCTAssertTrue(coordinator.canLeave) - } - } - - func testCoordinatorCancelDropsSignedPaymentAfterFailedBroadcast() async throws { - let funding = MockHwFunding() - let connecting = MockHwConnecting() - let manager = HwWalletManager() - let coordinator = HwSendCoordinator( - walletId: "trezor:wallet", - signerFactory: { [self] _, address, satsPerVByte in - makeSigner( - funding: funding, - connecting: connecting, - feeRate: satsPerVByte, - address: address - ) - } - ) - funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") - - await assertThrowsAsync { - _ = try await coordinator.signAndBroadcast( - manager: manager, - address: "bc1qtest", - sats: 42000, - satsPerVByte: 2 - ) - } - - XCTAssertTrue(coordinator.hasPendingBroadcast) - - coordinator.cancel() - - XCTAssertFalse(coordinator.hasPendingBroadcast) - - funding.broadcastError = nil - _ = try await coordinator.signAndBroadcast( - manager: manager, - address: "bc1qtest", - sats: 42000, - satsPerVByte: 2 - ) - - XCTAssertEqual(funding.signCalls, 2) - XCTAssertEqual(funding.broadcastCalls, 2) - } - - func testCoordinatorRetriesPreparationFailureBeforeBroadcast() async throws { - let funding = MockHwFunding() - let manager = HwWalletManager() - let coordinator = HwSendCoordinator( - walletId: "trezor:wallet", - signerFactory: { [self] _, address, satsPerVByte in - makeSigner( - funding: funding, - connecting: MockHwConnecting(), - feeRate: satsPerVByte, - address: address - ) - } - ) - var beforeFirstBroadcastCalls = 0 - - await assertThrowsAsync { - _ = try await coordinator.signAndBroadcast( - manager: manager, - address: "bc1qtest", - sats: 42000, - satsPerVByte: 2, - beforeFirstBroadcast: { - beforeFirstBroadcastCalls += 1 - throw MockHwFunding.TestError() - } - ) - } - - XCTAssertTrue(coordinator.hasPendingBroadcast) - XCTAssertEqual(funding.broadcastCalls, 0) - - _ = try await coordinator.signAndBroadcast( - manager: manager, - address: "bc1qtest", - sats: 42000, - satsPerVByte: 2, - beforeFirstBroadcast: { beforeFirstBroadcastCalls += 1 } + beforeFirstBroadcast: { _ in beforeFirstBroadcastCalls += 1 } ) XCTAssertEqual(beforeFirstBroadcastCalls, 2) @@ -808,7 +857,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) } ) @@ -824,7 +873,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) } ) @@ -883,6 +932,407 @@ final class HwFundingSignerTests: XCTestCase { _ = try await payment.value } + 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\"}", + "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()) + 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) + } + } + + 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", + "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)) + } + 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 + stringErrors { + 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: requestId + ) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 2) + } + } + + 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] { + 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() + 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: { RetainedHardwareOnchainPayment(signedTx: receipt, hasAttemptedBroadcast: true) }, + 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 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( + 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 { + _ = 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 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() @@ -960,7 +1410,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() } @@ -969,7 +1419,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() } @@ -994,6 +1444,203 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertFalse(coordinator.isSigning) } + func testCancelledAuthorizationKeepsOwnershipUntilProofCleanupFinishes() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "jade:wallet", funding: funding, connecting: MockHwConnecting()) + let authorization = AsyncGate() + let cleanup = AsyncGate() + var authorizationStarted = false + var cleanupStarted = false + var proofStarted = false + var outcomes: [PrivatePaymentListSendOutcome] = [] + let first = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + beforeFirstBroadcast: { _ in proofStarted = true }, + beforeBroadcastAttempt: { + authorizationStarted = true + await authorization.wait() + }, + afterFailure: { outcome in + outcomes.append(outcome) + cleanupStarted = true + await cleanup.wait() + proofStarted = false + } + ) + } + await waitUntil { authorizationStarted } + XCTAssertTrue(proofStarted) + coordinator.cancel() + authorization.open() + await waitUntil { cleanupStarted || !coordinator.isSigning } + XCTAssertTrue(cleanupStarted, "Cancellation must release the definitely unsent original proof") + XCTAssertEqual(outcomes, [.definitePreBroadcastFailure]) + XCTAssertEqual(funding.broadcastCalls, 0) + var secondPrepared = false + let second = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + beforeFirstBroadcast: { _ in secondPrepared = true } + ) + } + await Task.yield() + XCTAssertFalse(secondPrepared, "New proof preparation must wait for the original cleanup") + cleanup.open() + await assertThrowsAsync { _ = try await first.value } _: { XCTAssertTrue($0 is CancellationError) } + await assertThrowsAsync { _ = try await second.value } _: { XCTAssertTrue($0 is CancellationError) } + XCTAssertFalse(proofStarted) + XCTAssertFalse(coordinator.hasPendingBroadcast) + _ = try await signAndBroadcast(coordinator, manager: manager) + 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 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, + loadSignedPayment: { + loads += 1 + return receipt.map { RetainedHardwareOnchainPayment(signedTx: $0, hasAttemptedBroadcast: false) } + }, + beforeBroadcastAttempt: { authCalls += 1 }, + retainSignedPayment: { signed in + receipt = signed + retainingStarted = true + await retaining.wait() + }, + clearSignedPaymentBeforeDispatch: { _ in + clearingStarted = true + await clearing.wait() + if cleanupSucceeds { + receipt = nil + } + return cleanupSucceeds + } + ) + } + let first = Task { try await send() } + await waitUntil { retainingStarted } + XCTAssertTrue(retainingStarted) + coordinator.cancel() + 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)") + } + } + 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") + } + } + } + + 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() @@ -1011,7 +1658,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)] @@ -1102,7 +1749,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/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index 6f8ab6305..d0fa59cc9 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -115,8 +115,20 @@ final class MockHwFunding: HwTransferFunding { return signedTx } - func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { - try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: broadcastNow()) + func broadcastFunding( + serializedTx: String, + paymentDeadline: PaykitPreciseInstant? = nil, + beforeDispatch: @escaping @MainActor @Sendable () async throws -> Void = {} + ) async throws -> String { + 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 { diff --git a/BitkitTests/HwWalletManagerTests.swift b/BitkitTests/HwWalletManagerTests.swift index d57e89e12..5f3c5eefc 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 = HwKnownDevice( + 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 = HwKnownDevice( + 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/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift new file mode 100644 index 000000000..6444913cd --- /dev/null +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -0,0 +1,1559 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +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?] { + 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" + ) + 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 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() + 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) + 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") + 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) + 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)") + defer { try? FileManager.default.removeItem(at: storage) } + let builder = Builder() + builder.setStorageDirPath(storageDirPath: storage.path) + let node = try builder.build() + let payment = node.onchainPayment() + let address = try payment.newAddress() + XCTAssertThrowsError(try payment.prepareSendToAddress(address: address, amountSats: 1, feeRate: nil, utxosToSpend: nil)) { + guard case .NotRunning = $0 as? NodeError else { + return XCTFail("Expected native NotRunning, got \($0)") + } + } + XCTAssertThrowsError(try payment.prepareSendAllToAddress(address: address, retainReserves: true, feeRate: nil)) { + guard case .NotRunning = $0 as? NodeError else { + return XCTFail("Expected native NotRunning, got \($0)") + } + } + } + } + + func testPreparedMaxFundingMustRespectOriginalOrderBeforeBroadcast() async throws { + 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) + 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 { + XCTAssertFalse(valid, "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() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + sender.amount = max ? 1500 : 1234 + sender.onBroadcast = { + let attempt = try XCTUnwrap(store.snapshot().first) + XCTAssertEqual(attempt.recoveryContext?.inputs, sender.inputs) + XCTAssertEqual(attempt.recoveryContext?.candidateTxids, [sender.txid]) + XCTAssertEqual(attempt.amountSats, sender.amount) + } + _ = try await service.send(using: sender, address: "original", amountSats: 1234, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: max) + XCTAssertEqual(sender.preparations, 1) + XCTAssertEqual(sender.legacyCalls, 0) + XCTAssertEqual(sender.broadcasts, 1) + } + } + + 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 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() + 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 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() + 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)) + 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, isConfirmed: true) + 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 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) + 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) + let sender = PreparedAttemptNodeMock() + _ = try await service.send(using: sender, address: "original", amountSats: 1234, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: true) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + var authorizationCount = 0 + let result = try await service.retrySamePayment( + using: sender, context: OnchainSendPendingContext(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 2, + authorize: { admitted, feeRate in + authorizationCount += 1 + XCTAssertEqual(admitted.id, original.id) + XCTAssertEqual(feeRate, 2) + } + ) + XCTAssertEqual(result, .unknown(txid: sender.txid)) + XCTAssertEqual(sender.lastAddress, "original") + XCTAssertEqual(sender.lastAmount, original.amountSats) + XCTAssertEqual(authorizationCount, 1) + 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]) + XCTAssertEqual(sender.broadcasts, 2) + } + + func testOriginalObservationWhileRetryPreparesStopsAnotherBroadcast() 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: false) + let original = try XCTUnwrap(store.snapshot().first) + let originalTxid = try XCTUnwrap(original.txid) + sender.txid = String(repeating: "cd", count: 32) + sender.onPrepare = { _ = try await service.observeConfirmedTransaction(txid: originalTxid) } + let result = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), authorize: { _, _ in } + ) + XCTAssertEqual(result, .accepted(txid: originalTxid)) + XCTAssertEqual(sender.broadcasts, 1) + XCTAssertEqual(store.snapshot().first?.txid, originalTxid) + } + + func testLateOriginalAcceptanceWinsOverUnknownOrRefusedSuccessor() async throws { + for refused 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: 2, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + let originalTxid = try XCTUnwrap(original.txid) + sender.txid = String(repeating: "cd", count: 32) + sender.result = refused ? .rejected(txid: sender.txid, reason: "fixture refusal") : .unknown(txid: sender.txid) + sender.onBroadcast = { + XCTAssertEqual(store.snapshot().first?.recoveryContext?.candidateTxids, [originalTxid, sender.txid]) + _ = try await service.observeConfirmedTransaction(txid: originalTxid) + do { + _ = try await service.admit(walletId: original.walletId, requestId: nil, orderId: nil, + address: "disjoint", amountSats: 1, isMaxAmount: false) + XCTFail("An in-flight sibling allowed another operation") + } catch {} + } + let result = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalTxid), authorize: { _, _ in } + ) + XCTAssertEqual(result, .accepted(txid: originalTxid)) + XCTAssertEqual(store.snapshot().first?.status, .accepted) + XCTAssertEqual(store.snapshot().first?.txid, originalTxid) + let restarted = OnchainSendAttemptService(store: store) + let loaded = try await restarted.ordinaryPendingAttempt(context: .init( + attemptId: original.id, walletId: original.walletId, txid: sender.txid + )) + XCTAssertEqual(loaded?.txid, originalTxid) + } + } + + func testRetryRejectsChangedAmountInputsWalletAndContextWithoutBroadcast() async throws { + for invalid in 0 ..< 5 { + 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: false) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + if invalid == 0 { + sender.amount -= 1 + } + if invalid == 1 { + sender.inputs = [OnchainSendInput(txid: String(repeating: "aa", count: 32), vout: 1)] + } + if invalid == 2 { + sender.currentWalletIndex = 1 + } + if invalid == 4 { + sender.onPrepare = { sender.currentWalletIndex = 1 } + } + let context = OnchainSendPendingContext(attemptId: invalid == 3 ? UUID() : original.id, + walletId: original.walletId, txid: original.txid) + do { _ = try await service.retrySamePayment(using: sender, context: context, authorize: { _, _ in + }); XCTFail("Invalid recovery dispatched") } catch {} + XCTAssertEqual(sender.broadcasts, 1) + XCTAssertEqual(store.snapshot().first, original) + } + } + + func testRetryReceiptSaveOrAuthorizationFailureRetainsOriginalGuard() async throws { + for saveFails 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: 2, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + sender.txid = String(repeating: "cd", count: 32) + if saveFails { + sender.onPrepare = { store.failSave = true } + } + do { + _ = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + authorize: { + _, _ in if !saveFails { + throw MemoryAttemptStoreError.failed + } + } + ) + XCTFail("Failed authorization/receipt save dispatched") + } catch {} + store.failSave = false + let retained = try XCTUnwrap(store.snapshot().first) + XCTAssertEqual(retained.id, original.id) + XCTAssertEqual(retained.txid, original.txid) + XCTAssertEqual(retained.status, original.status) + XCTAssertEqual(retained.amountSats, original.amountSats) + XCTAssertEqual(retained.recoveryContext?.inputs, original.recoveryContext?.inputs) + XCTAssertEqual(sender.broadcasts, 1) + do { _ = try await service.send(using: sender, address: "disjoint", amountSats: 1, + satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false); XCTFail("Original guard was released") } catch {} + } + } + + 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: "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() } + } + ) + 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) + 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) + 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") + XCTAssertFalse(wrongWallet) + XCTAssertEqual(store.snapshot().first, original) + do { + _ = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: txid), authorize: { _, _ in } + ) + XCTFail("Missing receipt permitted an unconstrained recovery") + } catch {} + XCTAssertEqual(sender.calls, 0) + } + + func testFirstReceiptWriteFailureNeverBroadcastsAndRetainsDurableGuard() async throws { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + sender.onPrepare = { store.failSave = true } + do { + _ = try await service.send(using: sender, address: "original", amountSats: sender.amount, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false) + XCTFail("Receipt persistence failure broadcast") + } catch {} + store.failSave = false + XCTAssertEqual(sender.broadcasts, 0) + XCTAssertEqual(store.snapshot().first?.status, .pending) + XCTAssertNil(store.snapshot().first?.recoveryContext) + } + + func testConstrainedConstructionAndTransportErrorsAreActionableWithoutReleasingOriginal() async throws { + for construction 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: 2, utxosToSpend: nil, isMaxAmount: false) + let original = try XCTUnwrap(store.snapshot().first) + sender.onPrepare = { + if construction { + throw NodeError.InsufficientFunds(message: "raw native fee detail") + } + throw NodeError.ConnectionFailed(message: "raw native transport detail") + } + do { + _ = try await service.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: original.txid), + satsPerVbyte: 3, authorize: { _, _ in XCTFail("Preparation failure prompted authorization") } + ) + XCTFail("Failed preparation dispatched") + } catch let error as OnchainSendAttemptError { + if construction { + guard case .retryConstruction = error else { return XCTFail("Missing fee/headroom explanation") } + } else { + guard case .retryUnavailable = error else { return XCTFail("Missing uncertainty/connection explanation") } + } + XCTAssertFalse(error.localizedDescription.contains("raw native")) + } + XCTAssertEqual(store.snapshot().first, original) + XCTAssertEqual(sender.broadcasts, 1) + } + } + + 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 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) + } + } + + func testUnsignedShopAttemptDoesNotBlockAfterServiceRestart() 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 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) + XCTAssertNil(pending, "Proof-start cannot precede persistence of the signed receipt") + _ = try await admit(restarted) + } + + 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 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) + 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) + XCTAssertEqual(store.snapshot().first?.txid, txid, "Prepared receipt must survive outcome-storage failure") + XCTAssertFalse(store.snapshot().first?.recoveryContext?.inputs.isEmpty ?? true) + 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.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 + ) + ) + } + await preparation.waitForStart() + let competingSends = await withTaskGroup(of: Bool.self) { group in + for index in 1 ..< 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, + transferContext: index % 3 == 0 ? OnchainSendTransferContext( + clientBalanceSats: 900, txTotalSats: 1100, + preTransferOnchainSats: 1100, originalOrderFeeSats: 1000 + ) : nil + ) + return true + } catch { return false } + } + } + var count = 0 + for await sent in group where sent { + count += 1 + } + return count + } + 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) + } + + 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)) + 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 .preDispatch = error else { return XCTFail("Proven callback failure was not classified before dispatch") } + } + XCTAssertEqual(node.calls, 0) + 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 { + for failRelease in [false, true] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let node = AttemptNodeMock(result: .accepted(txid: txid)) + node.preparationError = NodeError.NotRunning(message: "not running") + node.onPrepare = { 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 + let result = try await send(service, node: node) + XCTAssertEqual(result, .unknown(txid: txid)) + XCTAssertEqual(store.snapshot().first?.status, .unknown) + } + } + + 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 + } + + var preparedMiningFeeSats: UInt64? + var preparedRecipientAmountSats: UInt64? + var preparationError: Error? + var onPrepare: (() async throws -> Void)? + + func prepareOnchainSend(address: String, sats: UInt64, satsPerVbyte: UInt32, + utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, + expectedWalletIndex: Int, expectedNode: AnyObject?, + paymentDeadline: PaykitPreciseInstant?) 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): txid + } + return PreparedOnchainSendDispatch(txid: candidate, + 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) + } + } + + 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 + } +} + +final class PreparedAttemptNodeMock: OnchainSending { + var currentWalletIndex = 0 + let dispatchNode = NSObject() + var onchainDispatchNode: AnyObject? { + dispatchNode + } + + 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 + var legacyCalls = 0 + var lastAddress: String? + var lastAmount: UInt64? + var lastFeeRate: UInt32? + var lastMax: Bool? + var lastInputs: [OnchainSendInput]? + 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, + expectedWalletIndex: Int, expectedNode: AnyObject?, + paymentDeadline: PaykitPreciseInstant?) async throws -> PreparedOnchainSendDispatch + { + preparations += 1 + lastAddress = address + lastAmount = sats + lastFeeRate = satsPerVbyte + lastMax = isMaxAmount + 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, miningFeeSats: miningFee) { + self.onNativeQueueDispatch?() + 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) + } + } + + func send(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]?, isMaxAmount: Bool, + expectedWalletIndex: Int?, expectedNode: AnyObject?) async throws -> OnchainSendResult + { + legacyCalls += 1 + 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 + } +} + +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 } +} + +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/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index d91d8472b..2e78e4ba7 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -12,6 +12,744 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private let preimage = String(repeating: "00", count: 32) private let onchainAddress = "bcrt1qpaymentproof" + private let hardwareWalletId = "trezor:original-ios-wallet" + + func testReplacementWinnerBindsOnlyOriginalUnverifiedStartedProof() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + for mismatch in 0 ..< 6 { + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let attemptsStore = MemoryAttemptStore() + let attempts = OnchainSendAttemptService(store: attemptsStore, localFollowup: FailingShopActivityFollowup()) + 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 originalId = try XCTUnwrap(original.txid) + sender.txid = String(repeating: "cd", count: 32) + sender.result = .accepted(txid: sender.txid) + _ = try await attempts.retrySamePayment( + using: sender, context: .init(attemptId: original.id, walletId: original.walletId, txid: originalId), + authorize: { admitted, feeRate in + XCTAssertEqual(feeRate, 2) + XCTAssertEqual(admitted.requestId, request.id) + XCTAssertEqual(admitted.recoveryContext?.paymentIdentity, self.identity) + XCTAssertEqual(admitted.address, self.onchainAddress) + XCTAssertEqual(admitted.amountSats, request.amountSats) + } + ) + let proof = PendingPaykitPaymentProof( + identity: mismatch == 1 ? "pubky" + String(repeating: "x", count: 52) : identity, + requestId: request.id, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, + paymentStarted: true, paymentIdentifier: mismatch == 2 ? String(repeating: "ef", count: 32) : originalId, + proofData: mismatch == 5 ? originalId : nil, + onchainAddress: mismatch == 3 ? "different-address" : onchainAddress, + onchainAmountSats: mismatch == 4 ? request.amountSats - 1 : request.amountSats, + onchainAcceptanceVerified: mismatch == 5 + ) + let store = PaymentProofMemoryStore() + await store.seed([proof]) + 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 + ) + 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) + XCTAssertEqual(saved.first?.proofData, sender.txid) + XCTAssertEqual(saved.first?.onchainAcceptanceVerified, true) + } else { + XCTAssertEqual(saved, [proof], "Foreign/verified proof was overwritten") + } + XCTAssertEqual(sender.broadcasts, 2) + } + } + + func testSoftwareStartedProofRequiresCapturedPayerWhenProvided() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let other = "pubky" + String(repeating: "x", count: 52) + let proofs = [identity, other].map { + PendingPaykitPaymentProof(identity: $0, requestId: request.id, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, + kind: .onchain, paymentIdentifier: nil, proofData: nil) + } + let store = PaymentProofMemoryStore() + await store.seed(proofs) + let sdk = PaymentProofSdkMock(identity: other, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + do { + try await service.markOnchainPaymentStarted(request, address: onchainAddress, paymentIdentity: identity) + XCTFail("Profile switch marked another payer's proof") + } catch {} + let saved = await store.snapshot() + XCTAssertEqual(saved, proofs) + } + + func testRecoveryAuthorizationUsesOriginalStartedRequestWithoutProofMutationOrSubmission() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + 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() + 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, + 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 % 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, restoreStartedProof: false) + XCTAssertEqual(invalid % 4, 0) + XCTAssertEqual(authorized.id, request.id) + XCTAssertEqual(authorized.amountSats, request.amountSats) + } catch { XCTAssertNotEqual(invalid % 4, 0) } + let saved = await store.snapshot() + let submissions = await sdk.submissionCount() + XCTAssertEqual(saved, [proof]) + XCTAssertEqual(submissions, 0) + XCTAssertEqual(sender.broadcasts, 1) + } + } + + 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]) + 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, paymentAppId: "bitkit", 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) + 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, paymentAppId: "bitkit", 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, 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 sdk.setSubmissionFailure(false) + await reopened.reconcile() + XCTAssertEqual( + resolutions, + [originalResolution, originalResolution], + "Returning to the original payer must resume its already verified local result" + ) + 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") + } + + 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]) + 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, paymentAppId: "bitkit", 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 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() + 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, paymentAppId: "bitkit", 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, paymentAppId: "bitkit", 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 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 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?.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) + 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) + 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 + ) + 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) + 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( + 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) + } + + 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) + 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( + 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 + 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 + ) + 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?.hardwareMiningFeeSats, signedTx.miningFeeSats) + XCTAssertEqual(restored?.hardwareFeeRate, UInt64(signedTx.feeRate)) + XCTAssertEqual(restored?.hardwareTotalSpent, signedTx.totalSpent) + XCTAssertEqual(restored?.paymentIdentifier, txid) + XCTAssertEqual(restored?.onchainAcceptanceVerified, true) + } + + 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, paymentAppId: "bitkit", 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) + } + + 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, paymentAppId: "bitkit", paymentEndpointIdentifier: onchainEndpoint, + kind: .onchain, paymentIdentifier: nil, proofData: nil + ) + let lightningId = PaykitPaymentRequest.ID( + paymentRequestId: UUID().uuidString, counterparty: counterparty, billingPeriodStartsAt: nil + ) + let lightning = PendingPaykitPaymentProof( + identity: identity, requestId: lightningId, + paymentAppId: "bitkit", 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, + 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, @@ -137,14 +875,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, - paymentAppId: "bitkit", - paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, - kind: .lightning - ) - try await service.associateLightningPayment(request, paymentHash: paymentHash) - await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + await store.seed([PendingPaykitPaymentProof( + identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, paymentStarted: true, paymentIdentifier: paymentHash, proofData: preimage + )]) + await service.reconcile() let submissionCount = await sdk.submissionCount() let remainingProofs = await store.snapshot() @@ -388,7 +1124,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.markOnchainPaymentStarted(request, address: onchainAddress) let inFlightRequestIds = await service.inFlightRequestIds(identity: identity) XCTAssertEqual(inFlightRequestIds, [request.id]) - await service.completeOnchainPayment(request, txid: txid, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint) + try await recordAcceptedAttempt(service: service, request: request, txid: String(repeating: "ab", count: 32)) + await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) await sdk.waitForSubmissionStart() await fulfillment(of: [proofRemoved], timeout: 1) @@ -416,6 +1153,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { 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: String(repeating: "ab", count: 32)) await sdk.suspendSubmission() let completionTask = Task { await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) @@ -428,12 +1166,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let persistedProof = await store.snapshot().first XCTAssertEqual(persistedProof?.paymentIdentifier, txid) XCTAssertEqual(persistedProof?.proofData, txid) + XCTAssertEqual(persistedProof?.onchainAcceptanceVerified, true) await sdk.resumeSubmission() 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())) @@ -443,13 +1182,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 { @@ -468,232 +1219,605 @@ 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, paymentAppId: "bitkit", 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, paymentAppId: "bitkit", 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) + + try await service.prepare(request: request, paymentAppId: "bitkit", 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() + + let submittedProof = await sdk.lastSubmission() + let submission = try XCTUnwrap(submittedProof) + XCTAssertEqual( + try proofValues(submission.proof.exportText()), + ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + } + + 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, 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 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, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) + 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) + 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) + 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, .unknown, "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, paymentAppId: "bitkit", 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, paymentAppId: "bitkit", 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, paymentAppId: "bitkit", 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, paymentAppId: "bitkit", 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, paymentAppId: "bitkit", 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) + 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) + XCTAssertEqual(saved?.fee, 123) + XCTAssertEqual(node.calls, 1) + await repointCoreToAppStorage() + 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() + 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]) + 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 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 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]) + 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, paymentAppId: "bitkit", 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) + 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) + 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, localFollowup: FailingShopActivityFollowup()) + let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + 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 app = AppViewModel() - app.contactPaymentContext = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) - let resolutionExpectation = expectation(description: "On-chain payment resolution published") - let resolution = PaykitPaymentProofService.onchainPaymentResolutionPublisher - .filter { $0.requestId == request.id } - .first() - .sink { resolution in - Task { @MainActor in - XCTAssertEqual(resolution.identity, self.identity) - XCTAssertEqual(resolution.transactionId, txid) - app.retainPaykitOnchainPaymentResolution(resolution, identity: self.identity) - await service.consumeOnchainPaymentResolution(resolution) - resolutionExpectation.fulfill() - } - } - + let node = AttemptNodeMock(result: .accepted(txid: txid)) try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) - try await service.markOnchainPaymentStarted(request, address: onchainAddress) - let unresolvedProofs = await store.snapshot() - XCTAssertTrue(try XCTUnwrap(unresolvedProofs.first).paymentStarted) - XCTAssertNil(unresolvedProofs.first?.proofData) - await service.reconcile() + _ = 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, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) + XCTAssertFalse(saved) + XCTAssertEqual(attemptStore.snapshot().first?.status, .accepted) + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false) + try await service.prepare(request: request, paymentAppId: "bitkit", 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, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) + XCTAssertFalse(retrySaved) + XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false) await sdk.waitForSubmissionStart() - await fulfillment(of: [resolutionExpectation, proofRemoved], timeout: 1) - withExtendedLifetime(resolution) {} - XCTAssertEqual(app.paykitOnchainPaymentResolution?.requestId, request.id) - XCTAssertEqual(app.paykitOnchainPaymentResolution?.transactionId, txid) + 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) + } - 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] + 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 remainingProofs = await store.snapshot() - XCTAssertTrue(remainingProofs.isEmpty) + 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: PaymentProofSdkMock(identity: identity, records: [paidRecord]), + store: PaymentProofMemoryStore(), + attemptService: attempts + ) + do { + try await service.prepare( + request: request, + paymentAppId: "bitkit", 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, paymentAppId: "bitkit", 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 testUncertainOnchainPaymentDoesNotReuseTransactionFromBeforeAttempt() async throws { + 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, + paymentAppId: "bitkit", 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 oldTransactionId = String(repeating: "ab", count: 32) - let service = paymentProofService( - sdk: sdk, - store: store, - onchainTxids: [oldTransactionId], - existingOnchainTxids: [oldTransactionId] - ) + let service = paymentProofService(sdk: sdk, store: store) - try await service.prepare(request: request, paymentAppId: "bitkit", 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]) - } - - func testHardwareProofReconcilesFromWalletActivityAfterBackupRestore() async throws { - try await withHardwareProofWallet { walletId in - let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue - var record = try paymentRequestRecord(endpoints: [endpoint]) - let deadline = Date().addingTimeInterval(-60) - record.terms?.paymentDeadline = .at(timestamp: ISO8601DateFormatter().string(from: deadline)) - let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: deadline.addingTimeInterval(-1))) - let oldTxid = String(repeating: "ab", count: 32) - let txid = String(repeating: "cd", count: 32) - try await storeHardwareProofTransaction(walletId: walletId, txid: oldTxid) - let store = PaymentProofMemoryStore() - let sdk = PaymentProofSdkMock(identity: identity, records: [record]) - await sdk.setSubmissionFailure(true) - let service = PaykitPaymentProofService(sdk: sdk, store: store) - - try await service.prepare( - request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, walletId: walletId - ) - try await service.markOnchainPaymentStarted(request, address: onchainAddress, walletId: walletId) - let prepared = await store.snapshot() - XCTAssertEqual(prepared.first?.onchainWalletId, walletId) - XCTAssertEqual(prepared.first?.onchainMatchingTransactionIdsBeforeAttempt, [oldTxid]) - XCTAssertNil(prepared.first?.proofData) - await service.cancelPreparation(request, walletId: walletId) - let afterDismissal = await store.snapshot() - XCTAssertEqual(afterDismissal, prepared) - - let backup = try await service.backupSnapshot() - let restoredBackup = try JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: JSONEncoder().encode(backup)) - let restoredStore = PaymentProofMemoryStore() - let restoredService = PaykitPaymentProofService(sdk: sdk, store: restoredStore) - try await restoredService.restoreBackup(restoredBackup) - let restored = await restoredStore.snapshot() - XCTAssertEqual(restored, prepared) - - try await storeHardwareProofTransaction(walletId: "trezor:other", txid: txid) - await restoredService.reconcile() - let beforeMatchingActivity = await restoredStore.snapshot() - XCTAssertEqual(beforeMatchingActivity, prepared) - let inFlightRequests = await restoredService.inFlightRequestIds(identity: identity) - XCTAssertEqual(inFlightRequests, [request.id]) - let submissionsBeforeMatchingActivity = await sdk.submissionCount() - XCTAssertEqual(submissionsBeforeMatchingActivity, 0) - - let app = AppViewModel() - app.contactPaymentContext = ContactPaymentContext(publicKey: counterparty, incomingPaymentRequest: request) - let delivered = expectation(description: "Wallet-scoped resolution retained before source consumption") - let subscriber = PaykitPaymentProofService.onchainPaymentResolutionPublisher - .filter { $0.requestId == request.id && $0.walletId == walletId } - .prefix(1) - .sink { [identity] resolution in - Task { @MainActor in - app.retainPaykitOnchainPaymentResolution(resolution, identity: identity) - await restoredService.consumeOnchainPaymentResolution(resolution) - delivered.fulfill() - } - } - try await storeHardwareProofTransaction(walletId: walletId, txid: txid) - await restoredService.reconcile() - await sdk.waitForSubmissionStart() - await fulfillment(of: [delivered], timeout: 2) - withExtendedLifetime(subscriber) {} - let completed = await restoredStore.snapshot() - XCTAssertEqual(completed.first?.proofData, txid) - XCTAssertEqual(completed.first?.onchainWalletId, walletId) - XCTAssertEqual(app.paykitOnchainPaymentResolution?.transactionId, txid) - XCTAssertEqual(app.paykitOnchainPaymentResolution?.walletId, walletId) - XCTAssertTrue(request.isPaymentDeadlineExpired(at: Date())) - } + XCTAssertEqual(storedProofs, [legacyProof]) + let completedKinds = await service.completedRequestProofKindsAwaitingSubmission(identity: identity) + XCTAssertTrue(completedKinds.isEmpty) } - func testDefaultHardwareProofLookupRequiresWalletDirectionAndExactOutput() async throws { - try await withHardwareProofWallet { walletId in - let lookup = PaykitOnchainPaymentProofLookup() - let txid = String(repeating: "ab", count: 32) - try await storeHardwareProofTransaction(walletId: "trezor:other", txid: txid) - try await storeHardwareProofTransaction(walletId: walletId, txid: txid, outputAddress: "different-address") - try await storeHardwareProofTransaction(walletId: walletId, txid: "wrong-amount", outputAmount: 999) - try await storeHardwareProofTransaction(walletId: walletId, txid: "received", txType: .received) - try await storeHardwareProofTransaction(walletId: walletId, txid: "removed", doesExist: false) - let unmatched = try await lookup.existingTransactionIds(address: onchainAddress, amountSats: 1000, walletId: walletId) - XCTAssertTrue(unmatched.isEmpty) - - try await storeHardwareProofTransaction(walletId: walletId, txid: txid) - let matching = try await lookup.existingTransactionIds(address: onchainAddress, amountSats: 1000, walletId: walletId) - XCTAssertEqual(matching, [txid]) - let excluded = try await lookup.transactionId(address: onchainAddress, amountSats: 1000, walletId: walletId, excluding: [txid]) - XCTAssertNil(excluded) - HwKnownDeviceStorage.remove(walletId: walletId) - XCTAssertFalse(lookup.supportsWallet(walletId)) - do { - _ = try await lookup.transactionId(address: onchainAddress, amountSats: 1000, walletId: walletId, excluding: []) - XCTFail("An unpaired wallet must not resolve through another wallet's activity") - } catch let error as PaykitPaymentRequestError { - XCTAssertEqual(error, .requestUnavailable) - } - } - } + 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 + ) - private func withHardwareProofWallet(_ body: (String) async throws -> Void) async throws { - let walletId = "trezor:proof-\(UUID().uuidString)" - let directory = FileManager.default.temporaryDirectory.appendingPathComponent("HardwareProof-\(UUID().uuidString)") - _ = CoreService.shared - await drainCoreServiceQueue() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - _ = try initDb(basePath: directory.path) - HwKnownDeviceStorage.save(HwKnownDevice( - id: walletId, name: "Proof wallet", path: "", transportType: "usb", lastConnectedAt: Date(), walletId: walletId - )) + try await service.prepare(request: request, paymentAppId: "bitkit", 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 body(walletId) - } catch { - HwKnownDeviceStorage.remove(walletId: walletId) - await repointCoreToAppStorage() - try? FileManager.default.removeItem(at: directory) - throw error + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: lightningEndpoint, kind: .lightning) + XCTFail("Lightning method switch bypassed the on-chain attempt") + } catch let error as PaykitPaymentRequestError { + XCTAssertEqual(error, .operationInProgress) } - HwKnownDeviceStorage.remove(walletId: walletId) - await repointCoreToAppStorage() - try FileManager.default.removeItem(at: directory) } - private func storeHardwareProofTransaction( - walletId: String, - txid: String, - outputAddress: String? = nil, - outputAmount: Int64 = 1000, - txType: PaymentType = .sent, - doesExist: Bool = true - ) async throws { - let activity = OnchainActivity( - walletId: walletId, id: txid, txType: txType, txId: txid, value: 1000, fee: 100, feeRate: 1, - address: onchainAddress, confirmed: false, timestamp: 1_700_000_000, isBoosted: false, boostTxIds: [], - isTransfer: false, doesExist: doesExist, confirmTimestamp: nil, channelId: nil, transferTxId: nil, - contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil - ) - let details = BitkitCore.TransactionDetails( - walletId: walletId, txId: txid, amountSats: 1000, inputs: [], - outputs: [BitkitCore.TxOutput( - scriptpubkey: "", scriptpubkeyType: "v0_p2wpkh", scriptpubkeyAddress: outputAddress ?? onchainAddress, value: outputAmount, n: 0 - )] - ) - try await CoreService.shared.activity.replaceHwSnapshot( - walletId: walletId, activities: [.onchain(activity)], transactionDetails: [details], pruneMissing: false + 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, + paymentAppId: "bitkit", 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 { @@ -717,11 +1841,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( @@ -775,7 +1895,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() @@ -959,7 +2079,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 { @@ -1032,6 +2152,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { 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: String(repeating: "ab", count: 32)) await service.completeOnchainPayment( request, txid: String(repeating: "ab", count: 32), @@ -1087,8 +2208,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() @@ -1101,16 +2221,108 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(remainingProofs, [onchainProof]) } - 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)) + 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) } - XCTAssertFalse(PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(NSError(domain: "unknown", code: 1))) + } + + 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]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -1302,38 +2514,32 @@ 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) try await service.prepare(request: request, paymentAppId: "bitkit", 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), paymentAppId: "bitkit", 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())) @@ -1345,27 +2551,20 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextSave() - await sdk.setSubmissionFailure(true) + try await recordAcceptedAttempt(service: service, request: request, txid: String(repeating: "ab", count: 32)) await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", 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) @@ -1373,19 +2572,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentAppId: "bitkit", 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, paymentAppId: "bitkit", 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 { @@ -1399,26 +2592,37 @@ 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, - onchainTxids: [String] = [], - existingOnchainTxids: Set = [], - onchainLookupFails: Bool = false + hardwareLookup: any PaykitHardwareTransactionLookingUp = PaymentProofHardwareLookup(result: .failure(PaymentProofStoreMockError.load)), + attemptService: OnchainSendAttemptService = OnchainSendAttemptService(store: MemoryAttemptStore()), + hardwareFollowup: @escaping @Sendable (PendingPaykitPaymentProof, TransactionDetail?) async throws -> Void = { _, _ in } ) -> PaykitPaymentProofService { - PaykitPaymentProofService( + let service = PaykitPaymentProofService( sdk: sdk, store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), - onchainPaymentLookup: PaymentProofOnchainLookup( - transactionIds: onchainTxids, - existingTransactionIds: existingOnchainTxids, - transactionLookupFails: onchainLookupFails - ), + hardwareTransactionLookup: hardwareLookup, + hardwareFollowup: hardwareFollowup, + attemptService: attemptService, logInfo: { _ in }, logWarning: { _ in } ) + serviceAttempts[ObjectIdentifier(service)] = attemptService + return service } private func paymentRequestRecord( @@ -1496,22 +2700,71 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } -private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { +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 + } +} + +actor PaymentProofMemoryStore: PaykitPaymentProofStoring { private var proofs: [PendingPaykitPaymentProof] = [] 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 { @@ -1552,25 +2805,35 @@ private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp } } -private struct PaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { - let transactionIds: [String] - let existingTransactionIds: Set - let transactionLookupFails: Bool +actor PaymentProofHardwareLookup: PaykitHardwareTransactionLookingUp { + struct Call: Equatable { + let walletId: String + let txid: String + } + + let result: Result + private var observed: [Call] = [] - func existingTransactionIds(address _: String, amountSats _: UInt64, walletId _: String) async throws -> Set { - existingTransactionIds + init(result: Result) { + self.result = result } - func transactionId(address _: String, amountSats _: UInt64, walletId _: String, excluding transactionIds: Set) async throws -> String? { - if transactionLookupFails { - throw PaymentProofLookupMockError.transactionLookup - } - return self.transactionIds.first { !transactionIds.contains($0) } + 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 } } -private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { - private let identity: String +actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { + private var identity: String private var records: [PaymentRequestRecord] private var submissions: [PaymentProofSubmission] = [] private var shouldFailSubmission = false @@ -1596,6 +2859,10 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { return IdentityStatus(publicKey: identity, capability: .privateLinkCapable) } + func setIdentity(_ identity: String) { + self.identity = identity + } + func setIdentityAvailable(_ isAvailable: Bool) { isIdentityAvailable = isAvailable } @@ -1722,6 +2989,64 @@ private enum PaymentProofStoreMockError: Error { case save } -private enum PaymentProofLookupMockError: Error { - case transactionLookup +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 + } +} + +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 } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 0311b71d3..69825d4bc 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1212,6 +1212,21 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(triggers.values.filter { $0.day == 15 && $0.second == 32 }.count, 1) } + func testHardwareResolutionDoesNotReapplyOriginalContact() { + let app = AppViewModel() + let resolution = PaykitOnchainPaymentResolution( + identity: "payer", requestId: .init(paymentRequestId: "original", counterparty: "original-peer"), + transactionId: "original-tx", walletId: "trezor:wallet" + ) + app.addPendingContactPaymentContext("original-tx", context: ContactPaymentContext(publicKey: "original-peer")) + app.addPendingContactPaymentContext("other-tx", context: ContactPaymentContext(publicKey: "new-peer")) + app.prepareResolvedOnchainContactContext(resolution, isHardware: true) + XCTAssertNil(app.contactPaymentContext(forPendingPaymentHash: "original-tx")) + XCTAssertEqual(app.contactPaymentContext(forPendingPaymentHash: "other-tx")?.publicKey, "new-peer") + app.prepareResolvedOnchainContactContext(resolution, isHardware: false) + XCTAssertEqual(app.contactPaymentContext(forPendingPaymentHash: "original-tx")?.publicKey, "original-peer") + } + func testContactPaymentContextClaimIsExclusiveAndIdentityBased() { let app = AppViewModel() let first = ContactPaymentContext(publicKey: "pubkycontact") @@ -1229,34 +1244,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(app.claimContactPaymentContext(second)) } - func testOnchainResolutionRemainsAvailableForTheMatchingSendUntilConsumed() throws { - let app = AppViewModel() - let identity = "pubky" + String(repeating: "y", count: 52) - let otherIdentity = "pubky" + String(repeating: "b", count: 52) - let request = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord(), now: Date())) - app.contactPaymentContext = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) - let resolution = PaykitOnchainPaymentResolution(identity: identity, requestId: request.id, transactionId: "resolved-tx") - let unrelated = PaykitOnchainPaymentResolution( - identity: identity, - requestId: PaykitPaymentRequest.ID(paymentRequestId: "other", counterparty: request.counterparty), - transactionId: "other-tx" - ) - - app.retainPaykitOnchainPaymentResolution(resolution, identity: otherIdentity) - XCTAssertNil(app.paykitOnchainPaymentResolution) - app.retainPaykitOnchainPaymentResolution(resolution, identity: identity) - app.retainPaykitOnchainPaymentResolution(unrelated, identity: identity) - app.consumePaykitOnchainPaymentResolution(unrelated) - app.resetSendState(preservingContactPaymentContext: true) - XCTAssertEqual(app.paykitOnchainPaymentResolution, resolution) - app.consumePaykitOnchainPaymentResolution(resolution) - XCTAssertNil(app.paykitOnchainPaymentResolution) - - app.retainPaykitOnchainPaymentResolution(resolution, identity: identity) - app.resetSendState() - XCTAssertNil(app.paykitOnchainPaymentResolution) - } - func testBlockingPeerHidesRequestsFromAnEarlierSnapshot() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let record = try paymentRequestRecord( @@ -1700,7 +1687,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") } } } @@ -3391,7 +3378,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(id: "recurring", recurrence: recurrence)]) let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) await manager.refresh() - await sdk.setPaymentRequestListError(PaymentRequestSdkMockError.receive) + await sdk.setReceiveError(.receive) let request = try await manager.accept(XCTUnwrap(manager.subscriptions.first)) @@ -3946,6 +3933,227 @@ 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 sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager( + sdk: sdk, 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) + 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) + 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 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())) @@ -4649,6 +4857,184 @@ 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 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() + 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 + 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 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 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: recoveredPrivateService + ) + 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" @@ -4681,7 +5067,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let proofService = PaykitPaymentProofService( sdk: sdk, store: proofStore, - onchainPaymentLookup: HardwarePaymentProofOnchainLookup(), logInfo: { _ in }, logWarning: { _ in } ) @@ -5933,6 +6318,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(snapshot.acceptedRequests.isEmpty) } + + func testAcceptanceDefersScopedDeliveryWithoutDrainingMessagesAndDropsWorkAfterIdentityChanges() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( @@ -5975,6 +6362,34 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testOnchainResolutionRemainsAvailableForTheMatchingSendUntilConsumed() throws { + let app = AppViewModel() + let identity = "pubky" + String(repeating: "y", count: 52) + let otherIdentity = "pubky" + String(repeating: "b", count: 52) + let request = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord(), now: Date())) + app.contactPaymentContext = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + let resolution = PaykitOnchainPaymentResolution(identity: identity, requestId: request.id, transactionId: "resolved-tx") + let unrelated = PaykitOnchainPaymentResolution( + identity: identity, + requestId: PaykitPaymentRequest.ID(paymentRequestId: "other", counterparty: request.counterparty), + transactionId: "other-tx" + ) + + app.retainPaykitOnchainPaymentResolution(resolution, identity: otherIdentity) + XCTAssertNil(app.paykitOnchainPaymentResolution) + app.retainPaykitOnchainPaymentResolution(resolution, identity: identity) + app.retainPaykitOnchainPaymentResolution(unrelated, identity: identity) + app.consumePaykitOnchainPaymentResolution(unrelated) + app.resetSendState(preservingContactPaymentContext: true) + XCTAssertEqual(app.paykitOnchainPaymentResolution, resolution) + app.consumePaykitOnchainPaymentResolution(resolution) + XCTAssertNil(app.paykitOnchainPaymentResolution) + + app.retainPaykitOnchainPaymentResolution(resolution, identity: identity) + app.resetSendState() + XCTAssertNil(app.paykitOnchainPaymentResolution) + } + func testAcceptInvalidatesAnOverlappingRefreshSnapshot() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) @@ -7329,6 +7744,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 }, @@ -7340,6 +7797,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, @@ -7366,6 +7824,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { subscriptionNotificationScheduler: subscriptionNotificationScheduler, completedPaymentProofKinds: { _ in completedPaymentProofKinds }, inFlightPaymentRequestIds: { _ in inFlightPaymentRequestIds }, + retainedHardwarePaymentsForRetry: { _ in retainedHardwareRetries }, protectedRequestIdsForSubscriptionCancellation: { _, _ in protectedRequestIdsForSubscriptionCancellation }, now: now, subscriptionNow: subscriptionNow, @@ -7880,12 +8339,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 ) @@ -8256,22 +8722,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 } } - -private struct HardwarePaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { - func existingTransactionIds(address _: String, amountSats _: UInt64, walletId _: String) async throws -> Set { - ["transaction-before-attempt"] - } - - func transactionId(address _: String, amountSats _: UInt64, walletId _: String, excluding _: Set) async throws -> String? { - nil - } -} diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 53e080c3f..d5a7f4c29 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,83 @@ 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", + "billingPeriodStartsAt": null + }, + "paymentEndpointIdentifier": "btc-regtest-p2wpkh", + "paymentAppId": "bitkit", + "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", + "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,9 +103,378 @@ final class PaykitPaymentStateBackupTests: XCTestCase { try Keychain.delete(key: .paykitSubscriptionState) try Keychain.delete(key: .paykitPendingPaymentProofs) try Keychain.delete(key: .paykitPendingBackupRestore) + try Keychain.delete(key: .onchainSendAttempts) try Keychain.delete(key: .paykitAcceptedPaymentRequests) } + 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 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 { + .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, "42bd135dbc91aa0b2004f2633f6f8b28c46dddf8da3c6f949cf2ff036c07e0a6") + 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() } + 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 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) + 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.completeAttemptGolden()) + 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, + 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])) + 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 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" + 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) + } + + 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) + 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 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"]] + { + 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 testAcceptedSuccessorBackupRequiresItsFeeRate() throws { + 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() } + 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"] = [successor: "4"] + 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.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]) + 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() + 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.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]) + 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.completeAttemptGolden()) + 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 { let id = PaykitSubscription.ID(paymentRequestId: "subscription", counterparty: identity) let period = try XCTUnwrap(PaykitBillingPeriod(sdkPeriod: BillingPeriod( @@ -55,7 +503,8 @@ final class PaykitPaymentStateBackupTests: XCTestCase { onchainAddress: "test-address", onchainAmountSats: 1000, onchainWalletId: "trezor:android", - onchainMatchingTransactionIdsBeforeAttempt: ["previous-transaction"] + onchainMatchingTransactionIdsBeforeAttempt: ["previous-transaction"], + onchainAcceptanceVerified: true ) let acceptedId = PaykitPaymentRequest.ID(paymentRequestId: "one-time", counterparty: identity) let acceptanceStore = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) @@ -66,8 +515,12 @@ final class PaykitPaymentStateBackupTests: XCTestCase { acceptedOneTimeRequests: acceptanceStore.backupSnapshot() ) 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?.onchainAcceptanceVerified, true) try PaykitSubscriptionStateStore().restoreBackup(decoded.subscriptions) try await PaykitPaymentProofService.shared.restoreBackup(decoded.pendingProofs) try Keychain.delete(key: .paykitAcceptedPaymentRequests) @@ -96,6 +549,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 } @@ -281,6 +742,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let proof = try XCTUnwrap(backup.pendingProofs.first).restored() XCTAssertTrue(proof.paymentStarted) + 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/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 28b933c26..1bac4e761 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -514,7 +514,12 @@ private final class ControlledSendWallet: WalletViewModel { availableUtxos = [] } - override func setUtxoSelection(coinSelectionAlgorythm: CoinSelectionAlgorithm, isCurrentSend: () -> Bool = { true }) async throws { + override func setUtxoSelection( + coinSelectionAlgorythm: CoinSelectionAlgorithm, + isCurrentSend: () -> Bool = { true }, + listSpendable: (() async throws -> [SpendableUtxo])? = nil, + selectForAmount: ((UInt64, UInt32, CoinSelectionAlgorithm) async throws -> [SpendableUtxo])? = nil + ) async throws { if operation == .selection { try await suspend() } guard !Task.isCancelled, isCurrentSend() else { throw CancellationError() } selectedUtxos = [] diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 1536775ec..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] { @@ -76,7 +87,7 @@ final class SendConfirmationViewTests: XCTestCase { currentOutcome: .uncertain, walletType: .onchain, onchainPaymentStarted: true, - error: NodeError.InsufficientFunds(message: "insufficient funds") + error: OnchainSendAttemptError.preDispatch(NodeError.InsufficientFunds(message: "insufficient funds")) ), .definitePreBroadcastFailure ) diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index a0c810782..a8369829f 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -1,5 +1,9 @@ @testable import Bitkit import BitkitCore +import Combine +import LDKNode +import Paykit +import SwiftUI import XCTest /// Regression coverage for the hardware-wallet pending transfer activity. `OnchainActivity.channelId` @@ -23,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) @@ -35,12 +40,1344 @@ 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), + paymentRequestId: paymentRequestId, + localRole: .payer, + state: state, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), + paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: recurrence, + acceptedPaymentEndpointIdentifiers: endpoints, + paymentEndpoints: nil, + requiredAppId: "bitkit", + 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), lightningService: .shared, - blocktankService: Bitkit.CoreService.shared.blocktank + blocktankService: Bitkit.CoreService.shared.blocktank, + isGeoBlocked: { false } + ) + } + + @MainActor + private func makeWallet(attempts: OnchainSendAttemptService) -> WalletViewModel { + WalletViewModel( + transferService: makeService(), sheetViewModel: SheetViewModel(), + feeEstimatesManager: FeeEstimatesManager(), onchainAttemptService: attempts + ) + } + + 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") + } + } + + @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) + 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 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") + 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 = 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) + XCTAssertEqual(node.calls, 2) + } + + @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 }) + ) + .environment(PaykitPaymentRequestManager()) + .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 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( + paymentRequestId: UUID().uuidString, counterparty: "pubky" + String(repeating: "y", count: 52), + 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 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), + 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, paymentAppId: "bitkit", 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), activeIdentity: identity) + 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 }) + ) + .environment(PaykitPaymentRequestManager()) + .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 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, + 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, + paymentAppId: "bitkit", + 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) + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: UUID().uuidString, counterparty: "pubky" + String(repeating: "y", count: 52), + billingPeriodStartsAt: nil + ) + let walletId = "trezor:original-ios-wallet" + let txid = String(repeating: "ab", count: 32) + let proof = PendingPaykitPaymentProof( + identity: identity, requestId: requestId, paymentAppId: "bitkit", 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 }) + ) + .environment(PaykitPaymentRequestManager()) + .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, paymentAppId: "bitkit", 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 testReceivedExactRequestAndOrderObservationPromotesBeforeConfirmation() 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 + } + for isOrder in [false, true] { + for refused in [false, true] { + transferDefaults.removeObject(forKey: "transfers") + let store = MemoryAttemptStore() + let txid = String(repeating: isOrder ? "cd" : "ab", count: 32) + let sender = AttemptNodeMock(result: refused ? .rejected(txid: txid, reason: "fixture refusal") : .unknown(txid: txid)) + let request = PaykitPaymentRequest.ID(paymentRequestId: UUID().uuidString, + counterparty: "pubky" + String(repeating: "y", count: 52), + billingPeriodStartsAt: nil) + let service = OnchainSendAttemptService(store: store, hasPaidOrder: { _ in false }) + _ = try await service.send(using: sender, address: "original", amountSats: 4321, + satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, + requestId: isOrder ? nil : request, orderId: isOrder ? "original-order" : nil, + followupContext: .init(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100), + transferContext: isOrder ? .init( + clientBalanceSats: 3333, + txTotalSats: 4444, + preTransferOnchainSats: 10000 + ) : nil) + let wallet = makeWallet(attempts: service) + await Bitkit.LightningService.shared.onchainTransactionReceived?(String(repeating: "ef", count: 32)) + XCTAssertNotEqual(store.snapshot().first?.status, .accepted) + await Bitkit.LightningService.shared.onchainTransactionReceived?(txid) + XCTAssertEqual( + store.snapshot().first?.status, + .accepted, + "Exact outgoing observation did not promote request/order before confirmation" + ) + XCTAssertEqual(store.snapshot().first?.txid, txid) + XCTAssertEqual(store.snapshot().first?.walletId, OnchainSendAttemptService.walletId(index: sender.currentWalletIndex)) + if isOrder { + let tracking = try Bitkit.TransferStorage(defaults: transferDefaults).getAll().first + XCTAssertEqual(tracking?.fundingTxId, txid) + XCTAssertEqual(tracking?.txTotalSats, 4444) + XCTAssertEqual(tracking?.preTransferOnchainSats, 10000) + XCTAssertEqual(store.snapshot().first?.localFollowupComplete, true) + } + XCTAssertEqual(sender.calls, 1) + withExtendedLifetime(wallet) {} + } + } + } + + @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 + 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 testRestoredGoldenShopAttemptCompletesObservedOriginalCandidateWithoutNewPayment() async throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: PaykitPaymentStateBackupTests.completeAttemptGolden()) + 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) + // 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)) + 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 }) + 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") + 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, 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) + 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 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 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() + 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") + 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)") } + 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) + XCTAssertEqual(context.walletId, original.walletId) + XCTAssertEqual(context.txid, txid) + XCTAssertEqual(node.calls, 1) + } + + @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] { + 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 + if isMax { + node.preparedMiningFeeSats = 123 + node.preparedRecipientAmountSats = initialTotal - 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 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() + 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 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) + 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) + XCTAssertEqual(try activity.isContactDetached(activityId: XCTUnwrap(exactSaved).id, + walletId: XCTUnwrap(exactSaved).walletId), editedContact == nil) + 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: "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) + 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 { + 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 { @@ -95,3 +1432,39 @@ 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]) {} +} + +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 + } +} 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/BitkitTests/WalletViewModelSendTests.swift b/BitkitTests/WalletViewModelSendTests.swift index c306e0aab..0671ce917 100644 --- a/BitkitTests/WalletViewModelSendTests.swift +++ b/BitkitTests/WalletViewModelSendTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import LDKNode import XCTest @MainActor @@ -21,6 +22,87 @@ final class WalletViewModelSendTests: XCTestCase { XCTAssertFalse(wallet.feeRateLoadFailed) } + func testUtxoSelectionDoesNotOverwriteAnExpiredSendAfterAwait() async throws { + for isMax in [false, true] { + let wallet = WalletViewModel() + wallet.sendAmountSats = isMax ? 99819 : 10000 + wallet.selectedFeeRateSatsPerVByte = 1 + wallet.isMaxAmountSend = isMax + let original = SpendableUtxo(outpoint: OutPoint(txid: String(repeating: "a", count: 64), vout: 0), valueSats: 60000) + let replacement = SpendableUtxo(outpoint: OutPoint(txid: String(repeating: "b", count: 64), vout: 0), valueSats: 40000) + wallet.selectedUtxos = [original] + var isCurrent = true + var crossedSelection = false + let finishSelection = { + await Task.yield() + crossedSelection = true + isCurrent = false + return [replacement] + } + do { + try await wallet.setUtxoSelection( + coinSelectionAlgorythm: .branchAndBound, isCurrentSend: { isCurrent }, + listSpendable: { + XCTAssertTrue(isMax) + return await finishSelection() + }, + selectForAmount: { _, _, _ in + XCTAssertFalse(isMax) + return await finishSelection() + } + ) + XCTFail("An expired send must not apply the selection after its await") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertTrue(crossedSelection) + XCTAssertEqual(wallet.selectedUtxos, [original]) + } + } + + 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 diff --git a/changelog.d/next/844.fixed.md b/changelog.d/next/844.fixed.md new file mode 100644 index 000000000..9f9c2567c --- /dev/null +++ b/changelog.d/next/844.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/README.md b/journeys/README.md index 2f9842685..17c03c1b3 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 new file mode 100644 index 000000000..135e1f4b6 --- /dev/null +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -0,0 +1,26 @@ + + + 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. 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 + 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/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 + + diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml new file mode 100644 index 000000000..0cf24d518 --- /dev/null +++ b/journeys/send/onchain-accepted-result.xml @@ -0,0 +1,32 @@ + + + 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. + 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. + 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. + + + Hand the receiving test wallet address to the app with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>?amount=0.00001" + 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 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>" + 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 new file mode 100644 index 000000000..7b4d8fd7b --- /dev/null +++ b/journeys/send/onchain-original-payment-retry.xml @@ -0,0 +1,28 @@ + + + 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. + + + 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 + +