diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index c875dee38..08e792e1f 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -775,7 +775,8 @@ class LightningService { sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]? = nil, - isMaxAmount: Bool = false + isMaxAmount: Bool = false, + beforeBroadcastAttempt: @escaping () async throws -> Void = {} ) async throws -> Txid { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -784,30 +785,117 @@ class LightningService { Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))") do { - return try await ServiceQueue.background(.ldk) { - if isMaxAmount { - // For max amount sends, use sendAllToAddress to send all available funds - try node.onchainPayment().sendAllToAddress( - address: address, - retainReserve: true, - feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte) - ) - } else { - // For normal sends, use sendToAddress with specific amount - try node.onchainPayment().sendToAddress( - address: address, - amountSats: sats, - feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte), - utxosToSpend: utxosToSpend - ) - } - } + return try await Self.performOnchainSend( + onchainPayment: node.onchainPayment(), + address: address, + sats: sats, + feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte), + utxosToSpend: utxosToSpend, + isMaxAmount: isMaxAmount, + beforeBroadcastAttempt: beforeBroadcastAttempt + ) } catch { dumpLdkLogs() throw error } } + static func performOnchainSend( + onchainPayment: OnchainPayment, + address: String, + sats: UInt64, + feeRate: FeeRate, + utxosToSpend: [SpendableUtxo]?, + isMaxAmount: Bool, + beforeBroadcastAttempt: @escaping () async throws -> Void = {} + ) async throws -> Txid { + try await ServiceQueue.background(.ldk) { + try ensureNoPendingOnchainBroadcast(onchainPayment: onchainPayment) + } + try await beforeBroadcastAttempt() + + try await ServiceQueue.background(.ldk) { + try executeOnchainSend( + onchainPayment: onchainPayment, + address: address, + sats: sats, + feeRate: feeRate, + utxosToSpend: utxosToSpend, + isMaxAmount: isMaxAmount + ) + } + } + + static func executeOnchainSend( + onchainPayment: OnchainPayment, + address: String, + sats: UInt64, + feeRate: FeeRate, + utxosToSpend: [SpendableUtxo]?, + isMaxAmount: Bool + ) throws -> Txid { + try ensureNoPendingOnchainBroadcast(onchainPayment: onchainPayment) + + if isMaxAmount { + return try onchainPayment.sendAllToAddress(address: address, retainReserve: true, feeRate: feeRate) + } + + return try onchainPayment.sendToAddress( + address: address, + amountSats: sats, + feeRate: feeRate, + utxosToSpend: utxosToSpend + ) + } + + private static func ensureNoPendingOnchainBroadcast(onchainPayment: OnchainPayment) throws { + if let pendingBroadcast = try onchainPayment.listPendingBroadcasts().first { + throw ExistingPendingOnchainBroadcastError(txid: pendingBroadcast.txid) + } + } + + func onchainBroadcastOutcome(txid: Txid) async throws -> BroadcastOutcome? { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + try Self.onchainBroadcastOutcome(onchainPayment: node.onchainPayment(), txid: txid) + } + } + + func acknowledgeOnchainBroadcastOutcome(txid: Txid) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + try await ServiceQueue.background(.ldk) { + try Self.acknowledgeOnchainBroadcastOutcome(onchainPayment: node.onchainPayment(), txid: txid) + } + } + + func rebroadcastOnchainTransaction(txid: Txid) async throws -> Txid { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + try Self.rebroadcastOnchainTransaction(onchainPayment: node.onchainPayment(), txid: txid) + } + } + + static func onchainBroadcastOutcome(onchainPayment: OnchainPayment, txid: Txid) throws -> BroadcastOutcome? { + try onchainPayment.broadcastOutcome(txid: txid) + } + + static func acknowledgeOnchainBroadcastOutcome(onchainPayment: OnchainPayment, txid: Txid) throws { + try onchainPayment.acknowledgeBroadcastOutcome(txid: txid) + } + + static func rebroadcastOnchainTransaction(onchainPayment: OnchainPayment, txid: Txid) throws -> Txid { + try onchainPayment.rebroadcastTransaction(txid: txid) + } + func send(bolt11: String, sats: UInt64? = nil, params: RouteParametersConfig? = nil) async throws -> PaymentHash { guard let node else { throw AppError(serviceError: .nodeNotSetup) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 81c98bf21..8bca9691a 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -138,9 +138,18 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { } } +enum PaykitOnchainPaymentProofStatus: Equatable { + case pending(activeTxid: Txid) + case accepted(txid: Txid) + case abandoned + case unknown +} + protocol PaykitOnchainPaymentProofLookingUp: Sendable { func existingTransactionIds(address: String, amountSats: UInt64) async throws -> Set func transactionId(address: String, amountSats: UInt64, excluding transactionIds: Set) async throws -> String? + func status(txid: Txid) async -> PaykitOnchainPaymentProofStatus + func acknowledge(txid: Txid) async throws } struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp { @@ -154,6 +163,23 @@ struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp { .first { !transactionIds.contains($0.lowercased()) } } + func status(txid: Txid) async -> PaykitOnchainPaymentProofStatus { + do { + guard let outcome = try await LightningService.shared.onchainBroadcastOutcome(txid: txid) else { return .unknown } + return switch outcome.status { + case .pending: .pending(activeTxid: outcome.txid) + case .accepted: .accepted(txid: outcome.txid) + case .abandoned: .abandoned + } + } catch { + return .unknown + } + } + + func acknowledge(txid: Txid) async throws { + try await LightningService.shared.acknowledgeOnchainBroadcastOutcome(txid: txid) + } + private func matchingTransactionIds(address: String, amountSats: UInt64) async throws -> [String] { guard let payments = await LightningService.shared.listPayments() else { throw PaykitPaymentRequestError.requestUnavailable @@ -319,6 +345,28 @@ actor PaykitPaymentProofService { Self.proofStateChangedSubject.send() } + func associateOnchainPayment(_ request: PaykitPaymentRequest, txid: Txid) async throws { + guard Self.isHex(txid, 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) && + $0.requestId == request.id && + $0.kind == .onchain && + $0.paymentStarted && + $0.paymentIdentifier == nil && + $0.proofData == nil + }) else { + throw PaykitPaymentRequestError.requestUnavailable + } + pendingProofs[index].paymentIdentifier = txid.lowercased() + try await persist(pendingProofs) + Self.proofStateChangedSubject.send() + } + func completeLightningPayment(paymentHash: String, preimage: String?) async { guard let preimage, Self.preimage(preimage, matchesPaymentHash: paymentHash) @@ -349,7 +397,8 @@ actor PaykitPaymentProofService { func completeOnchainPayment( _ request: PaykitPaymentRequest, txid: String, - paymentEndpointIdentifier: String + paymentEndpointIdentifier: String, + associatedTxid: Txid? = nil ) async { guard let identity = try? await currentIdentity() else { return } let fallbackProof = try? await pendingProof( @@ -361,6 +410,7 @@ actor PaykitPaymentProofService { requestId: request.id, identity: identity, txid: txid, + associatedTxid: associatedTxid, fallbackProof: fallbackProof ) } @@ -369,6 +419,7 @@ actor PaykitPaymentProofService { requestId: PaykitPaymentRequest.ID, identity: String, txid: String, + associatedTxid: Txid? = nil, fallbackProof: PendingPaykitPaymentProof? = nil ) async { guard Self.isHex(txid, byteCount: 32) else { @@ -383,9 +434,9 @@ actor PaykitPaymentProofService { $0.requestId == requestId && $0.kind == .onchain && $0.paymentStarted && - $0.paymentIdentifier == nil && $0.proofData == nil }) else { return } + let acknowledgementTxid = pendingProofs[index].paymentIdentifier ?? associatedTxid pendingProofs[index].paymentIdentifier = txid.lowercased() pendingProofs[index].proofData = txid.lowercased() let completedProof = pendingProofs[index] @@ -398,6 +449,9 @@ actor PaykitPaymentProofService { logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") } if didPersist { + if let acknowledgementTxid { + await acknowledgeOnchainOutcome(txid: acknowledgementTxid) + } submitInBackground(completedProof) } else { persistAndSubmitInBackground(completedProof, allProofs: pendingProofs) @@ -455,6 +509,33 @@ actor PaykitPaymentProofService { } } + func abandonOnchainPayment(_ request: PaykitPaymentRequest, txid: Txid) async { + do { + let identity = try await currentIdentity() + let pendingProofs = try await loadProofs() + let acknowledgementTxid = pendingProofs.last(where: { + $0.requestId == request.id && + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.kind == .onchain && + $0.paymentStarted && + $0.proofData == nil + })?.paymentIdentifier ?? txid + let remainingProofs = pendingProofs.filter { + !($0.requestId == request.id && + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.kind == .onchain && + $0.paymentStarted && + $0.proofData == nil) + } + guard remainingProofs != pendingProofs else { return } + try await persist(remainingProofs) + Self.proofStateChangedSubject.send() + await acknowledgeOnchainOutcome(txid: acknowledgementTxid) + } catch { + logWarning("Failed to clear an abandoned Paykit on-chain payment proof: \(error)") + } + } + func cancelPreparation(_ request: PaykitPaymentRequest) async { await removeRequestProofs(request) { !$0.paymentStarted && @@ -482,27 +563,20 @@ actor PaykitPaymentProofService { await submit(proof) continue } - if proof.kind == .onchain, - proof.paymentStarted, - let address = proof.onchainAddress, - let amountSats = proof.onchainAmountSats, - let txid = try await onchainPaymentLookup.transactionId( - address: address, - amountSats: amountSats, - excluding: proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] - ) - { - await completeOnchainPayment(requestId: proof.requestId, identity: proof.identity, txid: txid) - continue - } - guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue } - switch await lightningPaymentLookup.status(paymentHash: paymentHash) { - case .pending, .unknown: - continue - case .failed: - await failLightningPayment(paymentHash: paymentHash) - case let .succeeded(preimage): - await completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + + switch proof.kind { + case .lightning: + guard let paymentHash = proof.paymentIdentifier else { continue } + switch await lightningPaymentLookup.status(paymentHash: paymentHash) { + case .pending, .unknown: + continue + case .failed: + await failLightningPayment(paymentHash: paymentHash) + case let .succeeded(preimage): + await completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + } + case .onchain: + try await reconcileOnchainPayment(proof) } } catch { logWarning("Failed to reconcile a pending Paykit payment proof: \(error)") @@ -513,6 +587,116 @@ actor PaykitPaymentProofService { } } + private func reconcileOnchainPayment(_ proof: PendingPaykitPaymentProof) async throws { + guard proof.paymentStarted else { return } + + let associatedProof: PendingPaykitPaymentProof + if proof.paymentIdentifier != nil { + associatedProof = proof + } else { + guard let address = proof.onchainAddress, + let amountSats = proof.onchainAmountSats, + let txid = try await onchainPaymentLookup.transactionId( + address: address, + amountSats: amountSats, + excluding: proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] + ), + let updatedProof = try await updatePersistedOnchainAssociation(proof, txid: txid) + else { return } + associatedProof = updatedProof + } + + guard let associatedTxid = associatedProof.paymentIdentifier else { return } + switch await onchainPaymentLookup.status(txid: associatedTxid) { + case let .pending(activeTxid): + if activeTxid.caseInsensitiveCompare(associatedTxid) != .orderedSame { + _ = try await updatePersistedOnchainAssociation(associatedProof, txid: activeTxid) + } + case let .accepted(txid): + await completePersistedOnchainPayment(associatedProof, acceptedTxid: txid) + case .abandoned: + await abandonPersistedOnchainPayment(associatedProof) + case .unknown: + return + } + } + + private func updatePersistedOnchainAssociation( + _ proof: PendingPaykitPaymentProof, + txid: Txid + ) async throws -> PendingPaykitPaymentProof? { + var pendingProofs = try await loadProofs() + guard let index = pendingProofs.lastIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, proof.identity) && + $0.requestId == proof.requestId && + $0.kind == .onchain && + $0.paymentStarted && + $0.paymentIdentifier == proof.paymentIdentifier && + $0.proofData == nil + }) else { return nil } + pendingProofs[index].paymentIdentifier = txid.lowercased() + try await persist(pendingProofs) + Self.proofStateChangedSubject.send() + return pendingProofs[index] + } + + private func completePersistedOnchainPayment(_ proof: PendingPaykitPaymentProof, acceptedTxid: Txid) async { + guard let associatedTxid = proof.paymentIdentifier else { return } + + do { + var pendingProofs = try await loadProofs() + guard let index = pendingProofs.lastIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, proof.identity) && + $0.requestId == proof.requestId && + $0.kind == .onchain && + $0.paymentStarted && + $0.paymentIdentifier?.caseInsensitiveCompare(associatedTxid) == .orderedSame && + $0.proofData == nil + }) else { return } + pendingProofs[index].paymentIdentifier = acceptedTxid.lowercased() + pendingProofs[index].proofData = acceptedTxid.lowercased() + let completedProof = pendingProofs[index] + do { + try await persist(pendingProofs) + await acknowledgeOnchainOutcome(txid: associatedTxid) + submitInBackground(completedProof) + } catch { + logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") + persistAndSubmitInBackground(completedProof, allProofs: pendingProofs) + } + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: completedProof.identity, + requestId: completedProof.requestId, + transactionId: acceptedTxid.lowercased() + )) + } catch { + logWarning("Failed to complete a reconciled Paykit on-chain payment proof: \(error)") + } + } + + private func abandonPersistedOnchainPayment(_ proof: PendingPaykitPaymentProof) async { + guard let associatedTxid = proof.paymentIdentifier else { return } + + do { + let pendingProofs = try await loadProofs() + let remainingProofs = pendingProofs.filter { $0 != proof } + guard remainingProofs != pendingProofs else { return } + try await persist(remainingProofs) + Self.proofStateChangedSubject.send() + await acknowledgeOnchainOutcome(txid: associatedTxid) + } catch { + logWarning("Failed to clear an abandoned Paykit on-chain payment proof: \(error)") + } + } + + private func acknowledgeOnchainOutcome(txid: Txid) async { + do { + try await onchainPaymentLookup.acknowledge(txid: txid) + } catch { + logWarning("Failed to acknowledge a handled on-chain broadcast outcome: \(error)") + } + } + func completedRequestProofKindsAwaitingSubmission(identity: String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] { do { return try await loadProofs().reduce(into: [:]) { result, proof in @@ -721,7 +905,7 @@ actor PaykitPaymentProofService { } } - static func isDefiniteOnchainPreBroadcastFailure(_ error: Error) -> Bool { + static func isDefiniteOnchainFailure(_ error: Error) -> Bool { let underlyingError = (error as? AppError)?.underlyingError ?? error if let serviceError = underlyingError as? CustomServiceError { switch serviceError { @@ -736,7 +920,8 @@ actor PaykitPaymentProofService { switch nodeError { case .NotRunning, .OnchainTxCreationFailed, .OnchainWalletAccountNotRegistered, .OnchainTxSigningFailed, .WalletOperationFailed, .PersistenceFailed, .InvalidAddress, .InvalidAmount, .InvalidNetwork, - .InvalidFeeRate, .InsufficientFunds, .CoinSelectionFailed, .NoSpendableOutputs: + .InvalidFeeRate, .InsufficientFunds, .CoinSelectionFailed, .NoSpendableOutputs, + .OnchainTxBroadcastRejected, .OnchainTxBroadcastNotDispatched: return true default: return false diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 216acb575..fec7cf975 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -75,6 +75,47 @@ enum PaymentTimeoutError: Error { case timedOut } +struct ExistingPendingOnchainBroadcastError: LocalizedError { + let txid: Txid + + var errorDescription: String? { + "An on-chain transaction still requires broadcast reconciliation." + } +} + +struct AbandonedOnchainBroadcastError: LocalizedError { + var errorDescription: String? { + "The on-chain transaction was abandoned before broadcast." + } +} + +enum PendingOnchainBroadcastSource: Equatable { + case currentPayment + case existingPayment +} + +struct PendingOnchainBroadcastErrorContext: Equatable { + let txid: Txid + let source: PendingOnchainBroadcastSource +} + +func pendingOnchainBroadcastContext(for error: Error) -> PendingOnchainBroadcastErrorContext? { + let underlyingError = (error as? AppError)?.underlyingError ?? error + + if let existingPendingError = underlyingError as? ExistingPendingOnchainBroadcastError { + return PendingOnchainBroadcastErrorContext(txid: existingPendingError.txid, source: .existingPayment) + } + + guard let nodeError = underlyingError as? NodeError else { return nil } + + switch nodeError { + case let .OnchainTxBroadcastFailed(txid), let .OnchainTxBroadcastTimeout(txid): + return PendingOnchainBroadcastErrorContext(txid: txid, source: .currentPayment) + default: + return nil + } +} + extension Error { var isChannelSizeExceedsMaximum: Bool { if let serviceError = self as? CustomServiceError { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index e104ec232..bbe0d4ea8 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -603,7 +603,7 @@ class WalletViewModel: ObservableObject { address: String, sats: UInt64, isMaxAmount: Bool = false, - beforeBroadcastAttempt: () async throws -> Void = {} + beforeBroadcastAttempt: @escaping () async throws -> Void = {} ) async throws -> Txid { guard let selectedFeeRateSatsPerVByte else { throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.") @@ -615,13 +615,13 @@ class WalletViewModel: ObservableObject { Logger.warn("No UTXO selected, using default selection algorithm.") } - try await beforeBroadcastAttempt() let txid = try await lightningService.send( address: address, sats: sats, satsPerVbyte: selectedFeeRateSatsPerVByte, utxosToSpend: selectedUtxos, - isMaxAmount: isMaxAmount + isMaxAmount: isMaxAmount, + beforeBroadcastAttempt: beforeBroadcastAttempt ) Task { @@ -632,6 +632,24 @@ class WalletViewModel: ObservableObject { return txid } + func onchainBroadcastOutcome(txid: Txid) async throws -> BroadcastOutcome? { + try await lightningService.onchainBroadcastOutcome(txid: txid) + } + + func acknowledgeOnchainBroadcastOutcome(txid: Txid) async throws { + try await lightningService.acknowledgeOnchainBroadcastOutcome(txid: txid) + } + + func rebroadcastOnchainTransaction(txid: Txid) async throws -> Txid { + let acceptedTxid = try await lightningService.rebroadcastOnchainTransaction(txid: txid) + + Task { + try await sync() + } + + return acceptedTxid + } + /// Sets the fee rate for the send flow /// - Parameter speed: The transaction speed determining the fee rate. If nil, the user's default transaction speed will be used. func setFeeRate(speed: TransactionSpeed) async throws { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 2c964a012..b7bd2696c 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -29,9 +29,25 @@ struct SendConfirmationView: View { @State private var pendingWarnings: [WarningType] = [] @State private var warningContinuation: CheckedContinuation? @State private var swipeProgress: CGFloat = 0 + @State private var pendingOnchainBroadcast: PendingOnchainBroadcast? @State private var hasStartedAutomaticPayment = false @State private var requiresPaymentConfirmation = false + private struct OnchainSendCompletion { + let address: String + let amountSats: UInt64 + let feeSats: UInt64 + let feeRate: UInt32 + let contactPublicKey: String? + let incomingPaymentRequest: PaykitPaymentRequest? + let paymentEndpointIdentifier: String? + } + + private struct PendingOnchainBroadcast { + let txid: Txid + let completion: OnchainSendCompletion? + } + var accentColor: Color { if hwSend.isActive { return .blueAccent @@ -160,10 +176,23 @@ struct SendConfirmationView: View { } var body: some View { - ZStack { - confirmationContent - if shouldAutomaticallyPay { - InitialSubscriptionPaymentProgress() + if let pendingOnchainBroadcast { + OnchainBroadcastPendingScreen( + txid: pendingOnchainBroadcast.txid, + amountSats: pendingOnchainBroadcast.completion?.amountSats, + onAccepted: { acceptedTxid in + await resolvePendingOnchainBroadcast(pendingOnchainBroadcast, acceptedTxid: acceptedTxid) + }, + onAbandoned: { + await resolveAbandonedOnchainBroadcast(pendingOnchainBroadcast) + } + ) + } else { + ZStack { + confirmationContent + if shouldAutomaticallyPay { + InitialSubscriptionPaymentProgress() + } } } } @@ -783,47 +812,79 @@ struct SendConfirmationView: View { } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount) - let txid = try await wallet.send( + let completion = OnchainSendCompletion( address: invoice.address, - sats: amount, - isMaxAmount: useMaxAmount - ) { - if let incomingPaymentRequest { - try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( - incomingPaymentRequest, - address: invoice.address - ) - onchainPaymentStarted = true - } - } - shouldCancelPaymentProof = false - if let incomingPaymentRequest, let preparedPaymentProof { - await PaykitPaymentProofService.shared.completeOnchainPayment( - incomingPaymentRequest, - txid: txid, - 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), + amountSats: amount, + feeSats: UInt64(transactionFee), feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1, - contact: contactPublicKey + contactPublicKey: contactPublicKey, + incomingPaymentRequest: incomingPaymentRequest, + paymentEndpointIdentifier: preparedPaymentProof?.endpointIdentifier ) - // Set the amount for the success screen - wallet.sendAmountSats = amount - - Logger.info("Onchain send result txid: \(txid)") + do { + let txid = try await wallet.send( + address: invoice.address, + sats: amount, + isMaxAmount: useMaxAmount + ) { + if let incomingPaymentRequest { + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( + incomingPaymentRequest, + address: invoice.address + ) + onchainPaymentStarted = true + } + } + shouldCancelPaymentProof = false + onchainPaymentStarted = false + await completeOnchainSend(txid: txid, completion: completion, createMetadata: true) + } catch { + guard let pendingContext = pendingOnchainBroadcastContext(for: error) else { throw error } + + if pendingContext.source == .currentPayment { + shouldCancelPaymentProof = false + onchainPaymentStarted = false + wallet.sendAmountSats = amount + await createPreActivityMetadata( + paymentId: pendingContext.txid, + address: invoice.address, + txId: pendingContext.txid, + feeRate: completion.feeRate + ) + if let contactPublicKey { + app.addPendingContactPaymentContext(pendingContext.txid, contactPublicKey: contactPublicKey) + } + if let incomingPaymentRequest { + do { + try await PaykitPaymentProofService.shared.associateOnchainPayment( + incomingPaymentRequest, + txid: pendingContext.txid + ) + } catch { + Logger.warn("Failed to persist pending on-chain payment proof: \(error)", context: "SendConfirmationView") + } + } + pendingOnchainBroadcast = PendingOnchainBroadcast(txid: pendingContext.txid, completion: completion) + } else { + if let incomingPaymentRequest { + if onchainPaymentStarted { + await PaykitPaymentProofService.shared.failOnchainPayment(incomingPaymentRequest) + onchainPaymentStarted = false + } else if shouldCancelPaymentProof { + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) + } + } + shouldCancelPaymentProof = false + pendingOnchainBroadcast = PendingOnchainBroadcast(txid: pendingContext.txid, completion: nil) + } - navigationPath.append(.success(paymentId: txid)) + Logger.warn( + "On-chain broadcast pending reconciliation: \(pendingContext.txid)", + context: "SendConfirmationView" + ) + return + } } else { throw NSError( domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid payment method or missing invoice data"] @@ -836,7 +897,7 @@ struct SendConfirmationView: View { return } catch { if onchainPaymentStarted, let incomingPaymentRequest { - if PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error) { + if PaykitPaymentProofService.isDefiniteOnchainFailure(error) { await PaykitPaymentProofService.shared.failOnchainPayment(incomingPaymentRequest) onchainPaymentStarted = false } else { @@ -871,6 +932,119 @@ struct SendConfirmationView: View { } } + @MainActor + private func resolvePendingOnchainBroadcast(_ pending: PendingOnchainBroadcast, acceptedTxid: Txid) async { + guard pendingOnchainBroadcast?.txid == pending.txid else { return } + + if let completion = pending.completion { + await completeOnchainSend( + txid: acceptedTxid, + completion: completion, + createMetadata: acceptedTxid != pending.txid, + associatedTxid: pending.txid + ) + } else { + pendingOnchainBroadcast = nil + swipeProgress = 0 + } + } + + @MainActor + private func resolveAbandonedOnchainBroadcast(_ pending: PendingOnchainBroadcast) async { + guard pendingOnchainBroadcast?.txid == pending.txid else { return } + + if let completion = pending.completion { + let contactPaymentContext = app.contactPaymentContext + try? await CoreService.shared.activity.deletePreActivityMetadata(paymentId: pending.txid) + app.consumeContactPaymentContext(forPendingPaymentHash: pending.txid) + pendingOnchainBroadcast = nil + navigationPath.append(.failure(SendFailureContext( + error: AbandonedOnchainBroadcastError(), + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil, + contactPaymentContext: contactPaymentContext + ))) + + if let incomingPaymentRequest = completion.incomingPaymentRequest { + await PaykitPaymentProofService.shared.abandonOnchainPayment( + incomingPaymentRequest, + txid: pending.txid + ) + } else { + do { + try await wallet.acknowledgeOnchainBroadcastOutcome(txid: pending.txid) + } catch { + Logger.warn("Failed to acknowledge abandoned on-chain broadcast: \(error)", context: "SendConfirmationView") + } + } + } else { + pendingOnchainBroadcast = nil + swipeProgress = 0 + } + } + + @MainActor + private func completeOnchainSend( + txid: Txid, + completion: OnchainSendCompletion, + createMetadata: Bool, + associatedTxid: Txid? = nil + ) async { + let outcomeTxid = associatedTxid ?? txid + var outcomeHandledByPaykit = false + if let incomingPaymentRequest = completion.incomingPaymentRequest, + let paymentEndpointIdentifier = completion.paymentEndpointIdentifier + { + outcomeHandledByPaykit = true + await PaykitPaymentProofService.shared.completeOnchainPayment( + incomingPaymentRequest, + txid: txid, + paymentEndpointIdentifier: paymentEndpointIdentifier, + associatedTxid: outcomeTxid + ) + } + + if createMetadata { + await createPreActivityMetadata( + paymentId: txid, + address: completion.address, + txId: txid, + feeRate: completion.feeRate + ) + if let associatedTxid, associatedTxid != txid { + try? await CoreService.shared.activity.deletePreActivityMetadata(paymentId: associatedTxid) + } + } + + await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: txid, + address: completion.address, + amount: completion.amountSats, + fee: completion.feeSats, + feeRate: completion.feeRate, + contact: completion.contactPublicKey + ) + + if let associatedTxid, associatedTxid != txid { + app.consumeContactPaymentContext(forPendingPaymentHash: associatedTxid) + } + + wallet.sendAmountSats = completion.amountSats + pendingOnchainBroadcast = nil + + if !outcomeHandledByPaykit { + do { + try await wallet.acknowledgeOnchainBroadcastOutcome(txid: outcomeTxid) + } catch { + Logger.warn("Failed to acknowledge accepted on-chain broadcast: \(error)", context: "SendConfirmationView") + } + } + + Logger.info("Onchain send result txid: \(txid)") + navigationPath.append(.success(paymentId: txid)) + } + private func paymentProofPreparation() throws -> (endpointIdentifier: String, kind: PaykitPaymentProofKind) { switch app.selectedWalletToPayFrom { case .lightning: diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 26025b59a..3a1da7b9e 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 { @@ -23,6 +24,142 @@ struct HourglassLoadingView: View { } } +struct OnchainBroadcastPendingScreen: View { + let txid: Txid + let amountSats: UInt64? + let onAccepted: (Txid) async -> Void + let onAbandoned: () async -> Void + + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel + + @State private var isResolving = false + @State private var hasResolved = false + @State private var activeTxid: Txid + + init( + txid: Txid, + amountSats: UInt64?, + onAccepted: @escaping (Txid) async -> Void, + onAbandoned: @escaping () async -> Void + ) { + self.txid = txid + self.amountSats = amountSats + self.onAccepted = onAccepted + self.onAbandoned = onAbandoned + _activeTxid = State(initialValue: txid) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("wallet__send_pending"), showBackButton: false) + .accessibilityIdentifier("OnchainBroadcastPending") + + if let amountSats { + MoneyStack(sats: Int(amountSats), showSymbol: true) + .padding(.bottom, 32) + } + + BodyMText(t("wallet__send_pending_note")) + .accessibilityIdentifier("OnchainBroadcastPendingMessage") + + Spacer() + + HourglassLoadingView() + + Spacer() + + HStack(spacing: 16) { + CustomButton(title: t("common__close"), variant: .secondary, isDisabled: isResolving) { + sheets.hideSheet() + } + .accessibilityIdentifier("OnchainBroadcastPendingClose") + + CustomButton(title: t("common__retry"), isLoading: isResolving) { + Task { await rebroadcast() } + } + .accessibilityIdentifier("OnchainBroadcastPendingRetry") + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .padding(.horizontal, 16) + .sheetBackground() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + await reconcile() + } + } + + @MainActor + private func reconcile() async { + guard !isResolving, !hasResolved else { return } + isResolving = true + defer { isResolving = false } + + do { + try await wallet.sync() + } catch { + Logger.warn("On-chain pending reconciliation sync failed: \(error)", context: "OnchainBroadcastPendingScreen") + } + + do { + try await applyBroadcastOutcome() + } catch { + Logger.warn("Failed to reconcile on-chain broadcast outcome: \(error)", context: "OnchainBroadcastPendingScreen") + } + } + + @MainActor + private func rebroadcast() async { + guard !isResolving, !hasResolved else { return } + isResolving = true + defer { isResolving = false } + + do { + let acceptedTxid = try await wallet.rebroadcastOnchainTransaction(txid: activeTxid) + await accept(acceptedTxid) + } catch { + do { + try await applyBroadcastOutcome() + } catch { + Logger.warn("Failed to reconcile on-chain rebroadcast error: \(error)", context: "OnchainBroadcastPendingScreen") + } + guard !hasResolved else { return } + app.toast(error) + } + } + + @MainActor + private func applyBroadcastOutcome() async throws { + guard let outcome = try await wallet.onchainBroadcastOutcome(txid: txid) else { return } + + switch outcome.status { + case .pending: + activeTxid = outcome.txid + case .accepted: + await accept(outcome.txid) + case .abandoned: + await abandon() + } + } + + @MainActor + private func accept(_ acceptedTxid: Txid) async { + guard !hasResolved else { return } + hasResolved = true + await onAccepted(acceptedTxid) + } + + @MainActor + private func abandon() async { + guard !hasResolved else { return } + hasResolved = true + await onAbandoned() + } +} + struct SendPendingScreen: View { let paymentHash: String? let retryRoute: SendRetryRoute diff --git a/BitkitTests/OnchainSendResultTests.swift b/BitkitTests/OnchainSendResultTests.swift new file mode 100644 index 000000000..ad175aec5 --- /dev/null +++ b/BitkitTests/OnchainSendResultTests.swift @@ -0,0 +1,457 @@ +@testable import Bitkit +import Foundation +import LDKNode +import XCTest + +final class OnchainSendResultTests: XCTestCase { + private let feeRate = FeeRate.fromSatPerKwu(satKwu: 253) + + func testAmountSendReturnsAcceptedTransactionId() throws { + let payment = FakeOnchainPayment { "accepted-txid" } + + let txid = try LightningService.executeOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false + ) + + XCTAssertEqual(txid, "accepted-txid") + XCTAssertEqual(payment.invocation, .sendToAddress(address: "recipient", amountSats: 1000)) + } + + func testMaxSendReturnsAcceptedTransactionId() throws { + let payment = FakeOnchainPayment { "accepted-max-txid" } + + let txid = try LightningService.executeOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: true + ) + + XCTAssertEqual(txid, "accepted-max-txid") + XCTAssertEqual(payment.invocation, .sendAllToAddress(address: "recipient", retainReserve: true)) + } + + func testRejectedBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastRejected(txid: "rejected-txid"), + expectedFailureType: "OnchainTxBroadcastRejected" + ) + } + + func testFailedBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastFailed(txid: "failed-txid"), + expectedFailureType: "OnchainTxBroadcastFailed" + ) + } + + func testTimedOutBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastTimeout(txid: "timed-out-txid"), + expectedFailureType: "OnchainTxBroadcastTimeout" + ) + } + + func testNotDispatchedBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastNotDispatched(txid: "not-dispatched-txid"), + expectedFailureType: "OnchainTxBroadcastNotDispatched" + ) + } + + func testBroadcastErrorsRemainIdentifiableWhenWrapped() { + let cases: [(NodeError, String, String, String)] = [ + (.OnchainTxBroadcastRejected(txid: "rejected-txid"), "Onchain transaction was rejected", "OnchainTxBroadcastRejected", "rejected-txid"), + (.OnchainTxBroadcastFailed(txid: "failed-txid"), "Failed to broadcast onchain transaction", "OnchainTxBroadcastFailed", "failed-txid"), + ( + .OnchainTxBroadcastTimeout(txid: "timed-out-txid"), + "Onchain transaction broadcast timed out", + "OnchainTxBroadcastTimeout", + "timed-out-txid" + ), + ( + .OnchainTxBroadcastNotDispatched(txid: "not-dispatched-txid"), + "Onchain transaction was not dispatched", + "OnchainTxBroadcastNotDispatched", + "not-dispatched-txid" + ), + ] + + for (nodeError, expectedMessage, expectedFailureType, expectedTxid) in cases { + let appError = Bitkit.AppError(error: nodeError) + + XCTAssertEqual(appError.message, expectedMessage) + XCTAssertTrue(appError.debugMessage?.contains(expectedTxid) == true) + XCTAssertEqual(sendFailureType(for: appError), expectedFailureType) + } + } + + func testFailedAndTimedOutBroadcastsPreservePendingTransactionId() { + let cases: [(NodeError, String)] = [ + (.OnchainTxBroadcastFailed(txid: "failed-txid"), "failed-txid"), + (.OnchainTxBroadcastTimeout(txid: "timed-out-txid"), "timed-out-txid"), + ] + + for (error, expectedTxid) in cases { + XCTAssertEqual( + pendingOnchainBroadcastContext(for: error), + PendingOnchainBroadcastErrorContext(txid: expectedTxid, source: .currentPayment) + ) + XCTAssertEqual( + pendingOnchainBroadcastContext(for: Bitkit.AppError(error: error)), + PendingOnchainBroadcastErrorContext(txid: expectedTxid, source: .currentPayment) + ) + } + } + + func testConclusiveBroadcastErrorsRemainRetryableFreshSends() { + XCTAssertNil(pendingOnchainBroadcastContext(for: NodeError.OnchainTxBroadcastRejected(txid: "rejected-txid"))) + XCTAssertNil(pendingOnchainBroadcastContext(for: NodeError.OnchainTxBroadcastNotDispatched(txid: "not-dispatched-txid"))) + } + + func testExistingPendingBroadcastPreventsFreshSpend() { + let pending = PendingBroadcastInfo(txid: "pending-txid", lineage: ["pending-txid"]) + let payment = FakeOnchainPayment(pendingResult: { [pending] }) { "unexpected-txid" } + + XCTAssertThrowsError(try LightningService.executeOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false + )) { error in + XCTAssertEqual( + pendingOnchainBroadcastContext(for: error), + PendingOnchainBroadcastErrorContext(txid: "pending-txid", source: .existingPayment) + ) + XCTAssertEqual( + pendingOnchainBroadcastContext(for: Bitkit.AppError(error: error)), + PendingOnchainBroadcastErrorContext(txid: "pending-txid", source: .existingPayment) + ) + } + XCTAssertNil(payment.invocation) + } + + func testExistingPendingBroadcastSkipsBeforeBroadcastAttempt() async { + let pending = PendingBroadcastInfo(txid: "pending-txid", lineage: ["pending-txid"]) + let payment = FakeOnchainPayment(pendingResult: { [pending] }) { "unexpected-txid" } + let beforeBroadcastAttemptCalled = ThreadSafeFlag() + + do { + _ = try await LightningService.performOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false, + beforeBroadcastAttempt: { beforeBroadcastAttemptCalled.set() } + ) + XCTFail("Expected the pending broadcast to prevent a new attempt") + } catch { + XCTAssertEqual( + pendingOnchainBroadcastContext(for: error), + PendingOnchainBroadcastErrorContext(txid: "pending-txid", source: .existingPayment) + ) + } + + XCTAssertFalse(beforeBroadcastAttemptCalled.value) + XCTAssertNil(payment.invocation) + } + + func testBeforeBroadcastAttemptRunsBeforeTransactionCreation() async throws { + let beforeBroadcastAttemptCalled = ThreadSafeFlag() + let payment = FakeOnchainPayment { + XCTAssertTrue(beforeBroadcastAttemptCalled.value) + return "accepted-txid" + } + + let txid = try await LightningService.performOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false, + beforeBroadcastAttempt: { beforeBroadcastAttemptCalled.set() } + ) + + XCTAssertEqual(txid, "accepted-txid") + } + + func testPendingBroadcastQueryFailurePreventsFreshSpend() { + let payment = FakeOnchainPayment(pendingResult: { throw QueryError.failed }) { "unexpected-txid" } + + XCTAssertThrowsError(try LightningService.executeOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: true + )) + XCTAssertNil(payment.invocation) + } + + func testPendingCheckAndTransactionCreationAreSerialized() async { + let payment = SerializingFakeOnchainPayment() + + async let firstResult = pendingContextForSerializedSend( + payment: payment, + feeRate: feeRate + ) + async let secondResult = pendingContextForSerializedSend( + payment: payment, + feeRate: feeRate + ) + let contexts = await [firstResult, secondResult] + + XCTAssertEqual(payment.sendInvocationCount, 1) + XCTAssertEqual(contexts.compactMap(\.self).filter { $0.source == .currentPayment }.count, 1) + XCTAssertEqual(contexts.compactMap(\.self).filter { $0.source == .existingPayment }.count, 1) + } + + func testPendingBroadcastUsesExactTransactionForRetry() throws { + let payment = FakeOnchainPayment(rebroadcastResult: { txid in "accepted-\(txid)" }) { "unused" } + + let txid = try LightningService.rebroadcastOnchainTransaction(onchainPayment: payment, txid: "pending-txid") + + XCTAssertEqual(txid, "accepted-pending-txid") + XCTAssertEqual(payment.invocation, .rebroadcast(txid: "pending-txid")) + } + + func testPendingOutcomeFollowsOriginalTransactionToActiveReplacement() throws { + let replacement = BroadcastOutcome( + status: .pending, + txid: "replacement-txid", + lineage: ["original-txid", "replacement-txid"] + ) + let payment = FakeOnchainPayment(outcomeResult: { _ in replacement }) { "unused" } + + let outcome = try LightningService.onchainBroadcastOutcome( + onchainPayment: payment, + txid: "original-txid" + ) + + XCTAssertEqual(outcome, replacement) + XCTAssertEqual(payment.invocation, .broadcastOutcome(txid: "original-txid")) + } + + func testTerminalOutcomeAcknowledgesOriginalLineageTransaction() throws { + let payment = FakeOnchainPayment { "unused" } + + try LightningService.acknowledgeOnchainBroadcastOutcome( + onchainPayment: payment, + txid: "original-txid" + ) + + XCTAssertEqual(payment.invocation, .acknowledgeOutcome(txid: "original-txid")) + } + + func testConclusiveErrorsDoNotPersistFreshSpendBlocker() throws { + for error in [ + NodeError.OnchainTxBroadcastRejected(txid: "rejected-txid"), + NodeError.OnchainTxBroadcastNotDispatched(txid: "not-dispatched-txid"), + ] { + let rejectedPayment = FakeOnchainPayment { throw error } + XCTAssertThrowsError(try LightningService.executeOnchainSend( + onchainPayment: rejectedPayment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false + )) + + let nextPayment = FakeOnchainPayment { "accepted-txid" } + XCTAssertEqual(try LightningService.executeOnchainSend( + onchainPayment: nextPayment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false + ), "accepted-txid") + } + } + + func testAcceptedReplacementRemainsResolvableAfterPendingWindow() throws { + let replacement = BroadcastOutcome( + status: .accepted, + txid: "replacement-txid", + lineage: ["original-txid", "replacement-txid"] + ) + let payment = FakeOnchainPayment(outcomeResult: { _ in replacement }) { "unused" } + + let outcome = try LightningService.onchainBroadcastOutcome( + onchainPayment: payment, + txid: "original-txid" + ) + + XCTAssertEqual(outcome, replacement) + } + + private func assertBroadcastErrorIsPropagated( + _ broadcastError: NodeError, + expectedFailureType: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + let payment = FakeOnchainPayment { throw broadcastError } + + XCTAssertThrowsError(try LightningService.executeOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false + ), file: file, line: line) { error in + XCTAssertEqual(sendFailureType(for: error), expectedFailureType, file: file, line: line) + } + } +} + +private func pendingContextForSerializedSend( + payment: OnchainPayment, + feeRate: FeeRate +) async -> PendingOnchainBroadcastErrorContext? { + do { + _ = try await LightningService.performOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false + ) + return nil + } catch { + return pendingOnchainBroadcastContext(for: error) + } +} + +private final class FakeOnchainPayment: OnchainPayment { + enum Invocation: Equatable { + case sendToAddress(address: String, amountSats: UInt64) + case sendAllToAddress(address: String, retainReserve: Bool) + case rebroadcast(txid: Txid) + case broadcastOutcome(txid: Txid) + case acknowledgeOutcome(txid: Txid) + } + + private let result: () throws -> Txid + private let pendingResult: () throws -> [PendingBroadcastInfo] + private let rebroadcastResult: (Txid) throws -> Txid + private let outcomeResult: (Txid) throws -> BroadcastOutcome? + private(set) var invocation: Invocation? + + init( + pendingResult: @escaping () throws -> [PendingBroadcastInfo] = { [] }, + rebroadcastResult: @escaping (Txid) throws -> Txid = { $0 }, + outcomeResult: @escaping (Txid) throws -> BroadcastOutcome? = { _ in nil }, + result: @escaping () throws -> Txid + ) { + self.result = result + self.pendingResult = pendingResult + self.rebroadcastResult = rebroadcastResult + self.outcomeResult = outcomeResult + super.init(noPointer: .init()) + } + + @available(*, unavailable) + required init(unsafeFromRawPointer _: UnsafeMutableRawPointer) { + fatalError("init(unsafeFromRawPointer:) is unavailable") + } + + override func sendToAddress( + address: Address, + amountSats: UInt64, + feeRate _: FeeRate?, + utxosToSpend _: [SpendableUtxo]? + ) throws -> Txid { + invocation = .sendToAddress(address: address, amountSats: amountSats) + return try result() + } + + override func sendAllToAddress(address: Address, retainReserve: Bool, feeRate _: FeeRate?) throws -> Txid { + invocation = .sendAllToAddress(address: address, retainReserve: retainReserve) + return try result() + } + + override func listPendingBroadcasts() throws -> [PendingBroadcastInfo] { + try pendingResult() + } + + override func rebroadcastTransaction(txid: Txid) throws -> Txid { + invocation = .rebroadcast(txid: txid) + return try rebroadcastResult(txid) + } + + override func broadcastOutcome(txid: Txid) throws -> BroadcastOutcome? { + invocation = .broadcastOutcome(txid: txid) + return try outcomeResult(txid) + } + + override func acknowledgeBroadcastOutcome(txid: Txid) throws { + invocation = .acknowledgeOutcome(txid: txid) + } +} + +private enum QueryError: Error { + case failed +} + +private final class ThreadSafeFlag: @unchecked Sendable { + private let lock = NSLock() + private var storedValue = false + + var value: Bool { + lock.lock() + defer { lock.unlock() } + return storedValue + } + + func set() { + lock.lock() + storedValue = true + lock.unlock() + } +} + +private final class SerializingFakeOnchainPayment: OnchainPayment { + private var pendingBroadcast: PendingBroadcastInfo? + private(set) var sendInvocationCount = 0 + + init() { + super.init(noPointer: .init()) + } + + @available(*, unavailable) + required init(unsafeFromRawPointer _: UnsafeMutableRawPointer) { + fatalError("init(unsafeFromRawPointer:) is unavailable") + } + + override func listPendingBroadcasts() throws -> [PendingBroadcastInfo] { + pendingBroadcast.map { [$0] } ?? [] + } + + override func sendToAddress( + address _: Address, + amountSats _: UInt64, + feeRate _: FeeRate?, + utxosToSpend _: [SpendableUtxo]? + ) throws -> Txid { + sendInvocationCount += 1 + pendingBroadcast = PendingBroadcastInfo(txid: "pending-txid", lineage: ["pending-txid"]) + throw NodeError.OnchainTxBroadcastTimeout(txid: "pending-txid") + } +} diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index e80a275d5..2781eb8d7 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -363,14 +363,24 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } let sdk = PaymentProofSdkMock(identity: identity, records: [record]) - let service = paymentProofService(sdk: sdk, store: store) + let acknowledgements = PaymentProofAcknowledgements() + let service = paymentProofService( + sdk: sdk, + store: store, + onchainAcknowledgements: acknowledgements + ) let txid = String(repeating: "ab", count: 32) try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) let inFlightRequestIds = await service.inFlightRequestIds(identity: identity) XCTAssertEqual(inFlightRequestIds, [request.id]) - await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await service.completeOnchainPayment( + request, + txid: txid, + paymentEndpointIdentifier: endpoint, + associatedTxid: txid + ) await sdk.waitForSubmissionStart() await fulfillment(of: [proofRemoved], timeout: 1) @@ -382,7 +392,9 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] ) let remainingProofs = await store.snapshot() + let acknowledgedTxids = await acknowledgements.snapshot() XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertEqual(acknowledgedTxids, [txid]) } func testOnchainCompletionDoesNotWaitForProofDelivery() async throws { @@ -430,6 +442,222 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(proof.paymentStarted) } + func testPendingOnchainProofRemainsPending() 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 txid = String(repeating: "ab", count: 32) + let service = paymentProofService(sdk: sdk, store: store, onchainStatus: .pending(activeTxid: txid)) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await service.associateOnchainPayment(request, txid: txid) + await service.reconcile() + + let pendingProof = await store.snapshot().first + let submissionCount = await sdk.submissionCount() + XCTAssertEqual(pendingProof?.paymentIdentifier, txid) + XCTAssertNil(pendingProof?.proofData) + XCTAssertEqual(submissionCount, 0) + } + + func testAcceptedAssociatedOnchainProofCompletesAfterReconciliation() 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 sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let txid = String(repeating: "ab", count: 32) + let acknowledgements = PaymentProofAcknowledgements() + + let initialService = paymentProofService(sdk: sdk, store: store) + try await initialService.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await initialService.markOnchainPaymentStarted(request, address: onchainAddress) + try await initialService.associateOnchainPayment(request, txid: txid) + + let restartedService = paymentProofService( + sdk: sdk, + store: store, + onchainStatus: .accepted(txid: txid), + onchainAcknowledgements: acknowledgements + ) + await restartedService.reconcile() + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) + + let lastSubmission = await sdk.lastSubmission() + let submittedProof = try XCTUnwrap(lastSubmission) + XCTAssertEqual(submittedProof.paymentEndpointIdentifier, endpoint) + XCTAssertEqual( + try proofValues(submittedProof.proof.exportText()), + ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + let acknowledgedTxids = await acknowledgements.snapshot() + XCTAssertEqual(acknowledgedTxids, [txid]) + } + + func testVisiblePendingReplacementAdvancesProofAssociationWithoutSubmitting() 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 originalTxid = String(repeating: "ab", count: 32) + let replacementTxid = String(repeating: "cd", count: 32) + let service = paymentProofService( + sdk: sdk, + store: store, + onchainStatus: .pending(activeTxid: replacementTxid) + ) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await service.associateOnchainPayment(request, txid: originalTxid) + await service.reconcile() + + let pendingProof = await store.snapshot().first + let submissionCount = await sdk.submissionCount() + XCTAssertEqual(pendingProof?.paymentIdentifier, replacementTxid) + XCTAssertNil(pendingProof?.proofData) + XCTAssertEqual(submissionCount, 0) + } + + func testCompletionAcknowledgesPersistedReplacementAssociation() 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 sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let originalTxid = String(repeating: "ab", count: 32) + let replacementTxid = String(repeating: "cd", count: 32) + let acknowledgements = PaymentProofAcknowledgements() + let service = paymentProofService( + sdk: sdk, + store: store, + onchainStatus: .pending(activeTxid: replacementTxid), + onchainAcknowledgements: acknowledgements + ) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await service.associateOnchainPayment(request, txid: originalTxid) + await service.reconcile() + await service.completeOnchainPayment( + request, + txid: replacementTxid, + paymentEndpointIdentifier: endpoint, + associatedTxid: originalTxid + ) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) + + let acknowledgedTxids = await acknowledgements.snapshot() + XCTAssertEqual(acknowledgedTxids, [replacementTxid]) + } + + func testUnknownOnchainOutcomeRemainsPending() 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 txid = String(repeating: "ab", count: 32) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await service.associateOnchainPayment(request, txid: txid) + await service.reconcile() + + let pendingProof = await store.snapshot().first + let submissionCount = await sdk.submissionCount() + XCTAssertEqual(pendingProof?.paymentIdentifier, txid) + XCTAssertNil(pendingProof?.proofData) + XCTAssertEqual(submissionCount, 0) + } + + func testAcceptedReplacementCompletesAfterPendingWindow() 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 sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let originalTxid = String(repeating: "ab", count: 32) + let replacementTxid = String(repeating: "cd", count: 32) + let acknowledgements = PaymentProofAcknowledgements() + + let initialService = paymentProofService(sdk: sdk, store: store) + try await initialService.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await initialService.markOnchainPaymentStarted(request, address: onchainAddress) + try await initialService.associateOnchainPayment(request, txid: originalTxid) + + let restartedService = paymentProofService( + sdk: sdk, + store: store, + onchainStatus: .accepted(txid: replacementTxid), + onchainAcknowledgements: acknowledgements + ) + await restartedService.reconcile() + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) + + let lastSubmission = await sdk.lastSubmission() + let submittedProof = try XCTUnwrap(lastSubmission) + XCTAssertEqual( + try proofValues(submittedProof.proof.exportText()), + ["data": replacementTxid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + let acknowledgedTxids = await acknowledgements.snapshot() + XCTAssertEqual(acknowledgedTxids, [originalTxid]) + } + + func testAbandonedOnchainProofClearsAfterReconciliation() 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 txid = String(repeating: "ab", count: 32) + let acknowledgements = PaymentProofAcknowledgements() + + let initialService = paymentProofService(sdk: sdk, store: store) + try await initialService.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await initialService.markOnchainPaymentStarted(request, address: onchainAddress) + try await initialService.associateOnchainPayment(request, txid: txid) + + let restartedService = paymentProofService( + sdk: sdk, + store: store, + onchainStatus: .abandoned, + onchainAcknowledgements: acknowledgements + ) + await restartedService.reconcile() + + let remainingProofs = await store.snapshot() + let submissionCount = await sdk.submissionCount() + let acknowledgedTxids = await acknowledgements.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertEqual(submissionCount, 0) + XCTAssertEqual(acknowledgedTxids, [txid]) + } + func testUncertainOnchainPaymentReconcilesFromPrivateDestination() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord( @@ -445,7 +673,14 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let txid = String(repeating: "ab", count: 32) - let service = paymentProofService(sdk: sdk, store: store, onchainTxids: [txid]) + let acknowledgements = PaymentProofAcknowledgements() + let service = paymentProofService( + sdk: sdk, + store: store, + onchainTxids: [txid], + onchainStatus: .accepted(txid: txid), + onchainAcknowledgements: acknowledgements + ) let resolutionExpectation = expectation(description: "On-chain payment resolution published") let resolution = PaykitPaymentProofService.onchainPaymentResolutionPublisher .filter { $0.requestId == request.id } @@ -471,7 +706,9 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] ) let remainingProofs = await store.snapshot() + let acknowledgedTxids = await acknowledgements.snapshot() XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertEqual(acknowledgedTxids, [txid]) } func testUncertainOnchainPaymentDoesNotReuseTransactionFromBeforeAttempt() async throws { @@ -559,11 +796,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { NodeError.WalletOperationFailed(message: "wallet"), NodeError.PersistenceFailed(message: "io"), Bitkit.AppError(error: NodeError.PersistenceFailed(message: "io")), + NodeError.OnchainTxBroadcastRejected(txid: String(repeating: "ab", count: 32)), + NodeError.OnchainTxBroadcastNotDispatched(txid: String(repeating: "cd", count: 32)), ] for error in errors { - XCTAssertTrue(PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error)) + XCTAssertTrue(PaykitPaymentProofService.isDefiniteOnchainFailure(error)) } - XCTAssertFalse(PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(NSError(domain: "unknown", code: 1))) + XCTAssertFalse(PaykitPaymentProofService.isDefiniteOnchainFailure(NSError(domain: "unknown", code: 1))) let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -746,7 +985,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(remainingProofs.first?.requestId.paymentRequestId, secondRequestId) } - func testOnchainPaymentSubmitsWhenCompletedProofCannotBePersisted() async throws { + func testOnchainPaymentSubmitsWithoutAcknowledgingWhenCompletedProofCannotBePersisted() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -757,23 +996,32 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } let sdk = PaymentProofSdkMock(identity: identity, records: [record]) - let service = paymentProofService(sdk: sdk, store: store) + let acknowledgements = PaymentProofAcknowledgements() + let service = paymentProofService( + sdk: sdk, + store: store, + onchainAcknowledgements: acknowledgements + ) + let txid = String(repeating: "ab", count: 32) try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextSave() await service.completeOnchainPayment( request, - txid: String(repeating: "ab", count: 32), - paymentEndpointIdentifier: endpoint + txid: txid, + paymentEndpointIdentifier: endpoint, + associatedTxid: txid ) await sdk.waitForSubmissionStart() await fulfillment(of: [proofRemoved], timeout: 1) let submissionCount = await sdk.submissionCount() let remainingProofs = await store.snapshot() + let acknowledgedTxids = await acknowledgements.snapshot() XCTAssertEqual(submissionCount, 1) XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertTrue(acknowledgedTxids.isEmpty) } func testCompletedOnchainProofRemainsDurableWhenPersistenceAndSubmissionInitiallyFail() async throws { @@ -848,7 +1096,9 @@ final class PaykitPaymentProofServiceTests: XCTestCase { lightningStatus: PaykitLightningPaymentProofStatus = .unknown, onchainTxids: [String] = [], existingOnchainTxids: Set = [], - onchainLookupFails: Bool = false + onchainLookupFails: Bool = false, + onchainStatus: PaykitOnchainPaymentProofStatus = .unknown, + onchainAcknowledgements: PaymentProofAcknowledgements? = nil ) -> PaykitPaymentProofService { PaykitPaymentProofService( sdk: sdk, @@ -857,7 +1107,9 @@ final class PaykitPaymentProofServiceTests: XCTestCase { onchainPaymentLookup: PaymentProofOnchainLookup( transactionIds: onchainTxids, existingTransactionIds: existingOnchainTxids, - transactionLookupFails: onchainLookupFails + transactionLookupFails: onchainLookupFails, + paymentStatus: onchainStatus, + acknowledgements: onchainAcknowledgements ), logInfo: { _ in }, logWarning: { _ in } @@ -989,6 +1241,8 @@ private struct PaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { let transactionIds: [String] let existingTransactionIds: Set let transactionLookupFails: Bool + let paymentStatus: PaykitOnchainPaymentProofStatus + let acknowledgements: PaymentProofAcknowledgements? func existingTransactionIds(address _: String, amountSats _: UInt64) async throws -> Set { existingTransactionIds @@ -1000,6 +1254,26 @@ private struct PaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { } return self.transactionIds.first { !transactionIds.contains($0) } } + + func status(txid _: Txid) async -> PaykitOnchainPaymentProofStatus { + paymentStatus + } + + func acknowledge(txid: Txid) async throws { + await acknowledgements?.record(txid) + } +} + +private actor PaymentProofAcknowledgements { + private var txids: [Txid] = [] + + func record(_ txid: Txid) { + txids.append(txid) + } + + func snapshot() -> [Txid] { + txids + } } private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index d7ce35e65..73421b110 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -53,23 +53,23 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { } func testDuplicatePaymentIsNotHardReject() { - XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(NodeError.DuplicatePayment(message: "dup"))) + XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(NodeError.DuplicatePayment)) } func testInvalidInvoiceIsHardReject() { - XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(NodeError.InvalidInvoice(message: "bad"))) + XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(NodeError.InvalidInvoice)) } func testPersistenceIsNotHardReject() { - XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(NodeError.PersistenceFailed(message: "io"))) + XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(NodeError.PersistenceFailed)) } func testWrappedInvalidInvoiceIsHardReject() { - XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(Bitkit.AppError(error: NodeError.InvalidInvoice(message: "bad")))) + XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(Bitkit.AppError(error: NodeError.InvalidInvoice))) } func testWrappedDuplicatePaymentIsDuplicateNotHardReject() { - let wrapped = Bitkit.AppError(error: NodeError.DuplicatePayment(message: "dup")) + let wrapped = Bitkit.AppError(error: NodeError.DuplicatePayment) XCTAssertTrue(QuickPayPaymentCoordinator.isDuplicatePayment(wrapped)) XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(wrapped)) } @@ -86,14 +86,14 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { } func testWrappedPersistenceIsNotHardReject() { - XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(Bitkit.AppError(error: NodeError.PersistenceFailed(message: "io")))) + XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(Bitkit.AppError(error: NodeError.PersistenceFailed))) } func testWrappedHardRejectReleasesSpendAndFails() async throws { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw Bitkit.AppError(error: NodeError.InvalidInvoice(message: "bad")) + throw Bitkit.AppError(error: NodeError.InvalidInvoice) } ) @@ -108,7 +108,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw Bitkit.AppError(error: NodeError.DuplicatePayment(message: "dup")) + throw Bitkit.AppError(error: NodeError.DuplicatePayment) }, listRows: { [ @@ -227,7 +227,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw NodeError.PersistenceFailed(message: "io") + throw NodeError.PersistenceFailed } ) @@ -243,7 +243,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw NodeError.PersistenceFailed(message: "io") + throw NodeError.PersistenceFailed }, listRows: { [ @@ -267,7 +267,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw NodeError.DuplicatePayment(message: "dup") + throw NodeError.DuplicatePayment } ) @@ -284,7 +284,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw NodeError.DuplicatePayment(message: "dup") + throw NodeError.DuplicatePayment }, listRows: { [ @@ -310,7 +310,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw NodeError.DuplicatePayment(message: "dup") + throw NodeError.DuplicatePayment }, listRows: { [ @@ -335,7 +335,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw NodeError.PersistenceFailed(message: "io") + throw NodeError.PersistenceFailed }, listRows: { [ @@ -368,7 +368,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { store?.signalCompletion(paymentId: nil, paymentHash: invoiceHash, success: true) return invoiceHash } - throw NodeError.DuplicatePayment(message: "dup") + throw NodeError.DuplicatePayment }, listRows: { [ @@ -403,7 +403,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in - throw NodeError.DuplicatePayment(message: "dup") + throw NodeError.DuplicatePayment }, listRows: { throw NSError(domain: "QuickPayLookup", code: 1) @@ -425,7 +425,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { store: store, sendBolt11: { _ in sendCount += 1 - throw NodeError.DuplicatePayment(message: "dup") + throw NodeError.DuplicatePayment }, listRows: { [ @@ -548,7 +548,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { let invoiceHash = try Self.invoiceHash let coordinator = QuickPayPaymentCoordinator( store: store, - sendBolt11: { _ in throw NodeError.DuplicatePayment(message: "dup") }, + sendBolt11: { _ in throw NodeError.DuplicatePayment }, listRows: { [] } ) XCTAssertFalse(coordinator.hasOpen(invoiceHash)) diff --git a/changelog.d/next/717.fixed.md b/changelog.d/next/717.fixed.md new file mode 100644 index 000000000..ce342069f --- /dev/null +++ b/changelog.d/next/717.fixed.md @@ -0,0 +1 @@ +On-chain sends now report success only after the configured backend accepts the transaction. diff --git a/journeys/README.md b/journeys/README.md index 8f20778e2..ff7f5c3fb 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -135,6 +135,7 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | | [hardware-wallet](hardware-wallet) | 16 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | +| [onchain-send](onchain-send) | 2 | Backend-accepted and backend-rejected send results | ## Not ported diff --git a/journeys/onchain-send/README.md b/journeys/onchain-send/README.md new file mode 100644 index 000000000..22eb7cfbc --- /dev/null +++ b/journeys/onchain-send/README.md @@ -0,0 +1,30 @@ +# On-chain Send Journeys + +These journeys verify the user-visible boundary introduced by `synonymdev/ldk-node#112`: + +- `broadcast-accepted.xml` requires explicit backend acceptance before `SendSuccess`. +- `broadcast-rejected.xml` requires a backend rejection to show `SendFailure` without `SendSuccess`. + +Run both against an iOS build consuming the Swift artifact from the exact LDK Node #112 head under +validation. The accepted fixture uses a clean funded regtest wallet connected directly to the local +Electrum backend at `tcp://127.0.0.1:60001`. + +For the rejected fixture, run from the repository root: + +```bash +node scripts/reject-electrum-broadcast.js +``` + +The proxy listens on port `61001`, forwards normal Electrum traffic to port `60001`, and returns a +deterministic RPC `-26 non-final` rejection for every transaction broadcast. Configure Bitkit to use +`tcp://127.0.0.1:61001` before running `broadcast-rejected.xml`. + +The app result is necessary but not sufficient evidence. After each journey, record the transaction +ID when present and query the active backend. The accepted transaction must be present in its mempool +or chain. The rejected transaction must be absent, and Bitkit must not create a sent activity for it. + +Android shows a failure toast (`OnchainSendFailedToast`). iOS navigates to a failure screen +(`SendFailure`). This intentional presentation difference implements the same rejected-send result. + +Identifiers: `Send`, `RecipientManual`, `RecipientInput`, `AddressContinue`, `SendAmount`, `N1`, +`N000`, `ContinueAmount`, `GRAB`, `SendSuccess`, and `SendFailure`. diff --git a/journeys/onchain-send/broadcast-accepted.xml b/journeys/onchain-send/broadcast-accepted.xml new file mode 100644 index 000000000..cfe42b42c --- /dev/null +++ b/journeys/onchain-send/broadcast-accepted.xml @@ -0,0 +1,23 @@ + + + Verifies that a normal on-chain send reaches Bitcoin Sent only after the configured regtest + backend accepts the transaction. Requires a funded wallet, a valid destination address, camera + permission already resolved, and the LDK Node broadcast-result contract from + synonymdev/ldk-node#112. + + + Verify the Bitkit wallet home screen is visible + Tap the Send button (id "Send") + Tap Enter Manually (id "RecipientManual") + Type the valid regtest destination address into the recipient field (id "RecipientInput") + Tap Continue (id "AddressContinue") + Verify the amount screen is visible (id "SendAmount") + Tap the 1 key (id "N1"), then the triple-zero key (id "N000"), to enter 1,000 sats + Tap Continue (id "ContinueAmount") + Verify the send review screen is visible + Swipe the confirm handle (id "GRAB") from left to right + Verify the accepted transaction success screen is visible (id "SendSuccess") + Close the send sheet and open All Activity + Verify the accepted transaction appears as a pending sent on-chain activity + + diff --git a/journeys/onchain-send/broadcast-rejected.xml b/journeys/onchain-send/broadcast-rejected.xml new file mode 100644 index 000000000..6821b14d7 --- /dev/null +++ b/journeys/onchain-send/broadcast-rejected.xml @@ -0,0 +1,24 @@ + + + Verifies that a deterministic backend rejection never reaches Bitcoin Sent. Requires a funded + wallet, a valid destination address, camera permission already resolved, and the LDK Node + broadcast-result contract from synonymdev/ldk-node#112. Run scripts/reject-electrum-broadcast.js + on the host and configure Bitkit to use tcp://127.0.0.1:61001 before starting the send. + + + Verify the Bitkit wallet home screen is visible + Tap the Send button (id "Send") + Tap Enter Manually (id "RecipientManual") + Type the valid regtest destination address into the recipient field (id "RecipientInput") + Tap Continue (id "AddressContinue") + Verify the amount screen is visible (id "SendAmount") + Tap the 1 key (id "N1"), then the triple-zero key (id "N000"), to enter 1,000 sats + Tap Continue (id "ContinueAmount") + Verify the send review screen is visible + Swipe the confirm handle (id "GRAB") from left to right + Verify the transaction failure feedback is visible (id "SendFailure") + Verify the success screen is absent (id "SendSuccess") + Close the send sheet and open All Activity + Verify the rejected transaction is not listed as a sent on-chain activity + + diff --git a/scripts/reject-electrum-broadcast.js b/scripts/reject-electrum-broadcast.js new file mode 100755 index 000000000..9721af411 --- /dev/null +++ b/scripts/reject-electrum-broadcast.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +const crypto = require("node:crypto") +const net = require("node:net") + +function option(name, fallback) { + const index = process.argv.indexOf(`--${name}`) + return index >= 0 ? process.argv[index + 1] : fallback +} + +const listenHost = option("listen-host", "127.0.0.1") +const listenPort = Number(option("listen-port", "61001")) +const upstreamHost = option("upstream-host", "127.0.0.1") +const upstreamPort = Number(option("upstream-port", "60001")) +const rejectionMessage = option("message", "non-final") + +function rejection(request) { + return { + jsonrpc: request.jsonrpc ?? "2.0", + id: request.id, + error: { code: -26, message: rejectionMessage }, + } +} + +function doubleSha256(transaction) { + const firstHash = crypto.createHash("sha256").update(transaction).digest() + return Buffer.from(crypto.createHash("sha256").update(firstHash).digest()).reverse().toString("hex") +} + +function compactSize(transaction, offset) { + const prefix = transaction[offset] + if (prefix === undefined) throw new Error("missing compact size") + if (prefix < 0xfd) return { value: prefix, nextOffset: offset + 1 } + + const byteLength = prefix === 0xfd ? 2 : prefix === 0xfe ? 4 : 8 + const valueOffset = offset + 1 + const nextOffset = valueOffset + byteLength + if (nextOffset > transaction.length) throw new Error("truncated compact size") + + const value = + byteLength === 2 + ? BigInt(transaction.readUInt16LE(valueOffset)) + : byteLength === 4 + ? BigInt(transaction.readUInt32LE(valueOffset)) + : transaction.readBigUInt64LE(valueOffset) + if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("compact size is too large") + + return { value: Number(value), nextOffset } +} + +function skip(transaction, offset, byteLength) { + const nextOffset = offset + byteLength + if (nextOffset > transaction.length) throw new Error("truncated transaction") + return nextOffset +} + +function transactionId(request) { + const rawTransaction = request.params?.[0] + if (typeof rawTransaction !== "string" || rawTransaction.length === 0 || rawTransaction.length % 2 !== 0) { + return "unknown" + } + + try { + const transaction = Buffer.from(rawTransaction, "hex") + if (transaction.length < 10 || transaction[4] !== 0 || transaction[5] === 0) return doubleSha256(transaction) + + let offset = 6 + const bodyOffset = offset + const inputs = compactSize(transaction, offset) + offset = inputs.nextOffset + for (let input = 0; input < inputs.value; input += 1) { + offset = skip(transaction, offset, 36) + const script = compactSize(transaction, offset) + offset = skip(transaction, script.nextOffset, script.value + 4) + } + + const outputs = compactSize(transaction, offset) + offset = outputs.nextOffset + for (let output = 0; output < outputs.value; output += 1) { + offset = skip(transaction, offset, 8) + const script = compactSize(transaction, offset) + offset = skip(transaction, script.nextOffset, script.value) + } + const outputsEndOffset = offset + + for (let input = 0; input < inputs.value; input += 1) { + const items = compactSize(transaction, offset) + offset = items.nextOffset + for (let item = 0; item < items.value; item += 1) { + const witness = compactSize(transaction, offset) + offset = skip(transaction, witness.nextOffset, witness.value) + } + } + + if (offset + 4 !== transaction.length) throw new Error("unexpected transaction length") + const transactionWithoutWitness = Buffer.concat([ + transaction.subarray(0, 4), + transaction.subarray(bodyOffset, outputsEndOffset), + transaction.subarray(offset), + ]) + return doubleSha256(transactionWithoutWitness) + } catch { + return "unknown" + } +} + +function forwardClientLines(client, upstream) { + let buffered = "" + + client.on("data", chunk => { + buffered += chunk.toString("utf8") + const lines = buffered.split("\n") + buffered = lines.pop() ?? "" + + for (const line of lines) { + if (line.length === 0) continue + + let request + try { + request = JSON.parse(line) + } catch { + upstream.write(`${line}\n`) + continue + } + + if (!Array.isArray(request) && request.method === "blockchain.transaction.broadcast") { + process.stdout.write(`Rejected Electrum broadcast ${transactionId(request)}: ${rejectionMessage}\n`) + client.write(`${JSON.stringify(rejection(request))}\n`) + } else { + upstream.write(`${line}\n`) + } + } + }) +} + +const server = net.createServer(client => { + const upstream = net.createConnection({ host: upstreamHost, port: upstreamPort }) + + forwardClientLines(client, upstream) + upstream.pipe(client) + + client.on("error", () => upstream.destroy()) + upstream.on("error", error => client.destroy(error)) + client.on("close", () => upstream.destroy()) + upstream.on("close", () => client.destroy()) +}) + +server.listen(listenPort, listenHost, () => { + process.stdout.write( + `Electrum rejection proxy listening on ${listenHost}:${listenPort}, forwarding to ${upstreamHost}:${upstreamPort}\n` + ) +}) + +process.on("SIGINT", () => server.close()) +process.on("SIGTERM", () => server.close())