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..b980d9dc1 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -243,212 +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 .OnchainWalletAccountNotRegistered(message: ldkMessage): + case .OnchainTxBroadcastRejected: + message = "Onchain transaction was rejected" + case .OnchainTxBroadcastFailed: + message = "Failed to broadcast onchain transaction" + case .OnchainTxBroadcastTimeout: + message = "Onchain transaction broadcast timed out" + 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 new file mode 100644 index 000000000..424111949 --- /dev/null +++ b/BitkitTests/OnchainSendResultTests.swift @@ -0,0 +1,149 @@ +@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(txid: "rejected-txid"), + expectedFailureType: "OnchainTxBroadcastRejected" + ) + } + + func testFailedBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastFailed(txid: "failed-txid"), + expectedFailureType: "OnchainTxBroadcastFailed" + ) + } + + func testTimedOutBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastTimeout(txid: "timed-out-txid"), + expectedFailureType: "OnchainTxBroadcastTimeout" + ) + } + + func testNotDispatchedBroadcastIsPropagated() { + assertBroadcastErrorIsPropagated( + .OnchainTxBroadcastNotDispatched(txid: "not-dispatched-txid"), + expectedFailureType: "OnchainTxBroadcastNotDispatched" + ) + } + + func testBroadcastErrorsRemainIdentifiableWhenWrapped() { + let cases: [(NodeError, String, String, String)] = [ + (.OnchainTxBroadcastRejected(txid: "rejected-txid"), "Onchain transaction was rejected", "OnchainTxBroadcastRejected", "rejected-txid"), + (.OnchainTxBroadcastFailed(txid: "failed-txid"), "Failed to broadcast onchain transaction", "OnchainTxBroadcastFailed", "failed-txid"), + ( + .OnchainTxBroadcastTimeout(txid: "timed-out-txid"), + "Onchain transaction broadcast timed out", + "OnchainTxBroadcastTimeout", + "timed-out-txid" + ), + ( + .OnchainTxBroadcastNotDispatched(txid: "not-dispatched-txid"), + "Onchain transaction was not dispatched", + "OnchainTxBroadcastNotDispatched", + "not-dispatched-txid" + ), + ] + + for (nodeError, expectedMessage, expectedFailureType, expectedTxid) in cases { + let appError = Bitkit.AppError(error: nodeError) + + XCTAssertEqual(appError.message, expectedMessage) + XCTAssertTrue(appError.debugMessage?.contains(expectedTxid) == true) + XCTAssertEqual(sendFailureType(for: appError), expectedFailureType) + } + } + + 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/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)) 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..9721af411 --- /dev/null +++ b/scripts/reject-electrum-broadcast.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +const crypto = require("node:crypto") +const net = require("node:net") + +function option(name, fallback) { + const index = process.argv.indexOf(`--${name}`) + return index >= 0 ? process.argv[index + 1] : fallback +} + +const listenHost = option("listen-host", "127.0.0.1") +const listenPort = Number(option("listen-port", "61001")) +const upstreamHost = option("upstream-host", "127.0.0.1") +const upstreamPort = Number(option("upstream-port", "60001")) +const rejectionMessage = option("message", "non-final") + +function rejection(request) { + return { + jsonrpc: request.jsonrpc ?? "2.0", + id: request.id, + error: { code: -26, message: rejectionMessage }, + } +} + +function doubleSha256(transaction) { + const firstHash = crypto.createHash("sha256").update(transaction).digest() + return Buffer.from(crypto.createHash("sha256").update(firstHash).digest()).reverse().toString("hex") +} + +function compactSize(transaction, offset) { + const prefix = transaction[offset] + if (prefix === undefined) throw new Error("missing compact size") + if (prefix < 0xfd) return { value: prefix, nextOffset: offset + 1 } + + const byteLength = prefix === 0xfd ? 2 : prefix === 0xfe ? 4 : 8 + const valueOffset = offset + 1 + const nextOffset = valueOffset + byteLength + if (nextOffset > transaction.length) throw new Error("truncated compact size") + + const value = + byteLength === 2 + ? BigInt(transaction.readUInt16LE(valueOffset)) + : byteLength === 4 + ? BigInt(transaction.readUInt32LE(valueOffset)) + : transaction.readBigUInt64LE(valueOffset) + if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("compact size is too large") + + return { value: Number(value), nextOffset } +} + +function skip(transaction, offset, byteLength) { + const nextOffset = offset + byteLength + if (nextOffset > transaction.length) throw new Error("truncated transaction") + return nextOffset +} + +function transactionId(request) { + const rawTransaction = request.params?.[0] + if (typeof rawTransaction !== "string" || rawTransaction.length === 0 || rawTransaction.length % 2 !== 0) { + return "unknown" + } + + try { + const transaction = Buffer.from(rawTransaction, "hex") + if (transaction.length < 10 || transaction[4] !== 0 || transaction[5] === 0) return doubleSha256(transaction) + + let offset = 6 + const bodyOffset = offset + const inputs = compactSize(transaction, offset) + offset = inputs.nextOffset + for (let input = 0; input < inputs.value; input += 1) { + offset = skip(transaction, offset, 36) + const script = compactSize(transaction, offset) + offset = skip(transaction, script.nextOffset, script.value + 4) + } + + const outputs = compactSize(transaction, offset) + offset = outputs.nextOffset + for (let output = 0; output < outputs.value; output += 1) { + offset = skip(transaction, offset, 8) + const script = compactSize(transaction, offset) + offset = skip(transaction, script.nextOffset, script.value) + } + const outputsEndOffset = offset + + for (let input = 0; input < inputs.value; input += 1) { + const items = compactSize(transaction, offset) + offset = items.nextOffset + for (let item = 0; item < items.value; item += 1) { + const witness = compactSize(transaction, offset) + offset = skip(transaction, witness.nextOffset, witness.value) + } + } + + if (offset + 4 !== transaction.length) throw new Error("unexpected transaction length") + const transactionWithoutWitness = Buffer.concat([ + transaction.subarray(0, 4), + transaction.subarray(bodyOffset, outputsEndOffset), + transaction.subarray(offset), + ]) + return doubleSha256(transactionWithoutWitness) + } catch { + return "unknown" + } +} + +function forwardClientLines(client, upstream) { + let buffered = "" + + client.on("data", chunk => { + buffered += chunk.toString("utf8") + const lines = buffered.split("\n") + buffered = lines.pop() ?? "" + + for (const line of lines) { + if (line.length === 0) continue + + let request + try { + request = JSON.parse(line) + } catch { + upstream.write(`${line}\n`) + continue + } + + if (!Array.isArray(request) && request.method === "blockchain.transaction.broadcast") { + process.stdout.write(`Rejected Electrum broadcast ${transactionId(request)}: ${rejectionMessage}\n`) + client.write(`${JSON.stringify(rejection(request))}\n`) + } else { + upstream.write(`${line}\n`) + } + } + }) +} + +const server = net.createServer(client => { + const upstream = net.createConnection({ host: upstreamHost, port: upstreamPort }) + + forwardClientLines(client, upstream) + upstream.pipe(client) + + client.on("error", () => upstream.destroy()) + upstream.on("error", error => client.destroy(error)) + client.on("close", () => upstream.destroy()) + upstream.on("close", () => client.destroy()) +}) + +server.listen(listenPort, listenHost, () => { + process.stdout.write( + `Electrum rejection proxy listening on ${listenHost}:${listenPort}, forwarding to ${upstreamHost}:${upstreamPort}\n` + ) +}) + +process.on("SIGINT", () => server.close()) +process.on("SIGTERM", () => server.close())