From 49ba310dde56ca31075f2c9a530f189bc2216c70 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 15:41:50 -0400 Subject: [PATCH 1/4] feat(receive): prepare offline invoice flow --- .../Localization/en.lproj/Localizable.strings | 5 + Bitkit/Services/OfflineReceiveSession.swift | 171 +++++++++++ Bitkit/ViewModels/WalletViewModel.swift | 161 ++++++++-- Bitkit/Views/Offline/OfflineSheetScreen.swift | 11 +- .../Views/Wallets/Receive/ReceiveEdit.swift | 65 ++++ .../Views/Wallets/Receive/ReceiveSheet.swift | 30 +- BitkitTests/OfflineReceiveSessionTests.swift | 284 ++++++++++++++++++ BitkitTests/ReceiveSheetSessionTests.swift | 10 + BitkitTests/WalletViewModelReceiveTests.swift | 39 +++ Docs/OfflineReceive.md | 78 +++++ changelog.d/next/766.added.md | 1 + 11 files changed, 830 insertions(+), 25 deletions(-) create mode 100644 Bitkit/Services/OfflineReceiveSession.swift create mode 100644 BitkitTests/OfflineReceiveSessionTests.swift create mode 100644 Docs/OfflineReceive.md create mode 100644 changelog.d/next/766.added.md diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9c968fc6d..cea1997c2 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1747,3 +1747,8 @@ "settings__adv__pp_both" = "Both"; "settings__adv__pp_lightning_short" = "Lightning"; "settings__adv__pp_onchain_short" = "On-chain"; +"wallet__receive_offline" = "Receive Offline"; +"wallet__receive_offline_description" = "Prepare this invoice to receive while Bitkit is closed."; +"wallet__receive_offline_unavailable" = "Offline receiving is unavailable. Try again or turn off Receive Offline."; +"wallet__receive_offline_liquidity" = "The amount is outside your available offline receiving liquidity."; +"wallet__receive_offline_failed" = "Could not prepare an offline invoice. Please try again."; diff --git a/Bitkit/Services/OfflineReceiveSession.swift b/Bitkit/Services/OfflineReceiveSession.swift new file mode 100644 index 000000000..daf8e5f26 --- /dev/null +++ b/Bitkit/Services/OfflineReceiveSession.swift @@ -0,0 +1,171 @@ +import Foundation +import Observation + +struct PreparedOfflineInvoice { + let bolt11: String +} + +struct OfflineReceiveInvoice: Equatable { + let bolt11: String + let amountSats: UInt64 + let note: String + let paymentHash: String + let expiresAt: Date + + func canDisplay(amountSats: UInt64, note: String, now: Date = .now) -> Bool { + self.amountSats == amountSats && self.note == note && expiresAt > now + } +} + +@MainActor +protocol OfflineReceiveProviding { + func canReceive(amountSats: UInt64) async throws -> Bool + + /// Returns only after FFOR activation and recovery data are durably stored. + /// Implementations must not return an ordinary Lightning invoice on failure. + func prepareInvoice(requestId: String, amountSats: UInt64, description: String, expirySecs: UInt32) async throws -> PreparedOfflineInvoice +} + +enum OfflineReceiveError: LocalizedError { + case unavailable + case insufficientLiquidity + case invalidInvoice + + var errorDescription: String? { + switch self { + case .unavailable: t("wallet__receive_offline_unavailable") + case .insufficientLiquidity: t("wallet__receive_offline_liquidity") + case .invalidInvoice: t("wallet__receive_offline_failed") + } + } +} + +struct UnavailableOfflineReceiveProvider: OfflineReceiveProviding { + nonisolated init() {} + + func canReceive(amountSats _: UInt64) async throws -> Bool { false } + + func prepareInvoice(requestId _: String, amountSats _: UInt64, description _: String, + expirySecs _: UInt32) async throws -> PreparedOfflineInvoice + { + throw OfflineReceiveError.unavailable + } +} + +struct OfflineReceiveEligibility: Equatable, Hashable { + let amountSats: UInt64 + let inboundCapacitySats: UInt64? + let isNodeRunning: Bool + let supportsLightning: Bool + + var hasLiquidity: Bool { + guard supportsLightning, isNodeRunning, amountSats > 0, let inboundCapacitySats else { return false } + return amountSats <= inboundCapacitySats + } +} + +@MainActor +@Observable +final class OfflineReceiveSession { + private let provider: any OfflineReceiveProviding + private var eligibility: OfflineReceiveEligibility? + private var revision = UUID() + private var preparation: Preparation? + private(set) var isEligible = false + private(set) var isSelected = false + + private final class Preparation { + let requestId = UUID().uuidString + let amountSats: UInt64 + let description: String + let expirySecs: UInt32 + var attempted = false + + init(amountSats: UInt64, description: String, expirySecs: UInt32) { + self.amountSats = amountSats + self.description = description + self.expirySecs = expirySecs + } + } + + init(provider: any OfflineReceiveProviding) { + self.provider = provider + } + + func updateEligibility(_ eligibility: OfflineReceiveEligibility) async { + let revision = UUID() + self.revision = revision + let hasAttempt = preparation?.attempted == true && preparation?.amountSats == eligibility.amountSats + if self.eligibility?.amountSats != eligibility.amountSats || !eligibility.supportsLightning || (!eligibility.hasLiquidity && !hasAttempt) { + isSelected = false + } + self.eligibility = eligibility + isEligible = false + guard eligibility.hasLiquidity else { return } + + let supported = await (try? provider.canReceive(amountSats: eligibility.amountSats)) == true + guard self.revision == revision, !Task.isCancelled else { return } + isEligible = supported + if !supported, !(preparation?.attempted == true && preparation?.amountSats == eligibility.amountSats) { + isSelected = false + } + } + + func setSelected(_ selected: Bool) { + let hasAttempt = preparation?.attempted == true && preparation?.amountSats == eligibility?.amountSats + isSelected = selected && (isEligible || hasAttempt) + } + + func canSelect(for eligibility: OfflineReceiveEligibility) -> Bool { + isEligible && self.eligibility == eligibility + } + + func reset() { + revision = UUID() + eligibility = nil + isEligible = false + isSelected = false + preparation = nil + } + + func hasPreparationAttempt(amountSats: UInt64, description: String) -> Bool { + preparation?.attempted == true && preparation?.amountSats == amountSats && preparation?.description == description + } + + func expirePreparation() { + preparation = nil + } + + func prepareInvoice( + eligibility: OfflineReceiveEligibility, + description: String, + expirySecs: UInt32 + ) async throws -> PreparedOfflineInvoice { + let preparation: Preparation + if let current = self.preparation, + current.amountSats == eligibility.amountSats, + current.description == description, + current.expirySecs == expirySecs + { + preparation = current + } else { + preparation = Preparation(amountSats: eligibility.amountSats, description: description, expirySecs: expirySecs) + self.preparation = preparation + } + if !preparation.attempted { + guard eligibility.hasLiquidity else { throw OfflineReceiveError.insufficientLiquidity } + guard try await provider.canReceive(amountSats: eligibility.amountSats) else { throw OfflineReceiveError.unavailable } + } + try Task.checkCancellation() + preparation.attempted = true + let invoice = try await provider.prepareInvoice( + requestId: preparation.requestId, + amountSats: eligibility.amountSats, + description: description, + expirySecs: expirySecs + ) + try Task.checkCancellation() + guard !invoice.bolt11.isEmpty else { throw OfflineReceiveError.invalidInvoice } + return invoice + } +} diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index e104ec232..da99af431 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -48,6 +48,21 @@ class WalletViewModel: ObservableObject { // For bolt11 details and bip21 params var invoiceAmountSats: UInt64 = 0 var invoiceNote: String = "" + @Published var invoiceReceiveOffline = false + @Published private(set) var offlineInvoice: OfflineReceiveInvoice? + let offlineReceive: OfflineReceiveSession + private var receiveRefreshRevision = UUID() + private var currentReceiveInvoice: ReceiveInvoiceRequest? + + private struct ReceiveInvoiceRequest: Equatable { + let amountSats: UInt64 + let note: String + let receiveOffline: Bool + } + + private var receiveInvoiceRequest: ReceiveInvoiceRequest { + ReceiveInvoiceRequest(amountSats: invoiceAmountSats, note: invoiceNote, receiveOffline: invoiceReceiveOffline) + } @Published var nodeLifecycleState: NodeLifecycleState = .stopped @Published var nodeStatus: NodeStatus? @@ -101,8 +116,10 @@ class WalletViewModel: ObservableObject { rgsConfigService: RgsConfigService = RgsConfigService(), transferService: TransferService, sheetViewModel: SheetViewModel, - feeEstimatesManager: FeeEstimatesManager + feeEstimatesManager: FeeEstimatesManager, + offlineReceiveProvider: any OfflineReceiveProviding = UnavailableOfflineReceiveProvider() ) { + offlineReceive = OfflineReceiveSession(provider: offlineReceiveProvider) self.lightningService = lightningService self.coreService = coreService self.electrumConfigService = electrumConfigService @@ -206,7 +223,12 @@ class WalletViewModel: ObservableObject { routeFeeMsat: routeFeeMsat ) case let .paymentReceived(_, paymentHash, _, _): - self.bolt11 = "" + if !self.hasPreparedOfflineInvoice || self.offlineInvoice?.paymentHash == paymentHash { + self.bolt11 = "" + if self.invoiceReceiveOffline { self.bip21 = "" } + self.offlineInvoice = nil + self.offlineReceive.expirePreparation() + } if self.isPaykitUIActive { self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) } @@ -218,7 +240,7 @@ class WalletViewModel: ObservableObject { try? await self.refreshBip21() } case .channelReady: - self.bolt11 = "" + if !self.hasPreparedOfflineInvoice { self.bolt11 = "" } Task { await self.refreshAndSyncState() try? await self.refreshBip21() @@ -235,7 +257,7 @@ class WalletViewModel: ObservableObject { } case let .channelClosed(channelId, _, _, reason): - self.bolt11 = "" + if !self.hasPreparedOfflineInvoice { self.bolt11 = "" } Task { await self.refreshAndSyncState() await self.handleChannelClosed(channelId: channelId, reason: reason) @@ -531,6 +553,69 @@ class WalletViewModel: ObservableObject { return invoice.lowercased() } + func offlineReceiveEligibility(amountSats: UInt64, supportsLightning: Bool = true) -> OfflineReceiveEligibility { + OfflineReceiveEligibility( + amountSats: amountSats, + inboundCapacitySats: totalReadyInboundLightningSats, + isNodeRunning: nodeLifecycleState == .running, + supportsLightning: supportsLightning + ) + } + + func resetOfflineReceive() { + invoiceReceiveOffline = false + offlineInvoice = nil + offlineReceive.reset() + receiveRefreshRevision = UUID() + } + + func createReceiveInvoice(amountSats: UInt64?, note: String, receiveOffline: Bool) async throws -> String { + guard receiveOffline else { return try await createInvoice(amountSats: amountSats, note: note) } + return try await prepareOfflineReceiveInvoice(amountSats: amountSats ?? 0, note: note).bolt11 + } + + private func prepareOfflineReceiveInvoice(amountSats: UInt64, note: String) async throws -> OfflineReceiveInvoice { + let invoice = try await offlineReceive.prepareInvoice( + eligibility: offlineReceiveEligibility(amountSats: amountSats), + description: note, + expirySecs: 60 * 60 * 24 + ) + return try await ServiceQueue.background(.ldk) { + let parsed = try Bolt11Invoice.fromStr(invoiceStr: invoice.bolt11) + let (amountMsats, overflow) = amountSats.multipliedReportingOverflow(by: 1000) + guard !overflow, + parsed.amountMilliSatoshis() == amountMsats, + parsed.network() == Env.network, + case let .direct(description) = parsed.invoiceDescription(), + description == note, + !parsed.isExpired() + else { throw OfflineReceiveError.invalidInvoice } + return OfflineReceiveInvoice( + bolt11: invoice.bolt11.lowercased(), + amountSats: amountSats, + note: note, + paymentHash: parsed.paymentHash(), + expiresAt: Date(timeIntervalSince1970: Double(parsed.secondsSinceEpoch()) + Double(parsed.expiryTimeSeconds())) + ) + } + } + + var hasPreparedOfflineInvoice: Bool { + invoiceReceiveOffline && offlineInvoice?.bolt11 == bolt11 && + offlineInvoice?.canDisplay(amountSats: invoiceAmountSats, note: invoiceNote) == true + } + + func expireOfflineInvoice(now: Date = .now) { + guard let offlineInvoice, offlineInvoice.expiresAt <= now else { return } + self.offlineInvoice = nil + offlineReceive.expirePreparation() + if bolt11 == offlineInvoice.bolt11 { + bolt11 = "" + bip21 = "" + } + receiveRefreshRevision = UUID() + } + @discardableResult func waitForNodeToRun(timeoutSeconds: Double = 10.0) async -> Bool { guard nodeLifecycleState != .running else { return true } @@ -1230,6 +1315,7 @@ class WalletViewModel: ObservableObject { } var canCreateReceiveLightningInvoice: Bool { + if hasPreparedOfflineInvoice { return true } let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil return canCreateReceiveLightningInvoice(amountSats: amountSats) } @@ -1383,8 +1469,22 @@ class WalletViewModel: ObservableObject { } func refreshBip21(forceRefreshBolt11: Bool = false) async throws { + if !forceRefreshBolt11, hasPreparedOfflineInvoice { return } + let revision = UUID() + receiveRefreshRevision = revision + let request = receiveInvoiceRequest + let mustReplaceInvoice = forceRefreshBolt11 || currentReceiveInvoice != request + var nextBolt11 = mustReplaceInvoice ? "" : bolt11 + var nextOfflineInvoice: OfflineReceiveInvoice? // Get old payment ID and tags before refreshing (which may change payment ID) let oldPaymentId = await paymentId() + guard receiveRefreshRevision == revision, receiveInvoiceRequest == request else { return } + if (request.receiveOffline && (mustReplaceInvoice || bolt11.isEmpty)) || + (mustReplaceInvoice && currentReceiveInvoice?.receiveOffline == true) + { + bolt11 = "" + bip21 = "" + } var tagsToMigrate: [String] = [] if let oldPaymentId, !oldPaymentId.isEmpty { if let oldMetadata = try? await coreService.activity.getPreActivityMetadata(searchKey: oldPaymentId, searchByAddress: false) { @@ -1393,44 +1493,63 @@ class WalletViewModel: ObservableObject { } try await refreshReusableOnchainAddress() + guard receiveRefreshRevision == revision, receiveInvoiceRequest == request else { return } var newBip21 = "bitcoin:\(onchainAddress)" - let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil + let amountSats = request.amountSats > 0 ? request.amountSats : nil - if canCreateReceiveLightningInvoice(amountSats: amountSats) { - if forceRefreshBolt11 || bolt11.isEmpty { - bolt11 = try await createInvoice(amountSats: amountSats, note: invoiceNote) + if request.receiveOffline { + if !forceRefreshBolt11, + let offlineInvoice, + offlineInvoice.canDisplay(amountSats: request.amountSats, note: request.note) + { + nextOfflineInvoice = offlineInvoice } else { - // Existing invoice needs to be checked for expiry - if case let .lightning(lightningInvoice) = try await decode(invoice: bolt11) { - if lightningInvoice.isExpired { - bolt11 = try await createInvoice(amountSats: amountSats, note: invoiceNote) - } - } + expireOfflineInvoice() + receiveRefreshRevision = revision + nextOfflineInvoice = try await prepareOfflineReceiveInvoice(amountSats: request.amountSats, note: request.note) + } + nextBolt11 = nextOfflineInvoice?.bolt11 ?? "" + } else if canCreateReceiveLightningInvoice(amountSats: amountSats) { + if !nextBolt11.isEmpty, + case let .lightning(lightningInvoice) = try await decode(invoice: nextBolt11), + lightningInvoice.isExpired + { + nextBolt11 = "" + guard receiveRefreshRevision == revision, receiveInvoiceRequest == request else { return } + bolt11 = "" + bip21 = "" + } + if nextBolt11.isEmpty { + nextBolt11 = try await createReceiveInvoice(amountSats: amountSats, note: request.note, receiveOffline: request.receiveOffline) } } else { - bolt11 = "" + nextBolt11 = "" } - if !bolt11.isEmpty { - newBip21 += "?lightning=\(bolt11)" + guard receiveRefreshRevision == revision, receiveInvoiceRequest == request else { return } + if !nextBolt11.isEmpty { + newBip21 += "?lightning=\(nextBolt11)" } // Add amount and note if available - if invoiceAmountSats > 0 { + if request.amountSats > 0 { let separator = newBip21.contains("?") ? "&" : "?" - let formattedAmount = Self.formatBitcoinAmount(sats: invoiceAmountSats) + let formattedAmount = Self.formatBitcoinAmount(sats: request.amountSats) newBip21 += "\(separator)amount=\(formattedAmount)" } - if !invoiceNote.isEmpty { + if !request.note.isEmpty { let separator = newBip21.contains("?") ? "&" : "?" - if let encodedNote = invoiceNote.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { + if let encodedNote = request.note.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { newBip21 += "\(separator)message=\(encodedNote)" } } + bolt11 = nextBolt11 + offlineInvoice = nextOfflineInvoice + currentReceiveInvoice = request bip21 = newBip21 // Persist metadata with migrated tags diff --git a/Bitkit/Views/Offline/OfflineSheetScreen.swift b/Bitkit/Views/Offline/OfflineSheetScreen.swift index 6531f52e6..f65f76a9e 100644 --- a/Bitkit/Views/Offline/OfflineSheetScreen.swift +++ b/Bitkit/Views/Offline/OfflineSheetScreen.swift @@ -4,6 +4,10 @@ import SwiftUI struct OfflineSheetScreen: View { let title: String + static func shouldShow(isConnected: Bool, allowOffline: Bool, forceShow: Bool) -> Bool { + (!isConnected && !allowOffline) || forceShow + } + var body: some View { ZStack(alignment: .top) { SheetHeader(title: title, showBackButton: false) @@ -26,9 +30,10 @@ private struct OfflineSheetOverlayModifier: ViewModifier { let title: String var forceShow = false + var allowOffline = false private var isShowing: Bool { - !network.isConnected || forceShow + OfflineSheetScreen.shouldShow(isConnected: network.isConnected, allowOffline: allowOffline, forceShow: forceShow) } func body(content: Content) -> some View { @@ -54,7 +59,7 @@ extension View { /// Overlays a `OfflineSheetScreen` when the device is offline, or whenever `forceShow` is true /// (e.g. connection issues beyond device connectivity, like an unreachable Lightning peer). /// The underlying content remains mounted so navigation state and inputs are preserved. - func offlineSheetOverlay(title: String, forceShow: Bool = false) -> some View { - modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow)) + func offlineSheetOverlay(title: String, forceShow: Bool = false, allowOffline: Bool = false) -> some View { + modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow, allowOffline: allowOffline)) } } diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index 1b675ebd8..63937ccb4 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -21,6 +21,7 @@ struct ReceiveEdit: View { @State private var amountViewModel = AmountInputViewModel() @State private var note = "" @State private var isPreparingReceive = false + @State private var hasLoadedInvoice = false @State private var isAmountInputFocused: Bool = false @FocusState private var isNoteEditorFocused: Bool @@ -28,6 +29,25 @@ struct ReceiveEdit: View { amountViewModel.amountSats } + private var offlineEligibility: OfflineReceiveEligibility { + wallet.offlineReceiveEligibility( + amountSats: hasLoadedInvoice ? amountSats : wallet.invoiceAmountSats, + supportsLightning: !onchainOnly && liquiditySource != .savings + ) + } + + private var canReceiveOffline: Bool { + wallet.offlineReceive.canSelect(for: offlineEligibility) + } + + private var canRetryOffline: Bool { + !onchainOnly && liquiditySource != .savings && wallet.offlineReceive.hasPreparationAttempt(amountSats: amountSats, description: note) + } + + private var receiveOfflineBinding: Binding { + Binding(get: { wallet.offlineReceive.isSelected }, set: { wallet.offlineReceive.setSelected($0) }) + } + private var liquiditySource: ReceiveLiquiditySource { switch sourceTab { case .savings: @@ -94,9 +114,28 @@ struct ReceiveEdit: View { .frame(maxWidth: .infinity, alignment: .leading) } + if !isNoteEditorFocused, canReceiveOffline || canRetryOffline { + CheckboxRow( + title: t("wallet__receive_offline"), + subtitle: t("wallet__receive_offline_description"), + subtitleUrl: nil, + isChecked: receiveOfflineBinding + ) + .padding(.top, 16) + .accessibilityElement(children: .ignore) + .accessibilityLabel(t("wallet__receive_offline")) + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(wallet.offlineReceive.isSelected ? .isSelected : []) + .accessibilityAction { + wallet.offlineReceive.setSelected(!wallet.offlineReceive.isSelected) + } + .accessibilityIdentifier("ReceiveOffline") + } + Spacer() if !onchainOnly, + !wallet.offlineReceive.isSelected, PaykitFeatureFlags.isUIAvailable, isPaykitUIEnabled, !paymentRequests.eligibleTargets.isEmpty @@ -152,10 +191,20 @@ struct ReceiveEdit: View { .accessibilityIdentifier("ReceiveNumberPad") } } + .disabled(isPreparingReceive) .navigationBarHidden(true) .padding(.horizontal, 16) .sheetBackground() + .task(id: offlineEligibility) { + await wallet.offlineReceive.updateEligibility(offlineEligibility) + } + .onChange(of: note) { _, _ in + if !canReceiveOffline, !canRetryOffline { + wallet.offlineReceive.setSelected(false) + } + } .task { + guard !hasLoadedInvoice else { return } // Initialize with existing values from wallet model if wallet.invoiceAmountSats > 0 { amountViewModel.updateFromSats(wallet.invoiceAmountSats, currency: currency) @@ -163,6 +212,7 @@ struct ReceiveEdit: View { if !wallet.invoiceNote.isEmpty { note = wallet.invoiceNote } + hasLoadedInvoice = true } } @@ -170,12 +220,17 @@ struct ReceiveEdit: View { guard !isPreparingReceive else { return } + guard !wallet.offlineReceive.isSelected || canReceiveOffline || canRetryOffline else { + app.toast(OfflineReceiveError.unavailable) + return + } isPreparingReceive = true defer { isPreparingReceive = false } wallet.invoiceAmountSats = amountSats wallet.invoiceNote = note + wallet.invoiceReceiveOffline = wallet.offlineReceive.isSelected await Self.finishEditing( onchainOnly: onchainOnly, @@ -228,6 +283,12 @@ struct ReceiveEdit: View { // Wait until node is running if it's in starting state if await wallet.waitForNodeToRun() { do { + if wallet.invoiceReceiveOffline { + try await wallet.refreshBip21(forceRefreshBolt11: true) + finishWithQr() + return + } + var maxCjitAmountSats: UInt64? if needsCjitLimitsForAdditionalLiquidity() { try? await blocktank.refreshMinCjitSats() @@ -248,6 +309,10 @@ struct ReceiveEdit: View { finishWithRoute(.cjitGeoBlocked) } } catch { + if wallet.invoiceReceiveOffline { + app.toast(error) + return + } if error.isCjitNodeCapacityExceeded { showNodeCapacityExceededToast() return diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift index 1c02fc3e1..41063873e 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift @@ -43,6 +43,7 @@ struct ReceiveSheet: View { @EnvironmentObject private var tagManager: TagManager @EnvironmentObject private var wallet: WalletViewModel @Environment(TrezorManager.self) private var trezorManager + @Environment(\.scenePhase) private var scenePhase let config: ReceiveSheetItem @@ -58,14 +59,36 @@ struct ReceiveSheet: View { } .id(config.id) } - .offlineSheetOverlay(title: t("wallet__receive_bitcoin")) + .offlineSheetOverlay( + title: t("wallet__receive_bitcoin"), + allowOffline: Self.canDisplayOfflineInvoice( + on: navigationPath.last ?? config.initialRoute, + hasPreparedInvoice: wallet.hasPreparedOfflineInvoice + ) + ) + .task(id: wallet.offlineInvoice?.expiresAt) { + while !Task.isCancelled, let expiresAt = wallet.offlineInvoice?.expiresAt { + wallet.expireOfflineInvoice() + guard wallet.offlineInvoice != nil else { return } + do { + try await Task.sleep(for: .seconds(min(1, max(0.01, expiresAt.timeIntervalSinceNow)))) + } catch { return } + } + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { wallet.expireOfflineInvoice() } + } .sheet(isPresented: reconnectPairingBinding) { HardwarePairingSheet(config: HardwarePairingSheetItem()) } + .onDisappear { + wallet.resetOfflineReceive() + } .onAppear { navigationPath = [] wallet.invoiceAmountSats = 0 wallet.invoiceNote = "" + wallet.resetOfflineReceive() tagManager.clearSelectedTags() Task { // Reset tags for current payment ID before refreshing @@ -88,6 +111,11 @@ struct ReceiveSheet: View { ) } + static func canDisplayOfflineInvoice(on route: ReceiveRoute, hasPreparedInvoice: Bool) -> Bool { + guard hasPreparedInvoice, case .qr(cjitInvoice: nil, tab: _) = route else { return false } + return true + } + @ViewBuilder private func viewForRoute(_ route: ReceiveRoute) -> some View { switch route { diff --git a/BitkitTests/OfflineReceiveSessionTests.swift b/BitkitTests/OfflineReceiveSessionTests.swift new file mode 100644 index 000000000..5ae65a26d --- /dev/null +++ b/BitkitTests/OfflineReceiveSessionTests.swift @@ -0,0 +1,284 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class OfflineReceiveSessionTests: XCTestCase { + func testLiveProviderDoesNotAdvertiseOfflineSupport() async throws { + let session = OfflineReceiveSession(provider: UnavailableOfflineReceiveProvider()) + await session.updateEligibility(eligibility()) + session.setSelected(true) + + XCTAssertFalse(session.isEligible) + XCTAssertFalse(session.isSelected) + do { + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "", expirySecs: 3600) + XCTFail("Unsupported offline receiving must fail") + } catch { + XCTAssertTrue(error is OfflineReceiveError) + } + } + + func testInvalidAmountsAndWalletStatesDoNotQueryProvider() async { + let provider = TestOfflineReceiveProvider() + let session = OfflineReceiveSession(provider: provider) + for request in [ + eligibility(amount: 0), + eligibility(amount: 1001), + eligibility(capacity: nil), + eligibility(capacity: 0), + eligibility(isNodeRunning: false), + eligibility(supportsLightning: false), + ] { + await session.updateEligibility(request) + session.setSelected(true) + XCTAssertFalse(session.isEligible) + XCTAssertFalse(session.isSelected) + } + XCTAssertTrue(provider.queriedAmounts.isEmpty) + } + + func testExactLiquidityBoundaryRequiresProviderSupport() async { + let provider = TestOfflineReceiveProvider() + provider.supported = false + let session = OfflineReceiveSession(provider: provider) + await session.updateEligibility(eligibility()) + XCTAssertFalse(session.isEligible) + + provider.supported = true + await session.updateEligibility(eligibility()) + session.setSelected(true) + XCTAssertTrue(session.canSelect(for: eligibility())) + XCTAssertTrue(session.isSelected) + XCTAssertEqual(provider.queriedAmounts, [1000, 1000]) + } + + func testEditingAmountClearsSelectionAndOldEligibility() async { + let provider = TestOfflineReceiveProvider() + let session = OfflineReceiveSession(provider: provider) + await session.updateEligibility(eligibility()) + session.setSelected(true) + + XCTAssertFalse(session.canSelect(for: eligibility(amount: 999))) + await session.updateEligibility(eligibility(amount: 999)) + XCTAssertTrue(session.isEligible) + XCTAssertFalse(session.isSelected) + XCTAssertEqual(provider.queriedAmounts, [1000, 999]) + } + + func testLiquidityLossClearsSelection() async { + let session = OfflineReceiveSession(provider: TestOfflineReceiveProvider()) + await session.updateEligibility(eligibility()) + session.setSelected(true) + await session.updateEligibility(eligibility(capacity: 999)) + + XCTAssertFalse(session.isEligible) + XCTAssertFalse(session.isSelected) + } + + func testProviderFailureClearsSelection() async { + let provider = TestOfflineReceiveProvider() + let session = OfflineReceiveSession(provider: provider) + await session.updateEligibility(eligibility()) + session.setSelected(true) + provider.query = { _ in throw TestFailure.activationFailed } + + await session.updateEligibility(eligibility()) + + XCTAssertFalse(session.isEligible) + XCTAssertFalse(session.isSelected) + } + + func testResetClearsSelectedInvoice() async { + let session = OfflineReceiveSession(provider: TestOfflineReceiveProvider()) + await session.updateEligibility(eligibility()) + session.setSelected(true) + + session.reset() + + XCTAssertFalse(session.isEligible) + XCTAssertFalse(session.isSelected) + } + + func testStaleEligibilityCannotRestoreSelection() async { + let provider = TestOfflineReceiveProvider() + let started = expectation(description: "Provider query started") + var reply: CheckedContinuation? + provider.query = { _ in + try await withCheckedThrowingContinuation { continuation in + reply = continuation + started.fulfill() + } + } + let session = OfflineReceiveSession(provider: provider) + let task = Task { await session.updateEligibility(eligibility()) } + await fulfillment(of: [started], timeout: 1) + await session.updateEligibility(eligibility(amount: 0)) + reply?.resume(returning: true) + await task.value + + XCTAssertFalse(session.isEligible) + XCTAssertFalse(session.isSelected) + } + + func testNewSessionInvalidatesPendingEligibility() async { + let provider = TestOfflineReceiveProvider() + let started = expectation(description: "Provider query started") + var reply: CheckedContinuation? + provider.query = { _ in + try await withCheckedThrowingContinuation { continuation in + reply = continuation + started.fulfill() + } + } + let session = OfflineReceiveSession(provider: provider) + let task = Task { await session.updateEligibility(eligibility()) } + await fulfillment(of: [started], timeout: 1) + session.reset() + reply?.resume(returning: true) + await task.value + + XCTAssertFalse(session.isEligible) + XCTAssertFalse(session.isSelected) + } + + func testPreparationRechecksSupportAndDoesNotCreateWhenItWasRevoked() async { + let provider = TestOfflineReceiveProvider() + let session = OfflineReceiveSession(provider: provider) + await session.updateEligibility(eligibility()) + provider.supported = false + + do { + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test", expirySecs: 3600) + XCTFail("Revoked support must not create an invoice") + } catch { + XCTAssertTrue(error is OfflineReceiveError) + } + XCTAssertEqual(provider.queriedAmounts, [1000, 1000]) + XCTAssertEqual(provider.prepareCount, 0) + } + + func testPreparationFailureIsPropagatedWithoutAnotherInvoiceAttempt() async { + let provider = TestOfflineReceiveProvider() + provider.preparationError = TestFailure.activationFailed + let session = OfflineReceiveSession(provider: provider) + + do { + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test", expirySecs: 3600) + XCTFail("Failed activation must not return an invoice") + } catch { + XCTAssertEqual(error as? TestFailure, .activationFailed) + } + XCTAssertEqual(provider.prepareCount, 1) + } + + func testPreparationPassesExactAmountDescriptionAndExpiry() async throws { + let provider = TestOfflineReceiveProvider() + let session = OfflineReceiveSession(provider: provider) + let result = try await session.prepareInvoice(eligibility: eligibility(), description: "Test", expirySecs: 3600) + + XCTAssertEqual(result.bolt11, "prepared-offline-invoice") + XCTAssertEqual(provider.preparedAmount, 1000) + XCTAssertEqual(provider.preparedDescription, "Test") + XCTAssertEqual(provider.preparedExpiry, 3600) + } + + func testRetryKeepsRequestIdentityAfterAReservationConsumesLiquidity() async throws { + let provider = TestOfflineReceiveProvider() + provider.preparationError = TestFailure.activationFailed + let session = OfflineReceiveSession(provider: provider) + await session.updateEligibility(eligibility()) + session.setSelected(true) + do { + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test", expirySecs: 3600) + XCTFail("Expected lost activation response") + } catch {} + + provider.supported = false + provider.preparationError = nil + await session.updateEligibility(eligibility(capacity: 0)) + XCTAssertTrue(session.isSelected) + _ = try await session.prepareInvoice(eligibility: eligibility(capacity: 0), description: "Test", expirySecs: 3600) + + XCTAssertEqual(provider.requestIds.count, 2) + XCTAssertEqual(provider.requestIds.first, provider.requestIds.last) + XCTAssertEqual(provider.queriedAmounts, [1000, 1000]) + + await session.updateEligibility(eligibility(supportsLightning: false)) + XCTAssertFalse(session.isSelected) + } + + func testEditedRequestAndNewSessionUseNewIdentities() async throws { + let provider = TestOfflineReceiveProvider() + let session = OfflineReceiveSession(provider: provider) + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "First", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Second", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(amount: 999), description: "Second", expirySecs: 3600) + session.reset() + _ = try await session.prepareInvoice(eligibility: eligibility(amount: 999), description: "Second", expirySecs: 3600) + + XCTAssertEqual(Set(provider.requestIds).count, 4) + } + + func testPreparedInvoiceCanBeDisplayedWithoutNodeOrNetworkUntilExpiry() { + let now = Date(timeIntervalSince1970: 1000) + let invoice = OfflineReceiveInvoice( + bolt11: "prepared-offline-invoice", + amountSats: 1000, + note: "Test", + paymentHash: "payment-hash", + expiresAt: now.addingTimeInterval(60) + ) + + XCTAssertTrue(invoice.canDisplay(amountSats: 1000, note: "Test", now: now)) + XCTAssertFalse(invoice.canDisplay(amountSats: 1000, note: "Test", now: now.addingTimeInterval(60))) + XCTAssertFalse(invoice.canDisplay(amountSats: 999, note: "Test", now: now)) + XCTAssertFalse(invoice.canDisplay(amountSats: 1000, note: "Edited", now: now)) + } + + private func eligibility( + amount: UInt64 = 1000, + capacity: UInt64? = 1000, + isNodeRunning: Bool = true, + supportsLightning: Bool = true + ) -> OfflineReceiveEligibility { + OfflineReceiveEligibility( + amountSats: amount, + inboundCapacitySats: capacity, + isNodeRunning: isNodeRunning, + supportsLightning: supportsLightning + ) + } +} + +private enum TestFailure: Error { + case activationFailed +} + +@MainActor +private final class TestOfflineReceiveProvider: OfflineReceiveProviding { + var supported = true + var queriedAmounts: [UInt64] = [] + var prepareCount = 0 + var preparedAmount: UInt64? + var preparedDescription: String? + var preparedExpiry: UInt32? + var preparationError: Error? + var requestIds: [String] = [] + var query: ((UInt64) async throws -> Bool)? + + func canReceive(amountSats: UInt64) async throws -> Bool { + queriedAmounts.append(amountSats) + if let query { return try await query(amountSats) } + return supported + } + + func prepareInvoice(requestId: String, amountSats: UInt64, description: String, expirySecs: UInt32) async throws -> PreparedOfflineInvoice { + prepareCount += 1 + requestIds.append(requestId) + if let preparationError { throw preparationError } + preparedAmount = amountSats + preparedDescription = description + preparedExpiry = expirySecs + return PreparedOfflineInvoice(bolt11: "prepared-offline-invoice") + } +} diff --git a/BitkitTests/ReceiveSheetSessionTests.swift b/BitkitTests/ReceiveSheetSessionTests.swift index b97b52a30..7ac73d088 100644 --- a/BitkitTests/ReceiveSheetSessionTests.swift +++ b/BitkitTests/ReceiveSheetSessionTests.swift @@ -3,6 +3,16 @@ import XCTest @MainActor final class ReceiveSheetSessionTests: XCTestCase { + func testOnlyPreparedOfflineQrBypassesConnectionOverlay() { + XCTAssertTrue(ReceiveSheet.canDisplayOfflineInvoice(on: .qr(cjitInvoice: nil, tab: .spending), hasPreparedInvoice: true)) + XCTAssertFalse(ReceiveSheet.canDisplayOfflineInvoice(on: .qr(cjitInvoice: nil, tab: .spending), hasPreparedInvoice: false)) + XCTAssertFalse(ReceiveSheet.canDisplayOfflineInvoice(on: .qr(cjitInvoice: "cjit", tab: .spending), hasPreparedInvoice: true)) + XCTAssertFalse(ReceiveSheet.canDisplayOfflineInvoice(on: .edit(tab: .spending, onchainOnly: false), hasPreparedInvoice: true)) + XCTAssertFalse(OfflineSheetScreen.shouldShow(isConnected: false, allowOffline: true, forceShow: false)) + XCTAssertTrue(OfflineSheetScreen.shouldShow(isConnected: false, allowOffline: false, forceShow: false)) + XCTAssertTrue(OfflineSheetScreen.shouldShow(isConnected: true, allowOffline: true, forceShow: true)) + } + func testReceiveSheetItemGetsFreshIdentityPerPresentation() { let sheets = SheetViewModel() diff --git a/BitkitTests/WalletViewModelReceiveTests.swift b/BitkitTests/WalletViewModelReceiveTests.swift index e62c13eaf..8950d558c 100644 --- a/BitkitTests/WalletViewModelReceiveTests.swift +++ b/BitkitTests/WalletViewModelReceiveTests.swift @@ -3,6 +3,45 @@ import XCTest @MainActor final class WalletViewModelReceiveTests: XCTestCase { + func testOfflineRequestNeverFallsBackToOrdinaryInvoiceCreation() async { + let wallet = WalletViewModel() + wallet.nodeLifecycleState = .running + wallet.channels = [.mock(isChannelReady: true, isUsable: true, inboundCapacityMsat: 1_000_000)] + + do { + _ = try await wallet.createReceiveInvoice(amountSats: 1000, note: "", receiveOffline: true) + XCTFail("An unavailable offline provider must fail") + } catch OfflineReceiveError.unavailable { + // Ordinary invoice creation would fail with nodeNotSetup instead. + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testOfflineRequestRequiresFixedAmount() async { + let wallet = WalletViewModel() + wallet.nodeLifecycleState = .running + wallet.channels = [.mock(isChannelReady: true, isUsable: true, inboundCapacityMsat: 1_000_000)] + + do { + _ = try await wallet.createReceiveInvoice(amountSats: nil, note: "", receiveOffline: true) + XCTFail("An offline invoice must have an amount") + } catch OfflineReceiveError.insufficientLiquidity { + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testNewReceiveSessionResetsInvoiceMode() { + let wallet = WalletViewModel() + wallet.invoiceReceiveOffline = true + wallet.resetOfflineReceive() + + XCTAssertFalse(wallet.invoiceReceiveOffline) + XCTAssertFalse(wallet.offlineReceive.isSelected) + XCTAssertFalse(wallet.offlineReceive.isEligible) + } + func testReceiveLightningInvoiceRequiresReadyChannel() { let wallet = WalletViewModel() wallet.channels = [ diff --git a/Docs/OfflineReceive.md b/Docs/OfflineReceive.md new file mode 100644 index 000000000..25ff7bcf3 --- /dev/null +++ b/Docs/OfflineReceive.md @@ -0,0 +1,78 @@ +# Offline receive integration + +This draft adds the receive UI and a provider boundary for FFOR. It does not enable +offline payments with the current LDK Node dependency. The live provider is +`UnavailableOfflineReceiveProvider`, so the checkbox remains hidden and attempts +to create an offline invoice fail. Ordinary invoices are never presented as +offline invoices. + +Tracking: https://github.com/synonymdev/ldk-node/issues/117 + +## Provider requirements + +Replace the unavailable provider through `WalletViewModel` initialization only +after the node and its channel peer support the FFOR protocol. The provider must: + +1. Check eligibility for the exact amount, including peer capability, negotiated + channel support, channel limits and existing offline reservations. The UI's + inbound liquidity check alone is insufficient. +2. Recheck and reserve capacity atomically when preparing an invoice. The + eligibility check is advisory and does not reserve funds. +3. Perform FFOR setup, durable registration and activation. Return + `PreparedOfflineInvoice` only after the protocol permits publishing it and all + local recovery state is durable. Ordinary BOLT11 creation is not a fallback. +4. Safely handle concurrent requests, cancellation, retries, expiry and failures + without orphaning reservations or exposing unactivated invoices. Treat + `requestId` as an idempotency key. A retry must recover the original result, + including when its reservation has already consumed inbound liquidity. +5. Recover pending registrations after restart and reconcile settlement through + the wallet's normal payment and activity paths. + +The wallet additionally parses the returned BOLT11 and checks its exact amount, +network and expiry. These checks do not prove FFOR activation. The node provider +must establish that guarantee. + +The pinned LDK Node version has no FFOR registration or activation API. Rust +Lightning channel support, the forwarding peer implementation, durable lifecycle +management and updated mobile bindings are still prerequisites. No generated +node API or production provider is assumed by this draft. + +## Receive behavior + +The checkbox uses the shared `ReceiveOffline` accessibility identifier and reads +"Receive Offline". It appears in Auto and Spending edits only after the running +node's provider confirms a positive amount within current ready inbound +liquidity. Savings and hardware-only edits never offer it. + +Changing the amount clears selection. Losing capacity or provider eligibility +before preparation also clears selection. An attempted preparation keeps its +identity and selected mode for retries of the same amount, note and expiry, +including after a lost activation response. A changed request or new session gets +a new identity. Discarding a stale result does not cancel durable node state. +Late eligibility responses cannot replace the current +amount's result or restore a closed session. A fresh receive sheet resets the +selection. + +Invoice generation snapshots the selected mode. Background refresh and expiry +use that same mode, and failed offline preparation cannot silently create an +ordinary invoice or retain a stale QR. A newer refresh or edit prevents an older +request from publishing its invoice. + +A prepared invoice remains visible and shareable in its QR screen after the +device disconnects or the node stops. Channel events do not replace it. The +connection overlay still covers ordinary invoices and invoice editing. Expiry +removes the prepared QR, and a matching payment event retires its display state. +Display state belongs to the current receive session; native registration and +recovery across app restarts remain provider responsibilities. + +## Verification + +`OfflineReceiveSessionTests` uses an injected provider to exercise capability, +liquidity boundaries, stale responses, session reset and failed activation. +Existing receive tests cover the ordinary invoice liquidity rules and edit +navigation. These are application contract tests, not a live FFOR payment test. + +Before enabling a production provider, verify a funded receiver can prepare an +invoice, stop the app, receive a payment through the forwarding peer, restart and +recover the payment. Include expiry, offline restart, abort, replay, capacity +exhaustion, loss of the peer connection and concurrent preparation cases. diff --git a/changelog.d/next/766.added.md b/changelog.d/next/766.added.md new file mode 100644 index 000000000..3b14957c6 --- /dev/null +++ b/changelog.d/next/766.added.md @@ -0,0 +1 @@ +Added groundwork for Receive Offline, with the option hidden until compatible Lightning support is available. From c6e259e3d7e4ace71e29426ba3a04cf5f39c8c1f Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 15:58:02 -0400 Subject: [PATCH 2/4] fix(receive): let native policy determine offline expiry --- Bitkit/Services/OfflineReceiveSession.swift | 22 ++++++---------- Bitkit/ViewModels/WalletViewModel.swift | 3 +-- BitkitTests/OfflineReceiveSessionTests.swift | 27 +++++++++----------- Docs/OfflineReceive.md | 11 +++++--- 4 files changed, 28 insertions(+), 35 deletions(-) diff --git a/Bitkit/Services/OfflineReceiveSession.swift b/Bitkit/Services/OfflineReceiveSession.swift index daf8e5f26..259bc0262 100644 --- a/Bitkit/Services/OfflineReceiveSession.swift +++ b/Bitkit/Services/OfflineReceiveSession.swift @@ -23,7 +23,8 @@ protocol OfflineReceiveProviding { /// Returns only after FFOR activation and recovery data are durably stored. /// Implementations must not return an ordinary Lightning invoice on failure. - func prepareInvoice(requestId: String, amountSats: UInt64, description: String, expirySecs: UInt32) async throws -> PreparedOfflineInvoice + /// The native provider chooses an expiry permitted by its negotiated policy. + func prepareInvoice(requestId: String, amountSats: UInt64, description: String) async throws -> PreparedOfflineInvoice } enum OfflineReceiveError: LocalizedError { @@ -45,9 +46,7 @@ struct UnavailableOfflineReceiveProvider: OfflineReceiveProviding { func canReceive(amountSats _: UInt64) async throws -> Bool { false } - func prepareInvoice(requestId _: String, amountSats _: UInt64, description _: String, - expirySecs _: UInt32) async throws -> PreparedOfflineInvoice - { + func prepareInvoice(requestId _: String, amountSats _: UInt64, description _: String) async throws -> PreparedOfflineInvoice { throw OfflineReceiveError.unavailable } } @@ -78,13 +77,11 @@ final class OfflineReceiveSession { let requestId = UUID().uuidString let amountSats: UInt64 let description: String - let expirySecs: UInt32 var attempted = false - init(amountSats: UInt64, description: String, expirySecs: UInt32) { + init(amountSats: UInt64, description: String) { self.amountSats = amountSats self.description = description - self.expirySecs = expirySecs } } @@ -138,18 +135,16 @@ final class OfflineReceiveSession { func prepareInvoice( eligibility: OfflineReceiveEligibility, - description: String, - expirySecs: UInt32 + description: String ) async throws -> PreparedOfflineInvoice { let preparation: Preparation if let current = self.preparation, current.amountSats == eligibility.amountSats, - current.description == description, - current.expirySecs == expirySecs + current.description == description { preparation = current } else { - preparation = Preparation(amountSats: eligibility.amountSats, description: description, expirySecs: expirySecs) + preparation = Preparation(amountSats: eligibility.amountSats, description: description) self.preparation = preparation } if !preparation.attempted { @@ -161,8 +156,7 @@ final class OfflineReceiveSession { let invoice = try await provider.prepareInvoice( requestId: preparation.requestId, amountSats: eligibility.amountSats, - description: description, - expirySecs: expirySecs + description: description ) try Task.checkCancellation() guard !invoice.bolt11.isEmpty else { throw OfflineReceiveError.invalidInvoice } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index da99af431..a939adb4a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -577,8 +577,7 @@ class WalletViewModel: ObservableObject { private func prepareOfflineReceiveInvoice(amountSats: UInt64, note: String) async throws -> OfflineReceiveInvoice { let invoice = try await offlineReceive.prepareInvoice( eligibility: offlineReceiveEligibility(amountSats: amountSats), - description: note, - expirySecs: 60 * 60 * 24 + description: note ) return try await ServiceQueue.background(.ldk) { let parsed = try Bolt11Invoice.fromStr(invoiceStr: invoice.bolt11) diff --git a/BitkitTests/OfflineReceiveSessionTests.swift b/BitkitTests/OfflineReceiveSessionTests.swift index 5ae65a26d..36103b407 100644 --- a/BitkitTests/OfflineReceiveSessionTests.swift +++ b/BitkitTests/OfflineReceiveSessionTests.swift @@ -11,7 +11,7 @@ final class OfflineReceiveSessionTests: XCTestCase { XCTAssertFalse(session.isEligible) XCTAssertFalse(session.isSelected) do { - _ = try await session.prepareInvoice(eligibility: eligibility(), description: "", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "") XCTFail("Unsupported offline receiving must fail") } catch { XCTAssertTrue(error is OfflineReceiveError) @@ -148,7 +148,7 @@ final class OfflineReceiveSessionTests: XCTestCase { provider.supported = false do { - _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test") XCTFail("Revoked support must not create an invoice") } catch { XCTAssertTrue(error is OfflineReceiveError) @@ -163,7 +163,7 @@ final class OfflineReceiveSessionTests: XCTestCase { let session = OfflineReceiveSession(provider: provider) do { - _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test") XCTFail("Failed activation must not return an invoice") } catch { XCTAssertEqual(error as? TestFailure, .activationFailed) @@ -171,15 +171,14 @@ final class OfflineReceiveSessionTests: XCTestCase { XCTAssertEqual(provider.prepareCount, 1) } - func testPreparationPassesExactAmountDescriptionAndExpiry() async throws { + func testPreparationPassesExactAmountAndDescription() async throws { let provider = TestOfflineReceiveProvider() let session = OfflineReceiveSession(provider: provider) - let result = try await session.prepareInvoice(eligibility: eligibility(), description: "Test", expirySecs: 3600) + let result = try await session.prepareInvoice(eligibility: eligibility(), description: "Test") XCTAssertEqual(result.bolt11, "prepared-offline-invoice") XCTAssertEqual(provider.preparedAmount, 1000) XCTAssertEqual(provider.preparedDescription, "Test") - XCTAssertEqual(provider.preparedExpiry, 3600) } func testRetryKeepsRequestIdentityAfterAReservationConsumesLiquidity() async throws { @@ -189,7 +188,7 @@ final class OfflineReceiveSessionTests: XCTestCase { await session.updateEligibility(eligibility()) session.setSelected(true) do { - _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Test") XCTFail("Expected lost activation response") } catch {} @@ -197,7 +196,7 @@ final class OfflineReceiveSessionTests: XCTestCase { provider.preparationError = nil await session.updateEligibility(eligibility(capacity: 0)) XCTAssertTrue(session.isSelected) - _ = try await session.prepareInvoice(eligibility: eligibility(capacity: 0), description: "Test", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(capacity: 0), description: "Test") XCTAssertEqual(provider.requestIds.count, 2) XCTAssertEqual(provider.requestIds.first, provider.requestIds.last) @@ -210,11 +209,11 @@ final class OfflineReceiveSessionTests: XCTestCase { func testEditedRequestAndNewSessionUseNewIdentities() async throws { let provider = TestOfflineReceiveProvider() let session = OfflineReceiveSession(provider: provider) - _ = try await session.prepareInvoice(eligibility: eligibility(), description: "First", expirySecs: 3600) - _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Second", expirySecs: 3600) - _ = try await session.prepareInvoice(eligibility: eligibility(amount: 999), description: "Second", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "First") + _ = try await session.prepareInvoice(eligibility: eligibility(), description: "Second") + _ = try await session.prepareInvoice(eligibility: eligibility(amount: 999), description: "Second") session.reset() - _ = try await session.prepareInvoice(eligibility: eligibility(amount: 999), description: "Second", expirySecs: 3600) + _ = try await session.prepareInvoice(eligibility: eligibility(amount: 999), description: "Second") XCTAssertEqual(Set(provider.requestIds).count, 4) } @@ -261,7 +260,6 @@ private final class TestOfflineReceiveProvider: OfflineReceiveProviding { var prepareCount = 0 var preparedAmount: UInt64? var preparedDescription: String? - var preparedExpiry: UInt32? var preparationError: Error? var requestIds: [String] = [] var query: ((UInt64) async throws -> Bool)? @@ -272,13 +270,12 @@ private final class TestOfflineReceiveProvider: OfflineReceiveProviding { return supported } - func prepareInvoice(requestId: String, amountSats: UInt64, description: String, expirySecs: UInt32) async throws -> PreparedOfflineInvoice { + func prepareInvoice(requestId: String, amountSats: UInt64, description: String) async throws -> PreparedOfflineInvoice { prepareCount += 1 requestIds.append(requestId) if let preparationError { throw preparationError } preparedAmount = amountSats preparedDescription = description - preparedExpiry = expirySecs return PreparedOfflineInvoice(bolt11: "prepared-offline-invoice") } } diff --git a/Docs/OfflineReceive.md b/Docs/OfflineReceive.md index 25ff7bcf3..30d99a100 100644 --- a/Docs/OfflineReceive.md +++ b/Docs/OfflineReceive.md @@ -28,9 +28,12 @@ after the node and its channel peer support the FFOR protocol. The provider must 5. Recover pending registrations after restart and reconcile settlement through the wallet's normal payment and activity paths. -The wallet additionally parses the returned BOLT11 and checks its exact amount, -network and expiry. These checks do not prove FFOR activation. The node provider -must establish that guarantee. +The native provider chooses the invoice expiry and offline settlement window from +its negotiated policy. The app does not request or assume a duration. It parses +the returned BOLT11 and checks its exact amount, direct description, network and +expiry, then uses that signed expiry for display. Invoice expiry and the protocol's +settlement deadline are distinct. These checks do not prove FFOR activation. The +node provider must establish that guarantee. The pinned LDK Node version has no FFOR registration or activation API. Rust Lightning channel support, the forwarding peer implementation, durable lifecycle @@ -46,7 +49,7 @@ liquidity. Savings and hardware-only edits never offer it. Changing the amount clears selection. Losing capacity or provider eligibility before preparation also clears selection. An attempted preparation keeps its -identity and selected mode for retries of the same amount, note and expiry, +identity and selected mode for retries of the same amount and note, including after a lost activation response. A changed request or new session gets a new identity. Discarding a stale result does not cancel durable node state. Late eligibility responses cannot replace the current From 3b72b82e6eb047336200e3f0fe5e9b2b0c6e05a3 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 03:17:27 -0400 Subject: [PATCH 3/4] fix: prevent stale offline invoice display after settlement --- .../Services/OfflineReceiveRegistration.swift | 57 ++++ Bitkit/ViewModels/WalletViewModel.swift | 109 +++++-- .../OfflineReceiveRegistrationTests.swift | 280 ++++++++++++++++++ Docs/OfflineReceive.md | 11 +- 4 files changed, 436 insertions(+), 21 deletions(-) create mode 100644 Bitkit/Services/OfflineReceiveRegistration.swift create mode 100644 BitkitTests/OfflineReceiveRegistrationTests.swift diff --git a/Bitkit/Services/OfflineReceiveRegistration.swift b/Bitkit/Services/OfflineReceiveRegistration.swift new file mode 100644 index 000000000..ccb4742c5 --- /dev/null +++ b/Bitkit/Services/OfflineReceiveRegistration.swift @@ -0,0 +1,57 @@ +import Foundation +import LDKNode +import Observation + +@MainActor +@Observable +final class OfflineReceiveRegistration { + private let payments: @MainActor () async -> [PaymentDetails]? + private var paymentRevision = UUID() + private var invoice: OfflineReceiveInvoice? + private(set) var revision = UUID() + + init(payments: @escaping @MainActor () async -> [PaymentDetails]?) { + self.payments = payments + } + + func reset() { + revision = UUID() + invoice = nil + } + + func beginPreparation() -> UUID { + reset() + return revision + } + + func paymentReceived(hash: String) { + paymentRevision = UUID() + if invoice?.paymentHash == hash { invoice = nil } + } + + func contains(_ invoice: OfflineReceiveInvoice) -> Bool { + self.invoice == invoice && invoice.expiresAt > .now + } + + func register(_ invoice: OfflineReceiveInvoice, revision: UUID) async throws { + while true { + try Task.checkCancellation() + guard self.revision == revision else { throw OfflineReceiveError.unavailable } + let paymentRevision = paymentRevision + guard let payments = await payments() else { throw OfflineReceiveError.unavailable } + try Task.checkCancellation() + guard self.revision == revision, invoice.expiresAt > .now else { throw OfflineReceiveError.unavailable } + let paid = payments.contains { payment in + guard payment.direction == .inbound, payment.status == .succeeded, + case let .bolt11(hash, _, _, _, _) = payment.kind + else { return false } + return hash == invoice.paymentHash + } + guard !paid else { throw OfflineReceiveError.unavailable } + if self.paymentRevision == paymentRevision { + self.invoice = invoice + return + } + } + } +} diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index a939adb4a..220fdf01a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -15,8 +15,26 @@ class WalletViewModel: ObservableObject { // Receive flow @AppStorage("onchainAddress") var onchainAddress = "" - @AppStorage("bolt11") var bolt11 = "" - @AppStorage("bip21") var bip21 = "" + @AppStorage("bolt11") private var ordinaryBolt11 = "" + @AppStorage("bip21") private var ordinaryBip21 = "" + @Published private var offlineBolt11 = "" + @Published private var offlineBip21 = "" + var bolt11: String { + get { invoiceReceiveOffline ? offlineBolt11 : ordinaryBolt11 } + set { + if invoiceReceiveOffline { offlineBolt11 = newValue } + else { ordinaryBolt11 = newValue } + } + } + + var bip21: String { + get { invoiceReceiveOffline ? offlineBip21 : ordinaryBip21 } + set { + if invoiceReceiveOffline { offlineBip21 = newValue } + else { ordinaryBip21 = newValue } + } + } + @AppStorage("publicPaykitBolt11") var publicPaykitBolt11 = "" @AppStorage("publicPaykitBolt11PaymentHash") var publicPaykitBolt11PaymentHash = "" @AppStorage("publicPaykitBolt11ExpiresAt") var publicPaykitBolt11ExpiresAt = 0.0 @@ -48,9 +66,20 @@ class WalletViewModel: ObservableObject { // For bolt11 details and bip21 params var invoiceAmountSats: UInt64 = 0 var invoiceNote: String = "" - @Published var invoiceReceiveOffline = false + @Published var invoiceReceiveOffline = false { + didSet { + if invoiceReceiveOffline != oldValue { + offlineBolt11 = "" + offlineBip21 = "" + offlineInvoice = nil + offlineInvoiceRegistration.reset() + } + } + } + @Published private(set) var offlineInvoice: OfflineReceiveInvoice? let offlineReceive: OfflineReceiveSession + let offlineInvoiceRegistration: OfflineReceiveRegistration private var receiveRefreshRevision = UUID() private var currentReceiveInvoice: ReceiveInvoiceRequest? @@ -117,9 +146,11 @@ class WalletViewModel: ObservableObject { transferService: TransferService, sheetViewModel: SheetViewModel, feeEstimatesManager: FeeEstimatesManager, - offlineReceiveProvider: any OfflineReceiveProviding = UnavailableOfflineReceiveProvider() + offlineReceiveProvider: any OfflineReceiveProviding = UnavailableOfflineReceiveProvider(), + offlineReceivePayments: (@MainActor () async -> [PaymentDetails]?)? = nil ) { offlineReceive = OfflineReceiveSession(provider: offlineReceiveProvider) + offlineInvoiceRegistration = OfflineReceiveRegistration(payments: offlineReceivePayments ?? { await lightningService.listPayments() }) self.lightningService = lightningService self.coreService = coreService self.electrumConfigService = electrumConfigService @@ -135,12 +166,15 @@ class WalletViewModel: ObservableObject { } /// Convenience initializer for previews and testing - convenience init() { + convenience init(offlineReceivePayments: (@MainActor () async -> [PaymentDetails]?)? = nil) { let transferService = TransferService( lightningService: .shared, blocktankService: CoreService.shared.blocktank ) - self.init(transferService: transferService, sheetViewModel: SheetViewModel(), feeEstimatesManager: FeeEstimatesManager()) + self.init( + transferService: transferService, sheetViewModel: SheetViewModel(), feeEstimatesManager: FeeEstimatesManager(), + offlineReceivePayments: offlineReceivePayments + ) } func setWalletExistsState() throws { @@ -223,12 +257,7 @@ class WalletViewModel: ObservableObject { routeFeeMsat: routeFeeMsat ) case let .paymentReceived(_, paymentHash, _, _): - if !self.hasPreparedOfflineInvoice || self.offlineInvoice?.paymentHash == paymentHash { - self.bolt11 = "" - if self.invoiceReceiveOffline { self.bip21 = "" } - self.offlineInvoice = nil - self.offlineReceive.expirePreparation() - } + self.receiveInvoicePaymentReceived(hash: paymentHash) if self.isPaykitUIActive { self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) } @@ -565,21 +594,43 @@ class WalletViewModel: ObservableObject { func resetOfflineReceive() { invoiceReceiveOffline = false offlineInvoice = nil + offlineBolt11 = "" + offlineBip21 = "" + offlineInvoiceRegistration.reset() offlineReceive.reset() receiveRefreshRevision = UUID() } + func receiveInvoicePaymentReceived(hash: String) { + offlineInvoiceRegistration.paymentReceived(hash: hash) + if invoiceReceiveOffline { + ordinaryBolt11 = "" + ordinaryBip21 = "" + guard offlineInvoice?.paymentHash == hash else { return } + offlineInvoice = nil + bolt11 = "" + bip21 = "" + offlineReceive.expirePreparation() + receiveRefreshRevision = UUID() + } else { + bolt11 = "" + } + } + func createReceiveInvoice(amountSats: UInt64?, note: String, receiveOffline: Bool) async throws -> String { guard receiveOffline else { return try await createInvoice(amountSats: amountSats, note: note) } - return try await prepareOfflineReceiveInvoice(amountSats: amountSats ?? 0, note: note).bolt11 + let invoice = try await prepareOfflineReceiveInvoice(amountSats: amountSats ?? 0, note: note) + guard offlineInvoiceRegistration.contains(invoice) else { throw OfflineReceiveError.unavailable } + return invoice.bolt11 } private func prepareOfflineReceiveInvoice(amountSats: UInt64, note: String) async throws -> OfflineReceiveInvoice { + let revision = offlineInvoiceRegistration.beginPreparation() let invoice = try await offlineReceive.prepareInvoice( eligibility: offlineReceiveEligibility(amountSats: amountSats), description: note ) - return try await ServiceQueue.background(.ldk) { + let validatedInvoice = try await ServiceQueue.background(.ldk) { let parsed = try Bolt11Invoice.fromStr(invoiceStr: invoice.bolt11) let (amountMsats, overflow) = amountSats.multipliedReportingOverflow(by: 1000) guard !overflow, @@ -597,16 +648,21 @@ class WalletViewModel: ObservableObject { expiresAt: Date(timeIntervalSince1970: Double(parsed.secondsSinceEpoch()) + Double(parsed.expiryTimeSeconds())) ) } + try await offlineInvoiceRegistration.register(validatedInvoice, revision: revision) + return validatedInvoice } var hasPreparedOfflineInvoice: Bool { - invoiceReceiveOffline && offlineInvoice?.bolt11 == bolt11 && - offlineInvoice?.canDisplay(amountSats: invoiceAmountSats, note: invoiceNote) == true + guard invoiceReceiveOffline, let offlineInvoice else { return false } + return offlineInvoice.bolt11 == bolt11 && + offlineInvoice.canDisplay(amountSats: invoiceAmountSats, note: invoiceNote) && + offlineInvoiceRegistration.contains(offlineInvoice) } func expireOfflineInvoice(now: Date = .now) { guard let offlineInvoice, offlineInvoice.expiresAt <= now else { return } self.offlineInvoice = nil + offlineInvoiceRegistration.reset() offlineReceive.expirePreparation() if bolt11 == offlineInvoice.bolt11 { bolt11 = "" @@ -1546,10 +1602,7 @@ class WalletViewModel: ObservableObject { } } - bolt11 = nextBolt11 - offlineInvoice = nextOfflineInvoice - currentReceiveInvoice = request - bip21 = newBip21 + try applyReceiveInvoice(bolt11: nextBolt11, offlineInvoice: nextOfflineInvoice, bip21: newBip21) // Persist metadata with migrated tags await persistPreActivityMetadata(tags: tagsToMigrate) @@ -1563,6 +1616,21 @@ class WalletViewModel: ObservableObject { } } + func applyReceiveInvoice(bolt11: String, offlineInvoice: OfflineReceiveInvoice?, bip21: String) throws { + if invoiceReceiveOffline { + guard let offlineInvoice, offlineInvoice.bolt11 == bolt11, + offlineInvoice.canDisplay(amountSats: invoiceAmountSats, note: invoiceNote), + offlineInvoiceRegistration.contains(offlineInvoice) + else { throw OfflineReceiveError.unavailable } + } else if offlineInvoice != nil { + throw OfflineReceiveError.unavailable + } + self.bolt11 = bolt11 + self.offlineInvoice = offlineInvoice + currentReceiveInvoice = receiveInvoiceRequest + self.bip21 = bip21 + } + /// Payment hash from the current bolt11 invoice, if available private func paymentHash() async -> String? { guard !bolt11.isEmpty else { return nil } @@ -1721,6 +1789,7 @@ class WalletViewModel: ObservableObject { maxSendLightningSats = 0 channelCount = 0 + resetOfflineReceive() onchainAddress = "" bolt11 = "" bip21 = "" diff --git a/BitkitTests/OfflineReceiveRegistrationTests.swift b/BitkitTests/OfflineReceiveRegistrationTests.swift new file mode 100644 index 000000000..f16d1a5de --- /dev/null +++ b/BitkitTests/OfflineReceiveRegistrationTests.swift @@ -0,0 +1,280 @@ +@testable import Bitkit +import LDKNode +import XCTest + +@MainActor +final class OfflineReceiveRegistrationTests: XCTestCase { + func testAlreadySettledInvoiceCannotRegister() async { + let invoice = validatedInvoice() + let registration = OfflineReceiveRegistration(payments: { [self.payment(hash: invoice.paymentHash)] }) + await assertUnavailable { try await registration.register(invoice, revision: registration.revision) } + XCTAssertFalse(registration.contains(invoice)) + } + + func testUnavailableHistoryCannotRegister() async { + let invoice = validatedInvoice() + let registration = OfflineReceiveRegistration(payments: { nil }) + await assertUnavailable { try await registration.register(invoice, revision: registration.revision) } + XCTAssertFalse(registration.contains(invoice)) + } + + func testOnlyMatchingSucceededInboundBolt11EstablishesPayment() async throws { + let invoice = validatedInvoice() + let registration = OfflineReceiveRegistration(payments: { + [ + self.payment(hash: "another-hash"), + self.payment(hash: invoice.paymentHash, status: .pending), + self.payment(hash: invoice.paymentHash, status: .failed), + self.payment(hash: invoice.paymentHash, direction: .outbound), + ] + }) + try await registration.register(invoice, revision: registration.revision) + XCTAssertTrue(registration.contains(invoice)) + } + + func testPaymentDuringHistoryReadRetriesSnapshotAndRejectsPaidInvoice() async { + let invoice = validatedInvoice() + var reads = 0 + var registration: OfflineReceiveRegistration! + registration = OfflineReceiveRegistration(payments: { + reads += 1 + if reads == 1 { + registration.paymentReceived(hash: invoice.paymentHash) + return [] // The lookup began before the payment was recorded. + } + return [self.payment(hash: invoice.paymentHash)] + }) + await assertUnavailable { try await registration.register(invoice, revision: registration.revision) } + XCTAssertEqual(reads, 2) + XCTAssertFalse(registration.contains(invoice)) + } + + func testUnrelatedEventDuringHistoryReadRetriesWithoutMarkingInvoicePaid() async throws { + let invoice = validatedInvoice() + var reads = 0 + var registration: OfflineReceiveRegistration! + registration = OfflineReceiveRegistration(payments: { + reads += 1 + if reads == 1 { registration.paymentReceived(hash: "another-hash") } + return [self.payment(hash: "another-hash")] + }) + try await registration.register(invoice, revision: registration.revision) + XCTAssertEqual(reads, 2) + XCTAssertTrue(registration.contains(invoice)) + } + + func testSessionResetDuringHistoryReadRejectsCandidate() async { + let invoice = validatedInvoice() + var registration: OfflineReceiveRegistration! + registration = OfflineReceiveRegistration(payments: { + registration.reset() + return [] + }) + await assertUnavailable { try await registration.register(invoice, revision: registration.revision) } + XCTAssertFalse(registration.contains(invoice)) + } + + func testStaleSessionDoesNotEvenQueryHistory() async { + let invoice = validatedInvoice() + var reads = 0 + let registration = OfflineReceiveRegistration(payments: { reads += 1; return [] }) + let revision = registration.revision + registration.reset() + await assertUnavailable { try await registration.register(invoice, revision: revision) } + XCTAssertEqual(reads, 0) + } + + func testOlderPreparationCannotReplaceNewerDisplayedInvoice() async throws { + let oldInvoice = validatedInvoice() + let newerInvoice = OfflineReceiveInvoice( + bolt11: "newer-validated-invoice", amountSats: 2000, note: "Edited note", + paymentHash: "newer-payment-hash", expiresAt: Date().addingTimeInterval(600) + ) + let olderReadStarted = expectation(description: "older history lookup suspended") + var olderRead: CheckedContinuation<[PaymentDetails]?, Never>? + var reads = 0 + let wallet = WalletViewModel(offlineReceivePayments: { + reads += 1 + if reads == 1 { + return await withCheckedContinuation { continuation in + olderRead = continuation + olderReadStarted.fulfill() + } + } + return [] + }) + wallet.invoiceReceiveOffline = true + let registration = wallet.offlineInvoiceRegistration + let oldRevision = registration.beginPreparation() + let oldAttempt = Task { @MainActor in + await self.assertUnavailable { try await registration.register(oldInvoice, revision: oldRevision) } + } + await fulfillment(of: [olderReadStarted], timeout: 2) + + wallet.invoiceAmountSats = newerInvoice.amountSats + wallet.invoiceNote = newerInvoice.note + let newRevision = registration.beginPreparation() + try await registration.register(newerInvoice, revision: newRevision) + try wallet.applyReceiveInvoice(bolt11: newerInvoice.bolt11, offlineInvoice: newerInvoice, bip21: "newer-offline-uri") + XCTAssertTrue(wallet.hasPreparedOfflineInvoice) + + olderRead?.resume(returning: []) + await oldAttempt.value + XCTAssertEqual(reads, 2) + XCTAssertTrue(registration.contains(newerInvoice)) + XCTAssertFalse(registration.contains(oldInvoice)) + XCTAssertTrue(wallet.hasPreparedOfflineInvoice) + XCTAssertEqual(wallet.bolt11, newerInvoice.bolt11) + } + + func testPaymentBetweenHistoryAndDisplayCannotInstallInvoice() async throws { + let invoice = validatedInvoice() + let wallet = walletForOfflineInvoice(invoice) + try await wallet.offlineInvoiceRegistration.register(invoice, revision: wallet.offlineInvoiceRegistration.revision) + wallet.receiveInvoicePaymentReceived(hash: invoice.paymentHash) + + XCTAssertThrowsError(try wallet.applyReceiveInvoice(bolt11: invoice.bolt11, offlineInvoice: invoice, bip21: "offline-uri")) + XCTAssertNil(wallet.offlineInvoice) + XCTAssertEqual(wallet.bolt11, "") + XCTAssertEqual(wallet.bip21, "") + XCTAssertFalse(wallet.hasPreparedOfflineInvoice) + } + + func testResetBetweenHistoryAndDisplayCannotInstallInvoice() async throws { + let invoice = validatedInvoice() + let wallet = walletForOfflineInvoice(invoice) + try await wallet.offlineInvoiceRegistration.register(invoice, revision: wallet.offlineInvoiceRegistration.revision) + wallet.resetOfflineReceive() + wallet.invoiceReceiveOffline = true + + XCTAssertThrowsError(try wallet.applyReceiveInvoice(bolt11: invoice.bolt11, offlineInvoice: invoice, bip21: "offline-uri")) + XCTAssertNil(wallet.offlineInvoice) + XCTAssertFalse(wallet.hasPreparedOfflineInvoice) + } + + func testOnlyMatchingPaymentClearsDisplayedOfflineInvoice() async throws { + let invoice = validatedInvoice() + let wallet = walletForOfflineInvoice(invoice) + try await wallet.offlineInvoiceRegistration.register(invoice, revision: wallet.offlineInvoiceRegistration.revision) + try wallet.applyReceiveInvoice(bolt11: invoice.bolt11, offlineInvoice: invoice, bip21: "offline-uri") + wallet.receiveInvoicePaymentReceived(hash: "another-hash") + XCTAssertTrue(wallet.hasPreparedOfflineInvoice) + XCTAssertEqual(wallet.bolt11, invoice.bolt11) + + wallet.receiveInvoicePaymentReceived(hash: invoice.paymentHash) + XCTAssertFalse(wallet.hasPreparedOfflineInvoice) + XCTAssertNil(wallet.offlineInvoice) + XCTAssertEqual(wallet.bolt11, "") + XCTAssertEqual(wallet.bip21, "") + } + + func testOfflineInvoiceNeverChangesOrdinaryAppStorageOrSurvivesRecreation() async throws { + let defaults = UserDefaults.standard + let previousBolt11 = defaults.object(forKey: "bolt11") + let previousBip21 = defaults.object(forKey: "bip21") + defer { + defaults.set(previousBolt11, forKey: "bolt11") + defaults.set(previousBip21, forKey: "bip21") + } + let ordinaryWallet = WalletViewModel() + ordinaryWallet.bolt11 = "ordinary-invoice" + ordinaryWallet.bip21 = "ordinary-uri" + let invoice = validatedInvoice() + let wallet = walletForOfflineInvoice(invoice) + try await wallet.offlineInvoiceRegistration.register(invoice, revision: wallet.offlineInvoiceRegistration.revision) + try wallet.applyReceiveInvoice(bolt11: invoice.bolt11, offlineInvoice: invoice, bip21: "offline-uri") + + XCTAssertEqual(defaults.string(forKey: "bolt11"), "ordinary-invoice") + XCTAssertEqual(defaults.string(forKey: "bip21"), "ordinary-uri") + XCTAssertEqual(wallet.bolt11, invoice.bolt11) + XCTAssertEqual(wallet.bip21, "offline-uri") + let reopened = WalletViewModel() + XCTAssertEqual(reopened.bolt11, "ordinary-invoice") + XCTAssertEqual(reopened.bip21, "ordinary-uri") + XCTAssertNil(reopened.offlineInvoice) + XCTAssertFalse(reopened.invoiceReceiveOffline) + + wallet.resetOfflineReceive() + wallet.invoiceReceiveOffline = true + XCTAssertEqual(wallet.bolt11, "") + XCTAssertEqual(wallet.bip21, "") + } + + func testExpiryClearsOfflineDisplayAndRegistration() async throws { + let invoice = validatedInvoice() + let wallet = walletForOfflineInvoice(invoice) + try await wallet.offlineInvoiceRegistration.register(invoice, revision: wallet.offlineInvoiceRegistration.revision) + try wallet.applyReceiveInvoice(bolt11: invoice.bolt11, offlineInvoice: invoice, bip21: "offline-uri") + wallet.expireOfflineInvoice(now: invoice.expiresAt) + XCTAssertFalse(wallet.hasPreparedOfflineInvoice) + XCTAssertFalse(wallet.offlineInvoiceRegistration.contains(invoice)) + XCTAssertNil(wallet.offlineInvoice) + XCTAssertEqual(wallet.bolt11, "") + XCTAssertEqual(wallet.bip21, "") + } + + func testOrdinaryPaymentDuringOfflineDisplayCannotResurrectOrdinaryCache() async throws { + let defaults = UserDefaults.standard + let previousBolt11 = defaults.object(forKey: "bolt11") + let previousBip21 = defaults.object(forKey: "bip21") + defer { + defaults.set(previousBolt11, forKey: "bolt11") + defaults.set(previousBip21, forKey: "bip21") + } + let invoice = validatedInvoice() + let wallet = WalletViewModel(offlineReceivePayments: { [] }) + wallet.bolt11 = "ordinary-invoice" + wallet.bip21 = "ordinary-uri" + wallet.invoiceReceiveOffline = true + wallet.invoiceAmountSats = invoice.amountSats + wallet.invoiceNote = invoice.note + try await wallet.offlineInvoiceRegistration.register(invoice, revision: wallet.offlineInvoiceRegistration.revision) + try wallet.applyReceiveInvoice(bolt11: invoice.bolt11, offlineInvoice: invoice, bip21: "offline-uri") + + wallet.receiveInvoicePaymentReceived(hash: "ordinary-payment-hash") + XCTAssertTrue(wallet.hasPreparedOfflineInvoice) + XCTAssertEqual(wallet.bolt11, invoice.bolt11) + XCTAssertEqual(wallet.bip21, "offline-uri") + XCTAssertEqual(defaults.string(forKey: "bolt11"), "") + XCTAssertEqual(defaults.string(forKey: "bip21"), "") + + wallet.resetOfflineReceive() + XCTAssertEqual(wallet.bolt11, "") + XCTAssertEqual(wallet.bip21, "") + let reopened = WalletViewModel() + XCTAssertEqual(reopened.bolt11, "") + XCTAssertEqual(reopened.bip21, "") + } + + private func walletForOfflineInvoice(_ invoice: OfflineReceiveInvoice) -> WalletViewModel { + let wallet = WalletViewModel(offlineReceivePayments: { [] }) + wallet.invoiceReceiveOffline = true + wallet.invoiceAmountSats = invoice.amountSats + wallet.invoiceNote = invoice.note + return wallet + } + + private func validatedInvoice() -> OfflineReceiveInvoice { + OfflineReceiveInvoice( + bolt11: "validated-offline-invoice", amountSats: 1000, note: "Offline", + paymentHash: "offline-payment-hash", expiresAt: Date().addingTimeInterval(600) + ) + } + + private func payment(hash: String, status: PaymentStatus = .succeeded, direction: PaymentDirection = .inbound) -> PaymentDetails { + PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: nil), + amountMsat: 1_000_000, feePaidMsat: nil, direction: direction, status: status, latestUpdateTimestamp: 0 + ) + } + + private func assertUnavailable(_ operation: () async throws -> Void) async { + do { + try await operation() + XCTFail("Expected offline receive to refuse the invoice") + } catch OfflineReceiveError.unavailable { + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/Docs/OfflineReceive.md b/Docs/OfflineReceive.md index 30d99a100..f985b5411 100644 --- a/Docs/OfflineReceive.md +++ b/Docs/OfflineReceive.md @@ -61,17 +61,26 @@ use that same mode, and failed offline preparation cannot silently create an ordinary invoice or retain a stale QR. A newer refresh or edit prevents an older request from publishing its invoice. +Before registering an offline invoice for display, the app checks native payment +history for a matching successful inbound BOLT11 payment. A payment event during +that lookup forces a fresh history snapshot. The final display update also checks +that the candidate still belongs to the current session and has not been retired +by a matching payment event. Missing payment history refuses display. + A prepared invoice remains visible and shareable in its QR screen after the device disconnects or the node stops. Channel events do not replace it. The connection overlay still covers ordinary invoices and invoice editing. Expiry removes the prepared QR, and a matching payment event retires its display state. Display state belongs to the current receive session; native registration and -recovery across app restarts remain provider responsibilities. +recovery across app restarts remain provider responsibilities. Offline BOLT11 and +BIP21 strings stay in memory and never enter the ordinary receive invoice cache. ## Verification `OfflineReceiveSessionTests` uses an injected provider to exercise capability, liquidity boundaries, stale responses, session reset and failed activation. +`OfflineReceiveRegistrationTests` covers paid-history checks, payment and session +races, expiry and isolation from the ordinary persistent invoice cache. Existing receive tests cover the ordinary invoice liquidity rules and edit navigation. These are application contract tests, not a live FFOR payment test. From 7d37a8739121dd7e992244ceff0ea70ed2abc3f1 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 23 Sep 2026 11:18:48 -0400 Subject: [PATCH 4/4] feat: connect the native offline receive provider behind a developer toggle --- Bitkit.xcodeproj/project.pbxproj | 2 + Bitkit/AppScene.swift | 3 +- .../LdkNodeOfflineReceiveClient.swift | 75 +++ .../Services/LdkOfflineReceiveProvider.swift | 271 +++++++++++ Bitkit/Services/LightningService.swift | 45 ++ Bitkit/Services/OfflineReceiveSettings.swift | 66 +++ Bitkit/Views/Settings/DevSettingsView.swift | 44 ++ .../LdkOfflineReceiveProviderTests.swift | 452 ++++++++++++++++++ Configs/OfflineReceiveLocalLdk.xcconfig | 4 + Docs/OfflineReceive.md | 164 ++++++- 10 files changed, 1103 insertions(+), 23 deletions(-) create mode 100644 Bitkit/Services/LdkNodeOfflineReceiveClient.swift create mode 100644 Bitkit/Services/LdkOfflineReceiveProvider.swift create mode 100644 Bitkit/Services/OfflineReceiveSettings.swift create mode 100644 BitkitTests/LdkOfflineReceiveProviderTests.swift create mode 100644 Configs/OfflineReceiveLocalLdk.xcconfig diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 191c32332..9611a0638 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -177,6 +177,7 @@ Services/HwSnapshotMerge.swift, Services/LightningService.swift, Services/MigrationsService.swift, + Services/OfflineReceiveSettings.swift, Services/RNBackupClient.swift, Services/ServiceQueue.swift, Services/TransferStorage.swift, @@ -210,6 +211,7 @@ Services/HwActivityTagBackup.swift, Services/HwSnapshotMerge.swift, Services/LightningService.swift, + Services/OfflineReceiveSettings.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, Services/WatchOnlyAccountService.swift, diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 86e18d2b6..bf6815020 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -247,7 +247,8 @@ struct AppScene: View { let walletVm = WalletViewModel( transferService: transferService, sheetViewModel: sheetViewModel, - feeEstimatesManager: feeEstimatesManager + feeEstimatesManager: feeEstimatesManager, + offlineReceiveProvider: OfflineReceiveProviderSelection.provider() ) _wallet = StateObject(wrappedValue: walletVm) _currency = StateObject(wrappedValue: CurrencyViewModel()) diff --git a/Bitkit/Services/LdkNodeOfflineReceiveClient.swift b/Bitkit/Services/LdkNodeOfflineReceiveClient.swift new file mode 100644 index 000000000..c6c89e944 --- /dev/null +++ b/Bitkit/Services/LdkNodeOfflineReceiveClient.swift @@ -0,0 +1,75 @@ +#if OFFLINE_RECEIVE_LOCAL_LDK + import Foundation + import LDKNode + + /// Bridges the generated `OfflineReceivePayment` API of the local ldk-node binding onto the + /// app-side client protocol. Compiled only when the build carries that binding. + struct LdkNodeOfflineReceiveClient: OfflineReceiveNodeClient { + let lightningService: LightningService + + init(lightningService: LightningService = .shared) { + self.lightningService = lightningService + } + + func nodeId() async throws -> String { + try await onLdkQueue { try lightningService.offlineReceiveNodeId() } + } + + func canReceive(amountMsat: UInt64) async throws -> Bool { + try await onLdkQueue { try lightningService.offlineReceivePayment().canReceive(amountMsat: amountMsat) } + } + + func prepare(requestId: String, amountMsat: UInt64, description: String) async throws -> OfflineReceiveNodeStatus { + try await onLdkQueue { + try OfflineReceiveNodeStatus( + lightningService.offlineReceivePayment().prepare(requestId: requestId, amountMsat: amountMsat, description: description) + ) + } + } + + func status(requestId: String) async throws -> OfflineReceiveNodeStatus { + try await onLdkQueue { try OfflineReceiveNodeStatus(lightningService.offlineReceivePayment().status(requestId: requestId)) } + } + + func cancel(requestId: String) async throws { + try await onLdkQueue { try lightningService.offlineReceivePayment().cancel(requestId: requestId) } + } + + private func onLdkQueue(_ operation: @escaping () throws -> T) async throws -> T { + try await ServiceQueue.background(.ldk, wrapErrors: false) { + do { + return try operation() + } catch let error as NodeError { + throw OfflineReceiveNodeError(error) ?? error + } + } + } + } + + extension OfflineReceiveNodeStatus { + init(_ status: LDKNode.OfflineReceiveStatus) { + switch status { + case .preparing: self = .preparing + case .awaitingActivation: self = .awaitingActivation + case .awaitingWitnesses: self = .awaitingWitnesses + case let .ready(bolt11): self = .ready(bolt11: bolt11) + case .expired: self = .expired + case let .settled(outcome): self = .settled(fulfilled: outcome == .fulfilled) + case let .failed(reason): self = .failed(reason: reason) + } + } + } + + extension OfflineReceiveNodeError { + init?(_ error: NodeError) { + switch error { + case .OfflineReceiveDisabled: self = .disabled + case .OfflineReceiveUnavailable: self = .unavailable + case .OfflineReceiveIneligible: self = .ineligible + case .OfflineReceiveRequestNotFound: self = .requestNotFound + case .OfflineReceiveRequestConflict: self = .requestConflict + default: return nil + } + } + } +#endif diff --git a/Bitkit/Services/LdkOfflineReceiveProvider.swift b/Bitkit/Services/LdkOfflineReceiveProvider.swift new file mode 100644 index 000000000..ff4f6a8d1 --- /dev/null +++ b/Bitkit/Services/LdkOfflineReceiveProvider.swift @@ -0,0 +1,271 @@ +import Foundation +import LDKNode + +/// App-side mirror of the offline receive request states reported by the node binding. +enum OfflineReceiveNodeStatus: Equatable { + case preparing + case awaitingActivation + case awaitingWitnesses + case ready(bolt11: String) + case expired + case settled(fulfilled: Bool) + case failed(reason: String) +} + +enum OfflineReceiveNodeError: Error, Equatable { + case disabled + case unavailable + case ineligible + case requestNotFound + case requestConflict +} + +/// Minimal surface of the node's offline receive API so the provider can be tested without the native framework. +protocol OfflineReceiveNodeClient { + func nodeId() async throws -> String + func canReceive(amountMsat: UInt64) async throws -> Bool + func prepare(requestId: String, amountMsat: UInt64, description: String) async throws -> OfflineReceiveNodeStatus + func status(requestId: String) async throws -> OfflineReceiveNodeStatus + func cancel(requestId: String) async throws +} + +struct OfflineReceiveInvoiceSummary: Equatable { + let amountMsat: UInt64? + let payeeNodeId: String +} + +struct OfflineReceiveRequestRecord: Codable, Equatable { + let requestId: String + let amountSats: UInt64 + let description: String +} + +/// Durable memory of which node request identity belongs to which receive intent, +/// so a restart resumes the same request instead of preparing a second one. +protocol OfflineReceiveRequestStore { + func load() -> [OfflineReceiveRequestRecord] + func save(_ records: [OfflineReceiveRequestRecord]) +} + +extension OfflineReceiveRequestStore { + static var capacity: Int { 16 } + + func record(amountSats: UInt64, description: String) -> OfflineReceiveRequestRecord? { + load().first { $0.amountSats == amountSats && $0.description == description } + } + + func remember(_ record: OfflineReceiveRequestRecord) { + var records = load().filter { !($0.amountSats == record.amountSats && $0.description == record.description) } + records.append(record) + save(Array(records.suffix(Self.capacity))) + } + + func forget(requestId: String) { + save(load().filter { $0.requestId != requestId }) + } +} + +final class UserDefaultsOfflineReceiveRequestStore: OfflineReceiveRequestStore { + static let key = "offlineReceivePendingRequests" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> [OfflineReceiveRequestRecord] { + guard let data = defaults.data(forKey: Self.key) else { return [] } + return (try? JSONDecoder().decode([OfflineReceiveRequestRecord].self, from: data)) ?? [] + } + + func save(_ records: [OfflineReceiveRequestRecord]) { + if records.isEmpty { + defaults.removeObject(forKey: Self.key) + } else if let data = try? JSONEncoder().encode(records) { + defaults.set(data, forKey: Self.key) + } + } +} + +final class InMemoryOfflineReceiveRequestStore: OfflineReceiveRequestStore { + private(set) var records: [OfflineReceiveRequestRecord] = [] + + init(records: [OfflineReceiveRequestRecord] = []) { + self.records = records + } + + func load() -> [OfflineReceiveRequestRecord] { records } + func save(_ records: [OfflineReceiveRequestRecord]) { self.records = records } +} + +/// Offline receive provider backed by the LDK Node offline receive API. +/// Only a request the node reports as ready produces an invoice, and that invoice is checked +/// for the exact amount and our own node as payee before it is handed to the receive flow. +@MainActor +final class LdkOfflineReceiveProvider: OfflineReceiveProviding { + typealias Inspect = @MainActor (String) throws -> OfflineReceiveInvoiceSummary + typealias Sleep = (TimeInterval) async throws -> Void + + static let defaultTimeout: TimeInterval = 60 + static let defaultPollInterval: TimeInterval = 0.5 + + private let client: any OfflineReceiveNodeClient + private let store: any OfflineReceiveRequestStore + private let inspect: Inspect + private let timeout: TimeInterval + private let pollInterval: TimeInterval + private let now: @MainActor () -> Date + private let sleep: Sleep + + init( + client: any OfflineReceiveNodeClient, + store: any OfflineReceiveRequestStore = UserDefaultsOfflineReceiveRequestStore(), + inspect: @escaping Inspect = LdkOfflineReceiveProvider.inspectBolt11, + timeout: TimeInterval = LdkOfflineReceiveProvider.defaultTimeout, + pollInterval: TimeInterval = LdkOfflineReceiveProvider.defaultPollInterval, + now: @escaping @MainActor () -> Date = Date.init, + sleep: @escaping Sleep = { try await Task.sleep(nanoseconds: UInt64($0 * 1_000_000_000)) } + ) { + self.client = client + self.store = store + self.inspect = inspect + self.timeout = timeout + self.pollInterval = pollInterval + self.now = now + self.sleep = sleep + } + + static func inspectBolt11(_ bolt11: String) throws -> OfflineReceiveInvoiceSummary { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) + return OfflineReceiveInvoiceSummary(amountMsat: invoice.amountMilliSatoshis(), payeeNodeId: invoice.recoverPayeePubKey()) + } + + func canReceive(amountSats: UInt64) async throws -> Bool { + guard let amountMsat = Self.amountMsat(amountSats) else { return false } + do { + return try await client.canReceive(amountMsat: amountMsat) + } catch OfflineReceiveNodeError.disabled, OfflineReceiveNodeError.unavailable, OfflineReceiveNodeError.ineligible { + return false + } + } + + func prepareInvoice(requestId: String, amountSats: UInt64, description: String) async throws -> PreparedOfflineInvoice { + guard let amountMsat = Self.amountMsat(amountSats) else { throw OfflineReceiveError.insufficientLiquidity } + let deadline = now().addingTimeInterval(timeout) + let (record, initialStatus) = try await begin(requestId: requestId, amountSats: amountSats, amountMsat: amountMsat, description: description) + var status = initialStatus + + while true { + try Task.checkCancellation() + switch status { + case let .ready(bolt11): + try await validate(bolt11: bolt11, amountMsat: amountMsat, record: record) + return PreparedOfflineInvoice(bolt11: bolt11) + case .expired, .settled, .failed: + store.forget(requestId: record.requestId) + throw OfflineReceiveError.unavailable + case .preparing, .awaitingActivation, .awaitingWitnesses: + guard now() < deadline else { throw OfflineReceiveError.unavailable } + try await sleep(pollInterval) + try Task.checkCancellation() + status = try await poll(record: record) + } + } + } + + /// Releases the node request behind an intent. The receive UI does not expose this yet. + func cancel(requestId: String) async throws { + do { + try await client.cancel(requestId: requestId) + } catch OfflineReceiveNodeError.requestNotFound {} + store.forget(requestId: requestId) + } + + private func begin( + requestId: String, + amountSats: UInt64, + amountMsat: UInt64, + description: String + ) async throws -> (OfflineReceiveRequestRecord, OfflineReceiveNodeStatus) { + if let stored = store.record(amountSats: amountSats, description: description) { + do { + return try await (stored, client.status(requestId: stored.requestId)) + } catch OfflineReceiveNodeError.requestNotFound { + store.forget(requestId: stored.requestId) + } catch let error as OfflineReceiveNodeError { + throw Self.map(error) + } + } + let record = OfflineReceiveRequestRecord(requestId: requestId, amountSats: amountSats, description: description) + store.remember(record) + do { + return try await (record, client.prepare(requestId: requestId, amountMsat: amountMsat, description: description)) + } catch let error as OfflineReceiveNodeError { + store.forget(requestId: requestId) + throw Self.map(error) + } + } + + private func poll(record: OfflineReceiveRequestRecord) async throws -> OfflineReceiveNodeStatus { + do { + return try await client.status(requestId: record.requestId) + } catch let error as OfflineReceiveNodeError { + store.forget(requestId: record.requestId) + throw Self.map(error) + } + } + + private func validate(bolt11: String, amountMsat: UInt64, record: OfflineReceiveRequestRecord) async throws { + let summary = try? inspect(bolt11) + let nodeId = try await client.nodeId() + guard let summary, summary.amountMsat == amountMsat, summary.payeeNodeId.lowercased() == nodeId.lowercased() else { + store.forget(requestId: record.requestId) + throw OfflineReceiveError.invalidInvoice + } + } + + private static func amountMsat(_ amountSats: UInt64) -> UInt64? { + let (amountMsat, overflow) = amountSats.multipliedReportingOverflow(by: 1000) + guard amountSats > 0, !overflow else { return nil } + return amountMsat + } + + private static func map(_ error: OfflineReceiveNodeError) -> OfflineReceiveError { + switch error { + case .disabled, .unavailable, .ineligible, .requestNotFound, .requestConflict: .unavailable + } + } +} + +/// Keeps the real provider behind the developer toggle so a production build never advertises offline receive. +@MainActor +struct DeveloperGatedOfflineReceiveProvider: OfflineReceiveProviding { + let isEnabled: @MainActor () -> Bool + let provider: any OfflineReceiveProviding + + func canReceive(amountSats: UInt64) async throws -> Bool { + guard isEnabled() else { return false } + return try await provider.canReceive(amountSats: amountSats) + } + + func prepareInvoice(requestId: String, amountSats: UInt64, description: String) async throws -> PreparedOfflineInvoice { + guard isEnabled() else { throw OfflineReceiveError.unavailable } + return try await provider.prepareInvoice(requestId: requestId, amountSats: amountSats, description: description) + } +} + +enum OfflineReceiveProviderSelection { + /// The real provider exists only in builds compiled against the local binding and stays gated behind the developer toggle. + @MainActor + static func provider(lightningService: LightningService = .shared) -> any OfflineReceiveProviding { + #if OFFLINE_RECEIVE_LOCAL_LDK + return DeveloperGatedOfflineReceiveProvider( + isEnabled: { OfflineReceiveSettings.isEnabled() }, + provider: LdkOfflineReceiveProvider(client: LdkNodeOfflineReceiveClient(lightningService: lightningService)) + ) + #else + _ = lightningService + return UnavailableOfflineReceiveProvider() + #endif + } +} diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index ca78ba743..db1455d69 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -160,6 +160,17 @@ class LightningService { builder.setEntropyBip39Mnemonic(mnemonic: mnemonic, passphrase: passphrase) + #if OFFLINE_RECEIVE_LOCAL_LDK + if let offlineReceiveConfiguration = OfflineReceiveSettings.nodeConfiguration() { + builder.setOfflineReceiveConfig(config: Self.offlineReceiveConfig(offlineReceiveConfiguration)) + Logger.info( + "Offline receive enabled with settlement node \(offlineReceiveConfiguration.settlementNodeId) " + + "and \(offlineReceiveConfiguration.witnessNodeIds.count) witnesses", + context: "OfflineReceive" + ) + } + #endif + guard !lnurlAuthServerUrl.isEmpty else { throw CustomServiceError.vssAuthRequired } @@ -1136,6 +1147,40 @@ extension LightningService { node != nil } + #if OFFLINE_RECEIVE_LOCAL_LDK + func offlineReceivePayment() throws -> OfflineReceivePayment { + guard let node else { throw AppError(serviceError: .nodeNotSetup) } + return node.offlineReceive() + } + + func offlineReceiveNodeId() throws -> String { + guard let node else { throw AppError(serviceError: .nodeNotSetup) } + return node.nodeId() + } + + static func offlineReceiveConfig(_ configuration: OfflineReceiveNodeConfiguration) -> OfflineReceiveConfig { + OfflineReceiveConfig( + settlementNodeId: configuration.settlementNodeId, + witnesses: configuration.witnessNodeIds.map { + OfflineReceiveWitnessConfig( + nodeId: $0, + retentionBlocks: configuration.witnessRetentionBlocks, + minimumReceipts: configuration.witnessMinimumReceipts + ) + }, + invoiceExpirySeconds: configuration.invoiceExpirySeconds, + invoiceSafetyMarginSeconds: configuration.invoiceSafetyMarginSeconds, + settlementDeadlineBlocks: configuration.settlementDeadlineBlocks, + deadlineSafetyMarginBlocks: configuration.deadlineSafetyMarginBlocks, + claimMarginBlocks: configuration.claimMarginBlocks, + voucherExpiryBlocks: configuration.voucherExpiryBlocks, + feeBaseMsat: configuration.feeBaseMsat, + feeProportionalMillionths: configuration.feeProportionalMillionths, + pollIntervalSecs: configuration.pollIntervalSecs + ) + } + #endif + /// Use cached values to avoid blocking LDK calls on main thread @MainActor var balances: BalanceDetails? { cachedBalances diff --git a/Bitkit/Services/OfflineReceiveSettings.swift b/Bitkit/Services/OfflineReceiveSettings.swift new file mode 100644 index 000000000..fe33c1e9b --- /dev/null +++ b/Bitkit/Services/OfflineReceiveSettings.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Node-side FFOR parameters passed to the LDK Node builder when the developer toggle is on. +/// Timing and fee values are the development defaults of the offline receive binding. +struct OfflineReceiveNodeConfiguration: Equatable { + let settlementNodeId: String + let witnessNodeIds: [String] + let invoiceExpirySeconds: UInt32 = 3600 + let invoiceSafetyMarginSeconds: UInt32 = 120 + let settlementDeadlineBlocks: UInt32 = 144 + let deadlineSafetyMarginBlocks: UInt32 = 6 + let claimMarginBlocks: UInt32 = 20 + let voucherExpiryBlocks: UInt32 = 288 + let feeBaseMsat: UInt32 = 0 + let feeProportionalMillionths: UInt32 = 0 + let pollIntervalSecs: UInt64 = 5 + let witnessRetentionBlocks: UInt32 = 288 + let witnessMinimumReceipts: UInt8 = 0 +} + +/// Developer-only switches for the experimental offline receive provider. +/// Nothing is configured unless the build carries the local binding and the toggle is on. +enum OfflineReceiveSettings { + static let enabledKey = "offlineReceiveExperimentalEnabled" + static let settlementNodeIdKey = "offlineReceiveSettlementNodeId" + static let witnessNodeIdsKey = "offlineReceiveWitnessNodeIds" + + static var isBuildAvailable: Bool { + #if OFFLINE_RECEIVE_LOCAL_LDK + true + #else + false + #endif + } + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + isBuildAvailable && defaults.bool(forKey: enabledKey) + } + + /// The settlement node defaults to the Blocktank LSP peer Bitkit already trusts for the current network. + static func nodeConfiguration( + defaults: UserDefaults = .standard, + trustedPeers: [LnPeer] = Env.trustedLnPeers + ) -> OfflineReceiveNodeConfiguration? { + guard defaults.bool(forKey: enabledKey) else { return nil } + let settlementNodeId = nodeIds(from: defaults.string(forKey: settlementNodeIdKey)).first ?? trustedPeers.first?.nodeId + guard let settlementNodeId, isNodeId(settlementNodeId) else { return nil } + let witnessNodeIds = nodeIds(from: defaults.string(forKey: witnessNodeIdsKey)).filter { $0 != settlementNodeId } + return OfflineReceiveNodeConfiguration(settlementNodeId: settlementNodeId, witnessNodeIds: witnessNodeIds) + } + + static func nodeIds(from text: String?) -> [String] { + guard let text else { return [] } + var seen = Set() + return text + .split(whereSeparator: { $0 == "," || $0.isWhitespace || $0.isNewline }) + .map { $0.lowercased() } + .filter { isNodeId($0) && seen.insert($0).inserted } + } + + static func isNodeId(_ candidate: String) -> Bool { + candidate.count == 66 && + (candidate.hasPrefix("02") || candidate.hasPrefix("03")) && + candidate.allSatisfy(\.isHexDigit) + } +} diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 525245834..ad3b5ce28 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -7,6 +7,9 @@ struct DevSettingsView: View { @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false @AppStorage(BoltzService.savingsSwapEnabledKey) private var isSavingsSwapEnabled = false + @AppStorage(OfflineReceiveSettings.enabledKey) private var isOfflineReceiveEnabled = false + @AppStorage(OfflineReceiveSettings.settlementNodeIdKey) private var offlineReceiveSettlementNodeId = "" + @AppStorage(OfflineReceiveSettings.witnessNodeIdsKey) private var offlineReceiveWitnessNodeIds = "" @EnvironmentObject var app: AppViewModel @EnvironmentObject var activity: ActivityListViewModel @@ -73,6 +76,10 @@ struct DevSettingsView: View { testIdentifier: "SavingsSwapToggle" ) + if OfflineReceiveSettings.isBuildAvailable { + offlineReceiveSection + } + SettingsSectionHeader("RECOVERY") .padding(.top, 16) @@ -218,6 +225,43 @@ struct DevSettingsView: View { } } + private var offlineReceiveSection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("OFFLINE RECEIVE") + .padding(.top, 16) + + SettingsRow( + title: "Offline receive (experimental)", + rightIcon: nil, + toggle: $isOfflineReceiveEnabled, + testIdentifier: "OfflineReceiveToggle" + ) + + if isOfflineReceiveEnabled { + VStack(alignment: .leading, spacing: 8) { + CaptionMText("Settlement node id (blank uses the Blocktank LSP for this network)") + TextField("Settlement node id", text: $offlineReceiveSettlementNodeId) + .lineLimit(1) + .truncationMode(.middle) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("OfflineReceiveSettlementNodeId") + + CaptionMText("Witness node ids (comma separated)") + TextField("Witness node ids", text: $offlineReceiveWitnessNodeIds) + .lineLimit(1) + .truncationMode(.middle) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("OfflineReceiveWitnessNodeIds") + + CaptionMText("Node configuration applies after the app restarts. Invalid ids are ignored.") + } + .padding(.top, 8) + } + } + } + @MainActor private func disablePaykitUI() async { isPaykitUIEnabled = false diff --git a/BitkitTests/LdkOfflineReceiveProviderTests.swift b/BitkitTests/LdkOfflineReceiveProviderTests.swift new file mode 100644 index 000000000..5222d31a5 --- /dev/null +++ b/BitkitTests/LdkOfflineReceiveProviderTests.swift @@ -0,0 +1,452 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class LdkOfflineReceiveProviderTests: XCTestCase { + private let nodeId = "02aa000000000000000000000000000000000000000000000000000000000000aa" + private let bolt11 = "lnbcrt10u1ready" + + func testReadyInvoiceIsReturnedAfterPollingAndKeepsIdentityForRetries() async throws { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.awaitingActivation, .awaitingWitnesses, .ready(bolt11: bolt11)] + let store = InMemoryOfflineReceiveRequestStore() + let clock = FakeClock() + let provider = makeProvider(client: client, store: store, clock: clock) + + let invoice = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "Coffee") + + XCTAssertEqual(invoice.bolt11, bolt11) + XCTAssertEqual(client.prepareCalls.map(\.requestId), ["request-1"]) + XCTAssertEqual(client.prepareCalls.first?.amountMsat, 1_000_000) + XCTAssertEqual(client.prepareCalls.first?.description, "Coffee") + XCTAssertEqual(client.statusCalls, ["request-1", "request-1", "request-1"]) + XCTAssertEqual(clock.sleeps.count, 3) + XCTAssertEqual(store.records, [OfflineReceiveRequestRecord(requestId: "request-1", amountSats: 1000, description: "Coffee")]) + } + + func testImmediatelyReadyRequestDoesNotPoll() async throws { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.ready(bolt11: bolt11)) + let clock = FakeClock() + let provider = makeProvider(client: client, clock: clock) + + let invoice = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + + XCTAssertEqual(invoice.bolt11, bolt11) + XCTAssertTrue(client.statusCalls.isEmpty) + XCTAssertTrue(clock.sleeps.isEmpty) + } + + func testTimeoutStopsPollingAndRetryResumesTheSameRequest() async throws { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.preparing] + let store = InMemoryOfflineReceiveRequestStore() + let clock = FakeClock(secondsPerSleep: 10) + let provider = makeProvider(client: client, store: store, clock: clock, timeout: 60) + + await assertThrows(OfflineReceiveError.unavailable) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertEqual(clock.sleeps.count, 6) + XCTAssertEqual(client.prepareCalls.count, 1) + XCTAssertEqual(store.records.map(\.requestId), ["request-1"]) + + client.statuses = [.ready(bolt11: bolt11)] + let invoice = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + + XCTAssertEqual(invoice.bolt11, bolt11) + XCTAssertEqual(client.prepareCalls.count, 1) + } + + func testTerminalStatesMapToUnavailableAndClearIdentity() async { + for terminal in [ + OfflineReceiveNodeStatus.expired, + .settled(fulfilled: true), + .settled(fulfilled: false), + .failed(reason: "settlement node offline"), + ] { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.preparing) + client.statuses = [terminal] + let store = InMemoryOfflineReceiveRequestStore() + let provider = makeProvider(client: client, store: store) + + await assertThrows(OfflineReceiveError.unavailable) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertTrue(store.records.isEmpty, "\(terminal) must forget the request identity") + XCTAssertTrue(client.cancelCalls.isEmpty) + } + } + + func testNodeErrorsDuringPreparationMapToUnavailableAndClearIdentity() async { + for nodeError in [OfflineReceiveNodeError.disabled, .unavailable, .ineligible, .requestConflict] { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .failure(nodeError) + let store = InMemoryOfflineReceiveRequestStore() + let provider = makeProvider(client: client, store: store) + + await assertThrows(OfflineReceiveError.unavailable) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertTrue(store.records.isEmpty, "\(nodeError) must forget the request identity") + } + } + + func testCanReceiveMapsNodeErrorsToFalseAndUsesMillisatoshis() async throws { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + let provider = makeProvider(client: client) + + let supported = try await provider.canReceive(amountSats: 1000) + XCTAssertTrue(supported) + XCTAssertEqual(client.canReceiveCalls, [1_000_000]) + + for nodeError in [OfflineReceiveNodeError.disabled, .unavailable, .ineligible] { + client.canReceiveResult = .failure(nodeError) + let result = try await provider.canReceive(amountSats: 1000) + XCTAssertFalse(result, "\(nodeError) must not advertise offline support") + } + + client.canReceiveResult = .success(true) + let zero = try await provider.canReceive(amountSats: 0) + XCTAssertFalse(zero) + let overflow = try await provider.canReceive(amountSats: UInt64.max) + XCTAssertFalse(overflow) + XCTAssertEqual(client.canReceiveCalls.count, 4) + } + + func testRestartRecoversReadyInvoiceWithoutPreparingAgain() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "before-restart", amountSats: 1000, description: "Coffee") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.ready(bolt11: bolt11)] + let provider = makeProvider(client: client, store: store) + + let invoice = try await provider.prepareInvoice(requestId: "after-restart", amountSats: 1000, description: "Coffee") + + XCTAssertEqual(invoice.bolt11, bolt11) + XCTAssertTrue(client.prepareCalls.isEmpty) + XCTAssertEqual(client.statusCalls, ["before-restart"]) + XCTAssertEqual(store.records, [stored]) + } + + func testRestartWithPendingRequestKeepsPollingTheStoredIdentity() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "before-restart", amountSats: 1000, description: "Coffee") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.awaitingWitnesses, .ready(bolt11: bolt11)] + let provider = makeProvider(client: client, store: store) + + _ = try await provider.prepareInvoice(requestId: "after-restart", amountSats: 1000, description: "Coffee") + + XCTAssertTrue(client.prepareCalls.isEmpty) + XCTAssertEqual(client.statusCalls, ["before-restart", "before-restart"]) + } + + func testRestartWithForgottenRequestPreparesWithTheNewIdentity() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "before-restart", amountSats: 1000, description: "Coffee") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statusError = OfflineReceiveNodeError.requestNotFound + client.prepareResult = .success(.ready(bolt11: bolt11)) + let provider = makeProvider(client: client, store: store) + + _ = try await provider.prepareInvoice(requestId: "after-restart", amountSats: 1000, description: "Coffee") + + XCTAssertEqual(client.statusCalls, ["before-restart"]) + XCTAssertEqual(client.prepareCalls.map(\.requestId), ["after-restart"]) + XCTAssertEqual(store.records.map(\.requestId), ["after-restart"]) + } + + func testDifferentIntentUsesItsOwnIdentityWithoutCancellingTheOldRequest() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "before-restart", amountSats: 1000, description: "Coffee") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.ready(bolt11: bolt11)) + client.inspectedAmountMsat = 2_000_000 + let provider = makeProvider(client: client, store: store) + + _ = try await provider.prepareInvoice(requestId: "edited", amountSats: 2000, description: "Coffee") + + XCTAssertEqual(client.prepareCalls.map(\.requestId), ["edited"]) + XCTAssertTrue(client.statusCalls.isEmpty) + XCTAssertTrue(client.cancelCalls.isEmpty) + XCTAssertEqual(store.records.map(\.requestId), ["before-restart", "edited"]) + } + + func testTaskCancellationStopsPollingWithoutCancellingTheNodeRequest() async { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.preparing] + let store = InMemoryOfflineReceiveRequestStore() + let polling = expectation(description: "provider is waiting between polls") + let provider = LdkOfflineReceiveProvider( + client: client, + store: store, + inspect: client.inspect, + sleep: { _ in + polling.fulfill() + try await Task.sleep(nanoseconds: 30_000_000_000) + } + ) + + let task = Task { try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") } + await fulfillment(of: [polling], timeout: 2) + task.cancel() + + do { + _ = try await task.value + XCTFail("Cancelled preparation must not return an invoice") + } catch { + XCTAssertTrue(error is CancellationError, "Unexpected error: \(error)") + } + XCTAssertTrue(client.cancelCalls.isEmpty) + XCTAssertEqual(store.records.map(\.requestId), ["request-1"]) + } + + func testCancelReleasesNodeRequestAndForgetsIdentity() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "request-1", amountSats: 1000, description: "") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + let provider = makeProvider(client: client, store: store) + + try await provider.cancel(requestId: "request-1") + XCTAssertEqual(client.cancelCalls, ["request-1"]) + XCTAssertTrue(store.records.isEmpty) + + store.save([stored]) + client.cancelError = OfflineReceiveNodeError.requestNotFound + try await provider.cancel(requestId: "request-1") + XCTAssertTrue(store.records.isEmpty) + } + + func testReadyInvoiceWithWrongAmountOrPayeeIsRejected() async { + for (amountMsat, payee) in [(999_000, nodeId), (1_000_000, "03bb000000000000000000000000000000000000000000000000000000000000bb")] { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.ready(bolt11: bolt11)) + client.inspectedAmountMsat = UInt64(amountMsat) + client.inspectedPayee = payee + let store = InMemoryOfflineReceiveRequestStore() + let provider = makeProvider(client: client, store: store) + + await assertThrows(OfflineReceiveError.invalidInvoice) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertTrue(store.records.isEmpty) + } + } + + func testUnparsableReadyInvoiceIsRejected() async { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.ready(bolt11: bolt11)) + client.inspectError = TestFailure.parse + let provider = makeProvider(client: client) + + await assertThrows(OfflineReceiveError.invalidInvoice) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + } + + func testDeveloperGateHidesProviderUntilEnabled() async throws { + let inner = CountingProvider() + var enabled = false + let gated = DeveloperGatedOfflineReceiveProvider(isEnabled: { enabled }, provider: inner) + + let hidden = try await gated.canReceive(amountSats: 1000) + XCTAssertFalse(hidden) + await assertThrows(OfflineReceiveError.unavailable) { + _ = try await gated.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertEqual(inner.canReceiveCount, 0) + XCTAssertEqual(inner.prepareCount, 0) + + enabled = true + let visible = try await gated.canReceive(amountSats: 1000) + XCTAssertTrue(visible) + _ = try await gated.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + XCTAssertEqual(inner.canReceiveCount, 1) + XCTAssertEqual(inner.prepareCount, 1) + } + + func testDefaultBuildSelectsTheUnavailableProvider() { + XCTAssertFalse(OfflineReceiveSettings.isBuildAvailable) + XCTAssertTrue(OfflineReceiveProviderSelection.provider() is UnavailableOfflineReceiveProvider) + } + + func testUserDefaultsStoreRoundTripsAndCapsRecords() { + let suite = "LdkOfflineReceiveProviderTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = UserDefaultsOfflineReceiveRequestStore(defaults: defaults) + + for index in 0 ..< 20 { + store.remember(OfflineReceiveRequestRecord(requestId: "request-\(index)", amountSats: UInt64(index + 1), description: "")) + } + XCTAssertEqual(store.load().count, 16) + XCTAssertEqual(store.load().last?.requestId, "request-19") + XCTAssertNil(store.record(amountSats: 1, description: "")) + XCTAssertEqual(store.record(amountSats: 20, description: "")?.requestId, "request-19") + + store.remember(OfflineReceiveRequestRecord(requestId: "replacement", amountSats: 20, description: "")) + XCTAssertEqual(store.record(amountSats: 20, description: "")?.requestId, "replacement") + XCTAssertEqual(store.load().count, 16) + + let reopened = UserDefaultsOfflineReceiveRequestStore(defaults: defaults) + XCTAssertEqual(reopened.load(), store.load()) + + for record in store.load() { + store.forget(requestId: record.requestId) + } + XCTAssertNil(defaults.object(forKey: UserDefaultsOfflineReceiveRequestStore.key)) + } + + func testNodeConfigurationRequiresToggleAndValidNodeIds() { + let suite = "OfflineReceiveSettingsTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let lsp = LnPeer(nodeId: "03cc000000000000000000000000000000000000000000000000000000000000cc", host: "lsp", port: 9735) + + XCTAssertNil(OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [lsp])) + XCTAssertFalse(OfflineReceiveSettings.isEnabled(defaults: defaults)) + + defaults.set(true, forKey: OfflineReceiveSettings.enabledKey) + XCTAssertFalse(OfflineReceiveSettings.isEnabled(defaults: defaults), "The provider gate also needs the local binding build") + let fromLsp = OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [lsp]) + XCTAssertEqual(fromLsp?.settlementNodeId, lsp.nodeId) + XCTAssertEqual(fromLsp?.witnessNodeIds, []) + XCTAssertEqual(fromLsp?.invoiceExpirySeconds, 3600) + XCTAssertEqual(fromLsp?.settlementDeadlineBlocks, 144) + + XCTAssertNil(OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [])) + + defaults.set("not-a-node-id", forKey: OfflineReceiveSettings.settlementNodeIdKey) + XCTAssertEqual(OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [lsp])?.settlementNodeId, lsp.nodeId) + + defaults.set(" \(nodeId.uppercased()) ", forKey: OfflineReceiveSettings.settlementNodeIdKey) + defaults.set( + "\(nodeId), 02dd000000000000000000000000000000000000000000000000000000000000dd,bad, \(lsp.nodeId)\n\(lsp.nodeId)", + forKey: OfflineReceiveSettings.witnessNodeIdsKey + ) + let custom = OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [lsp]) + XCTAssertEqual(custom?.settlementNodeId, nodeId) + XCTAssertEqual(custom?.witnessNodeIds, ["02dd000000000000000000000000000000000000000000000000000000000000dd", lsp.nodeId]) + } + + private func makeProvider( + client: FakeOfflineReceiveNodeClient, + store: InMemoryOfflineReceiveRequestStore? = nil, + clock: FakeClock? = nil, + timeout: TimeInterval = 60 + ) -> LdkOfflineReceiveProvider { + let store = store ?? InMemoryOfflineReceiveRequestStore() + let clock = clock ?? FakeClock() + return LdkOfflineReceiveProvider( + client: client, + store: store, + inspect: client.inspect, + timeout: timeout, + pollInterval: 0.5, + now: { clock.now }, + sleep: { try await clock.sleep($0) } + ) + } + + private func assertThrows(_ expected: OfflineReceiveError, _ operation: () async throws -> Void) async { + do { + try await operation() + XCTFail("Expected \(expected)") + } catch let error as OfflineReceiveError { + XCTAssertEqual(error.localizedDescription, expected.localizedDescription) + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} + +private enum TestFailure: Error { + case parse +} + +@MainActor +private final class FakeClock { + private(set) var now = Date(timeIntervalSince1970: 1_700_000_000) + private(set) var sleeps: [TimeInterval] = [] + private let secondsPerSleep: TimeInterval? + + init(secondsPerSleep: TimeInterval? = nil) { + self.secondsPerSleep = secondsPerSleep + } + + func sleep(_ interval: TimeInterval) async throws { + try Task.checkCancellation() + sleeps.append(interval) + now = now.addingTimeInterval(secondsPerSleep ?? interval) + await Task.yield() + } +} + +@MainActor +private final class FakeOfflineReceiveNodeClient: OfflineReceiveNodeClient { + let nodeIdValue: String + var canReceiveResult: Result = .success(true) + var prepareResult: Result = .success(.preparing) + var statuses: [OfflineReceiveNodeStatus] = [] + var statusError: Error? + var cancelError: Error? + var inspectedAmountMsat: UInt64? = 1_000_000 + var inspectedPayee: String? + var inspectError: Error? + + private(set) var canReceiveCalls: [UInt64] = [] + private(set) var prepareCalls: [(requestId: String, amountMsat: UInt64, description: String)] = [] + private(set) var statusCalls: [String] = [] + private(set) var cancelCalls: [String] = [] + + init(nodeId: String) { + nodeIdValue = nodeId + } + + func nodeId() async throws -> String { nodeIdValue } + + func canReceive(amountMsat: UInt64) async throws -> Bool { + canReceiveCalls.append(amountMsat) + return try canReceiveResult.get() + } + + func prepare(requestId: String, amountMsat: UInt64, description: String) async throws -> OfflineReceiveNodeStatus { + prepareCalls.append((requestId, amountMsat, description)) + return try prepareResult.get() + } + + func status(requestId: String) async throws -> OfflineReceiveNodeStatus { + statusCalls.append(requestId) + if let statusError { throw statusError } + guard !statuses.isEmpty else { throw OfflineReceiveNodeError.requestNotFound } + return statuses.count > 1 ? statuses.removeFirst() : statuses[0] + } + + func cancel(requestId: String) async throws { + cancelCalls.append(requestId) + if let cancelError { throw cancelError } + } + + func inspect(_: String) throws -> OfflineReceiveInvoiceSummary { + if let inspectError { throw inspectError } + return OfflineReceiveInvoiceSummary(amountMsat: inspectedAmountMsat, payeeNodeId: inspectedPayee ?? nodeIdValue) + } +} + +@MainActor +private final class CountingProvider: OfflineReceiveProviding { + private(set) var canReceiveCount = 0 + private(set) var prepareCount = 0 + + func canReceive(amountSats _: UInt64) async throws -> Bool { + canReceiveCount += 1 + return true + } + + func prepareInvoice(requestId _: String, amountSats _: UInt64, description _: String) async throws -> PreparedOfflineInvoice { + prepareCount += 1 + return PreparedOfflineInvoice(bolt11: "gated-invoice") + } +} diff --git a/Configs/OfflineReceiveLocalLdk.xcconfig b/Configs/OfflineReceiveLocalLdk.xcconfig new file mode 100644 index 000000000..050e50ac7 --- /dev/null +++ b/Configs/OfflineReceiveLocalLdk.xcconfig @@ -0,0 +1,4 @@ +// Compiles the LDK Node offline receive adapter. Use only together with a local ldk-node +// package override that provides the offline receive API (see Docs/OfflineReceive.md). +// The committed dependency pin stays at the released ldk-node version; CI never sets this. +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) OFFLINE_RECEIVE_LOCAL_LDK diff --git a/Docs/OfflineReceive.md b/Docs/OfflineReceive.md index f985b5411..f073af29f 100644 --- a/Docs/OfflineReceive.md +++ b/Docs/OfflineReceive.md @@ -1,21 +1,137 @@ # Offline receive integration -This draft adds the receive UI and a provider boundary for FFOR. It does not enable -offline payments with the current LDK Node dependency. The live provider is -`UnavailableOfflineReceiveProvider`, so the checkbox remains hidden and attempts -to create an offline invoice fail. Ordinary invoices are never presented as -offline invoices. +This draft adds the receive UI, a provider boundary and a real provider for FFOR. +The feature stays hidden in every ordinary build: the committed dependency pin is +`synonymdev/ldk-node` 0.7.0-rc.66, which has no offline receive API, so the app +compiles with `UnavailableOfflineReceiveProvider`, the checkbox never appears and +attempts to create an offline invoice fail. Ordinary invoices are never presented +as offline invoices, and no invoice is displayed unless the node reported it Ready. Tracking: https://github.com/synonymdev/ldk-node/issues/117 +## What is wired + +Three layers exist so the polling and persistence logic is testable without the +native framework: + +- `OfflineReceiveProviding` (`OfflineReceiveSession.swift`) is the boundary the + receive flow talks to. `UnavailableOfflineReceiveProvider` is the default. +- `LdkOfflineReceiveProvider` (`LdkOfflineReceiveProvider.swift`) is the real + provider. It talks to the node only through the small `OfflineReceiveNodeClient` + protocol, which mirrors the binding's `OfflineReceivePayment` API + (`canReceive`, `prepare`, `status`, `cancel`) with app-side status and error enums. + It always compiles. +- `LdkNodeOfflineReceiveClient` (`LdkNodeOfflineReceiveClient.swift`) adapts the + generated `OfflineReceivePayment` object onto that protocol and runs every call on + the LDK service queue. It compiles only when the `OFFLINE_RECEIVE_LOCAL_LDK` + compilation condition is set, because the API does not exist in rc.66. + +Provider behaviour: + +- `canReceive(amountSats:)` calls `canReceive(amountMsat:)` and reports `false` for + `OfflineReceiveDisabled`, `OfflineReceiveUnavailable` and `OfflineReceiveIneligible`. +- `prepareInvoice(requestId:amountSats:description:)` calls `prepare` and polls + `status` every 0.5 s for at most 60 s until the node reports `ready(bolt11)`. + `expired`, `settled` and `failed` throw `OfflineReceiveError.unavailable`. A timeout + also throws `unavailable` but keeps the request identity so a retry resumes the + same node request instead of preparing a second one. Task cancellation stops + polling without cancelling the node request. A `ready` invoice is accepted only if + its amount equals the request exactly and its payee is our own node id; the + receive flow then repeats its own amount, description, network and expiry checks. +- Request identity is persisted (`UserDefaultsOfflineReceiveRequestStore`, key + `offlineReceivePendingRequests`) as `(requestId, amountSats, description)`. After + process death the provider looks up the stored identity for the same amount and + description and calls `status(requestId:)` first, so a Ready invoice is recovered + without a new `prepare`. Entries are removed on terminal states, on invalid + invoices, on node errors during preparation and on `cancel`. A different amount + or description gets its own identity; the previous node request is left alone. +- `cancel(requestId:)` releases the node request and forgets the identity. The + receive UI does not call it yet. + +Node configuration (`OfflineReceiveSettings.swift`, applied in +`LightningService.setup` under the compilation condition): when the developer +toggle is on, `Builder.setOfflineReceiveConfig` receives the settlement node id +(the developer override, otherwise the Blocktank LSP peer Bitkit already trusts +for the current network), the developer-entered witness node ids with +`retentionBlocks` 288 and `minimumReceipts` 0, and the binding's development +defaults: invoice expiry 3600 s, safety margin 120 s, settlement deadline 144 +blocks, deadline margin 6, claim margin 20, voucher expiry 288, zero fees, poll +interval 5 s. Invalid ids are ignored rather than passed to the builder, and +without a valid settlement node nothing is configured. The builder reads the +configuration once, so a toggle change takes effect after the app restarts. The +notification extension has its own `UserDefaults` container and therefore builds +its node without the configuration. + +Provider selection happens in `AppScene` through +`OfflineReceiveProviderSelection.provider()`. With the compilation condition it +returns `DeveloperGatedOfflineReceiveProvider`, which consults the toggle on every +call and otherwise behaves like the unavailable provider. Without the condition it +returns `UnavailableOfflineReceiveProvider`. + +## Developer toggle + +Settings > Dev Settings shows an "OFFLINE RECEIVE" section only in builds compiled +with `OFFLINE_RECEIVE_LOCAL_LDK`. It has the "Offline receive (experimental)" +toggle (default off, key `offlineReceiveExperimentalEnabled`, +accessibility id `OfflineReceiveToggle`) and, once enabled, text fields for the +settlement node id override and comma separated witness node ids. Production and +CI builds neither compile the adapter nor show the section, so the checkbox in +the receive editor remains absent unless the local binding, the toggle and a +node that reports `canReceive` all agree. + +## Building against the local binding + +The committed pin stays at rc.66 for CI. To build the real provider locally: + +1. Prepare a local package whose directory is named `ldk-node` (SPM derives the + override identity from the directory name), for example + `/private/tmp/ffor-ios-ldk-node/ldk-node`, containing: + - `Package.swift` copied from the ldk-node checkout with the binary target + changed to `.binaryTarget(name: "LDKNodeFFI", path: "./bindings/swift/LDKNodeFFI.xcframework")`; + - `bindings/swift/Sources/LDKNode/LDKNode.swift` from the checkout; + - `bindings/swift/LDKNodeFFI.xcframework` unzipped from the release artifact. +2. In Xcode: File > Add Package Dependencies > Add Local, pick that directory. + A local package with the same identity overrides the remote `ldk-node` + dependency for that workspace session. Do not commit the resulting project or + `Package.resolved` changes. +3. Set the compilation condition. Either pass + `SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) OFFLINE_RECEIVE_LOCAL_LDK'` + on the `xcodebuild` command line (the same pattern as `E2E_BUILD`), or use + `-xcconfig Configs/OfflineReceiveLocalLdk.xcconfig`, or assign that xcconfig to + the Debug configuration of the Bitkit target in Xcode for the session. + +For `xcodebuild`, a throwaway workspace outside the repository gives the same +override without editing the project: + +```xml + + + + + + +``` + +```bash +xcodebuild -workspace /private/tmp/ffor-ios-local.xcworkspace -scheme Bitkit \ + -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17' \ + -derivedDataPath /private/tmp/ffor-ios-build-local \ + -clonedSourcePackagesDirPath /private/tmp/ffor-ios-spm-local \ + -xcconfig Configs/OfflineReceiveLocalLdk.xcconfig build +``` + +Use a separate derived data path and cloned packages directory so the rc.66 +module caches of the ordinary build are not invalidated (see the FFI header +warning in `AGENTS.md`). + ## Provider requirements -Replace the unavailable provider through `WalletViewModel` initialization only -after the node and its channel peer support the FFOR protocol. The provider must: +The provider must: 1. Check eligibility for the exact amount, including peer capability, negotiated channel support, channel limits and existing offline reservations. The UI's - inbound liquidity check alone is insufficient. + inbound liquidity check alone is insufficient. The node's `canReceive` is + responsible for this; the app only converts the amount. 2. Recheck and reserve capacity atomically when preparing an invoice. The eligibility check is advisory and does not reserve funds. 3. Perform FFOR setup, durable registration and activation. Return @@ -32,13 +148,8 @@ The native provider chooses the invoice expiry and offline settlement window fro its negotiated policy. The app does not request or assume a duration. It parses the returned BOLT11 and checks its exact amount, direct description, network and expiry, then uses that signed expiry for display. Invoice expiry and the protocol's -settlement deadline are distinct. These checks do not prove FFOR activation. The -node provider must establish that guarantee. - -The pinned LDK Node version has no FFOR registration or activation API. Rust -Lightning channel support, the forwarding peer implementation, durable lifecycle -management and updated mobile bindings are still prerequisites. No generated -node API or production provider is assumed by this draft. +settlement deadline are distinct. These checks do not prove FFOR activation. Only +the node's `ready` status establishes that. ## Receive behavior @@ -81,10 +192,19 @@ BIP21 strings stay in memory and never enter the ordinary receive invoice cache. liquidity boundaries, stale responses, session reset and failed activation. `OfflineReceiveRegistrationTests` covers paid-history checks, payment and session races, expiry and isolation from the ordinary persistent invoice cache. -Existing receive tests cover the ordinary invoice liquidity rules and edit -navigation. These are application contract tests, not a live FFOR payment test. - -Before enabling a production provider, verify a funded receiver can prepare an -invoice, stop the app, receive a payment through the forwarding peer, restart and -recover the payment. Include expiry, offline restart, abort, replay, capacity -exhaustion, loss of the peer connection and concurrent preparation cases. +`LdkOfflineReceiveProviderTests` drives `LdkOfflineReceiveProvider` with a fake +node client and a fake clock: the ready path, no polling for an immediately ready +request, the bounded timeout and retry resumption, terminal state and node error +mapping, `canReceive` mapping, identity recovery after a simulated restart +(ready, still pending, and forgotten requests), separate identities per intent, +Task cancellation, explicit cancel, amount and payee validation, the developer +gate, the `UserDefaults` store, the node configuration parser and the default +provider selection. Existing receive tests cover the ordinary invoice liquidity +rules and edit navigation. These are application contract tests, not a live FFOR +payment test. + +Device validation is pending. Before enabling a production provider, verify on a +device against a settlement node that a funded receiver can prepare an invoice, +stop the app, receive a payment through the forwarding peer, restart and recover +the payment. Include expiry, offline restart, abort, replay, capacity exhaustion, +loss of the peer connection and concurrent preparation cases.