Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions Bitkit/Components/SyncNodeView.swift
Original file line number Diff line number Diff line change
@@ -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?()
}
}
}
}
17 changes: 10 additions & 7 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,21 +408,24 @@
/// 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
}

Expand Down Expand Up @@ -521,7 +524,7 @@
}

func closeChannel(_ channel: ChannelDetails, force: Bool = false, forceCloseReason: String? = nil) async throws {
guard let node else {

Check warning on line 527 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

value 'node' was defined but never used; consider replacing with boolean test

Check warning on line 527 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

value 'node' was defined but never used; consider replacing with boolean test
throw AppError(serviceError: .nodeNotStarted)
}

Expand Down Expand Up @@ -762,7 +765,7 @@
onEvent?(event)

switch event {
case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat):

Check warning on line 768 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'paymentPreimage' was never used; consider replacing with '_' or removing it

Check warning on line 768 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'paymentPreimage' was never used; consider replacing with '_' or removing it
Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)")
Task {
let hash = paymentId ?? paymentHash
Expand All @@ -787,7 +790,7 @@
Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService")
}
}
case let .paymentReceived(paymentId, paymentHash, amountMsat, feePaidMsat):

Check warning on line 793 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'feePaidMsat' was never used; consider replacing with '_' or removing it
Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)")
Task {
let hash = paymentId ?? paymentHash
Expand All @@ -797,7 +800,7 @@
Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService")
}
}
case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, claimDeadline, customRecords):

Check warning on line 803 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'customRecords' was never used; consider replacing with '_' or removing it

Check warning on line 803 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'claimDeadline' was never used; consider replacing with '_' or removing it
Logger.info(
"🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)"
)
Expand Down Expand Up @@ -826,7 +829,7 @@

if let channel {
await registerClosedChannel(channel: channel, reason: reasonString)
await MainActor.run {

Check warning on line 832 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

result of call to 'run(resultType:body:)' is unused

Check warning on line 832 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

result of call to 'run(resultType:body:)' is unused
channelCache.removeValue(forKey: channelIdString)
}
} else {
Expand All @@ -849,7 +852,7 @@
Logger.error("Failed to handle transaction received for \(txid): \(error)", context: "LightningService")
}
}
case let .onchainTransactionConfirmed(txid, blockHash, blockHeight, confirmationTime, details):

Check warning on line 855 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'confirmationTime' was never used; consider replacing with '_' or removing it

Check warning on line 855 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'blockHash' was never used; consider replacing with '_' or removing it
Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)")
Task {
do {
Expand Down Expand Up @@ -903,7 +906,7 @@

// MARK: Balance Events

case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, oldTotalOnchain, newTotalOnchain, oldLightning, newLightning):

Check warning on line 909 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'newTotalOnchain' was never used; consider replacing with '_' or removing it

Check warning on line 909 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'oldTotalOnchain' was never used; consider replacing with '_' or removing it
Logger
.info("💰 Balance changed: onchain=\(oldSpendableOnchain)->\(newSpendableOnchain) lightning=\(oldLightning)->\(newLightning)")

Expand Down
155 changes: 102 additions & 53 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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,
Expand All @@ -358,6 +385,28 @@ extension AppViewModel {
return
}

// If node is running, we can check for channels and validate immediately
if lightningService.status?.isRunning == true {
// 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.
let hasAnyChannels = (lightningService.channels?.isEmpty == false)
if !hasAnyChannels {
let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0
showInsufficientSpendingToast(invoiceAmount: invoice.amountSatoshis, spendingBalance: spendingBalance)
return
}

// If channels are usable, validate capacity immediately
if 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)")
Expand Down
3 changes: 2 additions & 1 deletion Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading