From c15b1fce23b98b51d45f52c3c8295c4262f414cb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 17:27:16 +0100 Subject: [PATCH] chore: breez sdk 0.28.3 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Managers/SparkManager.swift | 19 ++ Bitkit/Services/LightningService.swift | 71 ++++++-- BitkitTests/ChannelFreeRailTests.swift | 165 ++++++++++++++++-- 5 files changed, 227 insertions(+), 34 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 91f26a99f..6a1419141 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1262,7 +1262,7 @@ repositoryURL = "https://github.com/breez/breez-sdk-spark-swift"; requirement = { kind = exactVersion; - version = 0.28.2; + version = 0.28.3; }; }; 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d0fcdd4a9..b9265674c 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/breez/breez-sdk-spark-swift", "state" : { - "revision" : "063f6ad1184674b77400c3a8f8e83bd9cd35dfd0", - "version" : "0.28.2" + "revision" : "30f5767605ddd02e008f9b6bbd42c06cbbc3fa25", + "version" : "0.28.3" } }, { diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index bb0aeff7a..8fe1a83ef 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -708,6 +708,25 @@ final class SparkManager: ChannelFreeLightningRail { return lastSyncedAt > seconds } + nonisolated func lightningSendStatus(bolt11: String) async throws -> LDKNode.PaymentStatus? { + let response = try await requireSdk().fetchLightningSendStatus(request: FetchLightningSendStatusRequest(invoice: bolt11)) + return response.status.map(Self.ldkStatus) + } + + nonisolated func mayPayBySparkTransfer(bolt11: String, sats: UInt64?) async throws -> Bool { + // Only then does the invoice's Spark address matter, so no other network prepares the payment for it. + guard Self.prefersSpark else { return false } + let prepared = try await prepareSend(paymentRequest: bolt11, amountSats: sats) + guard case let .bolt11Invoice(_, sparkTransferFeeSats, _) = prepared.paymentMethod else { return false } + return Self.paysBySparkTransfer(prefersSpark: Self.prefersSpark, sparkTransferFeeSats: sparkTransferFeeSats) + } + + /// The SDK quotes a Spark transfer fee only for an invoice that carries a Spark address, and sends to that address + /// only when the send prefers Spark. + nonisolated static func paysBySparkTransfer(prefersSpark: Bool, sparkTransferFeeSats: UInt64?) -> Bool { + prefersSpark && sparkTransferFeeSats != nil + } + nonisolated func lightningPayments() async throws -> [ChannelFreeRailPayment] { let payments = try await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments return payments.compactMap { payment in diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 5dff60a31..4e512d8b4 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -20,6 +20,11 @@ protocol ChannelFreeLightningRail: AnyObject { /// Whether the rail's last wallet sync reached it, and started after `seconds` since 1970 when given. The rail /// finishes an interrupted send only on such a sync, so before one its list can lack a send that went out. func hasSynced(after seconds: UInt64?) async -> Bool + /// What the operators show of this wallet's Lightning sends of `bolt11`, nil when they show none. Throws when they + /// can't be asked or the answer can't be read. + func lightningSendStatus(bolt11: String) async throws -> PaymentStatus? + /// Whether this app would have paid `bolt11` by a direct Spark transfer, which `lightningSendStatus` can't see. + func mayPayBySparkTransfer(bolt11: String, sats: UInt64?) async throws -> Bool } @MainActor @@ -1065,6 +1070,8 @@ class LightningService: NodeLifecycleService { if let paid = try await settledEarlierAttempt( paymentHash: paymentHash, on: [.ldk, .channelFree], + bolt11: bolt11, + sats: sats, invoiceCreatedAt: invoice?.secondsSinceEpoch(), invoiceExpiresAt: expiresAt ) { @@ -1129,6 +1136,8 @@ class LightningService: NodeLifecycleService { if let paid = try await settledEarlierAttempt( paymentHash: paymentHash, on: [.channelFree], + bolt11: bolt11, + sats: sats, invoiceCreatedAt: invoice?.secondsSinceEpoch(), invoiceExpiresAt: expiresAt ) { @@ -1198,7 +1207,7 @@ class LightningService: NodeLifecycleService { /// there. Until then an unlistable node refuses every Spark payment. static let ldkAttemptsSeededKey = "ldkAttemptedPaymentHashesSeeded" /// When the wallet was restored, in seconds since 1970, until Spark lists its sends after a sync since. No backup - /// keeps `railAttemptsKey`, so until then an invoice made before the restore may be one Spark already paid. + /// keeps `railAttemptsKey`, so that listing fills it, and the sends Spark listed hold the node after a relaunch. static let sparkHistoryUnseededSinceKey = "sparkHistoryUnseededSince" /// Called as a wallet is restored, before Spark starts. @@ -1212,7 +1221,8 @@ class LightningService: NodeLifecycleService { /// When the wallet was restored, in seconds since 1970. Spark can report a sync while its payment history failed to /// sync, so its list can lack a send it made before then, and a still-valid invoice created before it that Spark - /// hasn't listed as pending or paid is refused on either rail until it expires. + /// hasn't listed as pending or paid is refused on either rail until it expires, unless Spark's operators confirm + /// no send of it. static let sparkRestoredAtKey = "sparkRestoredAt" var sparkRestoredAt: Int? { @@ -1233,7 +1243,7 @@ class LightningService: NodeLifecycleService { eventCallbackLock.withLock { railAttemptsStore.object(forKey: Self.sparkHistoryUnseededSinceKey) as? Int } } - /// Reads Spark's sends after its sync, which ends the hold on invoices made before a restore. + /// Reads Spark's sends after its sync, which keeps the ones made before a restore in `railAttemptsKey`. func listRailSendsIfUnseeded() async { guard sparkHistoryUnseededSince != nil else { return } _ = try? await channelFreeRailPayments() @@ -1446,12 +1456,14 @@ class LightningService: NodeLifecycleService { /// An invoice must never go out on both rails: an earlier attempt still pending on `rails` refuses the payment as a /// duplicate, as LDK does its own, and a settled one returns its hash as paid. A failed or unknown one lets the - /// payment go. While either rail can't list its payments, an invoice it was sent over is refused as pending too, and - /// while Spark hasn't listed its sends since a restore, one created (`invoiceCreatedAt`) before it is refused, as - /// is one Spark's list doesn't show pending or paid until it expires (`refuseUnverifiedRestoredInvoice`). + /// payment go. While either rail can't list its payments, an invoice it was sent over is refused as pending too. + /// After a restore, a still-valid invoice created (`invoiceCreatedAt`) before it that Spark's list doesn't show + /// pending or paid is refused unless Spark's operators confirm no send of it (`refuseUnverifiedRestoredInvoice`). private func settledEarlierAttempt( paymentHash: String?, on rails: [LightningPaymentRail], + bolt11: String, + sats: UInt64?, invoiceCreatedAt: UInt64?, invoiceExpiresAt: Int? ) async throws -> PaymentHash? { @@ -1501,7 +1513,8 @@ class LightningService: NodeLifecycleService { releaseRailAttempt(paymentHash: paymentHash) if !isPaidOverTheNode { try await refuseUnverifiedRestoredInvoice( - paymentHash: paymentHash, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt + paymentHash: paymentHash, bolt11: bolt11, sats: sats, + invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt ) } } @@ -1514,13 +1527,10 @@ class LightningService: NodeLifecycleService { throw Self.pendingDuplicate(paymentHash: paymentHash) } else if isPaidOverTheNode { Logger.info("Skipped Spark's restore checks of '\(paymentHash)', the node already paid it", context: "LightningService") - } else if let invoiceCreatedAt, let since = sparkHistoryUnseededSince, Int(clamping: invoiceCreatedAt) < since { - // Nothing left the wallet, and an invoice made after the restore can't be one Spark paid before it. - Logger.warn("Refused paying '\(paymentHash)' until Spark lists its sends since the restore", context: "LightningService") - throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() } else { try await refuseUnverifiedRestoredInvoice( - paymentHash: paymentHash, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt + paymentHash: paymentHash, bolt11: bolt11, sats: sats, + invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt ) } } @@ -1535,18 +1545,45 @@ class LightningService: NodeLifecycleService { return nil } - /// After a restore, refuses a still-valid invoice created before it, whatever Spark's sync said, until it expires. - private func refuseUnverifiedRestoredInvoice(paymentHash: String, invoiceCreatedAt: UInt64?, invoiceExpiresAt: Int?) async throws { + /// After a restore, refuses a still-valid invoice created before it, as Spark's list can lack a send it made then, + /// unless Spark's operators show no Lightning send of it and it can't have been paid by a direct Spark transfer, + /// which they can't see. Any send they show, sent, pending or failed, or no answer, refuses it until it expires. + private func refuseUnverifiedRestoredInvoice( + paymentHash: String, + bolt11: String, + sats: UInt64?, + invoiceCreatedAt: UInt64?, + invoiceExpiresAt: Int? + ) async throws { guard let restoredAt = sparkRestoredAt, Self.holdsRestoredInvoice( restoredAt: restoredAt, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt, now: Int(now().timeIntervalSince1970) ) else { return } - let sparkRuns = channelFreeRail != nil ? true : await channelFreeRailSupported() + let rail = channelFreeRail ?? channelFreePaymentHistory + let sparkRuns = rail != nil ? true : await channelFreeRailSupported() guard sparkRuns else { return } - Logger.warn("Refused paying '\(paymentHash)', a pre-restore invoice Spark hasn't listed", context: "LightningService") - throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() + do { + guard let rail else { throw ChannelFreeRailUnavailable() } + if let status = try await rail.lightningSendStatus(bolt11: bolt11) { + Logger.warn("Refused paying '\(paymentHash)', a pre-restore invoice Spark shows as '\(status)'", context: "LightningService") + throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() + } + if try await rail.mayPayBySparkTransfer(bolt11: bolt11, sats: sats) { + Logger.warn("Refused paying '\(paymentHash)', a pre-restore invoice Spark may have paid by transfer", context: "LightningService") + throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() + } + Logger.info("Spark's operators show no send of pre-restore invoice '\(paymentHash)'", context: "LightningService") + } catch let refusal as ChannelFreeRailSendRefused { + throw refusal + } catch { + Logger.warn( + "Refused paying '\(paymentHash)', Spark couldn't show its sends of a pre-restore invoice: \(error)", + context: "LightningService" + ) + throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() + } } /// An unknown time counts as before the restore and never expiring. diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 74edbb889..1e2dadad2 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -1159,16 +1159,22 @@ extension ChannelFreeRailTests { service.now = previousNow } + /// Sets the clock two minutes past the fixture invoice's creation, while it can still be paid. + private func pinTheClockWhileTheInvoiceIsValid() throws { + let createdAt = try TimeInterval(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).secondsSinceEpoch()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: createdAt + 120) } + } + /// Spark can't list its sends and has no balance to pay the invoice, so the send goes to the node. private func leaveSparkUnsyncedAndEmpty() { rail.syncedAfter = { _ in false } rail.balanceSats = 0 } - private func assertRefusedUntilSparkLists(file: StaticString = #filePath, line: UInt = #line) async { + private func assertRefusedAsUnverifiedPreRestoreInvoice(file: StaticString = #filePath, line: UInt = #line) async { do { _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) - XCTFail("A send of an invoice made before the restore went out before Spark listed its sends", file: file, line: line) + XCTFail("A pre-restore invoice went out though Spark could not confirm no send of it", file: file, line: line) } catch { XCTAssertTrue((error as? Bitkit.ChannelFreeRailSendRefused)?.isRestoredInvoiceUnverified == true, "\(error)", file: file, line: line) } @@ -1195,10 +1201,10 @@ extension ChannelFreeRailTests { await service.listRailSendsIfUnseeded() XCTAssertNil(service.sparkHistoryUnseededSince, "Spark's own list still ends the unseeded hold") - await assertRefusedUntilSparkLists() + await assertRefusedAsUnverifiedPreRestoreInvoice() // A relaunch, before Spark syncs that run. rail.syncedAfter = { _ in false } - await assertRefusedUntilSparkLists() + await assertRefusedAsUnverifiedPreRestoreInvoice() service.now = { Date(timeIntervalSince1970: createdAt + TimeInterval(invoice.expiryTimeSeconds())) } await assertReachesTheNode() @@ -1215,7 +1221,7 @@ extension ChannelFreeRailTests { await service.listRailSendsIfUnseeded() rail.balanceSats = 100_000 - await assertRefusedUntilSparkLists() + await assertRefusedAsUnverifiedPreRestoreInvoice() service.now = { Date(timeIntervalSince1970: createdAt + TimeInterval(invoice.expiryTimeSeconds())) } _ = try await service.send(bolt11: Self.invoice) @@ -1233,7 +1239,7 @@ extension ChannelFreeRailTests { rail.listError = NSError(domain: "spark", code: 1) rail.balanceSats = 100_000 - await assertRefusedUntilSparkLists() + await assertRefusedAsUnverifiedPreRestoreInvoice() } func testAPreRestoreInvoiceTheNodeAlreadyPaidReadsAsPaidInsteadOfRefused() async throws { @@ -1302,18 +1308,21 @@ extension ChannelFreeRailTests { XCTAssertEqual(sendFailureMessage(for: wrapped), t("wallet__payment_restored_invoice")) } - func testAnInvoiceMadeBeforeARestoreIsRefusedOverTheNodeUntilSparkSyncs() async throws { + func testAnInvoiceMadeBeforeARestoreIsRefusedOverTheNodeWhileSparkCanNeitherListNorAnswer() async throws { try restore(secondsAfterTheInvoice: 60) + try pinTheClockWhileTheInvoiceIsValid() leaveSparkUnsyncedAndEmpty() - await assertRefusedUntilSparkLists() + await assertRefusedAsUnverifiedPreRestoreInvoice() } func testAnInvoiceMadeAfterARestorePaysOverTheNodeWhileSparkIsUnsynced() async throws { try restore(secondsAfterTheInvoice: -60) + try pinTheClockWhileTheInvoiceIsValid() leaveSparkUnsyncedAndEmpty() await assertReachesTheNode() + XCTAssertEqual(rail.sendStatusFetches, 0, "An invoice made after the restore was held") } func testAWalletNeverRestoredIsNeverHeldWhileSparkIsUnsynced() async { @@ -1323,15 +1332,17 @@ extension ChannelFreeRailTests { XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) } - func testSparksListingAfterASyncEndsTheRestoreHold() async throws { + func testSparksListingAfterASyncClearsTheUnseededMark() async throws { try restore(secondsAfterTheInvoice: 60) + try pinTheClockWhileTheInvoiceIsValid() leaveSparkUnsyncedAndEmpty() - await assertRefusedUntilSparkLists() + await assertRefusedAsUnverifiedPreRestoreInvoice() + Bitkit.LightningService.shared.now = previousNow // What Spark's synced event does once its sync reached it. rail.syncedAfter = { _ in true } await Bitkit.LightningService.shared.listRailSendsIfUnseeded() - // A later outage no longer holds the invoice: Spark's list showed no send of it, and it has expired. + // On the real clock the invoice has expired, so a later outage no longer holds it. rail.syncedAfter = { _ in false } XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) @@ -1359,13 +1370,123 @@ extension ChannelFreeRailTests { } } - func testAnInvoiceMadeBeforeARestoreIsRefusedOverAFundedSparkUntilSparkSyncs() async throws { + func testAnInvoiceMadeBeforeARestoreIsRefusedOverAFundedSparkThatCanNeitherListNorAnswer() async throws { try restore(secondsAfterTheInvoice: 60) - // Spark is ready and funded, but no sync since the restore has listed its sends. + try pinTheClockWhileTheInvoiceIsValid() + // Spark is ready and funded, but no sync since the restore has listed its sends, and its operators can't be reached. rail.syncedAfter = { _ in false } rail.balanceSats = 100_000 - await assertRefusedUntilSparkLists() + await assertRefusedAsUnverifiedPreRestoreInvoice() + } + + // MARK: - Restore hold: Spark's operators + + /// A restored wallet whose Spark synced and listed no send of the pre-restore invoice, with the clock inside its + /// validity, so the operators decide. `sparkFunded` sends over Spark, otherwise the send goes to the node. + private func holdForTheOperators(sparkFunded: Bool) async throws { + try restore(secondsAfterTheInvoice: 60) + try pinTheClockWhileTheInvoiceIsValid() + rail.syncedAfter = { _ in true } + await Bitkit.LightningService.shared.listRailSendsIfUnseeded() + rail.balanceSats = sparkFunded ? 100_000 : 0 + } + + private func assertOperatorsStatusRefusesBothRails( + _ status: LDKNode.PaymentStatus, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + for sparkFunded in [false, true] { + try await holdForTheOperators(sparkFunded: sparkFunded) + rail.sendStatus = .success(status) + rail.sendStatusFetches = 0 + + await assertRefusedAsUnverifiedPreRestoreInvoice(file: file, line: line) + XCTAssertEqual(rail.sendStatusFetches, 1, "Spark funded: \(sparkFunded)", file: file, line: line) + XCTAssertEqual(rail.sparkTransferChecks, 0, "A send the operators show needs no Spark address check", file: file, line: line) + } + } + + func testAPreRestoreInvoiceTheOperatorsShowCompletedIsRefusedOnBothRails() async throws { + try await assertOperatorsStatusRefusesBothRails(.succeeded) + } + + func testAPreRestoreInvoiceTheOperatorsShowPendingIsRefusedOnBothRails() async throws { + try await assertOperatorsStatusRefusesBothRails(.pending) + } + + func testAPreRestoreInvoiceTheOperatorsShowFailedIsRefusedOnBothRails() async throws { + try await assertOperatorsStatusRefusesBothRails(.failed) + } + + func testAPreRestoreInvoiceTheOperatorsShowNoSendOfPaysOverTheNode() async throws { + try await holdForTheOperators(sparkFunded: false) + rail.sendStatus = .success(nil) + + await assertReachesTheNode() + XCTAssertEqual(rail.sendStatusFetches, 1) + XCTAssertEqual(rail.sparkTransferChecks, 1) + } + + func testAPreRestoreInvoiceTheOperatorsShowNoSendOfPaysOverSpark() async throws { + try await holdForTheOperators(sparkFunded: true) + rail.sendStatus = .success(nil) + + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + + XCTAssertEqual(rail.sentBolt11, Self.invoice) + XCTAssertEqual(rail.sendStatusFetches, 1) + XCTAssertEqual(rail.sparkTransferChecks, 1) + } + + func testAPreRestoreInvoiceSparkMayHavePaidByTransferIsRefusedOnBothRails() async throws { + for sparkFunded in [false, true] { + try await holdForTheOperators(sparkFunded: sparkFunded) + // The operators can't see a direct Spark transfer to the invoice's Spark address. + rail.sendStatus = .success(nil) + rail.paysBySparkTransfer = .success(true) + + await assertRefusedAsUnverifiedPreRestoreInvoice() + } + } + + func testAPreRestoreInvoiceIsRefusedOnBothRailsWhenSparkCantAnswer() async throws { + let failures: [(status: Result, transfer: Result)] = [ + // Operators unreachable, an unknown status or an unparseable invoice. + (.failure(NSError(domain: "spark operators", code: 1)), .success(false)), + // No send shown, but the invoice can't be prepared to check its Spark address. + (.success(nil), .failure(NSError(domain: "spark prepare", code: 1))), + ] + for (status, transfer) in failures { + for sparkFunded in [false, true] { + try await holdForTheOperators(sparkFunded: sparkFunded) + rail.sendStatus = status + rail.paysBySparkTransfer = transfer + + await assertRefusedAsUnverifiedPreRestoreInvoice() + } + } + } + + func testAPreRestoreInvoiceIsRefusedWhenNoSparkRunsToAsk() async throws { + try restore(secondsAfterTheInvoice: 60) + try pinTheClockWhileTheInvoiceIsValid() + Bitkit.LightningService.shared.channelFreeRail = nil + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("A pre-restore invoice went to the node while Spark wasn't started") + } catch { + XCTAssertTrue((error as? Bitkit.ChannelFreeRailSendRefused)?.isRestoredInvoiceUnverified == true, "\(error)") + } + } + + func testOnlyASparkPreferringSendPaysAnInvoicesSparkAddressByTransfer() { + XCTAssertTrue(Bitkit.SparkManager.paysBySparkTransfer(prefersSpark: true, sparkTransferFeeSats: 0)) + XCTAssertFalse(Bitkit.SparkManager.paysBySparkTransfer(prefersSpark: false, sparkTransferFeeSats: 0)) + XCTAssertFalse(Bitkit.SparkManager.paysBySparkTransfer(prefersSpark: true, sparkTransferFeeSats: nil)) + XCTAssertFalse(Bitkit.SparkManager.paysBySparkTransfer(prefersSpark: false, sparkTransferFeeSats: nil)) } private func sendOverSpark() async throws { @@ -1662,6 +1783,12 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var errorAfterSubmission: Error? /// Whether the rail's last sync reached it, after the given seconds when there are any. var syncedAfter: (UInt64?) -> Bool = { _ in true } + /// What the operators show of the invoice's sends; unreachable unless a test sets it. + var sendStatus: Result = .failure(NSError(domain: "spark operators", code: 1)) + var sendStatusFetches = 0 + /// Whether the invoice carries a Spark address a Spark-preferring send pays directly. + var paysBySparkTransfer: Result = .success(false) + var sparkTransferChecks = 0 func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { "lnbcrt1fake" @@ -1696,6 +1823,16 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { syncedAfter(seconds) } + func lightningSendStatus(bolt11: String) async throws -> LDKNode.PaymentStatus? { + sendStatusFetches += 1 + return try sendStatus.get() + } + + func mayPayBySparkTransfer(bolt11: String, sats: UInt64?) async throws -> Bool { + sparkTransferChecks += 1 + return try paysBySparkTransfer.get() + } + func lightningPayments() async throws -> [Bitkit.ChannelFreeRailPayment] { onList?() if let listError {