diff --git a/Bitkit/Components/SyncNodeView.swift b/Bitkit/Components/SyncNodeView.swift new file mode 100644 index 00000000..0898babc --- /dev/null +++ b/Bitkit/Components/SyncNodeView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// Animated loading view with rotating ellipses and lightning icon +private struct SyncNodeLoadingView: View { + @State private var outerRotation: Double = 0 + @State private var innerRotation: Double = 0 + + var size: (container: CGFloat, image: CGFloat, inner: CGFloat) { + let container: CGFloat = UIScreen.main.isSmall ? 200 : 320 + let image = container * 0.8 + let inner = container * 0.7 + + return (container: container, image: image, inner: inner) + } + + var body: some View { + ZStack(alignment: .center) { + // Outer ellipse + Image("ellipse-outer-purple") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.container, height: size.container) + .rotationEffect(.degrees(outerRotation)) + + // Inner ellipse + Image("ellipse-inner-purple") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.inner, height: size.inner) + .rotationEffect(.degrees(innerRotation)) + + // Lightning image + Image("lightning") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.image, height: size.image) + } + .frame(width: size.container, height: size.container) + .clipped() + .frame(maxWidth: .infinity) + .onAppear { + withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { + outerRotation = -90 + } + + withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { + innerRotation = 120 + } + } + } +} + +/// A view that displays while the node is syncing. +/// Used as an overlay on screens that require the node to be fully synced. +struct SyncNodeView: View { + @EnvironmentObject var wallet: WalletViewModel + + /// Optional callback when sync completes + var onSyncComplete: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("wallet__send_bitcoin"), showBackButton: false) + + VStack(spacing: 0) { + BodyMText(t("lightning__wait_text_top")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 16) + + Spacer() + + SyncNodeLoadingView() + + Spacer() + + BodyMSBText(t("lightning__wait_text_bottom"), textColor: .white32) + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .sheetBackground() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: wallet.isSyncingWallet) { newValue in + if !newValue { + onSyncComplete?() + } + } + } +} diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 93a77136..75fbabb5 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -408,21 +408,24 @@ class LightningService { /// Checks if we have the correct outbound capacity to send the amount /// - Parameter amountSats: Amount to send in satoshis /// - Returns: True if we can send the amount + /// Note: Uses cached channels for fast, non-blocking checks @MainActor func canSend(amountSats: UInt64) -> Bool { guard let channels else { - Logger.warn("Channels not available") return false } - let totalNextOutboundHtlcLimitSats = - channels - .filter(\.isUsable) - .map(\.nextOutboundHtlcLimitMsat) - .reduce(0, +) / 1000 + let usableChannels = channels.filter(\.isUsable) + guard !usableChannels.isEmpty else { + return false + } + + let totalNextOutboundHtlcLimitSats = usableChannels + .map(\.nextOutboundHtlcLimitMsat) + .reduce(0, +) / 1000 guard totalNextOutboundHtlcLimitSats > amountSats else { - Logger.warn("Insufficient outbound capacity: \(totalNextOutboundHtlcLimitSats) < \(amountSats)") + Logger.warn("canSend: insufficient capacity: \(totalNextOutboundHtlcLimitSats) < \(amountSats)", context: "LightningService") return false } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 3fbaf910..6018c513 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -115,6 +115,55 @@ class AppViewModel: ObservableObject { } } + /// Shows insufficient spending balance toast with amount-specific or generic description + private func showInsufficientSpendingToast(invoiceAmount: UInt64, spendingBalance: UInt64) { + let amountNeeded = invoiceAmount > spendingBalance ? invoiceAmount - spendingBalance : 0 + let description = amountNeeded > 0 + ? t( + "other__pay_insufficient_spending_amount_description", + variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)] + ) + : t("other__pay_insufficient_spending_description") + + toast( + type: .error, + title: t("other__pay_insufficient_spending"), + description: description, + accessibilityIdentifier: "InsufficientSpendingToast" + ) + } + + /// Validates onchain balance and shows toast if insufficient. Returns true if sufficient. + private func validateOnchainBalance(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool { + if invoiceAmount > 0 { + guard onchainBalance >= invoiceAmount else { + let amountNeeded = invoiceAmount - onchainBalance + toast( + type: .error, + title: t("other__pay_insufficient_savings"), + description: t( + "other__pay_insufficient_savings_amount_description", + variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)] + ), + accessibilityIdentifier: "InsufficientSavingsToast" + ) + return false + } + } else { + // Zero-amount invoice: user must have some balance to proceed + guard onchainBalance > 0 else { + toast( + type: .error, + title: t("other__pay_insufficient_savings"), + description: t("other__pay_insufficient_savings_description"), + accessibilityIdentifier: "InsufficientSavingsToast" + ) + return false + } + } + return true + } + private func showValidationErrorToast(for result: ManualEntryValidationResult) { switch result { case .invalid: @@ -261,55 +310,53 @@ extension AppViewModel { } if let lnInvoice = invoice.params?["lightning"] { - guard lightningService.status?.isRunning == true else { - toast(type: .error, title: "Lightning not running", description: "Please try again later.") - return - } - // Lightning invoice param found, prefer lightning payment if possible + // Lightning invoice param found, prefer lightning payment if invoice is valid if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { // Check lightning invoice network let lnNetwork = NetworkValidationHelper.convertNetworkType(lightningInvoice.networkType) let lnNetworkMatch = !NetworkValidationHelper.isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) - let canSend = lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) + if lnNetworkMatch, !lightningInvoice.isExpired { + let nodeIsRunning = lightningService.status?.isRunning == true + + if nodeIsRunning { + // Node is running → we have fresh balances; validate immediately. + // Prefer lightning; if insufficient or no channels/capacity, fall back to onchain. + let canSendLightning = lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) + + if canSendLightning { + handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) + return + } + + // Lightning insufficient for any reason (no channels, no capacity, etc). + // Fall back to onchain and validate onchain balance immediately. + let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 + guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else { + return + } + + // Onchain is sufficient → proceed with onchain flow, do not open lightning flow. + handleScannedOnchainInvoice(invoice) + return + } - if lnNetworkMatch, !lightningInvoice.isExpired, canSend { + // Node not running: proceed with lightning; validation/fallback will happen in SendSheet after sync. handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } - // If Lightning is expired or insufficient, fall back to on-chain silently (no toast) + // If Lightning is expired or wrong network, fall back to on-chain silently (no toast) } } // Fallback to on-chain if address is available guard !invoice.address.isEmpty else { return } - // Check on-chain balance - let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 - if invoice.amountSatoshis > 0 { - guard onchainBalance >= invoice.amountSatoshis else { - let amountNeeded = invoice.amountSatoshis - onchainBalance - toast( - type: .error, - title: t("other__pay_insufficient_savings"), - description: t( - "other__pay_insufficient_savings_amount_description", - variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)] - ), - accessibilityIdentifier: "InsufficientSavingsToast" - ) - return - } - } else { - // Zero-amount invoice: user must have some balance to proceed - guard onchainBalance > 0 else { - toast( - type: .error, - title: t("other__pay_insufficient_savings"), - description: t("other__pay_insufficient_savings_description"), - accessibilityIdentifier: "InsufficientSavingsToast" - ) + // If node is running, validate balance immediately + if lightningService.status?.isRunning == true { + let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 + guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else { return } } @@ -328,26 +375,6 @@ extension AppViewModel { return } - guard lightningService.status?.isRunning == true else { - toast(type: .error, title: "Lightning not running", description: "Please try again later.") - return - } - - guard lightningService.canSend(amountSats: invoice.amountSatoshis) else { - let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - let amountNeeded = invoice.amountSatoshis > spendingBalance ? invoice.amountSatoshis - spendingBalance : 0 - let description = amountNeeded > 0 - ? t("other__pay_insufficient_spending_amount_description", variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)]) - : t("other__pay_insufficient_spending_description") - toast( - type: .error, - title: t("other__pay_insufficient_spending"), - description: description, - accessibilityIdentifier: "InsufficientSpendingToast" - ) - return - } - guard !invoice.isExpired else { toast( type: .error, @@ -358,6 +385,29 @@ extension AppViewModel { return } + // If user has no channels at all, they can never pay a pure lightning invoice. + // Show insufficient spending toast and do not navigate to the send flow. + // Check channels array directly (LightningService doesn't have channelCount cached) + let hasAnyChannels = (lightningService.channels?.isEmpty == false) + if !hasAnyChannels { + let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 + showInsufficientSpendingToast(invoiceAmount: invoice.amountSatoshis, spendingBalance: spendingBalance) + return + } + + // If node is running and channels are usable, validate immediately + if lightningService.status?.isRunning == true, + let channels = lightningService.channels, + channels.contains(where: \.isUsable) + { + guard lightningService.canSend(amountSats: invoice.amountSatoshis) else { + let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 + showInsufficientSpendingToast(invoiceAmount: invoice.amountSatoshis, spendingBalance: spendingBalance) + return + } + } + + // Proceed with lightning payment (validation will happen in SendSheet if node not ready) handleScannedLightningInvoice(invoice, bolt11: uri) case let .lnurlPay(data: lnurlPayData): Logger.debug("LNURL: \(lnurlPayData)") diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 16b6230c..5bed14fc 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -610,8 +610,9 @@ class WalletViewModel: ObservableObject { return capacity } + /// Returns true if there's at least one usable channel (ready AND peer connected) var hasUsableChannels: Bool { - return channels?.contains(where: \.isChannelReady) ?? false + return channels?.contains(where: \.isUsable) ?? false } func refreshBip21(forceRefreshBolt11: Bool = false) async throws { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index e5d8ffb1..8bd52fe1 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -40,25 +40,62 @@ struct SendSheetItem: SheetItem { struct SendSheet: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var settings: SettingsViewModel - @EnvironmentObject private var wallet: WalletViewModel + @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var tagManager: TagManager + @EnvironmentObject private var wallet: WalletViewModel let config: SendSheetItem @State private var navigationPath: [SendRoute] = [] + @State private var hasValidatedAfterSync = false + + /// Show sync overlay when node is not ready for payments + /// For lightning: need node running AND at least one usable channel (peer connected). + /// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue. + /// For onchain: only need node running. + private var shouldShowSyncOverlay: Bool { + // Node must be running + guard wallet.nodeLifecycleState == .running else { return true } + + // For lightning payments, also need usable channels (peer connected) + let isLightningPayment = app.scannedLightningInvoice != nil + || app.lnurlPayData != nil + || app.selectedWalletToPayFrom == .lightning + + if isLightningPayment { + // If there are no channels at all, don't show the sync overlay – + // there is nothing to \"sync into\". Let validation/UX handle this as + // an \"insufficient capacity / no channels\" case instead of a sync wait. + let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 + guard hasAnyChannels else { return false } + + // We have channels but none are usable yet → show sync overlay + return !wallet.hasUsableChannels + } + + return false + } var body: some View { Sheet(id: .send, data: config) { - NavigationStack(path: $navigationPath) { - viewForRoute(config.initialRoute) - .navigationDestination(for: SendRoute.self) { route in - viewForRoute(route) - } + if shouldShowSyncOverlay { + SyncNodeView() + .transition(.opacity) + } else { + NavigationStack(path: $navigationPath) { + viewForRoute(config.initialRoute) + .navigationDestination(for: SendRoute.self) { route in + viewForRoute(route) + } + } + .transition(.opacity) } } + .animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay) .onAppear { tagManager.clearSelectedTags() wallet.resetSendState(speed: settings.defaultTransactionSpeed) + hasValidatedAfterSync = false Task { do { @@ -68,6 +105,154 @@ struct SendSheet: View { } } } + .onChange(of: wallet.nodeLifecycleState) { state in + // When the node becomes running and we have a scanned invoice, run deferred validation. + // This covers: + // - Pure onchain invoices (node was not running at scan time) + // - Unified invoices where we may need to fall back from lightning to onchain + // Lightning-first flows where the node was already running are handled in AppViewModel. + let hasScannedInvoice = app.scannedLightningInvoice != nil + || app.scannedOnchainInvoice != nil + || app.lnurlPayData != nil + guard hasScannedInvoice else { return } + + if state == .running, !hasValidatedAfterSync { + validatePaymentAfterSync() + } + } + .onChange(of: wallet.hasUsableChannels) { hasUsable in + // Only validate if channels just became usable and we have a scanned invoice + // (Validation already happened in AppViewModel if channels were already usable) + let hasScannedInvoice = app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil || app.lnurlPayData != nil + guard hasScannedInvoice else { return } + + let isLightningPayment = app.scannedLightningInvoice != nil + || app.lnurlPayData != nil + || app.selectedWalletToPayFrom == .lightning + + if isLightningPayment, hasUsable, wallet.nodeLifecycleState == .running, !hasValidatedAfterSync { + validatePaymentAfterSync() + } + } + } + + /// Validates onchain balance and shows toast + dismisses sheet if insufficient. + /// Returns true if sufficient, false if insufficient. + private func validateOnchainBalanceAndDismissIfInsufficient(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool { + if invoiceAmount > 0 { + guard onchainBalance >= invoiceAmount else { + let amountNeeded = invoiceAmount - onchainBalance + app.toast( + type: .error, + title: t("other__pay_insufficient_savings"), + description: t( + "other__pay_insufficient_savings_amount_description", + variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)] + ), + accessibilityIdentifier: "InsufficientSavingsToast" + ) + sheets.hideSheet() + return false + } + } else { + // Zero-amount invoice: user must have some balance to proceed + guard onchainBalance > 0 else { + app.toast( + type: .error, + title: t("other__pay_insufficient_savings"), + description: t("other__pay_insufficient_savings_description"), + accessibilityIdentifier: "InsufficientSavingsToast" + ) + sheets.hideSheet() + return false + } + } + return true + } + + /// Shows insufficient spending toast with amount-specific or generic description + private func showInsufficientSpendingToast(invoiceAmount: UInt64, spendingBalance: UInt64) { + let amountNeeded = invoiceAmount > spendingBalance ? invoiceAmount - spendingBalance : 0 + let description = amountNeeded > 0 + ? t("other__pay_insufficient_spending_amount_description", variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)]) + : t("other__pay_insufficient_spending_description") + app.toast( + type: .error, + title: t("other__pay_insufficient_spending"), + description: description, + accessibilityIdentifier: "InsufficientSpendingToast" + ) + } + + /// Validates payment affordability after sync completes + /// For lightning: falls back to onchain for unified invoices, shows error for pure lightning invoices + /// For onchain: validates balance and shows error if insufficient + private func validatePaymentAfterSync() { + // Validate lightning payment if present + if let lightningInvoice = app.scannedLightningInvoice { + // For lightning, if we have channels but none are usable yet, wait for them + // to become usable. If there are no channels at all, or channels are already + // usable, proceed with validation/fallback. + // Use channelCount as fallback in case channels array is nil but count is cached + let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 + if hasAnyChannels, !wallet.hasUsableChannels { + // We have channels but none usable yet → wait + return + } + + // Check if we can afford the lightning payment + let canSend = LightningService.shared.canSend(amountSats: lightningInvoice.amountSatoshis) + + if !canSend { + // For unified invoices, fall back to onchain + if let onchainInvoice = app.scannedOnchainInvoice { + // Switch to onchain wallet type + app.selectedWalletToPayFrom = .onchain + app.scannedOnchainInvoice = onchainInvoice + app.scannedLightningInvoice = nil + + // Validate onchain balance BEFORE navigating + let onchainBalance = LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0 + guard validateOnchainBalanceAndDismissIfInsufficient( + invoiceAmount: onchainInvoice.amountSatoshis, + onchainBalance: onchainBalance + ) else { + hasValidatedAfterSync = true + return + } + + // Onchain balance is sufficient → navigate to amount screen + // (the sheet may have opened with .confirm or .quickpay route) + navigationPath = [.amount] + hasValidatedAfterSync = true + return + } else { + // For pure lightning invoices, show error toast + let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 + showInsufficientSpendingToast(invoiceAmount: lightningInvoice.amountSatoshis, spendingBalance: spendingBalance) + hasValidatedAfterSync = true + return + } + } else { + // Lightning payment is valid, we're done + hasValidatedAfterSync = true + return + } + } + + // Validate onchain payment balance (for pure onchain invoices) + if let onchainInvoice = app.scannedOnchainInvoice { + let onchainBalance = LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0 + guard validateOnchainBalanceAndDismissIfInsufficient( + invoiceAmount: onchainInvoice.amountSatoshis, + onchainBalance: onchainBalance + ) else { + hasValidatedAfterSync = true + return + } + } + + hasValidatedAfterSync = true } @ViewBuilder diff --git a/Docs/SCAN_INVOICE_TEST_MATRIX.md b/Docs/SCAN_INVOICE_TEST_MATRIX.md new file mode 100644 index 00000000..aecde197 --- /dev/null +++ b/Docs/SCAN_INVOICE_TEST_MATRIX.md @@ -0,0 +1,136 @@ +## Invoice Scanning & Validation Test Matrix + +This document enumerates manual test cases for scanning invoice, the send flow and balance/capacity validation. + +Dimensions covered: +- **Invoice type**: onchain (no amount / with amount), lightning (no amount / with amount / expired), unified (BIP21 + lightning, various amounts/expiry) +- **Balances**: + - Lightning: **0 channels**, **has channels but 0 usable capacity**, **has usable channels + sufficient capacity**, **has usable channels + insufficient capacity** + - Onchain: **0 onchain balance**, **insufficient onchain balance**, **sufficient onchain balance** + +**Note:** If the node is **not running** when scanning, expect the **sync overlay** to appear first. After the node becomes running and validation completes, the behavior matches the test cases below (same as if the node was already running). + +--- + +### Legend + +- **LN** = Lightning +- **OC** = Onchain +- **UI** = Unified Invoice (BIP21 + `lightning` param) +- **SS** = Send Sheet + +--- + +## 1. Pure Onchain – No Amount + +Sample: +`bcrt1qmd722klk04yph86ky8jz9gvj6g8n9kjep9zj7d` + +| ID | Onchain balance | LN channels / capacity | Expected behavior | +|----|-----------------|------------------------|-------------------| +| OC-NA-1 | **0** | any | **Toast only**, no SS. Toast: insufficient savings (generic). | +| OC-NA-2 | **> 0** | any | **Open SS** with onchain flow. Amount screen (user enters amount). | + +--- + +## 2. Pure Onchain – With Amount + +Sample: +`bitcoin:bcrt1qmd722klk04yph86ky8jz9gvj6g8n9kjep9zj7d?amount=0.000002` + +| ID | Onchain balance (vs invoice) | LN channels / capacity | Expected behavior | +|----|------------------------------|------------------------|-------------------| +| OC-A-1 | **insufficient** (bal \< amount) | any | **Toast only**, no SS. Toast: insufficient savings (amount-specific). | +| OC-A-2 | **sufficient** (bal ≥ amount) | any | **Open SS** with onchain flow. Amount screen (user may change amount) | + +--- + +## 3. Pure Lightning – No Amount + +Sample: +`lnbcrt1p5hnntldqqnp4q2ha8exmazh0ave4mw5gtsdrrdmlh94lkvjmuqceepnxvdmd7z6xupp5euzlz4sj5rk5vn9regdadsrzjq5hdady588p6y8g67n38fryf22ssp5npy5vgmu0ux0szz3uy4awpk7x9dk0p5pd0qct6h4yl6d9z0su8ms9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcgqqqqlgqqqqqqgq2qzpatunvm0yvknery9pdahnkrv0ret48ss74dm8gtgmge3wqy356hx57pkvjhldp0lhqkukameavvd4qfhlcsn0jlkl3vnx3kmsh5nxsq4qhtke` + +| ID | LN channels / capacity | Onchain balance | Expected behavior | +|----|------------------------|-----------------|-------------------| +| LN-NA-1 | **0 channels** | any | **Toast only**, no SS. Toast: insufficient spending (generic, since invoice has no amount). | +| LN-NA-2 | **has usable channels, 0 capacity** | any | **Toast only**, no SS. Toast: insufficient spending. | +| LN-NA-3 | **has usable channels, sufficient capacity** | any | **Open SS** with lightning flow. Amount screen (user enters sats). | + +--- + +## 4. Pure Lightning – With Amount + +Sample: +`lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgted72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt` + +| ID | LN channels / capacity (vs invoice) | Onchain balance | Expected behavior | +|----|--------------------------------------|-----------------|-------------------| +| LN-A-1 | **0 channels** | any | **Toast only**, no SS. Toast: insufficient spending (amount-specific). | +| LN-A-2 | **has usable channels, insufficient capacity** | any | **Toast only**, no SS. Toast: insufficient spending (amount-specific). | +| LN-A-3 | **has usable channels, sufficient capacity** | any | **Open SS** with lightning flow. Confirm screen. | + +--- + +## 5. Pure Lightning – Expired + +Sample: +`LNBCRT100N1P5H8KFWDQQNP4QDRM0Y4AT84E48QVDN8CSWVE204SF6FFRR3W3AK904GE3JSEGLP4GPP52KT7N4JY7P35N3F27SVX2MWURWPCSEE4TGEY95LH0C2DZ5FG6CMQSP54T60G6XU4EFK70VQLA7E86Q6UZ2ZAFW3X7MAAZ2D8QPF6EVCLRAS9QYYSGQCQPCXQRRSSRZJQ29GJY9SQJRRP48TZ7HJ2E5VM4L2DUKC4CSF2MN6QM32U3HTED5LEAPYQQQQQQQF95QQQQLGQQQQQQGQ2QUNUG6ZUMCH3GVT2LDD3FMAPERZQWMCY0ESN7YFUK6DJH0LG40ZHQCYH8M3L470M3FA8MU78HPT6PVSKC7WGAUJAQAE6PX2A6SHKU4FGQE3X6C5` + +| ID | Balances / channels | Expected behavior | +|----|---------------------|-------------------| +| LN-EXP-1 | any | **Toast only**, no SS. Toast: invoice expired. (Handled in `AppViewModel.handleScannedData()`.) | + +--- + +## 6. Unified – Zero Amount + +Sample: +`bitcoin:bcrt1qmd722klk04yph86ky8jz9gvj6g8n9kjep9zj7d?lightning=lnbcrt1p5hnntldqqnp4q2ha8exmazh0ave4mw5gtsdrrdmlh94lkvjmuqceepnxvdmd7z6xupp5euzlz4sj5rk5vn9regdadsrzjq5hdady588p6y8g67n38fryf22ssp5npy5vgmu0ux0szz3uy4awpk7x9dk0p5pd0qct6h4yl6d9z0su8ms9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcgqqqqlgqqqqqqgq2qzpatunvm0yvknery9pdahnkrv0ret48ss74dm8gtgmge3wqy356hx57pkvjhldp0lhqkukameavvd4qfhlcsn0jlkl3vnx3kmsh5nxsq4qhtke` + +| ID | LN channels / capacity | Onchain balance | Expected behavior | +|----|------------------------|-----------------|-------------------| +| UI-0A-1 | **has usable channels, sufficient capacity** | any | Prefers LN: **Open SS** with lightning flow. Amount screen (user enters sats). | +| UI-0A-2 | **has usable channels, insufficient capacity** | **0** | LN cannot send → fall back to OC; OC balance 0 → **toast only**, no SS. | +| UI-0A-3 | **has usable channels, insufficient capacity** | **> 0 (sufficient for OC)** | LN cannot send → fall back to OC; OC sufficient → **Open SS** onchain flow (amount/confirm as per design). | +| UI-0A-4 | **0 channels** | **0** | LN cannot send (no channels) → fall back to OC; OC 0 → **toast only**, no SS. | +| UI-0A-5 | **0 channels** | **> 0** | LN cannot send → fall back to OC; OC sufficient → **Open SS** onchain flow. | + +--- + +## 7. Unified – Small Amount + +Sample: +`bitcoin:bcrt1qd2m8c0vdaejjaechgzpwq5gnypkkwzptl5m07v?lightning=lnbcrt2u1p5hnnd3dqqnp4q2ha8exmazh0ave4mw5gtsdrrdmlh94lkvjmuqceepnxvdmd7z6xupp5p0nnjfhz9jkxnefw9e9zf58qw8mk2nqlk9e88vx05h6d9jw0q9xssp5z803k8yqp3r6c4lhjcwrvz0dnl08xmw95lp9shr285c8nury024q9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcgqqqqlgqqqqqqgq2q0krntne8ejg7ee5j8uvv50rzzfwlyjckezmqvd4c9ew7ddslrvwp6fj5vcxxrrukr6n0jfsqc5eggz5g60yhwk3g9hs37272t8uw0yqp7smtl0&amount=0.000002` + +| ID | LN capacity (vs LN invoice amount) | OC balance (vs OC amount) | Expected behavior | +|----|-------------------------------------|----------------------------|-------------------| +| UI-SA-1 | **sufficient** | any | LN can send → **Open SS** with lightning flow (confirm). | +| UI-SA-2 | **insufficient** | **sufficient** | LN cannot send → fall back to OC; OC sufficient → **Open SS** with onchain flow (confirm). | +| UI-SA-3 | **insufficient** | **insufficient** | LN cannot send → fall back to OC; OC insufficient → **toast only**, no SS. | +| UI-SA-4 | **0 channels** | **sufficient** | LN cannot send → fall back to OC; OC sufficient → **Open SS** onchain flow. | +| UI-SA-5 | **0 channels** | **insufficient** | LN cannot send → fall back to OC; OC insufficient → **toast only**, no SS. | + +--- + +## 8. Unified – Large Amount + +Sample: +`bitcoin:bcrt1qd2m8c0vdaejjaechgzpwq5gnypkkwzptl5m07v?lightning=lnbcrt2m1p5hnnwvdqqnp4q2ha8exmazh0ave4mw5gtsdrrdmlh94lkvjmuqceepnxvdmd7z6xupp5l5gg9j38pqsql9rzq6vtslhzja90sxczk0ydsawxfklsf2ndftwssp5qpyfd6pdqasn60sexduk07vq67ypqax26xc578jn00739avajaps9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcgqqqqlgqqqqqqgq2qeugggvklr0h4x3g26h3zqttwqknl9vnu8jsrn9mvqhvhvrafefghateuztr74e5y53ftueg8zj7j8vejz8tjzstvm2ruu5m666h78tgpwv4ww0&amount=0.002` + +| ID | LN capacity vs LN invoice | OC balance vs OC amount | Expected behavior | +|----|---------------------------|--------------------------|-------------------| +| UI-LA-1 | **sufficient** | any | LN can send → **Open SS** with lightning flow. | +| UI-LA-2 | **insufficient** | **sufficient** | LN insufficient → fall back to OC; OC sufficient → **Open SS** with onchain flow. | +| UI-LA-3 | **insufficient** | **insufficient** | LN insufficient → fall back to OC; OC insufficient → **toast only**, no SS. | + +--- + +## 9. Unified – With Amount – Lightning Expired + +Sample: +`bitcoin:bcrt1qmd722klk04yph86ky8jz9gvj6g8n9kjep9zj7d?amount=0.0000002&lightning=LNBCRT200N1P5H8H4HDQQNP4QDRM0Y4AT84E48QVDN8CSWVE204SF6FFRR3W3AK904GE3JSEGLP4GPP5VCSLQ6RPA2RHQJ40LG6ELHE4JA0548TNQZTX4UFSTKAJ8FAHKLASSP5E2EQTTN20PF2HV7EZQCDKY43DSCVLXXR5LC6E920GDR3GXJ0QYSQ9QYYSGQCQPCXQRRSSRZJQ29GJY9SQJRRP48TZ7HJ2E5VM4L2DUKC4CSF2MN6QM32U3HTED5LEAPYQQQQQQQF95QQQQLGQQQQQQGQ2Q9T08YP2026U697073LS2FRT0EY9MKEYQASUTM5DV9KV5FZS5X2UPPHLFYU4C3Q34LJ8GRTLQ2YG0SWPZJMSK74MV6WHWUEZWG870AHCQGD3XDW` + +| ID | Onchain balance vs OC amount | Expected behavior | +|----|------------------------------|-------------------| +| UI-EXP-1 | **insufficient** | Lightning param expired → silently ignore LN, treat as pure onchain. OC insufficient → **toast only**, no SS. | +| UI-EXP-2 | **sufficient** | LN expired → pure onchain; OC sufficient → **Open SS** with onchain flow. |