Skip to content
Open
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
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,7 @@
"wallet__activity_sent" = "Sent";
"wallet__activity_received" = "Received";
"wallet__activity_pending" = "Pending";
"wallet__activity_pending_nav_title" = "Payment Pending";
"wallet__activity_failed" = "Failed";
"wallet__activity_transfer" = "Transfer";
"wallet__activity_transfer_savings_pending" = "From Spending (±{duration})";
Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Utilities/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
}
}

enum PaymentTimeoutError: Error {
case timedOut
}

enum BlocktankError_deprecated: Error {
case missingResponse
case invalidResponse
Expand Down Expand Up @@ -370,7 +374,7 @@
case let .InvalidNodeAlias(message: ldkMessage):
message = "Invalid node alias"
debugMessage = ldkMessage
case let .InvalidCustomTlvs(message: ldkMessage):

Check warning on line 377 in Bitkit/Utilities/Errors.swift

View workflow job for this annotation

GitHub Actions / Run Tests

case is already handled by previous patterns; consider removing it

Check warning on line 377 in Bitkit/Utilities/Errors.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

case is already handled by previous patterns; consider removing it
message = "Invalid custom TLVs"
debugMessage = ldkMessage
case let .InvalidDateTime(message: ldkMessage):
Expand Down
67 changes: 57 additions & 10 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import Combine
import LDKNode
import SwiftUI

/// Published when a payment shown on the pending screen succeeds or fails.
/// SendPendingScreen observes this and navigates accordingly.
struct SendSheetPendingResolution: Equatable {
let paymentHash: String
let success: Bool
}

enum ManualEntryValidationResult: Equatable {
case valid
case empty
Expand Down Expand Up @@ -54,6 +61,14 @@ class AppViewModel: ObservableObject {
// Drawer menu
@Published var showDrawer = false

/// Payment hashes for which we navigated to the pending screen.
/// When payment succeeds/fails, we show toast and publish resolution so SendPendingScreen can navigate.
private var pendingPaymentHashes: Set<String> = []

/// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate.
/// Consumed by SendPendingScreen via consumeSendSheetPendingResolution.
@Published var sendSheetPendingResolution: SendSheetPendingResolution?

// App status init - shows "ready" until node is actually running
// This prevents flashing error status during startup/background transitions
@Published var appStatusInit: Bool = false
Expand Down Expand Up @@ -276,6 +291,24 @@ extension AppViewModel {
}
}

// MARK: Pending payment tracking

extension AppViewModel {
func addPendingPaymentHash(_ hash: String) {
pendingPaymentHashes.insert(hash)
}

func removePendingPaymentHash(_ hash: String) {
pendingPaymentHashes.remove(hash)
}

/// Called by SendPendingScreen when it consumes a resolution. Clears the published value.
func consumeSendSheetPendingResolution(paymentHash hash: String) {
guard sendSheetPendingResolution?.paymentHash == hash else { return }
sendSheetPendingResolution = nil
}
}

// MARK: Scanning/pasting handling

extension AppViewModel {
Expand Down Expand Up @@ -785,18 +818,32 @@ extension AppViewModel {
}
case .channelClosed(channelId: _, userChannelId: _, counterpartyNodeId: _, reason: _):
break
case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat):
// TODO: fee is not the sats sent. Need to get this amount from elsewhere like send flow or something.
break
case let .paymentSuccessful(paymentId, paymentHash, _, _):
let hash = paymentId ?? paymentHash
if pendingPaymentHashes.contains(hash) {
pendingPaymentHashes.remove(hash)
sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: true)
toast(
type: .lightning,
title: t("wallet__toast_payment_success_title"),
description: t("wallet__toast_payment_success_description"),
accessibilityIdentifier: "PaymentSuccessToast"
)
}
case let .paymentFailed(paymentId, paymentHash, _):
let hash = paymentId ?? paymentHash
if let hash, pendingPaymentHashes.contains(hash) {
pendingPaymentHashes.remove(hash)
sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false)
toast(
type: .error,
title: t("wallet__toast_payment_failed_title"),
description: t("wallet__toast_payment_failed_description"),
accessibilityIdentifier: "PaymentFailedToast"
)
}
case .paymentClaimable:
break
case .paymentFailed(paymentId: _, paymentHash: _, reason: _):
toast(
type: .error,
title: t("wallet__toast_payment_failed_title"),
description: t("wallet__toast_payment_failed_description"),
accessibilityIdentifier: "PaymentFailedToast"
)
case .paymentForwarded:
break

Expand Down
25 changes: 24 additions & 1 deletion Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,6 @@ class WalletViewModel: ObservableObject {
break
}


var info: IBtInfo?
do {
info = try await coreService.blocktank.info(refresh: true)
Expand Down Expand Up @@ -519,6 +518,30 @@ class WalletViewModel: ObservableObject {
return try await lightningService.estimateRoutingFees(bolt11: bolt11, amountSats: amountSats)
}

/// Sends a lightning payment with an optional timeout.
/// If the payment does not complete within `timeoutSeconds`, throws `PaymentTimeoutError.timedOut`.
/// The payment continues in the background; caller should navigate to pending screen on timeout.
/// `onTimeout` is called when the timeout fires (before throwing), so the UI can navigate even if the throw doesn't propagate.
@discardableResult
func sendWithTimeout(
bolt11: String,
sats: UInt64? = nil,
timeoutSeconds: TimeInterval = 10,
onTimeout: (@MainActor () -> Void)? = nil
) async throws -> PaymentHash {
try await withThrowingTaskGroup(of: PaymentHash.self) { group in
group.addTask { try await self.send(bolt11: bolt11, sats: sats) }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
if let onTimeout { await MainActor.run { onTimeout() } }
throw PaymentTimeoutError.timedOut
}
let first = try await group.next()!
group.cancelAll()
return first
}
}

/// Sends a lightning payment and waits for the result using async/await
/// A LN payment can throw an error right away, be successful right away,
/// or take a while to complete/fail because it's retrying different paths.
Expand Down
19 changes: 18 additions & 1 deletion Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,26 @@ struct ActivityExplorerView: View {
}
}

private var navTitle: String {
switch item {
case let .lightning(activity) where activity.status == .pending:
return t("wallet__activity_pending_nav_title")
case let .lightning(activity) where activity.txType == .sent:
return t("wallet__activity_bitcoin_sent")
case let .onchain(activity) where activity.isTransfer:
return activity.txType == .sent
? t("wallet__activity_transfer_spending_done")
: t("wallet__activity_transfer_savings_done")
case let .onchain(activity) where activity.txType == .sent:
return t("wallet__activity_bitcoin_sent")
default:
return t("wallet__activity_bitcoin_received")
}
}

var body: some View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("wallet__activity_bitcoin_received"))
NavigationBar(title: navTitle)
.padding(.bottom, 16)

HStack(alignment: .bottom) {
Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Views/Wallets/Activity/ActivityItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ struct ActivityItemView: View {
return t("wallet__activity_boost_fee")
}

if case let .lightning(activity) = viewModel.activity, activity.status == .pending {
return t("wallet__activity_pending_nav_title")
}

return isSent
? t("wallet__activity_bitcoin_sent")
: t("wallet__activity_bitcoin_received")
Expand Down
20 changes: 17 additions & 3 deletions Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import BitkitCore
import LDKNode
import SwiftUI

struct LnurlPayConfirm: View {
Expand Down Expand Up @@ -214,11 +215,24 @@ struct LnurlPayConfirm: View {
comment: comment.isEmpty ? nil : comment
)

let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11)
let paymentHash = String(describing: parsedInvoice.paymentHash())

do {
// Perform the Lightning payment
let paymentHash = try await wallet.send(bolt11: bolt11, sats: wallet.sendAmountSats)
// Perform the Lightning payment (10s timeout → navigate to pending for hold invoices)
try await wallet.sendWithTimeout(
bolt11: bolt11,
sats: wallet.sendAmountSats,
onTimeout: {
app.addPendingPaymentHash(paymentHash)
navigationPath.append(.pending(paymentHash: paymentHash))
}
)
Logger.info("LNURL payment successful: \(paymentHash)")
navigationPath.append(.success(paymentHash))
navigationPath.append(.success(paymentId: paymentHash))
} catch is PaymentTimeoutError {
// onTimeout callback already navigated to .pending; suppress throw
return
} catch {
Logger.error("LNURL payment failed: \(error)")

Expand Down
27 changes: 20 additions & 7 deletions Bitkit/Views/Wallets/Send/SendConfirmationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,19 +223,32 @@ struct SendConfirmationView: View {
do {
if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice {
let amount = wallet.sendAmountSats ?? invoice.amountSatoshis
// Set the amount for the success screen
// Set the amount for other screens
wallet.sendAmountSats = amount

// Create pre-activity metadata for tags and activity address
let paymentHash = invoice.paymentHash.hex
createdMetadataPaymentId = paymentHash
await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash)

// Perform the Lightning payment
try await wallet.send(bolt11: invoice.bolt11, sats: amount)
Logger.info("Lightning payment successful: \(paymentHash)")

navigationPath.append(.success(paymentHash))
// Perform the Lightning payment (10s timeout → navigate to pending for hold invoices)
do {
try await wallet.sendWithTimeout(
bolt11: invoice.bolt11,
sats: amount,
onTimeout: {
app.addPendingPaymentHash(paymentHash)
navigationPath.append(.pending(paymentHash: paymentHash))
}
)
Logger.info("Lightning payment successful: \(paymentHash)")
navigationPath.append(.success(paymentId: paymentHash))
} catch is PaymentTimeoutError {
// onTimeout callback already navigated to .pending; suppress throw
return
} catch {
throw error
}
} else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice {
let amount = wallet.sendAmountSats ?? invoice.amountSatoshis
// Use sendAll if explicitly MAX or if change would be dust
Expand All @@ -250,7 +263,7 @@ struct SendConfirmationView: View {

Logger.info("Onchain send result txid: \(txid)")

navigationPath.append(.success(txid))
navigationPath.append(.success(paymentId: txid))
} else {
throw NSError(
domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid payment method or missing invoice data"]
Expand Down
106 changes: 106 additions & 0 deletions Bitkit/Views/Wallets/Send/SendPendingScreen.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import BitkitCore
import SwiftUI

struct HourglassLoadingView: View {
@State private var rotation: Double = -16

private var size: CGFloat { UIScreen.main.isSmall ? 160 : 256 }

var body: some View {
Image("hourglass")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.rotationEffect(.degrees(rotation))
.frame(maxWidth: .infinity)
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
rotation = 16
}
}
}
}

struct SendPendingScreen: View {
let paymentHash: String
@Binding var navigationPath: [SendRoute]

@EnvironmentObject private var activityList: ActivityListViewModel
@EnvironmentObject private var app: AppViewModel
@EnvironmentObject private var navigation: NavigationViewModel
@EnvironmentObject private var sheets: SheetViewModel
@EnvironmentObject private var wallet: WalletViewModel

@State private var foundActivity: Activity?

var body: some View {
VStack(alignment: .leading, spacing: 0) {
SheetHeader(title: t("wallet__send_pending"), showBackButton: false)

if let sendAmountSats = wallet.sendAmountSats {
MoneyStack(sats: Int(sendAmountSats), showSymbol: true)
.padding(.bottom, 32)
}

BodyMText(t("wallet__send_pending_note"))

Spacer()

HourglassLoadingView()

Spacer()

HStack(spacing: 16) {
CustomButton(
title: t("wallet__send_details"),
variant: .secondary,
isDisabled: foundActivity == nil
) {
if let foundActivity {
navigation.navigate(.activityDetail(foundActivity))
sheets.hideSheet()
}
}

CustomButton(title: t("common__close")) {
sheets.hideSheet()
}
}
}
.navigationBarHidden(true)
.padding(.horizontal, 16)
.sheetBackground()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
await searchForActivity()
}
.onChange(of: app.sendSheetPendingResolution) { resolution in
guard let resolution, resolution.paymentHash == paymentHash else { return }
app.consumeSendSheetPendingResolution(paymentHash: paymentHash)
if resolution.success {
navigationPath.append(.success(paymentId: paymentHash))
} else {
navigationPath.append(.failure)
}
}
.onDisappear {
// Remove the pending payment hash from the app model when the screen disappears
app.removePendingPaymentHash(paymentHash)
}
}

private func searchForActivity() async {
do {
try? await activityList.syncLdkNodePayments()

let activity = try await tryNTimes(
toTry: { try await activityList.findActivity(byPaymentId: paymentHash) },
times: 12,
interval: 2
)
foundActivity = activity
} catch {
Logger.warn("Could not find activity for pending payment \(paymentHash): \(error)")
}
}
}
Loading
Loading