Skip to content
Draft
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?()
}
}
}
}
22 changes: 15 additions & 7 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@

listenForEvents(onEvent: storedEventCallback)

// DEBUG: Add artificial delay to test sync overlay UI
#if DEBUG
try? await Task.sleep(nanoseconds: 10_000_000_000) // 5 seconds
#endif

Comment on lines +217 to +221
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still want to leave this?

Logger.debug("Starting node...")
try await ServiceQueue.background(.ldk) {
try node.start()
Expand Down Expand Up @@ -408,21 +413,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 +529,7 @@
}

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

Check warning on line 532 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 532 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 @@ -752,7 +760,7 @@
onEvent?(event)

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

Check warning on line 763 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 763 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 @@ -777,7 +785,7 @@
Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService")
}
}
case let .paymentReceived(paymentId, paymentHash, amountMsat, feePaidMsat):

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

View workflow job for this annotation

GitHub Actions / Run Tests

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

Check warning on line 788 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 @@ -787,7 +795,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 798 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

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

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

View workflow job for this annotation

GitHub Actions / Run Tests

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

Check warning on line 798 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 798 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 @@ -816,7 +824,7 @@

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

Check warning on line 827 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 827 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 @@ -839,7 +847,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 850 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

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

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

View workflow job for this annotation

GitHub Actions / Run Tests

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

Check warning on line 850 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 850 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 @@ -893,7 +901,7 @@

// MARK: Balance Events

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

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

View workflow job for this annotation

GitHub Actions / Run Tests

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

Check warning on line 904 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 904 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
65 changes: 7 additions & 58 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,59 +261,27 @@ 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, canSend {
// Proceed with lightning if invoice is valid (network match, not expired)
// SendSheet will show sync overlay and validate capacity after node is ready
if lnNetworkMatch, !lightningInvoice.isExpired {
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"
)
return
}
}

// Proceed with onchain payment - SendSheet will show sync overlay and validate balance after node is ready
handleScannedOnchainInvoice(invoice)
case let .lightning(invoice):
// Check network first - treat wrong network as decoding error
Expand All @@ -328,26 +296,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 +306,7 @@ extension AppViewModel {
return
}

// Proceed with lightning payment - SendSheet will show sync overlay and validate capacity after node is 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