From 49ba310dde56ca31075f2c9a530f189bc2216c70 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 15:41:50 -0400 Subject: [PATCH 1/3] 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/3] 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/3] 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.