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/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/Services/OfflineReceiveSession.swift b/Bitkit/Services/OfflineReceiveSession.swift new file mode 100644 index 000000000..259bc0262 --- /dev/null +++ b/Bitkit/Services/OfflineReceiveSession.swift @@ -0,0 +1,165 @@ +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. + /// 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 { + 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) 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 + var attempted = false + + init(amountSats: UInt64, description: String) { + self.amountSats = amountSats + self.description = description + } + } + + 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 + ) async throws -> PreparedOfflineInvoice { + let preparation: Preparation + if let current = self.preparation, + current.amountSats == eligibility.amountSats, + current.description == description + { + preparation = current + } else { + preparation = Preparation(amountSats: eligibility.amountSats, description: description) + 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 + ) + 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..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,6 +66,32 @@ class WalletViewModel: ObservableObject { // For bolt11 details and bip21 params var invoiceAmountSats: UInt64 = 0 var invoiceNote: String = "" + @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? + + 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 +145,12 @@ class WalletViewModel: ObservableObject { rgsConfigService: RgsConfigService = RgsConfigService(), transferService: TransferService, sheetViewModel: SheetViewModel, - feeEstimatesManager: FeeEstimatesManager + feeEstimatesManager: FeeEstimatesManager, + 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 @@ -118,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 { @@ -206,7 +257,7 @@ class WalletViewModel: ObservableObject { routeFeeMsat: routeFeeMsat ) case let .paymentReceived(_, paymentHash, _, _): - self.bolt11 = "" + self.receiveInvoicePaymentReceived(hash: paymentHash) if self.isPaykitUIActive { self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) } @@ -218,7 +269,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 +286,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 +582,95 @@ 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 + 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) } + 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 + ) + let validatedInvoice = 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())) + ) + } + try await offlineInvoiceRegistration.register(validatedInvoice, revision: revision) + return validatedInvoice + } + + var hasPreparedOfflineInvoice: Bool { + 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 = "" + bip21 = "" + } + receiveRefreshRevision = UUID() + } + @discardableResult func waitForNodeToRun(timeoutSeconds: Double = 10.0) async -> Bool { guard nodeLifecycleState != .running else { return true } @@ -1230,6 +1370,7 @@ class WalletViewModel: ObservableObject { } var canCreateReceiveLightningInvoice: Bool { + if hasPreparedOfflineInvoice { return true } let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil return canCreateReceiveLightningInvoice(amountSats: amountSats) } @@ -1383,8 +1524,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,45 +1548,61 @@ 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)" } } - bip21 = newBip21 + try applyReceiveInvoice(bolt11: nextBolt11, offlineInvoice: nextOfflineInvoice, bip21: newBip21) // Persist metadata with migrated tags await persistPreActivityMetadata(tags: tagsToMigrate) @@ -1445,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 } @@ -1603,6 +1789,7 @@ class WalletViewModel: ObservableObject { maxSendLightningSats = 0 channelCount = 0 + resetOfflineReceive() onchainAddress = "" bolt11 = "" bip21 = "" 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/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/BitkitTests/OfflineReceiveSessionTests.swift b/BitkitTests/OfflineReceiveSessionTests.swift new file mode 100644 index 000000000..36103b407 --- /dev/null +++ b/BitkitTests/OfflineReceiveSessionTests.swift @@ -0,0 +1,281 @@ +@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: "") + 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") + 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") + XCTFail("Failed activation must not return an invoice") + } catch { + XCTAssertEqual(error as? TestFailure, .activationFailed) + } + XCTAssertEqual(provider.prepareCount, 1) + } + + func testPreparationPassesExactAmountAndDescription() async throws { + let provider = TestOfflineReceiveProvider() + let session = OfflineReceiveSession(provider: provider) + let result = try await session.prepareInvoice(eligibility: eligibility(), description: "Test") + + XCTAssertEqual(result.bolt11, "prepared-offline-invoice") + XCTAssertEqual(provider.preparedAmount, 1000) + XCTAssertEqual(provider.preparedDescription, "Test") + } + + 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") + 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") + + 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") + _ = 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") + + 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 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) async throws -> PreparedOfflineInvoice { + prepareCount += 1 + requestIds.append(requestId) + if let preparationError { throw preparationError } + preparedAmount = amountSats + preparedDescription = description + 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..f985b5411 --- /dev/null +++ b/Docs/OfflineReceive.md @@ -0,0 +1,90 @@ +# 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 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 +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 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 +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. + +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. 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. + +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.