From 4dd139f5895642158c5a0fd020cc64e278f0354d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:26:34 +0200 Subject: [PATCH 1/6] fix: prevent false on-chain send success Validated against the exact local LDK Node 0.7.0-rc.67 artifact from ldk-node#113. Publication requires non-author approval of ldk-node#113 and an authorized rc.67 release. --- Bitkit/Services/LightningService.swift | 44 ++++--- Bitkit/Utilities/Errors.swift | 9 ++ BitkitTests/OnchainSendResultTests.swift | 130 +++++++++++++++++++ changelog.d/next/717.fixed.md | 1 + journeys/README.md | 16 +++ journeys/onchain-send/README.md | 30 +++++ journeys/onchain-send/broadcast-accepted.xml | 23 ++++ journeys/onchain-send/broadcast-rejected.xml | 24 ++++ scripts/reject-electrum-broadcast.js | 84 ++++++++++++ 9 files changed, 345 insertions(+), 16 deletions(-) create mode 100644 BitkitTests/OnchainSendResultTests.swift create mode 100644 changelog.d/next/717.fixed.md create mode 100644 journeys/onchain-send/README.md create mode 100644 journeys/onchain-send/broadcast-accepted.xml create mode 100644 journeys/onchain-send/broadcast-rejected.xml create mode 100755 scripts/reject-electrum-broadcast.js diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 3dec793eb..613ae932e 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -785,22 +785,14 @@ class LightningService { 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 - ) - } + try Self.executeOnchainSend( + onchainPayment: node.onchainPayment(), + address: address, + sats: sats, + feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte), + utxosToSpend: utxosToSpend, + isMaxAmount: isMaxAmount + ) } } catch { dumpLdkLogs() @@ -808,6 +800,26 @@ class LightningService { } } + static func executeOnchainSend( + onchainPayment: OnchainPayment, + address: String, + sats: UInt64, + feeRate: FeeRate, + utxosToSpend: [SpendableUtxo]?, + isMaxAmount: Bool + ) throws -> Txid { + if isMaxAmount { + return try onchainPayment.sendAllToAddress(address: address, retainReserve: true, feeRate: feeRate) + } + + return try onchainPayment.sendToAddress( + address: address, + amountSats: sats, + feeRate: feeRate, + utxosToSpend: utxosToSpend + ) + } + 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/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 95e062490..7ac2cfca6 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -253,6 +253,15 @@ struct AppError: LocalizedError { case let .OnchainTxCreationFailed(message: ldkMessage): message = "Failed to create onchain transaction" debugMessage = ldkMessage + case let .OnchainTxBroadcastRejected(message: ldkMessage): + message = "Onchain transaction was rejected" + debugMessage = ldkMessage + case let .OnchainTxBroadcastFailed(message: ldkMessage): + message = "Failed to broadcast onchain transaction" + debugMessage = ldkMessage + case let .OnchainTxBroadcastTimeout(message: ldkMessage): + message = "Onchain transaction broadcast timed out" + debugMessage = ldkMessage case let .OnchainWalletAccountNotRegistered(message: ldkMessage): message = "Onchain wallet account is not registered" debugMessage = ldkMessage diff --git a/BitkitTests/OnchainSendResultTests.swift b/BitkitTests/OnchainSendResultTests.swift new file mode 100644 index 000000000..448b352ad --- /dev/null +++ b/BitkitTests/OnchainSendResultTests.swift @@ -0,0 +1,130 @@ +@testable import Bitkit +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(message: "non-final"), + expectedFailureType: "OnchainTxBroadcastRejected" + ) + } + + func testFailedBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastFailed(message: "connection closed"), + expectedFailureType: "OnchainTxBroadcastFailed" + ) + } + + func testTimedOutBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastTimeout(message: "timed out"), + expectedFailureType: "OnchainTxBroadcastTimeout" + ) + } + + func testBroadcastErrorsRemainIdentifiableWhenWrapped() { + let cases: [(NodeError, String, String)] = [ + (.OnchainTxBroadcastRejected(message: "non-final"), "Onchain transaction was rejected", "OnchainTxBroadcastRejected"), + (.OnchainTxBroadcastFailed(message: "connection closed"), "Failed to broadcast onchain transaction", "OnchainTxBroadcastFailed"), + (.OnchainTxBroadcastTimeout(message: "timed out"), "Onchain transaction broadcast timed out", "OnchainTxBroadcastTimeout"), + ] + + for (nodeError, expectedMessage, expectedFailureType) in cases { + let appError = Bitkit.AppError(error: nodeError) + + XCTAssertEqual(appError.message, expectedMessage) + XCTAssertEqual(sendFailureType(for: appError), expectedFailureType) + } + } + + 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 final class FakeOnchainPayment: OnchainPayment { + enum Invocation: Equatable { + case sendToAddress(address: String, amountSats: UInt64) + case sendAllToAddress(address: String, retainReserve: Bool) + } + + private let result: () throws -> Txid + private(set) var invocation: Invocation? + + init(result: @escaping () throws -> Txid) { + self.result = result + 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() + } +} 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 905af7d60..48bdb8e4d 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -102,6 +102,21 @@ maximum and the journeys pass for the wrong reason: exit status, so an empty response there is not a failure. Give the wallet ~20s to sync before reading the balance. +The rejected-broadcast journey uses a local proxy that forwards normal Electrum requests and +deterministically rejects `blockchain.transaction.broadcast`: + +```bash +node scripts/reject-electrum-broadcast.js \ + --listen-port 61001 \ + --upstream-host 127.0.0.1 \ + --upstream-port 60001 \ + --message non-final +``` + +Configure the app's Electrum server as `tcp://127.0.0.1:61001` for the rejected case. Use the direct +`tcp://127.0.0.1:60001` backend for the accepted counterexample. The proxy prints the rejected +transaction ID for the backend mempool assertion. + **The `lsp` helper is borrowed from the sibling Android checkout.** It is the `blocktank-api` plugin's script, and there is no iOS copy yet — #694 tracks porting it. The relative path assumes `bitkit-android` is cloned next to this repo, which is the usual layout here; the hardware-wallet @@ -135,6 +150,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) | 15 | 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..aa51f0ee9 --- /dev/null +++ b/scripts/reject-electrum-broadcast.js @@ -0,0 +1,84 @@ +#!/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 transactionId(request) { + const rawTransaction = request.params?.[0] + if (typeof rawTransaction !== "string" || rawTransaction.length === 0 || rawTransaction.length % 2 !== 0) { + return "unknown" + } + + const transaction = Buffer.from(rawTransaction, "hex") + const firstHash = crypto.createHash("sha256").update(transaction).digest() + return Buffer.from(crypto.createHash("sha256").update(firstHash).digest()).reverse().toString("hex") +} + +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()) From 6c3ff3aa97d7e3ef310940acb6f4700e9888f551 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 10:43:07 +0200 Subject: [PATCH 2/6] test: log canonical rejected transaction id --- scripts/reject-electrum-broadcast.js | 77 ++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/scripts/reject-electrum-broadcast.js b/scripts/reject-electrum-broadcast.js index aa51f0ee9..9721af411 100755 --- a/scripts/reject-electrum-broadcast.js +++ b/scripts/reject-electrum-broadcast.js @@ -22,15 +22,86 @@ function rejection(request) { } } +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" } - const transaction = Buffer.from(rawTransaction, "hex") - const firstHash = crypto.createHash("sha256").update(transaction).digest() - return Buffer.from(crypto.createHash("sha256").update(firstHash).digest()).reverse().toString("hex") + 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) { From 847d826f282ac79b3ea3071de13988a8487a1386 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:26:25 +0200 Subject: [PATCH 3/6] fix: adapt onchain errors to rc67 --- Bitkit/Utilities/Errors.swift | 219 ++++++++--------------- BitkitTests/OnchainSendResultTests.swift | 35 +++- 2 files changed, 102 insertions(+), 152 deletions(-) diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 7ac2cfca6..b980d9dc1 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -243,221 +243,152 @@ struct AppError: LocalizedError { private init(ldkError: NodeError) { underlyingError = ldkError paymentFailureReason = nil + debugMessage = String(describing: ldkError) switch ldkError as NodeError { - case let .AlreadyRunning(message: ldkMessage): + case .AlreadyRunning: message = "Node is already running" - debugMessage = ldkMessage - case let .NotRunning(message: ldkMessage): + case .NotRunning: message = "Node is not running" - debugMessage = ldkMessage - case let .OnchainTxCreationFailed(message: ldkMessage): + case .OnchainTxCreationFailed: message = "Failed to create onchain transaction" - debugMessage = ldkMessage - case let .OnchainTxBroadcastRejected(message: ldkMessage): + case .OnchainTxBroadcastRejected: message = "Onchain transaction was rejected" - debugMessage = ldkMessage - case let .OnchainTxBroadcastFailed(message: ldkMessage): + case .OnchainTxBroadcastFailed: message = "Failed to broadcast onchain transaction" - debugMessage = ldkMessage - case let .OnchainTxBroadcastTimeout(message: ldkMessage): + case .OnchainTxBroadcastTimeout: message = "Onchain transaction broadcast timed out" - debugMessage = ldkMessage - case let .OnchainWalletAccountNotRegistered(message: ldkMessage): + case .OnchainTxBroadcastNotDispatched: + message = "Onchain transaction was not dispatched" + case .OnchainWalletAccountNotRegistered: message = "Onchain wallet account is not registered" - debugMessage = ldkMessage - case let .ConnectionFailed(message: ldkMessage): + case .ConnectionFailed: message = "Failed to connect to node" - debugMessage = ldkMessage - case let .InvoiceCreationFailed(message: ldkMessage): + case .InvoiceCreationFailed: message = "Failed to create invoice" - debugMessage = ldkMessage - case let .InvoiceRequestCreationFailed(message: ldkMessage): + case .InvoiceRequestCreationFailed: message = "Failed to create invoice request" - debugMessage = ldkMessage - case let .OfferCreationFailed(message: ldkMessage): + case .OfferCreationFailed: message = "Failed to create offer" - debugMessage = ldkMessage - case let .RefundCreationFailed(message: ldkMessage): + case .RefundCreationFailed: message = "Failed to create refund" - debugMessage = ldkMessage - case let .PaymentSendingFailed(message: ldkMessage): - // message = "Failed to send payment. \(ldkMessage)" - message = ldkMessage - debugMessage = ldkMessage - case let .ProbeSendingFailed(message: ldkMessage): + case .PaymentSendingFailed: + message = "Failed to send payment" + case .ProbeSendingFailed: message = "Failed to send probe" - debugMessage = ldkMessage - case let .RouteNotFound(message: ldkMessage): + case .RouteNotFound: message = "Failed to find a route for fee estimation" - debugMessage = ldkMessage - case let .ChannelCreationFailed(message: ldkMessage): + case .ChannelCreationFailed: message = "Failed to create channel" - debugMessage = ldkMessage - case let .ChannelClosingFailed(message: ldkMessage): + case .ChannelClosingFailed: message = "Failed to close channel" - debugMessage = ldkMessage - case let .ChannelSplicingFailed(message: ldkMessage): + case .ChannelSplicingFailed: message = "Failed to splice channel" - debugMessage = ldkMessage - case let .ChannelConfigUpdateFailed(message: ldkMessage): + case .ChannelConfigUpdateFailed: message = "Failed to update channel config" - debugMessage = ldkMessage - case let .PersistenceFailed(message: ldkMessage): + case .PersistenceFailed: message = "Failed to persist data" - debugMessage = ldkMessage - case let .FeerateEstimationUpdateFailed(message: ldkMessage): + case .FeerateEstimationUpdateFailed: message = "Failed to update feerate estimation" - debugMessage = ldkMessage - case let .FeerateEstimationUpdateTimeout(message: ldkMessage): + case .FeerateEstimationUpdateTimeout: message = "Failed to update feerate estimation due to timeout" - debugMessage = ldkMessage - case let .WalletOperationFailed(message: ldkMessage): + case .WalletOperationFailed: message = "Failed to perform wallet operation" - debugMessage = ldkMessage - case let .WalletOperationTimeout(message: ldkMessage): + case .WalletOperationTimeout: message = "Failed to perform wallet operation due to timeout" - debugMessage = ldkMessage - case let .OnchainTxSigningFailed(message: ldkMessage): + case .OnchainTxSigningFailed: message = "Failed to sign onchain transaction" - debugMessage = ldkMessage - case let .TxSyncFailed(message: ldkMessage): + case .TxSyncFailed: message = "Failed to sync transaction" - debugMessage = ldkMessage - case let .TxSyncTimeout(message: ldkMessage): + case .TxSyncTimeout: message = "Failed to sync transaction due to timeout" - debugMessage = ldkMessage - case let .GossipUpdateFailed(message: ldkMessage): + case .GossipUpdateFailed: message = "Failed to update gossip" - debugMessage = ldkMessage - case let .GossipUpdateTimeout(message: ldkMessage): + case .GossipUpdateTimeout: message = "Failed to update gossip due to timeout" - debugMessage = ldkMessage - case let .LiquidityRequestFailed(message: ldkMessage): + case .LiquidityRequestFailed: message = "Failed to request liquidity" - debugMessage = ldkMessage - case let .UriParameterParsingFailed(message: ldkMessage): + case .UriParameterParsingFailed: message = "Failed to parse URI parameters" - debugMessage = ldkMessage - case let .InvalidAddress(message: ldkMessage): + case .InvalidAddress: message = "Invalid address" - debugMessage = ldkMessage - case let .InvalidSocketAddress(message: ldkMessage): + case .InvalidSocketAddress: message = "Invalid socket address" - debugMessage = ldkMessage - case let .InvalidPublicKey(message: ldkMessage): + case .InvalidPublicKey: message = "Invalid public key" - debugMessage = ldkMessage - case let .InvalidSecretKey(message: ldkMessage): + case .InvalidSecretKey: message = "Invalid secret key" - debugMessage = ldkMessage - case let .InvalidOfferId(message: ldkMessage): + case .InvalidOfferId: message = "Invalid offer ID" - debugMessage = ldkMessage - case let .InvalidNodeId(message: ldkMessage): + case .InvalidNodeId: message = "Invalid node ID" - debugMessage = ldkMessage - case let .InvalidPaymentId(message: ldkMessage): + case .InvalidPaymentId: message = "Invalid payment ID" - debugMessage = ldkMessage - case let .InvalidPaymentHash(message: ldkMessage): + case .InvalidPaymentHash: message = "Invalid payment hash" - debugMessage = ldkMessage - case let .InvalidPaymentPreimage(message: ldkMessage): + case .InvalidPaymentPreimage: message = "Invalid payment preimage" - debugMessage = ldkMessage - case let .InvalidPaymentSecret(message: ldkMessage): + case .InvalidPaymentSecret: message = "Invalid payment secret" - debugMessage = ldkMessage - case let .InvalidAmount(message: ldkMessage): + case .InvalidAmount: message = "Invalid amount" - debugMessage = ldkMessage - case let .InvalidInvoice(message: ldkMessage): + case .InvalidInvoice: message = "Invalid invoice" - debugMessage = ldkMessage - case let .InvalidOffer(message: ldkMessage): + case .InvalidOffer: message = "Invalid offer" - debugMessage = ldkMessage - case let .InvalidRefund(message: ldkMessage): + case .InvalidRefund: message = "Invalid refund" - debugMessage = ldkMessage - case let .InvalidChannelId(message: ldkMessage): + case .InvalidChannelId: message = "Invalid channel ID" - debugMessage = ldkMessage - case let .InvalidNetwork(message: ldkMessage): + case .InvalidNetwork: message = "Invalid network" - debugMessage = ldkMessage - case let .DuplicatePayment(message: ldkMessage): + case .DuplicatePayment: message = "Duplicate payment" - debugMessage = ldkMessage - case let .UnsupportedCurrency(message: ldkMessage): + case .UnsupportedCurrency: message = "Unsupported currency" - debugMessage = ldkMessage - case let .InsufficientFunds(message: ldkMessage): + case .InsufficientFunds: message = "Insufficient funds" - debugMessage = ldkMessage - case let .LiquiditySourceUnavailable(message: ldkMessage): + case .LiquiditySourceUnavailable: message = "Liquidity source unavailable" - debugMessage = ldkMessage - case let .LiquidityFeeTooHigh(message: ldkMessage): + case .LiquidityFeeTooHigh: message = "Liquidity fee too high" - debugMessage = ldkMessage - case let .InvalidBlindedPaths(message: ldkMessage): + case .InvalidBlindedPaths: message = "Invalid blinded paths" - debugMessage = ldkMessage - case let .AsyncPaymentServicesDisabled(message: ldkMessage): + case .AsyncPaymentServicesDisabled: message = "Async payment services disabled" - debugMessage = ldkMessage - case let .InvalidUri(message: ldkMessage): + case .InvalidUri: message = "Invalid URI" - debugMessage = ldkMessage - case let .InvalidQuantity(message: ldkMessage): + case .InvalidQuantity: message = "Invalid quantity" - debugMessage = ldkMessage - case let .InvalidNodeAlias(message: ldkMessage): + case .InvalidNodeAlias: message = "Invalid node alias" - debugMessage = ldkMessage - case let .InvalidCustomTlvs(message: ldkMessage): + case .InvalidCustomTlvs: message = "Invalid custom TLVs" - debugMessage = ldkMessage - case let .InvalidDateTime(message: ldkMessage): + case .InvalidDateTime: message = "Invalid date time" - debugMessage = ldkMessage - case let .InvalidFeeRate(message: ldkMessage): + case .InvalidFeeRate: message = "Invalid fee rate" - debugMessage = ldkMessage - case let .CannotRbfFundingTransaction(ldkMessage): + case .CannotRbfFundingTransaction: message = "Cannot RBF funding transaction" - debugMessage = ldkMessage - case let .TransactionNotFound(ldkMessage): + case .TransactionNotFound: message = "Transaction not found" - debugMessage = ldkMessage - case let .TransactionAlreadyConfirmed(ldkMessage): + case .TransactionAlreadyConfirmed: message = "Transaction already confirmed" - debugMessage = ldkMessage - case let .NoSpendableOutputs(ldkMessage): + case .NoSpendableOutputs: message = "No spendable outputs" - debugMessage = ldkMessage - case let .CoinSelectionFailed(ldkMessage): + case .CoinSelectionFailed: message = "Coin selection failed" - debugMessage = ldkMessage - case let .InvalidMnemonic(ldkMessage): + case .InvalidMnemonic: message = "Invalid mnemonic" - debugMessage = ldkMessage - case let .BackgroundSyncNotEnabled(ldkMessage): + case .BackgroundSyncNotEnabled: message = "Background sync not enabled" - debugMessage = ldkMessage - case let .AddressTypeAlreadyMonitored(message: ldkMessage): + case .AddressTypeAlreadyMonitored: message = "Address type already monitored" - debugMessage = ldkMessage - case let .AddressTypeIsPrimary(message: ldkMessage): + case .AddressTypeIsPrimary: message = "Address type is primary" - debugMessage = ldkMessage - case let .AddressTypeNotMonitored(message: ldkMessage): + case .AddressTypeNotMonitored: message = "Address type not monitored" - debugMessage = ldkMessage - case let .InvalidSeedBytes(message: ldkMessage): + case .InvalidSeedBytes: message = "Invalid seed bytes" - debugMessage = ldkMessage } Logger.error("\(message) [\(debugMessage ?? "")]", context: "ldk-node error") } diff --git a/BitkitTests/OnchainSendResultTests.swift b/BitkitTests/OnchainSendResultTests.swift index 448b352ad..424111949 100644 --- a/BitkitTests/OnchainSendResultTests.swift +++ b/BitkitTests/OnchainSendResultTests.swift @@ -39,36 +39,55 @@ final class OnchainSendResultTests: XCTestCase { func testRejectedBroadcastIsPropagated() { assertBroadcastErrorIsPropagated( - .OnchainTxBroadcastRejected(message: "non-final"), + .OnchainTxBroadcastRejected(txid: "rejected-txid"), expectedFailureType: "OnchainTxBroadcastRejected" ) } func testFailedBroadcastIsPropagated() { assertBroadcastErrorIsPropagated( - .OnchainTxBroadcastFailed(message: "connection closed"), + .OnchainTxBroadcastFailed(txid: "failed-txid"), expectedFailureType: "OnchainTxBroadcastFailed" ) } func testTimedOutBroadcastIsPropagated() { assertBroadcastErrorIsPropagated( - .OnchainTxBroadcastTimeout(message: "timed out"), + .OnchainTxBroadcastTimeout(txid: "timed-out-txid"), expectedFailureType: "OnchainTxBroadcastTimeout" ) } + func testNotDispatchedBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastNotDispatched(txid: "not-dispatched-txid"), + expectedFailureType: "OnchainTxBroadcastNotDispatched" + ) + } + func testBroadcastErrorsRemainIdentifiableWhenWrapped() { - let cases: [(NodeError, String, String)] = [ - (.OnchainTxBroadcastRejected(message: "non-final"), "Onchain transaction was rejected", "OnchainTxBroadcastRejected"), - (.OnchainTxBroadcastFailed(message: "connection closed"), "Failed to broadcast onchain transaction", "OnchainTxBroadcastFailed"), - (.OnchainTxBroadcastTimeout(message: "timed out"), "Onchain transaction broadcast timed out", "OnchainTxBroadcastTimeout"), + 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) in cases { + 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) } } From 6454ee3e1f15432b002b1f4f13a3ec10ba6ef407 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:34:09 +0200 Subject: [PATCH 4/6] test: adapt quickpay errors to rc67 --- .../QuickPayPaymentCoordinatorTests.swift | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index f79d28cc6..1268684a0 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)) From 6a6b0c06bf02775e2afebc467532065986a3efc8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 19:20:19 +0200 Subject: [PATCH 5/6] fix: reconcile uncertain on-chain broadcasts --- Bitkit/Services/LightningService.swift | 153 ++++++++- .../Services/PaykitPaymentProofService.swift | 121 ++++++- .../PendingOnchainBroadcastStore.swift | 90 +++++ Bitkit/Utilities/Errors.swift | 35 ++ Bitkit/ViewModels/WalletViewModel.swift | 18 + .../Wallets/Send/SendConfirmationView.swift | 169 +++++++-- .../Wallets/Send/SendPendingScreen.swift | 118 +++++++ BitkitTests/OnchainSendResultTests.swift | 325 +++++++++++++++++- .../PaykitPaymentProofServiceTests.swift | 104 +++++- 9 files changed, 1075 insertions(+), 58 deletions(-) create mode 100644 Bitkit/Services/PendingOnchainBroadcastStore.swift diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 613ae932e..9af3871bd 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -10,6 +10,7 @@ class LightningService { private var node: Node? var currentWalletIndex: Int = 0 + private let pendingOnchainBroadcastStore: any PendingOnchainBroadcastStoring = PendingOnchainBroadcastStore.shared private let syncStatusChangedSubject = PassthroughSubject() @@ -784,40 +785,154 @@ class LightningService { Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))") do { - return try await ServiceQueue.background(.ldk) { - try Self.executeOnchainSend( - onchainPayment: node.onchainPayment(), - address: address, - sats: sats, - feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte), - utxosToSpend: utxosToSpend, - isMaxAmount: isMaxAmount - ) - } + return try await Self.performOnchainSend( + onchainPayment: node.onchainPayment(), + address: address, + sats: sats, + feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte), + utxosToSpend: utxosToSpend, + isMaxAmount: isMaxAmount, + intentStore: pendingOnchainBroadcastStore, + walletIndex: currentWalletIndex + ) } catch { dumpLdkLogs() throw error } } + static func performOnchainSend( + onchainPayment: OnchainPayment, + address: String, + sats: UInt64, + feeRate: FeeRate, + utxosToSpend: [SpendableUtxo]?, + isMaxAmount: Bool, + intentStore: any PendingOnchainBroadcastStoring, + walletIndex: Int + ) async throws -> Txid { + try await ServiceQueue.background(.ldk) { + try executeOnchainSend( + onchainPayment: onchainPayment, + address: address, + sats: sats, + feeRate: feeRate, + utxosToSpend: utxosToSpend, + isMaxAmount: isMaxAmount, + intentStore: intentStore, + walletIndex: walletIndex + ) + } + } + static func executeOnchainSend( onchainPayment: OnchainPayment, address: String, sats: UInt64, feeRate: FeeRate, utxosToSpend: [SpendableUtxo]?, - isMaxAmount: Bool + isMaxAmount: Bool, + intentStore: any PendingOnchainBroadcastStoring, + walletIndex: Int ) throws -> Txid { - if isMaxAmount { - return try onchainPayment.sendAllToAddress(address: address, retainReserve: true, feeRate: feeRate) + let pendingBroadcasts = try onchainPayment.listPendingBroadcasts() + for pendingBroadcast in pendingBroadcasts { + intentStore.record( + PendingOnchainBroadcastIntent(activeTxid: pendingBroadcast.txid, lineage: pendingBroadcast.lineage), + walletIndex: walletIndex + ) } - return try onchainPayment.sendToAddress( - address: address, - amountSats: sats, - feeRate: feeRate, - utxosToSpend: utxosToSpend - ) + if let pendingBroadcast = pendingBroadcasts.first { + throw ExistingPendingOnchainBroadcastError(txid: pendingBroadcast.txid) + } + + if let persistedIntent = intentStore.intents(walletIndex: walletIndex).first { + throw ExistingPendingOnchainBroadcastError(txid: persistedIntent.activeTxid) + } + + do { + if isMaxAmount { + return try onchainPayment.sendAllToAddress(address: address, retainReserve: true, feeRate: feeRate) + } + + return try onchainPayment.sendToAddress( + address: address, + amountSats: sats, + feeRate: feeRate, + utxosToSpend: utxosToSpend + ) + } catch { + if let pendingContext = pendingOnchainBroadcastContext(for: error), pendingContext.source == .currentPayment { + intentStore.record( + PendingOnchainBroadcastIntent(activeTxid: pendingContext.txid, lineage: [pendingContext.txid]), + walletIndex: walletIndex + ) + } + throw error + } + } + + func pendingOnchainBroadcast(txid: Txid) async throws -> PendingBroadcastInfo? { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + let pendingBroadcast = try Self.pendingOnchainBroadcast(onchainPayment: node.onchainPayment(), txid: txid) + if let pendingBroadcast { + pendingOnchainBroadcastStore.record( + PendingOnchainBroadcastIntent(activeTxid: pendingBroadcast.txid, lineage: pendingBroadcast.lineage), + walletIndex: currentWalletIndex + ) + } + return pendingBroadcast + } + } + + func acceptedOnchainTransaction(reconciling txid: Txid) async throws -> Txid? { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + let intent = pendingOnchainBroadcastStore.intents(walletIndex: currentWalletIndex).first(where: { $0.contains(txid) }) + let candidateTxids = intent.map { $0.lineage + [$0.activeTxid] } ?? [txid] + guard let acceptedTxid = Self.acceptedOnchainTransaction(payments: node.listPayments(), candidateTxids: candidateTxids) else { + return nil + } + pendingOnchainBroadcastStore.remove(matching: acceptedTxid, walletIndex: currentWalletIndex) + return acceptedTxid + } + } + + func rebroadcastOnchainTransaction(txid: Txid) async throws -> Txid { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + let acceptedTxid = try Self.rebroadcastOnchainTransaction(onchainPayment: node.onchainPayment(), txid: txid) + pendingOnchainBroadcastStore.remove(matching: txid, walletIndex: currentWalletIndex) + return acceptedTxid + } + } + + static func pendingOnchainBroadcast(onchainPayment: OnchainPayment, txid: Txid) throws -> PendingBroadcastInfo? { + try onchainPayment.listPendingBroadcasts().first { $0.txid == txid || $0.lineage.contains(txid) } + } + + static func rebroadcastOnchainTransaction(onchainPayment: OnchainPayment, txid: Txid) throws -> Txid { + try onchainPayment.rebroadcastTransaction(txid: txid) + } + + static func acceptedOnchainTransaction(payments: [PaymentDetails], candidateTxids: [Txid]) -> Txid? { + for payment in payments { + guard payment.direction == .outbound, payment.status != .failed else { continue } + guard case let .onchain(txid, _) = payment.kind, candidateTxids.contains(txid) else { continue } + return txid + } + return nil } func send(bolt11: String, sats: UInt64? = nil, params: RouteParametersConfig? = nil) async throws -> PaymentHash { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 3d4980ab8..4d109c383 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -95,12 +95,39 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { } } +enum PaykitOnchainPaymentProofStatus: Equatable { + case pending(activeTxid: Txid) + case accepted(txid: Txid) + case unknown +} + +protocol PaykitOnchainPaymentProofLookingUp: Sendable { + func status(txid: Txid) async -> PaykitOnchainPaymentProofStatus +} + +struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp { + func status(txid: Txid) async -> PaykitOnchainPaymentProofStatus { + do { + if let pendingBroadcast = try await LightningService.shared.pendingOnchainBroadcast(txid: txid) { + return .pending(activeTxid: pendingBroadcast.txid) + } + if let acceptedTxid = try await LightningService.shared.acceptedOnchainTransaction(reconciling: txid) { + return .accepted(txid: acceptedTxid) + } + return .unknown + } catch { + return .unknown + } + } +} + actor PaykitPaymentProofService { static let shared = PaykitPaymentProofService() private let sdk: any PaykitPaymentProofSdkHandling private let store: any PaykitPaymentProofStoring private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp + private let onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp private let logInfo: @Sendable (String) -> Void private let logWarning: @Sendable (String) -> Void @@ -108,6 +135,7 @@ actor PaykitPaymentProofService { sdk: any PaykitPaymentProofSdkHandling = PaykitSdkService.shared, store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(), lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), + onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp = PaykitOnchainPaymentProofLookup(), logInfo: @escaping @Sendable (String) -> Void = { Logger.info($0, context: "PaykitPaymentProof") }, @@ -118,6 +146,7 @@ actor PaykitPaymentProofService { self.sdk = sdk self.store = store self.lightningPaymentLookup = lightningPaymentLookup + self.onchainPaymentLookup = onchainPaymentLookup self.logInfo = logInfo self.logWarning = logWarning } @@ -187,6 +216,24 @@ actor PaykitPaymentProofService { try await persist(pendingProofs) } + func associateOnchainPayment(_ request: PaykitPaymentRequest, txid: Txid) async throws { + guard Self.isHex(txid, byteCount: 32) else { + throw PaykitPaymentRequestError.requestUnavailable + } + + var pendingProofs = try await loadProofs() + guard let index = pendingProofs.lastIndex(where: { + $0.requestId == request.id && + $0.kind == .onchain && + $0.paymentIdentifier == nil && + $0.proofData == nil + }) else { + throw PaykitPaymentRequestError.requestUnavailable + } + pendingProofs[index].paymentIdentifier = txid.lowercased() + try await persist(pendingProofs) + } + func completeLightningPayment(paymentHash: String, preimage: String?) async { guard let preimage, Self.preimage(preimage, matchesPaymentHash: paymentHash) @@ -217,7 +264,8 @@ actor PaykitPaymentProofService { func completeOnchainPayment( _ request: PaykitPaymentRequest, txid: String, - paymentEndpointIdentifier: String + paymentEndpointIdentifier: String, + associatedTxid: Txid? = nil ) async { guard Self.isHex(txid, byteCount: 32) else { logWarning("Ignored a Paykit on-chain proof with an invalid transaction id") @@ -229,7 +277,9 @@ actor PaykitPaymentProofService { guard let index = pendingProofs.lastIndex(where: { $0.requestId == request.id && $0.kind == .onchain && - $0.paymentIdentifier == nil && + ($0.paymentIdentifier == nil || + $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame || + $0.paymentIdentifier?.caseInsensitiveCompare(associatedTxid ?? "") == .orderedSame) && $0.proofData == nil }) else { return } pendingProofs[index].paymentIdentifier = txid.lowercased() @@ -283,14 +333,29 @@ actor PaykitPaymentProofService { await submit(proof) 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) + + guard let paymentIdentifier = proof.paymentIdentifier else { continue } + switch proof.kind { + case .lightning: + switch await lightningPaymentLookup.status(paymentHash: paymentIdentifier) { + case .pending, .unknown: + continue + case .failed: + await failLightningPayment(paymentHash: paymentIdentifier) + case let .succeeded(preimage): + await completeLightningPayment(paymentHash: paymentIdentifier, preimage: preimage) + } + case .onchain: + switch await onchainPaymentLookup.status(txid: paymentIdentifier) { + case let .pending(activeTxid): + if activeTxid != paymentIdentifier { + await updatePersistedOnchainAssociation(proof, txid: activeTxid) + } + case let .accepted(txid): + await completePersistedOnchainPayment(proof, acceptedTxid: txid) + case .unknown: + continue + } } } } catch { @@ -298,6 +363,42 @@ actor PaykitPaymentProofService { } } + private func updatePersistedOnchainAssociation(_ proof: PendingPaykitPaymentProof, txid: Txid) async { + do { + var pendingProofs = try await loadProofs() + guard let index = pendingProofs.lastIndex(where: { + $0.requestId == proof.requestId && + $0.kind == .onchain && + $0.paymentIdentifier == proof.paymentIdentifier && + $0.proofData == nil + }) else { return } + pendingProofs[index].paymentIdentifier = txid.lowercased() + try await persist(pendingProofs) + } catch { + logWarning("Failed to update a pending Paykit on-chain payment proof: \(error)") + } + } + + 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: { + $0.requestId == proof.requestId && + $0.kind == .onchain && + $0.paymentIdentifier?.caseInsensitiveCompare(associatedTxid) == .orderedSame && + $0.proofData == nil + }) else { return } + pendingProofs[index].paymentIdentifier = acceptedTxid.lowercased() + pendingProofs[index].proofData = acceptedTxid.lowercased() + let completedProof = pendingProofs[index] + await persistAndSubmit([completedProof], allProofs: pendingProofs) + } catch { + logWarning("Failed to complete a reconciled Paykit on-chain payment proof: \(error)") + } + } + private func submit(_ pendingProof: PendingPaykitPaymentProof) async { guard let proofData = pendingProof.proofData else { return } do { diff --git a/Bitkit/Services/PendingOnchainBroadcastStore.swift b/Bitkit/Services/PendingOnchainBroadcastStore.swift new file mode 100644 index 000000000..261ce6a7f --- /dev/null +++ b/Bitkit/Services/PendingOnchainBroadcastStore.swift @@ -0,0 +1,90 @@ +import Foundation +import LDKNode + +struct PendingOnchainBroadcastIntent: Codable, Equatable { + var activeTxid: Txid + var lineage: [Txid] + + init(activeTxid: Txid, lineage: [Txid]) { + self.activeTxid = activeTxid + self.lineage = Array(Set(lineage + [activeTxid])).sorted() + } + + func contains(_ txid: Txid) -> Bool { + activeTxid == txid || lineage.contains(txid) + } +} + +protocol PendingOnchainBroadcastStoring: AnyObject { + func intents(walletIndex: Int) -> [PendingOnchainBroadcastIntent] + func record(_ intent: PendingOnchainBroadcastIntent, walletIndex: Int) + func remove(matching txid: Txid, walletIndex: Int) +} + +final class PendingOnchainBroadcastStore: PendingOnchainBroadcastStoring { + static let shared = PendingOnchainBroadcastStore() + + private struct State: Codable { + var wallets: [String: [PendingOnchainBroadcastIntent]] + } + + private let defaults: UserDefaults + private let key = "pendingOnchainBroadcastIntents" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func intents(walletIndex: Int) -> [PendingOnchainBroadcastIntent] { + state().wallets[String(walletIndex)] ?? [] + } + + func record(_ intent: PendingOnchainBroadcastIntent, walletIndex: Int) { + var state = state() + let walletKey = String(walletIndex) + var intents = state.wallets[walletKey] ?? [] + let matchingIndexes = intents.indices.filter { existingIndex in + let existing = intents[existingIndex] + return existing.contains(intent.activeTxid) || intent.lineage.contains(where: existing.contains) + } + let mergedLineage = matchingIndexes.reduce(into: intent.lineage) { result, index in + result.append(contentsOf: intents[index].lineage) + result.append(intents[index].activeTxid) + } + for index in matchingIndexes.reversed() { + intents.remove(at: index) + } + intents.append(PendingOnchainBroadcastIntent(activeTxid: intent.activeTxid, lineage: mergedLineage)) + state.wallets[walletKey] = intents + save(state) + } + + func remove(matching txid: Txid, walletIndex: Int) { + var state = state() + let walletKey = String(walletIndex) + guard var intents = state.wallets[walletKey] else { return } + intents.removeAll { $0.contains(txid) } + if intents.isEmpty { + state.wallets.removeValue(forKey: walletKey) + } else { + state.wallets[walletKey] = intents + } + save(state) + } + + private func state() -> State { + guard let data = defaults.data(forKey: key), + let state = try? JSONDecoder().decode(State.self, from: data) + else { return State(wallets: [:]) } + return state + } + + private func save(_ state: State) { + guard !state.wallets.isEmpty else { + defaults.removeObject(forKey: key) + return + } + guard let data = try? JSONEncoder().encode(state) else { return } + defaults.set(data, forKey: key) + } +} diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index b980d9dc1..bb544ff43 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -69,6 +69,41 @@ enum PaymentTimeoutError: Error { case timedOut } +struct ExistingPendingOnchainBroadcastError: LocalizedError { + let txid: Txid + + var errorDescription: String? { + "An on-chain transaction still requires broadcast reconciliation." + } +} + +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 + } +} + /// Translates LDK and BDK error messages into translated messages that can be displayed to end users struct AppError: LocalizedError { static let genericMessage = "App Error" diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index b4068245e..4c8f2ac19 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -622,6 +622,24 @@ class WalletViewModel: ObservableObject { return txid } + func pendingOnchainBroadcast(txid: Txid) async throws -> PendingBroadcastInfo? { + try await lightningService.pendingOnchainBroadcast(txid: txid) + } + + func acceptedOnchainTransaction(reconciling txid: Txid) async throws -> Txid? { + try await lightningService.acceptedOnchainTransaction(reconciling: 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 5ff0f4095..f52ba83c5 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -29,6 +29,22 @@ struct SendConfirmationView: View { @State private var pendingWarnings: [WarningType] = [] @State private var warningContinuation: CheckedContinuation? @State private var swipeProgress: CGFloat = 0 + @State private var pendingOnchainBroadcast: PendingOnchainBroadcast? + + 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 } @@ -151,6 +167,20 @@ struct SendConfirmationView: View { } var body: some View { + if let pendingOnchainBroadcast { + OnchainBroadcastPendingScreen( + txid: pendingOnchainBroadcast.txid, + amountSats: pendingOnchainBroadcast.completion?.amountSats, + onAccepted: { acceptedTxid in + await resolvePendingOnchainBroadcast(pendingOnchainBroadcast, acceptedTxid: acceptedTxid) + } + ) + } else { + confirmationContent + } + } + + private var confirmationContent: some View { VStack(alignment: .leading, spacing: 0) { SheetHeader( title: app.contactPaymentContext?.incomingPaymentRequest == nil ? t("wallet__send_review") : t("wallet__payment_request"), @@ -678,35 +708,60 @@ 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(address: invoice.address, sats: amount, isMaxAmount: useMaxAmount) - 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, + let completion = OnchainSendCompletion( 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) + shouldCancelPaymentProof = 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 + 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 shouldCancelPaymentProof, let incomingPaymentRequest { + 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"] @@ -736,6 +791,72 @@ 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 completeOnchainSend( + txid: Txid, + completion: OnchainSendCompletion, + createMetadata: Bool, + associatedTxid: Txid? = nil + ) async { + if let incomingPaymentRequest = completion.incomingPaymentRequest, + let paymentEndpointIdentifier = completion.paymentEndpointIdentifier + { + await PaykitPaymentProofService.shared.completeOnchainPayment( + incomingPaymentRequest, + txid: txid, + paymentEndpointIdentifier: paymentEndpointIdentifier, + associatedTxid: associatedTxid + ) + } + + 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 + 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 cc6d516dd..192aa2dca 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,123 @@ struct HourglassLoadingView: View { } } +struct OnchainBroadcastPendingScreen: View { + let txid: Txid + let amountSats: UInt64? + let onAccepted: (Txid) 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) { + self.txid = txid + self.amountSats = amountSats + self.onAccepted = onAccepted + _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 { + if let pendingBroadcast = try await wallet.pendingOnchainBroadcast(txid: txid) { + activeTxid = pendingBroadcast.txid + return + } + guard let acceptedTxid = try await wallet.acceptedOnchainTransaction(reconciling: txid) else { return } + await accept(acceptedTxid) + } catch { + Logger.warn("Failed to read pending on-chain broadcasts: \(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 { + if let pendingBroadcast = try await wallet.pendingOnchainBroadcast(txid: txid) { + activeTxid = pendingBroadcast.txid + } else if let acceptedTxid = try await wallet.acceptedOnchainTransaction(reconciling: txid) { + await accept(acceptedTxid) + return + } + } catch { + Logger.warn("Failed to reconcile on-chain rebroadcast error: \(error)", context: "OnchainBroadcastPendingScreen") + } + app.toast(error) + } + } + + @MainActor + private func accept(_ acceptedTxid: Txid) async { + guard !hasResolved else { return } + hasResolved = true + await onAccepted(acceptedTxid) + } +} + struct SendPendingScreen: View { let paymentHash: String let retryRoute: SendRetryRoute diff --git a/BitkitTests/OnchainSendResultTests.swift b/BitkitTests/OnchainSendResultTests.swift index 424111949..bf7ce52fc 100644 --- a/BitkitTests/OnchainSendResultTests.swift +++ b/BitkitTests/OnchainSendResultTests.swift @@ -4,6 +4,12 @@ import XCTest final class OnchainSendResultTests: XCTestCase { private let feeRate = FeeRate.fromSatPerKwu(satKwu: 253) + private var intentStore: PendingOnchainBroadcastMemoryStore! + + override func setUp() { + super.setUp() + intentStore = PendingOnchainBroadcastMemoryStore() + } func testAmountSendReturnsAcceptedTransactionId() throws { let payment = FakeOnchainPayment { "accepted-txid" } @@ -14,7 +20,9 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: false + isMaxAmount: false, + intentStore: intentStore, + walletIndex: 0 ) XCTAssertEqual(txid, "accepted-txid") @@ -30,7 +38,9 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: true + isMaxAmount: true, + intentStore: intentStore, + walletIndex: 0 ) XCTAssertEqual(txid, "accepted-max-txid") @@ -92,6 +102,222 @@ final class OnchainSendResultTests: XCTestCase { } } + 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, + intentStore: intentStore, + walletIndex: 0 + )) { 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 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, + intentStore: intentStore, + walletIndex: 0 + )) + XCTAssertNil(payment.invocation) + } + + func testPendingCheckAndTransactionCreationAreSerialized() async { + let payment = SerializingFakeOnchainPayment() + + async let firstResult = pendingContextForSerializedSend( + payment: payment, + feeRate: feeRate, + intentStore: intentStore + ) + async let secondResult = pendingContextForSerializedSend( + payment: payment, + feeRate: feeRate, + intentStore: intentStore + ) + 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 testPendingLookupFollowsOriginalTransactionToActiveReplacement() throws { + let replacement = PendingBroadcastInfo( + txid: "replacement-txid", + lineage: ["original-txid", "replacement-txid"] + ) + let payment = FakeOnchainPayment(pendingResult: { [replacement] }) { "unused" } + + let pending = try LightningService.pendingOnchainBroadcast( + onchainPayment: payment, + txid: "original-txid" + ) + + XCTAssertEqual(pending, replacement) + } + + func testAcceptanceUnknownIntentBlocksFreshSpendAfterPendingListDisappears() { + let firstPayment = FakeOnchainPayment { throw NodeError.OnchainTxBroadcastTimeout(txid: "pending-txid") } + + XCTAssertThrowsError(try LightningService.executeOnchainSend( + onchainPayment: firstPayment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false, + intentStore: intentStore, + walletIndex: 0 + )) + + let reopenedPayment = FakeOnchainPayment { "unsafe-fresh-txid" } + XCTAssertThrowsError(try LightningService.executeOnchainSend( + onchainPayment: reopenedPayment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false, + intentStore: intentStore, + walletIndex: 0 + )) { error in + XCTAssertEqual( + pendingOnchainBroadcastContext(for: error), + PendingOnchainBroadcastErrorContext(txid: "pending-txid", source: .existingPayment) + ) + } + XCTAssertNil(reopenedPayment.invocation) + } + + 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, + intentStore: intentStore, + walletIndex: 0 + )) + + let nextPayment = FakeOnchainPayment { "accepted-txid" } + XCTAssertEqual(try LightningService.executeOnchainSend( + onchainPayment: nextPayment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false, + intentStore: intentStore, + walletIndex: 0 + ), "accepted-txid") + } + } + + func testMissedAcceptedReplacementRemainsUnresolvedWithoutVisibleLineage() { + let replacement = PaymentDetails( + id: "replacement-txid", + kind: .onchain(txid: "replacement-txid", status: .unconfirmed), + amountMsat: 1_000_000, + feePaidMsat: 1000, + direction: .outbound, + status: .pending, + latestUpdateTimestamp: 1 + ) + + XCTAssertNil(LightningService.acceptedOnchainTransaction( + payments: [replacement], + candidateTxids: ["original-txid"] + )) + XCTAssertEqual(LightningService.acceptedOnchainTransaction( + payments: [replacement], + candidateTxids: ["original-txid", "replacement-txid"] + ), "replacement-txid") + } + + func testPendingIntentPersistsAcrossStoreInstancesAndTracksReplacementLineage() throws { + let suiteName = "PendingOnchainBroadcastStoreTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + PendingOnchainBroadcastStore(defaults: defaults).record( + PendingOnchainBroadcastIntent(activeTxid: "original-txid", lineage: ["original-txid"]), + walletIndex: 0 + ) + PendingOnchainBroadcastStore(defaults: defaults).record( + PendingOnchainBroadcastIntent(activeTxid: "replacement-txid", lineage: ["original-txid", "replacement-txid"]), + walletIndex: 0 + ) + + let restartedStore = PendingOnchainBroadcastStore(defaults: defaults) + let intent = try XCTUnwrap(restartedStore.intents(walletIndex: 0).first) + XCTAssertEqual(intent.activeTxid, "replacement-txid") + XCTAssertTrue(intent.contains("original-txid")) + XCTAssertTrue(intent.contains("replacement-txid")) + XCTAssertTrue(restartedStore.intents(walletIndex: 1).isEmpty) + } + private func assertBroadcastErrorIsPropagated( _ broadcastError: NodeError, expectedFailureType: String, @@ -106,24 +332,57 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: false + isMaxAmount: false, + intentStore: intentStore, + walletIndex: 0 ), file: file, line: line) { error in XCTAssertEqual(sendFailureType(for: error), expectedFailureType, file: file, line: line) } } } +private func pendingContextForSerializedSend( + payment: OnchainPayment, + feeRate: FeeRate, + intentStore: any PendingOnchainBroadcastStoring +) async -> PendingOnchainBroadcastErrorContext? { + do { + _ = try await LightningService.performOnchainSend( + onchainPayment: payment, + address: "recipient", + sats: 1000, + feeRate: feeRate, + utxosToSpend: nil, + isMaxAmount: false, + intentStore: intentStore, + walletIndex: 0 + ) + 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) } private let result: () throws -> Txid + private let pendingResult: () throws -> [PendingBroadcastInfo] + private let rebroadcastResult: (Txid) throws -> Txid private(set) var invocation: Invocation? - init(result: @escaping () throws -> Txid) { + init( + pendingResult: @escaping () throws -> [PendingBroadcastInfo] = { [] }, + rebroadcastResult: @escaping (Txid) throws -> Txid = { $0 }, + result: @escaping () throws -> Txid + ) { self.result = result + self.pendingResult = pendingResult + self.rebroadcastResult = rebroadcastResult super.init(noPointer: .init()) } @@ -146,4 +405,62 @@ private final class FakeOnchainPayment: OnchainPayment { 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) + } +} + +private enum QueryError: Error { + case failed +} + +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") + } +} + +private final class PendingOnchainBroadcastMemoryStore: PendingOnchainBroadcastStoring { + private var records: [Int: [PendingOnchainBroadcastIntent]] = [:] + + func intents(walletIndex: Int) -> [PendingOnchainBroadcastIntent] { + records[walletIndex] ?? [] + } + + func record(_ intent: PendingOnchainBroadcastIntent, walletIndex: Int) { + records[walletIndex] = [intent] + } + + func remove(matching txid: Txid, walletIndex: Int) { + records[walletIndex]?.removeAll { $0.contains(txid) } + } } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 336e83490..fc5efab7d 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import LDKNode import Paykit import XCTest @@ -204,6 +205,97 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await completionTask.value } + func testAcceptanceUnknownOnchainProofRemainsPending() 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.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 testAcceptedOnchainProofCompletesAfterReconciliation() 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 initialService = paymentProofService(sdk: sdk, store: store, onchainStatus: .pending(activeTxid: txid)) + try await initialService.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await initialService.associateOnchainPayment(request, txid: txid) + + let restartedService = paymentProofService(sdk: sdk, store: store, onchainStatus: .accepted(txid: txid)) + await restartedService.reconcile() + + 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 remainingProofs = await store.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + } + + 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.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 testMissedAcceptedReplacementRemainsUnresolvedWithoutAuthoritativeMapping() 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 service = paymentProofService(sdk: sdk, store: store, onchainStatus: .unknown) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.associateOnchainPayment(request, txid: originalTxid) + await service.reconcile() + + let pendingProof = await store.snapshot().first + let submissionCount = await sdk.submissionCount() + XCTAssertEqual(pendingProof?.paymentIdentifier, originalTxid) + XCTAssertNil(pendingProof?.proofData) + XCTAssertEqual(submissionCount, 0) + } + func testLightningRetryPreservesEarlierPaymentCorrelation() async throws { let record = try paymentRequestRecord() let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -333,12 +425,14 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private func paymentProofService( sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, - lightningStatus: PaykitLightningPaymentProofStatus = .unknown + lightningStatus: PaykitLightningPaymentProofStatus = .unknown, + onchainStatus: PaykitOnchainPaymentProofStatus = .unknown ) -> PaykitPaymentProofService { PaykitPaymentProofService( sdk: sdk, store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), + onchainPaymentLookup: PaymentProofOnchainLookup(status: onchainStatus), logInfo: { _ in }, logWarning: { _ in } ) @@ -458,6 +552,14 @@ private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp } } +private struct PaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { + let status: PaykitOnchainPaymentProofStatus + + func status(txid _: Txid) async -> PaykitOnchainPaymentProofStatus { + status + } +} + private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { private let identity: String private var records: [PaymentRequestRecord] From 90fe6a885eeab744520d25de7d0944f91b93a5c8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 20:15:45 +0200 Subject: [PATCH 6/6] fix: use authoritative broadcast outcomes --- Bitkit/Services/LightningService.swift | 98 +++------- .../Services/PaykitPaymentProofService.swift | 72 +++++++- .../PendingOnchainBroadcastStore.swift | 90 --------- Bitkit/Utilities/Errors.swift | 6 + Bitkit/ViewModels/WalletViewModel.swift | 8 +- .../Wallets/Send/SendConfirmationView.swift | 45 +++++ .../Wallets/Send/SendPendingScreen.swift | 47 +++-- BitkitTests/OnchainSendResultTests.swift | 172 +++++------------- .../PaykitPaymentProofServiceTests.swift | 136 ++++++++++++-- 9 files changed, 351 insertions(+), 323 deletions(-) delete mode 100644 Bitkit/Services/PendingOnchainBroadcastStore.swift diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 9af3871bd..9504319da 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -10,7 +10,6 @@ class LightningService { private var node: Node? var currentWalletIndex: Int = 0 - private let pendingOnchainBroadcastStore: any PendingOnchainBroadcastStoring = PendingOnchainBroadcastStore.shared private let syncStatusChangedSubject = PassthroughSubject() @@ -791,9 +790,7 @@ class LightningService { sats: sats, feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte), utxosToSpend: utxosToSpend, - isMaxAmount: isMaxAmount, - intentStore: pendingOnchainBroadcastStore, - walletIndex: currentWalletIndex + isMaxAmount: isMaxAmount ) } catch { dumpLdkLogs() @@ -807,9 +804,7 @@ class LightningService { sats: UInt64, feeRate: FeeRate, utxosToSpend: [SpendableUtxo]?, - isMaxAmount: Bool, - intentStore: any PendingOnchainBroadcastStoring, - walletIndex: Int + isMaxAmount: Bool ) async throws -> Txid { try await ServiceQueue.background(.ldk) { try executeOnchainSend( @@ -818,9 +813,7 @@ class LightningService { sats: sats, feeRate: feeRate, utxosToSpend: utxosToSpend, - isMaxAmount: isMaxAmount, - intentStore: intentStore, - walletIndex: walletIndex + isMaxAmount: isMaxAmount ) } } @@ -831,78 +824,42 @@ class LightningService { sats: UInt64, feeRate: FeeRate, utxosToSpend: [SpendableUtxo]?, - isMaxAmount: Bool, - intentStore: any PendingOnchainBroadcastStoring, - walletIndex: Int + isMaxAmount: Bool ) throws -> Txid { let pendingBroadcasts = try onchainPayment.listPendingBroadcasts() - for pendingBroadcast in pendingBroadcasts { - intentStore.record( - PendingOnchainBroadcastIntent(activeTxid: pendingBroadcast.txid, lineage: pendingBroadcast.lineage), - walletIndex: walletIndex - ) - } - if let pendingBroadcast = pendingBroadcasts.first { throw ExistingPendingOnchainBroadcastError(txid: pendingBroadcast.txid) } - if let persistedIntent = intentStore.intents(walletIndex: walletIndex).first { - throw ExistingPendingOnchainBroadcastError(txid: persistedIntent.activeTxid) + if isMaxAmount { + return try onchainPayment.sendAllToAddress(address: address, retainReserve: true, feeRate: feeRate) } - do { - if isMaxAmount { - return try onchainPayment.sendAllToAddress(address: address, retainReserve: true, feeRate: feeRate) - } - - return try onchainPayment.sendToAddress( - address: address, - amountSats: sats, - feeRate: feeRate, - utxosToSpend: utxosToSpend - ) - } catch { - if let pendingContext = pendingOnchainBroadcastContext(for: error), pendingContext.source == .currentPayment { - intentStore.record( - PendingOnchainBroadcastIntent(activeTxid: pendingContext.txid, lineage: [pendingContext.txid]), - walletIndex: walletIndex - ) - } - throw error - } + return try onchainPayment.sendToAddress( + address: address, + amountSats: sats, + feeRate: feeRate, + utxosToSpend: utxosToSpend + ) } - func pendingOnchainBroadcast(txid: Txid) async throws -> PendingBroadcastInfo? { + func onchainBroadcastOutcome(txid: Txid) async throws -> BroadcastOutcome? { guard let node else { throw AppError(serviceError: .nodeNotSetup) } return try await ServiceQueue.background(.ldk) { - let pendingBroadcast = try Self.pendingOnchainBroadcast(onchainPayment: node.onchainPayment(), txid: txid) - if let pendingBroadcast { - pendingOnchainBroadcastStore.record( - PendingOnchainBroadcastIntent(activeTxid: pendingBroadcast.txid, lineage: pendingBroadcast.lineage), - walletIndex: currentWalletIndex - ) - } - return pendingBroadcast + try Self.onchainBroadcastOutcome(onchainPayment: node.onchainPayment(), txid: txid) } } - func acceptedOnchainTransaction(reconciling txid: Txid) async throws -> Txid? { + func acknowledgeOnchainBroadcastOutcome(txid: Txid) async throws { guard let node else { throw AppError(serviceError: .nodeNotSetup) } - return try await ServiceQueue.background(.ldk) { - let intent = pendingOnchainBroadcastStore.intents(walletIndex: currentWalletIndex).first(where: { $0.contains(txid) }) - let candidateTxids = intent.map { $0.lineage + [$0.activeTxid] } ?? [txid] - guard let acceptedTxid = Self.acceptedOnchainTransaction(payments: node.listPayments(), candidateTxids: candidateTxids) else { - return nil - } - pendingOnchainBroadcastStore.remove(matching: acceptedTxid, walletIndex: currentWalletIndex) - return acceptedTxid + try await ServiceQueue.background(.ldk) { + try Self.acknowledgeOnchainBroadcastOutcome(onchainPayment: node.onchainPayment(), txid: txid) } } @@ -912,27 +869,20 @@ class LightningService { } return try await ServiceQueue.background(.ldk) { - let acceptedTxid = try Self.rebroadcastOnchainTransaction(onchainPayment: node.onchainPayment(), txid: txid) - pendingOnchainBroadcastStore.remove(matching: txid, walletIndex: currentWalletIndex) - return acceptedTxid + try Self.rebroadcastOnchainTransaction(onchainPayment: node.onchainPayment(), txid: txid) } } - static func pendingOnchainBroadcast(onchainPayment: OnchainPayment, txid: Txid) throws -> PendingBroadcastInfo? { - try onchainPayment.listPendingBroadcasts().first { $0.txid == txid || $0.lineage.contains(txid) } + static func onchainBroadcastOutcome(onchainPayment: OnchainPayment, txid: Txid) throws -> BroadcastOutcome? { + try onchainPayment.broadcastOutcome(txid: txid) } - static func rebroadcastOnchainTransaction(onchainPayment: OnchainPayment, txid: Txid) throws -> Txid { - try onchainPayment.rebroadcastTransaction(txid: txid) + static func acknowledgeOnchainBroadcastOutcome(onchainPayment: OnchainPayment, txid: Txid) throws { + try onchainPayment.acknowledgeBroadcastOutcome(txid: txid) } - static func acceptedOnchainTransaction(payments: [PaymentDetails], candidateTxids: [Txid]) -> Txid? { - for payment in payments { - guard payment.direction == .outbound, payment.status != .failed else { continue } - guard case let .onchain(txid, _) = payment.kind, candidateTxids.contains(txid) else { continue } - return txid - } - return nil + 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 { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 4d109c383..9ea58fa88 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -98,27 +98,32 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { enum PaykitOnchainPaymentProofStatus: Equatable { case pending(activeTxid: Txid) case accepted(txid: Txid) + case abandoned case unknown } protocol PaykitOnchainPaymentProofLookingUp: Sendable { func status(txid: Txid) async -> PaykitOnchainPaymentProofStatus + func acknowledge(txid: Txid) async throws } struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp { func status(txid: Txid) async -> PaykitOnchainPaymentProofStatus { do { - if let pendingBroadcast = try await LightningService.shared.pendingOnchainBroadcast(txid: txid) { - return .pending(activeTxid: pendingBroadcast.txid) + 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 } - if let acceptedTxid = try await LightningService.shared.acceptedOnchainTransaction(reconciling: txid) { - return .accepted(txid: acceptedTxid) - } - return .unknown } catch { return .unknown } } + + func acknowledge(txid: Txid) async throws { + try await LightningService.shared.acknowledgeOnchainBroadcastOutcome(txid: txid) + } } actor PaykitPaymentProofService { @@ -285,11 +290,16 @@ actor PaykitPaymentProofService { pendingProofs[index].paymentIdentifier = txid.lowercased() pendingProofs[index].proofData = txid.lowercased() let completedProof = pendingProofs[index] + var didPersist = false do { try await persist(pendingProofs) + didPersist = true } catch { logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") } + if didPersist, let associatedTxid { + await acknowledgeOnchainOutcome(txid: associatedTxid) + } submitInBackground(completedProof) } catch { logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)") @@ -301,6 +311,20 @@ actor PaykitPaymentProofService { } } + func abandonOnchainPayment(_ request: PaykitPaymentRequest, txid: Txid) async { + do { + let pendingProofs = try await loadProofs() + let remainingProofs = pendingProofs.filter { + !($0.requestId == request.id && $0.kind == .onchain && $0.proofData == nil) + } + guard remainingProofs != pendingProofs else { return } + try await persist(remainingProofs) + await acknowledgeOnchainOutcome(txid: txid) + } catch { + logWarning("Failed to clear an abandoned Paykit on-chain payment proof: \(error)") + } + } + func failLightningPayment(paymentHash: String) async { await removeProofs { $0.kind == .lightning && $0.paymentIdentifier?.caseInsensitiveCompare(paymentHash) == .orderedSame @@ -353,6 +377,8 @@ actor PaykitPaymentProofService { } case let .accepted(txid): await completePersistedOnchainPayment(proof, acceptedTxid: txid) + case .abandoned: + await abandonPersistedOnchainPayment(proof) case .unknown: continue } @@ -393,12 +419,44 @@ actor PaykitPaymentProofService { pendingProofs[index].paymentIdentifier = acceptedTxid.lowercased() pendingProofs[index].proofData = acceptedTxid.lowercased() let completedProof = pendingProofs[index] - await persistAndSubmit([completedProof], allProofs: pendingProofs) + var didPersist = false + do { + try await persist(pendingProofs) + didPersist = true + } catch { + logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") + } + if didPersist { + await acknowledgeOnchainOutcome(txid: associatedTxid) + } + await submit(completedProof) } 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) + 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)") + } + } + private func submit(_ pendingProof: PendingPaykitPaymentProof) async { guard let proofData = pendingProof.proofData else { return } do { diff --git a/Bitkit/Services/PendingOnchainBroadcastStore.swift b/Bitkit/Services/PendingOnchainBroadcastStore.swift deleted file mode 100644 index 261ce6a7f..000000000 --- a/Bitkit/Services/PendingOnchainBroadcastStore.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation -import LDKNode - -struct PendingOnchainBroadcastIntent: Codable, Equatable { - var activeTxid: Txid - var lineage: [Txid] - - init(activeTxid: Txid, lineage: [Txid]) { - self.activeTxid = activeTxid - self.lineage = Array(Set(lineage + [activeTxid])).sorted() - } - - func contains(_ txid: Txid) -> Bool { - activeTxid == txid || lineage.contains(txid) - } -} - -protocol PendingOnchainBroadcastStoring: AnyObject { - func intents(walletIndex: Int) -> [PendingOnchainBroadcastIntent] - func record(_ intent: PendingOnchainBroadcastIntent, walletIndex: Int) - func remove(matching txid: Txid, walletIndex: Int) -} - -final class PendingOnchainBroadcastStore: PendingOnchainBroadcastStoring { - static let shared = PendingOnchainBroadcastStore() - - private struct State: Codable { - var wallets: [String: [PendingOnchainBroadcastIntent]] - } - - private let defaults: UserDefaults - private let key = "pendingOnchainBroadcastIntents" - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - func intents(walletIndex: Int) -> [PendingOnchainBroadcastIntent] { - state().wallets[String(walletIndex)] ?? [] - } - - func record(_ intent: PendingOnchainBroadcastIntent, walletIndex: Int) { - var state = state() - let walletKey = String(walletIndex) - var intents = state.wallets[walletKey] ?? [] - let matchingIndexes = intents.indices.filter { existingIndex in - let existing = intents[existingIndex] - return existing.contains(intent.activeTxid) || intent.lineage.contains(where: existing.contains) - } - let mergedLineage = matchingIndexes.reduce(into: intent.lineage) { result, index in - result.append(contentsOf: intents[index].lineage) - result.append(intents[index].activeTxid) - } - for index in matchingIndexes.reversed() { - intents.remove(at: index) - } - intents.append(PendingOnchainBroadcastIntent(activeTxid: intent.activeTxid, lineage: mergedLineage)) - state.wallets[walletKey] = intents - save(state) - } - - func remove(matching txid: Txid, walletIndex: Int) { - var state = state() - let walletKey = String(walletIndex) - guard var intents = state.wallets[walletKey] else { return } - intents.removeAll { $0.contains(txid) } - if intents.isEmpty { - state.wallets.removeValue(forKey: walletKey) - } else { - state.wallets[walletKey] = intents - } - save(state) - } - - private func state() -> State { - guard let data = defaults.data(forKey: key), - let state = try? JSONDecoder().decode(State.self, from: data) - else { return State(wallets: [:]) } - return state - } - - private func save(_ state: State) { - guard !state.wallets.isEmpty else { - defaults.removeObject(forKey: key) - return - } - guard let data = try? JSONEncoder().encode(state) else { return } - defaults.set(data, forKey: key) - } -} diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index bb544ff43..11296cc29 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -77,6 +77,12 @@ struct ExistingPendingOnchainBroadcastError: LocalizedError { } } +struct AbandonedOnchainBroadcastError: LocalizedError { + var errorDescription: String? { + "The on-chain transaction was abandoned before broadcast." + } +} + enum PendingOnchainBroadcastSource: Equatable { case currentPayment case existingPayment diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 4c8f2ac19..6f65c5c9c 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -622,12 +622,12 @@ class WalletViewModel: ObservableObject { return txid } - func pendingOnchainBroadcast(txid: Txid) async throws -> PendingBroadcastInfo? { - try await lightningService.pendingOnchainBroadcast(txid: txid) + func onchainBroadcastOutcome(txid: Txid) async throws -> BroadcastOutcome? { + try await lightningService.onchainBroadcastOutcome(txid: txid) } - func acceptedOnchainTransaction(reconciling txid: Txid) async throws -> Txid? { - try await lightningService.acceptedOnchainTransaction(reconciling: txid) + func acknowledgeOnchainBroadcastOutcome(txid: Txid) async throws { + try await lightningService.acknowledgeOnchainBroadcastOutcome(txid: txid) } func rebroadcastOnchainTransaction(txid: Txid) async throws -> Txid { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index f52ba83c5..b228c0885 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -173,6 +173,9 @@ struct SendConfirmationView: View { amountSats: pendingOnchainBroadcast.completion?.amountSats, onAccepted: { acceptedTxid in await resolvePendingOnchainBroadcast(pendingOnchainBroadcast, acceptedTxid: acceptedTxid) + }, + onAbandoned: { + await resolveAbandonedOnchainBroadcast(pendingOnchainBroadcast) } ) } else { @@ -808,6 +811,39 @@ struct SendConfirmationView: View { } } + @MainActor + private func resolveAbandonedOnchainBroadcast(_ pending: PendingOnchainBroadcast) async { + guard pendingOnchainBroadcast?.txid == pending.txid else { return } + + if let completion = pending.completion { + 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 + ))) + + 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, @@ -853,6 +889,15 @@ struct SendConfirmationView: View { wallet.sendAmountSats = completion.amountSats pendingOnchainBroadcast = nil + + if let associatedTxid, completion.incomingPaymentRequest == nil { + do { + try await wallet.acknowledgeOnchainBroadcastOutcome(txid: associatedTxid) + } 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)) } diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 192aa2dca..a37355e2a 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -28,6 +28,7 @@ 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 @@ -37,10 +38,16 @@ struct OnchainBroadcastPendingScreen: View { @State private var hasResolved = false @State private var activeTxid: Txid - init(txid: Txid, amountSats: UInt64?, onAccepted: @escaping (Txid) async -> Void) { + 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) } @@ -98,14 +105,9 @@ struct OnchainBroadcastPendingScreen: View { } do { - if let pendingBroadcast = try await wallet.pendingOnchainBroadcast(txid: txid) { - activeTxid = pendingBroadcast.txid - return - } - guard let acceptedTxid = try await wallet.acceptedOnchainTransaction(reconciling: txid) else { return } - await accept(acceptedTxid) + try await applyBroadcastOutcome() } catch { - Logger.warn("Failed to read pending on-chain broadcasts: \(error)", context: "OnchainBroadcastPendingScreen") + Logger.warn("Failed to reconcile on-chain broadcast outcome: \(error)", context: "OnchainBroadcastPendingScreen") } } @@ -120,25 +122,42 @@ struct OnchainBroadcastPendingScreen: View { await accept(acceptedTxid) } catch { do { - if let pendingBroadcast = try await wallet.pendingOnchainBroadcast(txid: txid) { - activeTxid = pendingBroadcast.txid - } else if let acceptedTxid = try await wallet.acceptedOnchainTransaction(reconciling: txid) { - await accept(acceptedTxid) - return - } + 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 { diff --git a/BitkitTests/OnchainSendResultTests.swift b/BitkitTests/OnchainSendResultTests.swift index bf7ce52fc..a28788f44 100644 --- a/BitkitTests/OnchainSendResultTests.swift +++ b/BitkitTests/OnchainSendResultTests.swift @@ -4,12 +4,6 @@ import XCTest final class OnchainSendResultTests: XCTestCase { private let feeRate = FeeRate.fromSatPerKwu(satKwu: 253) - private var intentStore: PendingOnchainBroadcastMemoryStore! - - override func setUp() { - super.setUp() - intentStore = PendingOnchainBroadcastMemoryStore() - } func testAmountSendReturnsAcceptedTransactionId() throws { let payment = FakeOnchainPayment { "accepted-txid" } @@ -20,9 +14,7 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: false, - intentStore: intentStore, - walletIndex: 0 + isMaxAmount: false ) XCTAssertEqual(txid, "accepted-txid") @@ -38,9 +30,7 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: true, - intentStore: intentStore, - walletIndex: 0 + isMaxAmount: true ) XCTAssertEqual(txid, "accepted-max-txid") @@ -135,9 +125,7 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: false, - intentStore: intentStore, - walletIndex: 0 + isMaxAmount: false )) { error in XCTAssertEqual( pendingOnchainBroadcastContext(for: error), @@ -160,9 +148,7 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: true, - intentStore: intentStore, - walletIndex: 0 + isMaxAmount: true )) XCTAssertNil(payment.invocation) } @@ -172,13 +158,11 @@ final class OnchainSendResultTests: XCTestCase { async let firstResult = pendingContextForSerializedSend( payment: payment, - feeRate: feeRate, - intentStore: intentStore + feeRate: feeRate ) async let secondResult = pendingContextForSerializedSend( payment: payment, - feeRate: feeRate, - intentStore: intentStore + feeRate: feeRate ) let contexts = await [firstResult, secondResult] @@ -196,52 +180,32 @@ final class OnchainSendResultTests: XCTestCase { XCTAssertEqual(payment.invocation, .rebroadcast(txid: "pending-txid")) } - func testPendingLookupFollowsOriginalTransactionToActiveReplacement() throws { - let replacement = PendingBroadcastInfo( + func testPendingOutcomeFollowsOriginalTransactionToActiveReplacement() throws { + let replacement = BroadcastOutcome( + status: .pending, txid: "replacement-txid", lineage: ["original-txid", "replacement-txid"] ) - let payment = FakeOnchainPayment(pendingResult: { [replacement] }) { "unused" } + let payment = FakeOnchainPayment(outcomeResult: { _ in replacement }) { "unused" } - let pending = try LightningService.pendingOnchainBroadcast( + let outcome = try LightningService.onchainBroadcastOutcome( onchainPayment: payment, txid: "original-txid" ) - XCTAssertEqual(pending, replacement) + XCTAssertEqual(outcome, replacement) + XCTAssertEqual(payment.invocation, .broadcastOutcome(txid: "original-txid")) } - func testAcceptanceUnknownIntentBlocksFreshSpendAfterPendingListDisappears() { - let firstPayment = FakeOnchainPayment { throw NodeError.OnchainTxBroadcastTimeout(txid: "pending-txid") } + func testTerminalOutcomeAcknowledgesOriginalLineageTransaction() throws { + let payment = FakeOnchainPayment { "unused" } - XCTAssertThrowsError(try LightningService.executeOnchainSend( - onchainPayment: firstPayment, - address: "recipient", - sats: 1000, - feeRate: feeRate, - utxosToSpend: nil, - isMaxAmount: false, - intentStore: intentStore, - walletIndex: 0 - )) + try LightningService.acknowledgeOnchainBroadcastOutcome( + onchainPayment: payment, + txid: "original-txid" + ) - let reopenedPayment = FakeOnchainPayment { "unsafe-fresh-txid" } - XCTAssertThrowsError(try LightningService.executeOnchainSend( - onchainPayment: reopenedPayment, - address: "recipient", - sats: 1000, - feeRate: feeRate, - utxosToSpend: nil, - isMaxAmount: false, - intentStore: intentStore, - walletIndex: 0 - )) { error in - XCTAssertEqual( - pendingOnchainBroadcastContext(for: error), - PendingOnchainBroadcastErrorContext(txid: "pending-txid", source: .existingPayment) - ) - } - XCTAssertNil(reopenedPayment.invocation) + XCTAssertEqual(payment.invocation, .acknowledgeOutcome(txid: "original-txid")) } func testConclusiveErrorsDoNotPersistFreshSpendBlocker() throws { @@ -256,9 +220,7 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: false, - intentStore: intentStore, - walletIndex: 0 + isMaxAmount: false )) let nextPayment = FakeOnchainPayment { "accepted-txid" } @@ -268,54 +230,25 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: false, - intentStore: intentStore, - walletIndex: 0 + isMaxAmount: false ), "accepted-txid") } } - func testMissedAcceptedReplacementRemainsUnresolvedWithoutVisibleLineage() { - let replacement = PaymentDetails( - id: "replacement-txid", - kind: .onchain(txid: "replacement-txid", status: .unconfirmed), - amountMsat: 1_000_000, - feePaidMsat: 1000, - direction: .outbound, - status: .pending, - latestUpdateTimestamp: 1 + func testAcceptedReplacementRemainsResolvableAfterPendingWindow() throws { + let replacement = BroadcastOutcome( + status: .accepted, + txid: "replacement-txid", + lineage: ["original-txid", "replacement-txid"] ) + let payment = FakeOnchainPayment(outcomeResult: { _ in replacement }) { "unused" } - XCTAssertNil(LightningService.acceptedOnchainTransaction( - payments: [replacement], - candidateTxids: ["original-txid"] - )) - XCTAssertEqual(LightningService.acceptedOnchainTransaction( - payments: [replacement], - candidateTxids: ["original-txid", "replacement-txid"] - ), "replacement-txid") - } - - func testPendingIntentPersistsAcrossStoreInstancesAndTracksReplacementLineage() throws { - let suiteName = "PendingOnchainBroadcastStoreTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - PendingOnchainBroadcastStore(defaults: defaults).record( - PendingOnchainBroadcastIntent(activeTxid: "original-txid", lineage: ["original-txid"]), - walletIndex: 0 - ) - PendingOnchainBroadcastStore(defaults: defaults).record( - PendingOnchainBroadcastIntent(activeTxid: "replacement-txid", lineage: ["original-txid", "replacement-txid"]), - walletIndex: 0 + let outcome = try LightningService.onchainBroadcastOutcome( + onchainPayment: payment, + txid: "original-txid" ) - let restartedStore = PendingOnchainBroadcastStore(defaults: defaults) - let intent = try XCTUnwrap(restartedStore.intents(walletIndex: 0).first) - XCTAssertEqual(intent.activeTxid, "replacement-txid") - XCTAssertTrue(intent.contains("original-txid")) - XCTAssertTrue(intent.contains("replacement-txid")) - XCTAssertTrue(restartedStore.intents(walletIndex: 1).isEmpty) + XCTAssertEqual(outcome, replacement) } private func assertBroadcastErrorIsPropagated( @@ -332,9 +265,7 @@ final class OnchainSendResultTests: XCTestCase { sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: false, - intentStore: intentStore, - walletIndex: 0 + isMaxAmount: false ), file: file, line: line) { error in XCTAssertEqual(sendFailureType(for: error), expectedFailureType, file: file, line: line) } @@ -343,8 +274,7 @@ final class OnchainSendResultTests: XCTestCase { private func pendingContextForSerializedSend( payment: OnchainPayment, - feeRate: FeeRate, - intentStore: any PendingOnchainBroadcastStoring + feeRate: FeeRate ) async -> PendingOnchainBroadcastErrorContext? { do { _ = try await LightningService.performOnchainSend( @@ -353,9 +283,7 @@ private func pendingContextForSerializedSend( sats: 1000, feeRate: feeRate, utxosToSpend: nil, - isMaxAmount: false, - intentStore: intentStore, - walletIndex: 0 + isMaxAmount: false ) return nil } catch { @@ -368,21 +296,26 @@ private final class FakeOnchainPayment: OnchainPayment { 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()) } @@ -414,6 +347,15 @@ private final class FakeOnchainPayment: OnchainPayment { 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 { @@ -448,19 +390,3 @@ private final class SerializingFakeOnchainPayment: OnchainPayment { throw NodeError.OnchainTxBroadcastTimeout(txid: "pending-txid") } } - -private final class PendingOnchainBroadcastMemoryStore: PendingOnchainBroadcastStoring { - private var records: [Int: [PendingOnchainBroadcastIntent]] = [:] - - func intents(walletIndex: Int) -> [PendingOnchainBroadcastIntent] { - records[walletIndex] ?? [] - } - - func record(_ intent: PendingOnchainBroadcastIntent, walletIndex: Int) { - records[walletIndex] = [intent] - } - - func remove(matching txid: Txid, walletIndex: Int) { - records[walletIndex]?.removeAll { $0.contains(txid) } - } -} diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index fc5efab7d..d35238a9b 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -158,11 +158,21 @@ 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) - 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) @@ -174,7 +184,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 { @@ -205,7 +217,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await completionTask.value } - func testAcceptanceUnknownOnchainProofRemainsPending() async throws { + 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())) @@ -232,12 +244,18 @@ final class PaykitPaymentProofServiceTests: XCTestCase { 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, onchainStatus: .pending(activeTxid: txid)) try await initialService.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) try await initialService.associateOnchainPayment(request, txid: txid) - let restartedService = paymentProofService(sdk: sdk, store: store, onchainStatus: .accepted(txid: txid)) + let restartedService = paymentProofService( + sdk: sdk, + store: store, + onchainStatus: .accepted(txid: txid), + onchainAcknowledgements: acknowledgements + ) await restartedService.reconcile() let lastSubmission = await sdk.lastSubmission() @@ -248,7 +266,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 testVisiblePendingReplacementAdvancesProofAssociationWithoutSubmitting() async throws { @@ -276,7 +296,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(submissionCount, 0) } - func testMissedAcceptedReplacementRemainsUnresolvedWithoutAuthoritativeMapping() async throws { + 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())) @@ -296,6 +316,70 @@ final class PaykitPaymentProofServiceTests: XCTestCase { 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 store = PaymentProofMemoryStore() + 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.associateOnchainPayment(request, txid: originalTxid) + + let restartedService = paymentProofService( + sdk: sdk, + store: store, + onchainStatus: .accepted(txid: replacementTxid), + onchainAcknowledgements: acknowledgements + ) + await restartedService.reconcile() + + let lastSubmission = await sdk.lastSubmission() + let submittedProof = try XCTUnwrap(lastSubmission) + XCTAssertEqual(submittedProof.paymentEndpointIdentifier, endpoint) + XCTAssertEqual( + try proofValues(submittedProof.proof.exportText()), + ["data": replacementTxid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + let remainingProofs = await store.snapshot() + let acknowledgedTxids = await acknowledgements.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + 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.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 testLightningRetryPreservesEarlierPaymentCorrelation() async throws { let record = try paymentRequestRecord() let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -351,7 +435,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())) @@ -362,22 +446,31 @@ 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) 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 testOnchainPaymentSubmitsWhenPreparedProofCannotBeLoaded() async throws { @@ -426,13 +519,17 @@ final class PaykitPaymentProofServiceTests: XCTestCase { sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, lightningStatus: PaykitLightningPaymentProofStatus = .unknown, - onchainStatus: PaykitOnchainPaymentProofStatus = .unknown + onchainStatus: PaykitOnchainPaymentProofStatus = .unknown, + onchainAcknowledgements: PaymentProofAcknowledgements? = nil ) -> PaykitPaymentProofService { PaykitPaymentProofService( sdk: sdk, store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), - onchainPaymentLookup: PaymentProofOnchainLookup(status: onchainStatus), + onchainPaymentLookup: PaymentProofOnchainLookup( + status: onchainStatus, + acknowledgements: onchainAcknowledgements + ), logInfo: { _ in }, logWarning: { _ in } ) @@ -554,10 +651,27 @@ private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp private struct PaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { let status: PaykitOnchainPaymentProofStatus + let acknowledgements: PaymentProofAcknowledgements? func status(txid _: Txid) async -> PaykitOnchainPaymentProofStatus { status } + + 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 {