diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 191c32332..9611a0638 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -177,6 +177,7 @@ Services/HwSnapshotMerge.swift, Services/LightningService.swift, Services/MigrationsService.swift, + Services/OfflineReceiveSettings.swift, Services/RNBackupClient.swift, Services/ServiceQueue.swift, Services/TransferStorage.swift, @@ -210,6 +211,7 @@ Services/HwActivityTagBackup.swift, Services/HwSnapshotMerge.swift, Services/LightningService.swift, + Services/OfflineReceiveSettings.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, Services/WatchOnlyAccountService.swift, diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 86e18d2b6..bf6815020 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -247,7 +247,8 @@ struct AppScene: View { let walletVm = WalletViewModel( transferService: transferService, sheetViewModel: sheetViewModel, - feeEstimatesManager: feeEstimatesManager + feeEstimatesManager: feeEstimatesManager, + offlineReceiveProvider: OfflineReceiveProviderSelection.provider() ) _wallet = StateObject(wrappedValue: walletVm) _currency = StateObject(wrappedValue: CurrencyViewModel()) diff --git a/Bitkit/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/LdkNodeOfflineReceiveClient.swift b/Bitkit/Services/LdkNodeOfflineReceiveClient.swift new file mode 100644 index 000000000..c6c89e944 --- /dev/null +++ b/Bitkit/Services/LdkNodeOfflineReceiveClient.swift @@ -0,0 +1,75 @@ +#if OFFLINE_RECEIVE_LOCAL_LDK + import Foundation + import LDKNode + + /// Bridges the generated `OfflineReceivePayment` API of the local ldk-node binding onto the + /// app-side client protocol. Compiled only when the build carries that binding. + struct LdkNodeOfflineReceiveClient: OfflineReceiveNodeClient { + let lightningService: LightningService + + init(lightningService: LightningService = .shared) { + self.lightningService = lightningService + } + + func nodeId() async throws -> String { + try await onLdkQueue { try lightningService.offlineReceiveNodeId() } + } + + func canReceive(amountMsat: UInt64) async throws -> Bool { + try await onLdkQueue { try lightningService.offlineReceivePayment().canReceive(amountMsat: amountMsat) } + } + + func prepare(requestId: String, amountMsat: UInt64, description: String) async throws -> OfflineReceiveNodeStatus { + try await onLdkQueue { + try OfflineReceiveNodeStatus( + lightningService.offlineReceivePayment().prepare(requestId: requestId, amountMsat: amountMsat, description: description) + ) + } + } + + func status(requestId: String) async throws -> OfflineReceiveNodeStatus { + try await onLdkQueue { try OfflineReceiveNodeStatus(lightningService.offlineReceivePayment().status(requestId: requestId)) } + } + + func cancel(requestId: String) async throws { + try await onLdkQueue { try lightningService.offlineReceivePayment().cancel(requestId: requestId) } + } + + private func onLdkQueue(_ operation: @escaping () throws -> T) async throws -> T { + try await ServiceQueue.background(.ldk, wrapErrors: false) { + do { + return try operation() + } catch let error as NodeError { + throw OfflineReceiveNodeError(error) ?? error + } + } + } + } + + extension OfflineReceiveNodeStatus { + init(_ status: LDKNode.OfflineReceiveStatus) { + switch status { + case .preparing: self = .preparing + case .awaitingActivation: self = .awaitingActivation + case .awaitingWitnesses: self = .awaitingWitnesses + case let .ready(bolt11): self = .ready(bolt11: bolt11) + case .expired: self = .expired + case let .settled(outcome): self = .settled(fulfilled: outcome == .fulfilled) + case let .failed(reason): self = .failed(reason: reason) + } + } + } + + extension OfflineReceiveNodeError { + init?(_ error: NodeError) { + switch error { + case .OfflineReceiveDisabled: self = .disabled + case .OfflineReceiveUnavailable: self = .unavailable + case .OfflineReceiveIneligible: self = .ineligible + case .OfflineReceiveRequestNotFound: self = .requestNotFound + case .OfflineReceiveRequestConflict: self = .requestConflict + default: return nil + } + } + } +#endif diff --git a/Bitkit/Services/LdkOfflineReceiveProvider.swift b/Bitkit/Services/LdkOfflineReceiveProvider.swift new file mode 100644 index 000000000..ff4f6a8d1 --- /dev/null +++ b/Bitkit/Services/LdkOfflineReceiveProvider.swift @@ -0,0 +1,271 @@ +import Foundation +import LDKNode + +/// App-side mirror of the offline receive request states reported by the node binding. +enum OfflineReceiveNodeStatus: Equatable { + case preparing + case awaitingActivation + case awaitingWitnesses + case ready(bolt11: String) + case expired + case settled(fulfilled: Bool) + case failed(reason: String) +} + +enum OfflineReceiveNodeError: Error, Equatable { + case disabled + case unavailable + case ineligible + case requestNotFound + case requestConflict +} + +/// Minimal surface of the node's offline receive API so the provider can be tested without the native framework. +protocol OfflineReceiveNodeClient { + func nodeId() async throws -> String + func canReceive(amountMsat: UInt64) async throws -> Bool + func prepare(requestId: String, amountMsat: UInt64, description: String) async throws -> OfflineReceiveNodeStatus + func status(requestId: String) async throws -> OfflineReceiveNodeStatus + func cancel(requestId: String) async throws +} + +struct OfflineReceiveInvoiceSummary: Equatable { + let amountMsat: UInt64? + let payeeNodeId: String +} + +struct OfflineReceiveRequestRecord: Codable, Equatable { + let requestId: String + let amountSats: UInt64 + let description: String +} + +/// Durable memory of which node request identity belongs to which receive intent, +/// so a restart resumes the same request instead of preparing a second one. +protocol OfflineReceiveRequestStore { + func load() -> [OfflineReceiveRequestRecord] + func save(_ records: [OfflineReceiveRequestRecord]) +} + +extension OfflineReceiveRequestStore { + static var capacity: Int { 16 } + + func record(amountSats: UInt64, description: String) -> OfflineReceiveRequestRecord? { + load().first { $0.amountSats == amountSats && $0.description == description } + } + + func remember(_ record: OfflineReceiveRequestRecord) { + var records = load().filter { !($0.amountSats == record.amountSats && $0.description == record.description) } + records.append(record) + save(Array(records.suffix(Self.capacity))) + } + + func forget(requestId: String) { + save(load().filter { $0.requestId != requestId }) + } +} + +final class UserDefaultsOfflineReceiveRequestStore: OfflineReceiveRequestStore { + static let key = "offlineReceivePendingRequests" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> [OfflineReceiveRequestRecord] { + guard let data = defaults.data(forKey: Self.key) else { return [] } + return (try? JSONDecoder().decode([OfflineReceiveRequestRecord].self, from: data)) ?? [] + } + + func save(_ records: [OfflineReceiveRequestRecord]) { + if records.isEmpty { + defaults.removeObject(forKey: Self.key) + } else if let data = try? JSONEncoder().encode(records) { + defaults.set(data, forKey: Self.key) + } + } +} + +final class InMemoryOfflineReceiveRequestStore: OfflineReceiveRequestStore { + private(set) var records: [OfflineReceiveRequestRecord] = [] + + init(records: [OfflineReceiveRequestRecord] = []) { + self.records = records + } + + func load() -> [OfflineReceiveRequestRecord] { records } + func save(_ records: [OfflineReceiveRequestRecord]) { self.records = records } +} + +/// Offline receive provider backed by the LDK Node offline receive API. +/// Only a request the node reports as ready produces an invoice, and that invoice is checked +/// for the exact amount and our own node as payee before it is handed to the receive flow. +@MainActor +final class LdkOfflineReceiveProvider: OfflineReceiveProviding { + typealias Inspect = @MainActor (String) throws -> OfflineReceiveInvoiceSummary + typealias Sleep = (TimeInterval) async throws -> Void + + static let defaultTimeout: TimeInterval = 60 + static let defaultPollInterval: TimeInterval = 0.5 + + private let client: any OfflineReceiveNodeClient + private let store: any OfflineReceiveRequestStore + private let inspect: Inspect + private let timeout: TimeInterval + private let pollInterval: TimeInterval + private let now: @MainActor () -> Date + private let sleep: Sleep + + init( + client: any OfflineReceiveNodeClient, + store: any OfflineReceiveRequestStore = UserDefaultsOfflineReceiveRequestStore(), + inspect: @escaping Inspect = LdkOfflineReceiveProvider.inspectBolt11, + timeout: TimeInterval = LdkOfflineReceiveProvider.defaultTimeout, + pollInterval: TimeInterval = LdkOfflineReceiveProvider.defaultPollInterval, + now: @escaping @MainActor () -> Date = Date.init, + sleep: @escaping Sleep = { try await Task.sleep(nanoseconds: UInt64($0 * 1_000_000_000)) } + ) { + self.client = client + self.store = store + self.inspect = inspect + self.timeout = timeout + self.pollInterval = pollInterval + self.now = now + self.sleep = sleep + } + + static func inspectBolt11(_ bolt11: String) throws -> OfflineReceiveInvoiceSummary { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) + return OfflineReceiveInvoiceSummary(amountMsat: invoice.amountMilliSatoshis(), payeeNodeId: invoice.recoverPayeePubKey()) + } + + func canReceive(amountSats: UInt64) async throws -> Bool { + guard let amountMsat = Self.amountMsat(amountSats) else { return false } + do { + return try await client.canReceive(amountMsat: amountMsat) + } catch OfflineReceiveNodeError.disabled, OfflineReceiveNodeError.unavailable, OfflineReceiveNodeError.ineligible { + return false + } + } + + func prepareInvoice(requestId: String, amountSats: UInt64, description: String) async throws -> PreparedOfflineInvoice { + guard let amountMsat = Self.amountMsat(amountSats) else { throw OfflineReceiveError.insufficientLiquidity } + let deadline = now().addingTimeInterval(timeout) + let (record, initialStatus) = try await begin(requestId: requestId, amountSats: amountSats, amountMsat: amountMsat, description: description) + var status = initialStatus + + while true { + try Task.checkCancellation() + switch status { + case let .ready(bolt11): + try await validate(bolt11: bolt11, amountMsat: amountMsat, record: record) + return PreparedOfflineInvoice(bolt11: bolt11) + case .expired, .settled, .failed: + store.forget(requestId: record.requestId) + throw OfflineReceiveError.unavailable + case .preparing, .awaitingActivation, .awaitingWitnesses: + guard now() < deadline else { throw OfflineReceiveError.unavailable } + try await sleep(pollInterval) + try Task.checkCancellation() + status = try await poll(record: record) + } + } + } + + /// Releases the node request behind an intent. The receive UI does not expose this yet. + func cancel(requestId: String) async throws { + do { + try await client.cancel(requestId: requestId) + } catch OfflineReceiveNodeError.requestNotFound {} + store.forget(requestId: requestId) + } + + private func begin( + requestId: String, + amountSats: UInt64, + amountMsat: UInt64, + description: String + ) async throws -> (OfflineReceiveRequestRecord, OfflineReceiveNodeStatus) { + if let stored = store.record(amountSats: amountSats, description: description) { + do { + return try await (stored, client.status(requestId: stored.requestId)) + } catch OfflineReceiveNodeError.requestNotFound { + store.forget(requestId: stored.requestId) + } catch let error as OfflineReceiveNodeError { + throw Self.map(error) + } + } + let record = OfflineReceiveRequestRecord(requestId: requestId, amountSats: amountSats, description: description) + store.remember(record) + do { + return try await (record, client.prepare(requestId: requestId, amountMsat: amountMsat, description: description)) + } catch let error as OfflineReceiveNodeError { + store.forget(requestId: requestId) + throw Self.map(error) + } + } + + private func poll(record: OfflineReceiveRequestRecord) async throws -> OfflineReceiveNodeStatus { + do { + return try await client.status(requestId: record.requestId) + } catch let error as OfflineReceiveNodeError { + store.forget(requestId: record.requestId) + throw Self.map(error) + } + } + + private func validate(bolt11: String, amountMsat: UInt64, record: OfflineReceiveRequestRecord) async throws { + let summary = try? inspect(bolt11) + let nodeId = try await client.nodeId() + guard let summary, summary.amountMsat == amountMsat, summary.payeeNodeId.lowercased() == nodeId.lowercased() else { + store.forget(requestId: record.requestId) + throw OfflineReceiveError.invalidInvoice + } + } + + private static func amountMsat(_ amountSats: UInt64) -> UInt64? { + let (amountMsat, overflow) = amountSats.multipliedReportingOverflow(by: 1000) + guard amountSats > 0, !overflow else { return nil } + return amountMsat + } + + private static func map(_ error: OfflineReceiveNodeError) -> OfflineReceiveError { + switch error { + case .disabled, .unavailable, .ineligible, .requestNotFound, .requestConflict: .unavailable + } + } +} + +/// Keeps the real provider behind the developer toggle so a production build never advertises offline receive. +@MainActor +struct DeveloperGatedOfflineReceiveProvider: OfflineReceiveProviding { + let isEnabled: @MainActor () -> Bool + let provider: any OfflineReceiveProviding + + func canReceive(amountSats: UInt64) async throws -> Bool { + guard isEnabled() else { return false } + return try await provider.canReceive(amountSats: amountSats) + } + + func prepareInvoice(requestId: String, amountSats: UInt64, description: String) async throws -> PreparedOfflineInvoice { + guard isEnabled() else { throw OfflineReceiveError.unavailable } + return try await provider.prepareInvoice(requestId: requestId, amountSats: amountSats, description: description) + } +} + +enum OfflineReceiveProviderSelection { + /// The real provider exists only in builds compiled against the local binding and stays gated behind the developer toggle. + @MainActor + static func provider(lightningService: LightningService = .shared) -> any OfflineReceiveProviding { + #if OFFLINE_RECEIVE_LOCAL_LDK + return DeveloperGatedOfflineReceiveProvider( + isEnabled: { OfflineReceiveSettings.isEnabled() }, + provider: LdkOfflineReceiveProvider(client: LdkNodeOfflineReceiveClient(lightningService: lightningService)) + ) + #else + _ = lightningService + return UnavailableOfflineReceiveProvider() + #endif + } +} diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index ca78ba743..db1455d69 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -160,6 +160,17 @@ class LightningService { builder.setEntropyBip39Mnemonic(mnemonic: mnemonic, passphrase: passphrase) + #if OFFLINE_RECEIVE_LOCAL_LDK + if let offlineReceiveConfiguration = OfflineReceiveSettings.nodeConfiguration() { + builder.setOfflineReceiveConfig(config: Self.offlineReceiveConfig(offlineReceiveConfiguration)) + Logger.info( + "Offline receive enabled with settlement node \(offlineReceiveConfiguration.settlementNodeId) " + + "and \(offlineReceiveConfiguration.witnessNodeIds.count) witnesses", + context: "OfflineReceive" + ) + } + #endif + guard !lnurlAuthServerUrl.isEmpty else { throw CustomServiceError.vssAuthRequired } @@ -1136,6 +1147,40 @@ extension LightningService { node != nil } + #if OFFLINE_RECEIVE_LOCAL_LDK + func offlineReceivePayment() throws -> OfflineReceivePayment { + guard let node else { throw AppError(serviceError: .nodeNotSetup) } + return node.offlineReceive() + } + + func offlineReceiveNodeId() throws -> String { + guard let node else { throw AppError(serviceError: .nodeNotSetup) } + return node.nodeId() + } + + static func offlineReceiveConfig(_ configuration: OfflineReceiveNodeConfiguration) -> OfflineReceiveConfig { + OfflineReceiveConfig( + settlementNodeId: configuration.settlementNodeId, + witnesses: configuration.witnessNodeIds.map { + OfflineReceiveWitnessConfig( + nodeId: $0, + retentionBlocks: configuration.witnessRetentionBlocks, + minimumReceipts: configuration.witnessMinimumReceipts + ) + }, + invoiceExpirySeconds: configuration.invoiceExpirySeconds, + invoiceSafetyMarginSeconds: configuration.invoiceSafetyMarginSeconds, + settlementDeadlineBlocks: configuration.settlementDeadlineBlocks, + deadlineSafetyMarginBlocks: configuration.deadlineSafetyMarginBlocks, + claimMarginBlocks: configuration.claimMarginBlocks, + voucherExpiryBlocks: configuration.voucherExpiryBlocks, + feeBaseMsat: configuration.feeBaseMsat, + feeProportionalMillionths: configuration.feeProportionalMillionths, + pollIntervalSecs: configuration.pollIntervalSecs + ) + } + #endif + /// Use cached values to avoid blocking LDK calls on main thread @MainActor var balances: BalanceDetails? { cachedBalances diff --git a/Bitkit/Services/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/Services/OfflineReceiveSettings.swift b/Bitkit/Services/OfflineReceiveSettings.swift new file mode 100644 index 000000000..fe33c1e9b --- /dev/null +++ b/Bitkit/Services/OfflineReceiveSettings.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Node-side FFOR parameters passed to the LDK Node builder when the developer toggle is on. +/// Timing and fee values are the development defaults of the offline receive binding. +struct OfflineReceiveNodeConfiguration: Equatable { + let settlementNodeId: String + let witnessNodeIds: [String] + let invoiceExpirySeconds: UInt32 = 3600 + let invoiceSafetyMarginSeconds: UInt32 = 120 + let settlementDeadlineBlocks: UInt32 = 144 + let deadlineSafetyMarginBlocks: UInt32 = 6 + let claimMarginBlocks: UInt32 = 20 + let voucherExpiryBlocks: UInt32 = 288 + let feeBaseMsat: UInt32 = 0 + let feeProportionalMillionths: UInt32 = 0 + let pollIntervalSecs: UInt64 = 5 + let witnessRetentionBlocks: UInt32 = 288 + let witnessMinimumReceipts: UInt8 = 0 +} + +/// Developer-only switches for the experimental offline receive provider. +/// Nothing is configured unless the build carries the local binding and the toggle is on. +enum OfflineReceiveSettings { + static let enabledKey = "offlineReceiveExperimentalEnabled" + static let settlementNodeIdKey = "offlineReceiveSettlementNodeId" + static let witnessNodeIdsKey = "offlineReceiveWitnessNodeIds" + + static var isBuildAvailable: Bool { + #if OFFLINE_RECEIVE_LOCAL_LDK + true + #else + false + #endif + } + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + isBuildAvailable && defaults.bool(forKey: enabledKey) + } + + /// The settlement node defaults to the Blocktank LSP peer Bitkit already trusts for the current network. + static func nodeConfiguration( + defaults: UserDefaults = .standard, + trustedPeers: [LnPeer] = Env.trustedLnPeers + ) -> OfflineReceiveNodeConfiguration? { + guard defaults.bool(forKey: enabledKey) else { return nil } + let settlementNodeId = nodeIds(from: defaults.string(forKey: settlementNodeIdKey)).first ?? trustedPeers.first?.nodeId + guard let settlementNodeId, isNodeId(settlementNodeId) else { return nil } + let witnessNodeIds = nodeIds(from: defaults.string(forKey: witnessNodeIdsKey)).filter { $0 != settlementNodeId } + return OfflineReceiveNodeConfiguration(settlementNodeId: settlementNodeId, witnessNodeIds: witnessNodeIds) + } + + static func nodeIds(from text: String?) -> [String] { + guard let text else { return [] } + var seen = Set() + return text + .split(whereSeparator: { $0 == "," || $0.isWhitespace || $0.isNewline }) + .map { $0.lowercased() } + .filter { isNodeId($0) && seen.insert($0).inserted } + } + + static func isNodeId(_ candidate: String) -> Bool { + candidate.count == 66 && + (candidate.hasPrefix("02") || candidate.hasPrefix("03")) && + candidate.allSatisfy(\.isHexDigit) + } +} diff --git a/Bitkit/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/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 525245834..ad3b5ce28 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -7,6 +7,9 @@ struct DevSettingsView: View { @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false @AppStorage(BoltzService.savingsSwapEnabledKey) private var isSavingsSwapEnabled = false + @AppStorage(OfflineReceiveSettings.enabledKey) private var isOfflineReceiveEnabled = false + @AppStorage(OfflineReceiveSettings.settlementNodeIdKey) private var offlineReceiveSettlementNodeId = "" + @AppStorage(OfflineReceiveSettings.witnessNodeIdsKey) private var offlineReceiveWitnessNodeIds = "" @EnvironmentObject var app: AppViewModel @EnvironmentObject var activity: ActivityListViewModel @@ -73,6 +76,10 @@ struct DevSettingsView: View { testIdentifier: "SavingsSwapToggle" ) + if OfflineReceiveSettings.isBuildAvailable { + offlineReceiveSection + } + SettingsSectionHeader("RECOVERY") .padding(.top, 16) @@ -218,6 +225,43 @@ struct DevSettingsView: View { } } + private var offlineReceiveSection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("OFFLINE RECEIVE") + .padding(.top, 16) + + SettingsRow( + title: "Offline receive (experimental)", + rightIcon: nil, + toggle: $isOfflineReceiveEnabled, + testIdentifier: "OfflineReceiveToggle" + ) + + if isOfflineReceiveEnabled { + VStack(alignment: .leading, spacing: 8) { + CaptionMText("Settlement node id (blank uses the Blocktank LSP for this network)") + TextField("Settlement node id", text: $offlineReceiveSettlementNodeId) + .lineLimit(1) + .truncationMode(.middle) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("OfflineReceiveSettlementNodeId") + + CaptionMText("Witness node ids (comma separated)") + TextField("Witness node ids", text: $offlineReceiveWitnessNodeIds) + .lineLimit(1) + .truncationMode(.middle) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("OfflineReceiveWitnessNodeIds") + + CaptionMText("Node configuration applies after the app restarts. Invalid ids are ignored.") + } + .padding(.top, 8) + } + } + } + @MainActor private func disablePaykitUI() async { isPaykitUIEnabled = false diff --git a/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/LdkOfflineReceiveProviderTests.swift b/BitkitTests/LdkOfflineReceiveProviderTests.swift new file mode 100644 index 000000000..5222d31a5 --- /dev/null +++ b/BitkitTests/LdkOfflineReceiveProviderTests.swift @@ -0,0 +1,452 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class LdkOfflineReceiveProviderTests: XCTestCase { + private let nodeId = "02aa000000000000000000000000000000000000000000000000000000000000aa" + private let bolt11 = "lnbcrt10u1ready" + + func testReadyInvoiceIsReturnedAfterPollingAndKeepsIdentityForRetries() async throws { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.awaitingActivation, .awaitingWitnesses, .ready(bolt11: bolt11)] + let store = InMemoryOfflineReceiveRequestStore() + let clock = FakeClock() + let provider = makeProvider(client: client, store: store, clock: clock) + + let invoice = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "Coffee") + + XCTAssertEqual(invoice.bolt11, bolt11) + XCTAssertEqual(client.prepareCalls.map(\.requestId), ["request-1"]) + XCTAssertEqual(client.prepareCalls.first?.amountMsat, 1_000_000) + XCTAssertEqual(client.prepareCalls.first?.description, "Coffee") + XCTAssertEqual(client.statusCalls, ["request-1", "request-1", "request-1"]) + XCTAssertEqual(clock.sleeps.count, 3) + XCTAssertEqual(store.records, [OfflineReceiveRequestRecord(requestId: "request-1", amountSats: 1000, description: "Coffee")]) + } + + func testImmediatelyReadyRequestDoesNotPoll() async throws { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.ready(bolt11: bolt11)) + let clock = FakeClock() + let provider = makeProvider(client: client, clock: clock) + + let invoice = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + + XCTAssertEqual(invoice.bolt11, bolt11) + XCTAssertTrue(client.statusCalls.isEmpty) + XCTAssertTrue(clock.sleeps.isEmpty) + } + + func testTimeoutStopsPollingAndRetryResumesTheSameRequest() async throws { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.preparing] + let store = InMemoryOfflineReceiveRequestStore() + let clock = FakeClock(secondsPerSleep: 10) + let provider = makeProvider(client: client, store: store, clock: clock, timeout: 60) + + await assertThrows(OfflineReceiveError.unavailable) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertEqual(clock.sleeps.count, 6) + XCTAssertEqual(client.prepareCalls.count, 1) + XCTAssertEqual(store.records.map(\.requestId), ["request-1"]) + + client.statuses = [.ready(bolt11: bolt11)] + let invoice = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + + XCTAssertEqual(invoice.bolt11, bolt11) + XCTAssertEqual(client.prepareCalls.count, 1) + } + + func testTerminalStatesMapToUnavailableAndClearIdentity() async { + for terminal in [ + OfflineReceiveNodeStatus.expired, + .settled(fulfilled: true), + .settled(fulfilled: false), + .failed(reason: "settlement node offline"), + ] { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.preparing) + client.statuses = [terminal] + let store = InMemoryOfflineReceiveRequestStore() + let provider = makeProvider(client: client, store: store) + + await assertThrows(OfflineReceiveError.unavailable) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertTrue(store.records.isEmpty, "\(terminal) must forget the request identity") + XCTAssertTrue(client.cancelCalls.isEmpty) + } + } + + func testNodeErrorsDuringPreparationMapToUnavailableAndClearIdentity() async { + for nodeError in [OfflineReceiveNodeError.disabled, .unavailable, .ineligible, .requestConflict] { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .failure(nodeError) + let store = InMemoryOfflineReceiveRequestStore() + let provider = makeProvider(client: client, store: store) + + await assertThrows(OfflineReceiveError.unavailable) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertTrue(store.records.isEmpty, "\(nodeError) must forget the request identity") + } + } + + func testCanReceiveMapsNodeErrorsToFalseAndUsesMillisatoshis() async throws { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + let provider = makeProvider(client: client) + + let supported = try await provider.canReceive(amountSats: 1000) + XCTAssertTrue(supported) + XCTAssertEqual(client.canReceiveCalls, [1_000_000]) + + for nodeError in [OfflineReceiveNodeError.disabled, .unavailable, .ineligible] { + client.canReceiveResult = .failure(nodeError) + let result = try await provider.canReceive(amountSats: 1000) + XCTAssertFalse(result, "\(nodeError) must not advertise offline support") + } + + client.canReceiveResult = .success(true) + let zero = try await provider.canReceive(amountSats: 0) + XCTAssertFalse(zero) + let overflow = try await provider.canReceive(amountSats: UInt64.max) + XCTAssertFalse(overflow) + XCTAssertEqual(client.canReceiveCalls.count, 4) + } + + func testRestartRecoversReadyInvoiceWithoutPreparingAgain() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "before-restart", amountSats: 1000, description: "Coffee") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.ready(bolt11: bolt11)] + let provider = makeProvider(client: client, store: store) + + let invoice = try await provider.prepareInvoice(requestId: "after-restart", amountSats: 1000, description: "Coffee") + + XCTAssertEqual(invoice.bolt11, bolt11) + XCTAssertTrue(client.prepareCalls.isEmpty) + XCTAssertEqual(client.statusCalls, ["before-restart"]) + XCTAssertEqual(store.records, [stored]) + } + + func testRestartWithPendingRequestKeepsPollingTheStoredIdentity() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "before-restart", amountSats: 1000, description: "Coffee") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.awaitingWitnesses, .ready(bolt11: bolt11)] + let provider = makeProvider(client: client, store: store) + + _ = try await provider.prepareInvoice(requestId: "after-restart", amountSats: 1000, description: "Coffee") + + XCTAssertTrue(client.prepareCalls.isEmpty) + XCTAssertEqual(client.statusCalls, ["before-restart", "before-restart"]) + } + + func testRestartWithForgottenRequestPreparesWithTheNewIdentity() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "before-restart", amountSats: 1000, description: "Coffee") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statusError = OfflineReceiveNodeError.requestNotFound + client.prepareResult = .success(.ready(bolt11: bolt11)) + let provider = makeProvider(client: client, store: store) + + _ = try await provider.prepareInvoice(requestId: "after-restart", amountSats: 1000, description: "Coffee") + + XCTAssertEqual(client.statusCalls, ["before-restart"]) + XCTAssertEqual(client.prepareCalls.map(\.requestId), ["after-restart"]) + XCTAssertEqual(store.records.map(\.requestId), ["after-restart"]) + } + + func testDifferentIntentUsesItsOwnIdentityWithoutCancellingTheOldRequest() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "before-restart", amountSats: 1000, description: "Coffee") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.ready(bolt11: bolt11)) + client.inspectedAmountMsat = 2_000_000 + let provider = makeProvider(client: client, store: store) + + _ = try await provider.prepareInvoice(requestId: "edited", amountSats: 2000, description: "Coffee") + + XCTAssertEqual(client.prepareCalls.map(\.requestId), ["edited"]) + XCTAssertTrue(client.statusCalls.isEmpty) + XCTAssertTrue(client.cancelCalls.isEmpty) + XCTAssertEqual(store.records.map(\.requestId), ["before-restart", "edited"]) + } + + func testTaskCancellationStopsPollingWithoutCancellingTheNodeRequest() async { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.statuses = [.preparing] + let store = InMemoryOfflineReceiveRequestStore() + let polling = expectation(description: "provider is waiting between polls") + let provider = LdkOfflineReceiveProvider( + client: client, + store: store, + inspect: client.inspect, + sleep: { _ in + polling.fulfill() + try await Task.sleep(nanoseconds: 30_000_000_000) + } + ) + + let task = Task { try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") } + await fulfillment(of: [polling], timeout: 2) + task.cancel() + + do { + _ = try await task.value + XCTFail("Cancelled preparation must not return an invoice") + } catch { + XCTAssertTrue(error is CancellationError, "Unexpected error: \(error)") + } + XCTAssertTrue(client.cancelCalls.isEmpty) + XCTAssertEqual(store.records.map(\.requestId), ["request-1"]) + } + + func testCancelReleasesNodeRequestAndForgetsIdentity() async throws { + let stored = OfflineReceiveRequestRecord(requestId: "request-1", amountSats: 1000, description: "") + let store = InMemoryOfflineReceiveRequestStore(records: [stored]) + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + let provider = makeProvider(client: client, store: store) + + try await provider.cancel(requestId: "request-1") + XCTAssertEqual(client.cancelCalls, ["request-1"]) + XCTAssertTrue(store.records.isEmpty) + + store.save([stored]) + client.cancelError = OfflineReceiveNodeError.requestNotFound + try await provider.cancel(requestId: "request-1") + XCTAssertTrue(store.records.isEmpty) + } + + func testReadyInvoiceWithWrongAmountOrPayeeIsRejected() async { + for (amountMsat, payee) in [(999_000, nodeId), (1_000_000, "03bb000000000000000000000000000000000000000000000000000000000000bb")] { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.ready(bolt11: bolt11)) + client.inspectedAmountMsat = UInt64(amountMsat) + client.inspectedPayee = payee + let store = InMemoryOfflineReceiveRequestStore() + let provider = makeProvider(client: client, store: store) + + await assertThrows(OfflineReceiveError.invalidInvoice) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertTrue(store.records.isEmpty) + } + } + + func testUnparsableReadyInvoiceIsRejected() async { + let client = FakeOfflineReceiveNodeClient(nodeId: nodeId) + client.prepareResult = .success(.ready(bolt11: bolt11)) + client.inspectError = TestFailure.parse + let provider = makeProvider(client: client) + + await assertThrows(OfflineReceiveError.invalidInvoice) { + _ = try await provider.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + } + + func testDeveloperGateHidesProviderUntilEnabled() async throws { + let inner = CountingProvider() + var enabled = false + let gated = DeveloperGatedOfflineReceiveProvider(isEnabled: { enabled }, provider: inner) + + let hidden = try await gated.canReceive(amountSats: 1000) + XCTAssertFalse(hidden) + await assertThrows(OfflineReceiveError.unavailable) { + _ = try await gated.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + } + XCTAssertEqual(inner.canReceiveCount, 0) + XCTAssertEqual(inner.prepareCount, 0) + + enabled = true + let visible = try await gated.canReceive(amountSats: 1000) + XCTAssertTrue(visible) + _ = try await gated.prepareInvoice(requestId: "request-1", amountSats: 1000, description: "") + XCTAssertEqual(inner.canReceiveCount, 1) + XCTAssertEqual(inner.prepareCount, 1) + } + + func testDefaultBuildSelectsTheUnavailableProvider() { + XCTAssertFalse(OfflineReceiveSettings.isBuildAvailable) + XCTAssertTrue(OfflineReceiveProviderSelection.provider() is UnavailableOfflineReceiveProvider) + } + + func testUserDefaultsStoreRoundTripsAndCapsRecords() { + let suite = "LdkOfflineReceiveProviderTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = UserDefaultsOfflineReceiveRequestStore(defaults: defaults) + + for index in 0 ..< 20 { + store.remember(OfflineReceiveRequestRecord(requestId: "request-\(index)", amountSats: UInt64(index + 1), description: "")) + } + XCTAssertEqual(store.load().count, 16) + XCTAssertEqual(store.load().last?.requestId, "request-19") + XCTAssertNil(store.record(amountSats: 1, description: "")) + XCTAssertEqual(store.record(amountSats: 20, description: "")?.requestId, "request-19") + + store.remember(OfflineReceiveRequestRecord(requestId: "replacement", amountSats: 20, description: "")) + XCTAssertEqual(store.record(amountSats: 20, description: "")?.requestId, "replacement") + XCTAssertEqual(store.load().count, 16) + + let reopened = UserDefaultsOfflineReceiveRequestStore(defaults: defaults) + XCTAssertEqual(reopened.load(), store.load()) + + for record in store.load() { + store.forget(requestId: record.requestId) + } + XCTAssertNil(defaults.object(forKey: UserDefaultsOfflineReceiveRequestStore.key)) + } + + func testNodeConfigurationRequiresToggleAndValidNodeIds() { + let suite = "OfflineReceiveSettingsTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let lsp = LnPeer(nodeId: "03cc000000000000000000000000000000000000000000000000000000000000cc", host: "lsp", port: 9735) + + XCTAssertNil(OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [lsp])) + XCTAssertFalse(OfflineReceiveSettings.isEnabled(defaults: defaults)) + + defaults.set(true, forKey: OfflineReceiveSettings.enabledKey) + XCTAssertFalse(OfflineReceiveSettings.isEnabled(defaults: defaults), "The provider gate also needs the local binding build") + let fromLsp = OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [lsp]) + XCTAssertEqual(fromLsp?.settlementNodeId, lsp.nodeId) + XCTAssertEqual(fromLsp?.witnessNodeIds, []) + XCTAssertEqual(fromLsp?.invoiceExpirySeconds, 3600) + XCTAssertEqual(fromLsp?.settlementDeadlineBlocks, 144) + + XCTAssertNil(OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [])) + + defaults.set("not-a-node-id", forKey: OfflineReceiveSettings.settlementNodeIdKey) + XCTAssertEqual(OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [lsp])?.settlementNodeId, lsp.nodeId) + + defaults.set(" \(nodeId.uppercased()) ", forKey: OfflineReceiveSettings.settlementNodeIdKey) + defaults.set( + "\(nodeId), 02dd000000000000000000000000000000000000000000000000000000000000dd,bad, \(lsp.nodeId)\n\(lsp.nodeId)", + forKey: OfflineReceiveSettings.witnessNodeIdsKey + ) + let custom = OfflineReceiveSettings.nodeConfiguration(defaults: defaults, trustedPeers: [lsp]) + XCTAssertEqual(custom?.settlementNodeId, nodeId) + XCTAssertEqual(custom?.witnessNodeIds, ["02dd000000000000000000000000000000000000000000000000000000000000dd", lsp.nodeId]) + } + + private func makeProvider( + client: FakeOfflineReceiveNodeClient, + store: InMemoryOfflineReceiveRequestStore? = nil, + clock: FakeClock? = nil, + timeout: TimeInterval = 60 + ) -> LdkOfflineReceiveProvider { + let store = store ?? InMemoryOfflineReceiveRequestStore() + let clock = clock ?? FakeClock() + return LdkOfflineReceiveProvider( + client: client, + store: store, + inspect: client.inspect, + timeout: timeout, + pollInterval: 0.5, + now: { clock.now }, + sleep: { try await clock.sleep($0) } + ) + } + + private func assertThrows(_ expected: OfflineReceiveError, _ operation: () async throws -> Void) async { + do { + try await operation() + XCTFail("Expected \(expected)") + } catch let error as OfflineReceiveError { + XCTAssertEqual(error.localizedDescription, expected.localizedDescription) + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} + +private enum TestFailure: Error { + case parse +} + +@MainActor +private final class FakeClock { + private(set) var now = Date(timeIntervalSince1970: 1_700_000_000) + private(set) var sleeps: [TimeInterval] = [] + private let secondsPerSleep: TimeInterval? + + init(secondsPerSleep: TimeInterval? = nil) { + self.secondsPerSleep = secondsPerSleep + } + + func sleep(_ interval: TimeInterval) async throws { + try Task.checkCancellation() + sleeps.append(interval) + now = now.addingTimeInterval(secondsPerSleep ?? interval) + await Task.yield() + } +} + +@MainActor +private final class FakeOfflineReceiveNodeClient: OfflineReceiveNodeClient { + let nodeIdValue: String + var canReceiveResult: Result = .success(true) + var prepareResult: Result = .success(.preparing) + var statuses: [OfflineReceiveNodeStatus] = [] + var statusError: Error? + var cancelError: Error? + var inspectedAmountMsat: UInt64? = 1_000_000 + var inspectedPayee: String? + var inspectError: Error? + + private(set) var canReceiveCalls: [UInt64] = [] + private(set) var prepareCalls: [(requestId: String, amountMsat: UInt64, description: String)] = [] + private(set) var statusCalls: [String] = [] + private(set) var cancelCalls: [String] = [] + + init(nodeId: String) { + nodeIdValue = nodeId + } + + func nodeId() async throws -> String { nodeIdValue } + + func canReceive(amountMsat: UInt64) async throws -> Bool { + canReceiveCalls.append(amountMsat) + return try canReceiveResult.get() + } + + func prepare(requestId: String, amountMsat: UInt64, description: String) async throws -> OfflineReceiveNodeStatus { + prepareCalls.append((requestId, amountMsat, description)) + return try prepareResult.get() + } + + func status(requestId: String) async throws -> OfflineReceiveNodeStatus { + statusCalls.append(requestId) + if let statusError { throw statusError } + guard !statuses.isEmpty else { throw OfflineReceiveNodeError.requestNotFound } + return statuses.count > 1 ? statuses.removeFirst() : statuses[0] + } + + func cancel(requestId: String) async throws { + cancelCalls.append(requestId) + if let cancelError { throw cancelError } + } + + func inspect(_: String) throws -> OfflineReceiveInvoiceSummary { + if let inspectError { throw inspectError } + return OfflineReceiveInvoiceSummary(amountMsat: inspectedAmountMsat, payeeNodeId: inspectedPayee ?? nodeIdValue) + } +} + +@MainActor +private final class CountingProvider: OfflineReceiveProviding { + private(set) var canReceiveCount = 0 + private(set) var prepareCount = 0 + + func canReceive(amountSats _: UInt64) async throws -> Bool { + canReceiveCount += 1 + return true + } + + func prepareInvoice(requestId _: String, amountSats _: UInt64, description _: String) async throws -> PreparedOfflineInvoice { + prepareCount += 1 + return PreparedOfflineInvoice(bolt11: "gated-invoice") + } +} diff --git a/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/Configs/OfflineReceiveLocalLdk.xcconfig b/Configs/OfflineReceiveLocalLdk.xcconfig new file mode 100644 index 000000000..050e50ac7 --- /dev/null +++ b/Configs/OfflineReceiveLocalLdk.xcconfig @@ -0,0 +1,4 @@ +// Compiles the LDK Node offline receive adapter. Use only together with a local ldk-node +// package override that provides the offline receive API (see Docs/OfflineReceive.md). +// The committed dependency pin stays at the released ldk-node version; CI never sets this. +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) OFFLINE_RECEIVE_LOCAL_LDK diff --git a/Docs/OfflineReceive.md b/Docs/OfflineReceive.md new file mode 100644 index 000000000..f073af29f --- /dev/null +++ b/Docs/OfflineReceive.md @@ -0,0 +1,210 @@ +# Offline receive integration + +This draft adds the receive UI, a provider boundary and a real provider for FFOR. +The feature stays hidden in every ordinary build: the committed dependency pin is +`synonymdev/ldk-node` 0.7.0-rc.66, which has no offline receive API, so the app +compiles with `UnavailableOfflineReceiveProvider`, the checkbox never appears and +attempts to create an offline invoice fail. Ordinary invoices are never presented +as offline invoices, and no invoice is displayed unless the node reported it Ready. + +Tracking: https://github.com/synonymdev/ldk-node/issues/117 + +## What is wired + +Three layers exist so the polling and persistence logic is testable without the +native framework: + +- `OfflineReceiveProviding` (`OfflineReceiveSession.swift`) is the boundary the + receive flow talks to. `UnavailableOfflineReceiveProvider` is the default. +- `LdkOfflineReceiveProvider` (`LdkOfflineReceiveProvider.swift`) is the real + provider. It talks to the node only through the small `OfflineReceiveNodeClient` + protocol, which mirrors the binding's `OfflineReceivePayment` API + (`canReceive`, `prepare`, `status`, `cancel`) with app-side status and error enums. + It always compiles. +- `LdkNodeOfflineReceiveClient` (`LdkNodeOfflineReceiveClient.swift`) adapts the + generated `OfflineReceivePayment` object onto that protocol and runs every call on + the LDK service queue. It compiles only when the `OFFLINE_RECEIVE_LOCAL_LDK` + compilation condition is set, because the API does not exist in rc.66. + +Provider behaviour: + +- `canReceive(amountSats:)` calls `canReceive(amountMsat:)` and reports `false` for + `OfflineReceiveDisabled`, `OfflineReceiveUnavailable` and `OfflineReceiveIneligible`. +- `prepareInvoice(requestId:amountSats:description:)` calls `prepare` and polls + `status` every 0.5 s for at most 60 s until the node reports `ready(bolt11)`. + `expired`, `settled` and `failed` throw `OfflineReceiveError.unavailable`. A timeout + also throws `unavailable` but keeps the request identity so a retry resumes the + same node request instead of preparing a second one. Task cancellation stops + polling without cancelling the node request. A `ready` invoice is accepted only if + its amount equals the request exactly and its payee is our own node id; the + receive flow then repeats its own amount, description, network and expiry checks. +- Request identity is persisted (`UserDefaultsOfflineReceiveRequestStore`, key + `offlineReceivePendingRequests`) as `(requestId, amountSats, description)`. After + process death the provider looks up the stored identity for the same amount and + description and calls `status(requestId:)` first, so a Ready invoice is recovered + without a new `prepare`. Entries are removed on terminal states, on invalid + invoices, on node errors during preparation and on `cancel`. A different amount + or description gets its own identity; the previous node request is left alone. +- `cancel(requestId:)` releases the node request and forgets the identity. The + receive UI does not call it yet. + +Node configuration (`OfflineReceiveSettings.swift`, applied in +`LightningService.setup` under the compilation condition): when the developer +toggle is on, `Builder.setOfflineReceiveConfig` receives the settlement node id +(the developer override, otherwise the Blocktank LSP peer Bitkit already trusts +for the current network), the developer-entered witness node ids with +`retentionBlocks` 288 and `minimumReceipts` 0, and the binding's development +defaults: invoice expiry 3600 s, safety margin 120 s, settlement deadline 144 +blocks, deadline margin 6, claim margin 20, voucher expiry 288, zero fees, poll +interval 5 s. Invalid ids are ignored rather than passed to the builder, and +without a valid settlement node nothing is configured. The builder reads the +configuration once, so a toggle change takes effect after the app restarts. The +notification extension has its own `UserDefaults` container and therefore builds +its node without the configuration. + +Provider selection happens in `AppScene` through +`OfflineReceiveProviderSelection.provider()`. With the compilation condition it +returns `DeveloperGatedOfflineReceiveProvider`, which consults the toggle on every +call and otherwise behaves like the unavailable provider. Without the condition it +returns `UnavailableOfflineReceiveProvider`. + +## Developer toggle + +Settings > Dev Settings shows an "OFFLINE RECEIVE" section only in builds compiled +with `OFFLINE_RECEIVE_LOCAL_LDK`. It has the "Offline receive (experimental)" +toggle (default off, key `offlineReceiveExperimentalEnabled`, +accessibility id `OfflineReceiveToggle`) and, once enabled, text fields for the +settlement node id override and comma separated witness node ids. Production and +CI builds neither compile the adapter nor show the section, so the checkbox in +the receive editor remains absent unless the local binding, the toggle and a +node that reports `canReceive` all agree. + +## Building against the local binding + +The committed pin stays at rc.66 for CI. To build the real provider locally: + +1. Prepare a local package whose directory is named `ldk-node` (SPM derives the + override identity from the directory name), for example + `/private/tmp/ffor-ios-ldk-node/ldk-node`, containing: + - `Package.swift` copied from the ldk-node checkout with the binary target + changed to `.binaryTarget(name: "LDKNodeFFI", path: "./bindings/swift/LDKNodeFFI.xcframework")`; + - `bindings/swift/Sources/LDKNode/LDKNode.swift` from the checkout; + - `bindings/swift/LDKNodeFFI.xcframework` unzipped from the release artifact. +2. In Xcode: File > Add Package Dependencies > Add Local, pick that directory. + A local package with the same identity overrides the remote `ldk-node` + dependency for that workspace session. Do not commit the resulting project or + `Package.resolved` changes. +3. Set the compilation condition. Either pass + `SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) OFFLINE_RECEIVE_LOCAL_LDK'` + on the `xcodebuild` command line (the same pattern as `E2E_BUILD`), or use + `-xcconfig Configs/OfflineReceiveLocalLdk.xcconfig`, or assign that xcconfig to + the Debug configuration of the Bitkit target in Xcode for the session. + +For `xcodebuild`, a throwaway workspace outside the repository gives the same +override without editing the project: + +```xml + + + + + + +``` + +```bash +xcodebuild -workspace /private/tmp/ffor-ios-local.xcworkspace -scheme Bitkit \ + -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17' \ + -derivedDataPath /private/tmp/ffor-ios-build-local \ + -clonedSourcePackagesDirPath /private/tmp/ffor-ios-spm-local \ + -xcconfig Configs/OfflineReceiveLocalLdk.xcconfig build +``` + +Use a separate derived data path and cloned packages directory so the rc.66 +module caches of the ordinary build are not invalidated (see the FFI header +warning in `AGENTS.md`). + +## Provider requirements + +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. The node's `canReceive` is + responsible for this; the app only converts the amount. +2. Recheck and reserve capacity atomically when preparing an invoice. The + eligibility check is advisory and does not reserve funds. +3. Perform FFOR setup, durable registration and activation. Return + `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. Only +the node's `ready` status establishes that. + +## 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. +`LdkOfflineReceiveProviderTests` drives `LdkOfflineReceiveProvider` with a fake +node client and a fake clock: the ready path, no polling for an immediately ready +request, the bounded timeout and retry resumption, terminal state and node error +mapping, `canReceive` mapping, identity recovery after a simulated restart +(ready, still pending, and forgotten requests), separate identities per intent, +Task cancellation, explicit cancel, amount and payee validation, the developer +gate, the `UserDefaults` store, the node configuration parser and the default +provider selection. Existing receive tests cover the ordinary invoice liquidity +rules and edit navigation. These are application contract tests, not a live FFOR +payment test. + +Device validation is pending. Before enabling a production provider, verify on a +device against a settlement node that a funded receiver can prepare an invoice, +stop the app, receive a payment through the forwarding peer, restart and recover +the payment. Include expiry, offline restart, abort, replay, capacity exhaustion, +loss of the peer connection and concurrent preparation cases. 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.