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())