From fef1c065f049730bb63f6dd2a95e6d6c429c8521 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 16 Jul 2026 09:38:03 -0400 Subject: [PATCH 01/12] feat: add boltz swap support --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/AppScene.swift | 32 +++ Bitkit/Components/AmountSlider.swift | 94 +++++++ Bitkit/MainNavView.swift | 2 + .../Localization/en.lproj/Localizable.strings | 6 + Bitkit/Services/BoltzService.swift | 229 +++++++++++++++++ Bitkit/ViewModels/NavigationViewModel.swift | 2 + Bitkit/ViewModels/TransferViewModel.swift | 165 +++++++++++++ Bitkit/ViewModels/WalletViewModel.swift | 2 + Bitkit/Views/Settings/DevSettingsView.swift | 4 + Bitkit/Views/Settings/SwapsListView.swift | 231 ++++++++++++++++++ .../Views/Transfer/SavingsConfirmView.swift | 92 ++++++- .../Views/Transfer/SavingsProgressView.swift | 105 +++++--- BitkitTests/SavingsSwapTests.swift | 81 ++++++ changelog.d/next/632.added.md | 1 + 16 files changed, 1005 insertions(+), 47 deletions(-) create mode 100644 Bitkit/Components/AmountSlider.swift create mode 100644 Bitkit/Services/BoltzService.swift create mode 100644 Bitkit/Views/Settings/SwapsListView.swift create mode 100644 BitkitTests/SavingsSwapTests.swift create mode 100644 changelog.d/next/632.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 274346a1c..b1f0b4c2c 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1201,7 +1201,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.4.1; + version = 0.5.1; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 861c8f81b..c40e90715 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "aa32dbf3934871baa1a44576afdd2a92acb6bdd0", - "version" : "0.4.1" + "revision" : "158caf08177c7cbaf8dd3e8d23830ee6f51ec8a8", + "version" : "0.5.1" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5588eab02..b1c0f6199 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -42,6 +42,8 @@ struct AppScene: View { @State private var walletInitShouldFinish = false @State private var isPinVerified: Bool = false @State private var showRecoveryScreen = false + /// Long-lived collector that refreshes balances when a Boltz swap claim lands on-chain. + @State private var swapEventsTask: Task? /// Check if there's a critical update available private var hasCriticalUpdate: Bool { @@ -465,6 +467,10 @@ struct AppScene: View { // Start watching pending orders after wallet is ready await blocktank.startWatchingPendingOrders(transferViewModel: transfer) + // Open the swap updates stream so any pending LN -> onchain swaps resume + // and auto-claim once their lockup confirms. + await startSwapUpdates() + // Schedule full backup after wallet create/restore to prevent epoch dates in backup status await BackupService.shared.scheduleFullBackup() } catch { @@ -480,6 +486,32 @@ struct AppScene: View { } } + /// Start the Boltz updates stream using the wallet's current fee rate for auto-claims, + /// and refresh balances whenever a swap lands on-chain so savings reflect it without + /// a manual sync. + private func startSwapUpdates() async { + if swapEventsTask == nil { + swapEventsTask = Task { + for await event in BoltzService.shared.events() { + if case let .claimed(swapId, _) = event { + Logger.info("Savings swap claimed: \(swapId)", context: "AppScene") + await wallet.syncStateAsync() + } + } + } + } + + do { + var feeRate: Double? + if let rates = await feeEstimatesManager.getEstimates() { + feeRate = Double(SettingsViewModel.shared.defaultTransactionSpeed.getFeeRate(from: rates)) + } + try await BoltzService.shared.startUpdates(feeRateSatPerVb: feeRate) + } catch { + Logger.error(error, context: "Failed to start swap updates") + } + } + /// Handle orphaned keychain entries from previous app installs. /// If the installation marker doesn't exist but keychain has data, the app was reinstalled /// and the keychain data is orphaned (corresponding wallet data was deleted with the app). diff --git a/Bitkit/Components/AmountSlider.swift b/Bitkit/Components/AmountSlider.swift new file mode 100644 index 000000000..57165e3d1 --- /dev/null +++ b/Bitkit/Components/AmountSlider.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// Continuous slider over a `minValue`...`maxValue` range, styled to match `CustomSlider` +/// (same track and thumb) but without discrete steps. Used to pick a transfer amount +/// within its allowed limits. +struct AmountSlider: View { + @Binding var value: UInt64 + let minValue: UInt64 + let maxValue: UInt64 + + @State private var sliderWidth: CGFloat = 0 + + private var fraction: CGFloat { + guard maxValue > minValue else { return 0 } + let clamped = min(max(value, minValue), maxValue) + return CGFloat(clamped - minValue) / CGFloat(maxValue - minValue) + } + + private func value(at position: CGFloat) -> UInt64 { + guard sliderWidth > 0, maxValue > minValue else { return minValue } + let normalized = min(max(position / sliderWidth, 0), 1) + return minValue + UInt64((Double(maxValue - minValue) * Double(normalized)).rounded()) + } + + var body: some View { + Rectangle() + .fill(Color.clear) + .frame(height: 32) + .overlay( + ZStack { + // Track background + Rectangle() + .fill(Color.green32) + .frame(height: 8) + .cornerRadius(8) + + // Active track (from start to current position) + Rectangle() + .fill(Color.greenAccent) + .frame(width: fraction * sliderWidth, height: 8) + .cornerRadius(8) + .frame(maxWidth: .infinity, alignment: .leading) + + // Slider thumb + Circle() + .fill(Color.greenAccent) + .frame(width: 32, height: 32) + .overlay( + Circle() + .fill(Color.white) + .frame(width: 16, height: 16) + ) + .position(x: fraction * sliderWidth, y: 16) + .allowsHitTesting(false) + } + ) + .contentShape(Rectangle()) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + sliderWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { _, width in + sliderWidth = width + } + } + ) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + guard sliderWidth > 0 else { return } + value = value(at: gesture.location.x) + } + ) + } +} + +#Preview { + struct PreviewWrapper: View { + @State private var value: UInt64 = 72000 + + var body: some View { + VStack { + AmountSlider(value: $value, minValue: 50000, maxValue: 100_000) + Text("\(value) sats") + } + .padding(32) + } + } + + return PreviewWrapper() + .preferredColorScheme(.dark) +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index e496b0a32..c97946174 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -583,6 +583,8 @@ struct MainNavView: View { case .probingTool: ProbingToolScreen() case .legacyRnRecovery: LegacyRnRecoveryScreen() case .orders: ChannelOrders() + case .swaps: SwapsListView() + case let .swapDetail(id): SwapDetailView(swapId: id) case .logs: LogView() case .trezor: TrezorRootView() } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 495618475..2ed69f7ce 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -220,6 +220,12 @@ "lightning__availability__text" = "Funds transfer to savings is usually instant, but settlement may take up to 14 days under certain network conditions."; "lightning__savings_confirm__label" = "Transfer to savings"; "lightning__savings_confirm__transfer_all" = "Transfer all"; +"lightning__savings_confirm__amount" = "To savings"; +"lightning__savings_confirm__amount_too_low" = "Amount is too low to transfer to savings."; +"lightning__savings_confirm__close_instead" = "Close channel instead"; +"lightning__savings_confirm__network_fee" = "Network fee"; +"lightning__savings_confirm__receive" = "You'll receive"; +"lightning__savings_confirm__service_fee" = "Service fee"; "lightning__savings_advanced__title" = "Select funds\nto transfer"; "lightning__savings_advanced__text" = "You can transfer part of your spending balance to savings, because you have multiple active Lightning Connections."; "lightning__savings_advanced__total" = "Total selected"; diff --git a/Bitkit/Services/BoltzService.swift b/Bitkit/Services/BoltzService.swift new file mode 100644 index 000000000..ccdc55971 --- /dev/null +++ b/Bitkit/Services/BoltzService.swift @@ -0,0 +1,229 @@ +import BitkitCore +import Foundation +import LDKNode + +/// Thin wrapper around the bitkit-core Boltz swaps FFI (submarine + reverse swaps +/// between onchain Bitcoin and Lightning). +/// +/// Mirrors the existing service pattern (e.g. `CoreService`): a singleton that wraps +/// the FFI through `ServiceQueue` and bridges the `BoltzEventListener` foreign +/// callback to `AsyncStream`s. bitkit-core persists only a derivation index, never +/// key material; swap keys are re-derived on demand from the wallet mnemonic. +/// +/// The Lightning side (paying invoices, fresh onchain addresses) is owned by +/// `LightningService`; this service only talks to Boltz + the chain. +class BoltzService { + static let shared = BoltzService() + + private let continuationsLock = NSLock() + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + private lazy var listener = EventForwarder { [weak self] event in + Logger.info("Boltz event: \(event)", context: "BoltzService") + self?.emit(event) + } + + private init() {} + + // MARK: - Events + + /// Swap lifecycle events emitted while the updates stream is running. Each call + /// returns an independent stream; events are buffered from the moment the stream + /// is created, so subscribe before triggering the action whose events you await. + func events() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + continuationsLock.lock() + continuations[id] = continuation + continuationsLock.unlock() + + continuation.onTermination = { [weak self] _ in + guard let self else { return } + continuationsLock.lock() + continuations.removeValue(forKey: id) + continuationsLock.unlock() + } + } + } + + private func emit(_ event: BoltzSwapEvent) { + continuationsLock.lock() + let targets = Array(continuations.values) + continuationsLock.unlock() + + for continuation in targets { + continuation.yield(event) + } + } + + // MARK: - Limits + + func submarineLimits() async throws -> BoltzPairInfo { + try await ServiceQueue.background(.core) { + try await boltzGetSubmarineLimits(network: Self.boltzNetwork) + } + } + + func reverseLimits() async throws -> BoltzPairInfo { + try await ServiceQueue.background(.core) { + try await boltzGetReverseLimits(network: Self.boltzNetwork) + } + } + + // MARK: - Create + + /// Submarine swap: onchain BTC -> Lightning. Fund the returned lockup address. + func createSubmarineSwap(invoice: String) async throws -> SubmarineSwapResponse { + let (mnemonic, passphrase) = try credentials() + let response = try await ServiceQueue.background(.core) { + try await boltzCreateSubmarineSwap( + network: Self.boltzNetwork, + electrumUrl: Env.electrumServerUrl, + invoice: invoice, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + Logger.info("Created Boltz submarine swap \(response.id)", context: "BoltzService") + return response + } + + /// Reverse swap: Lightning -> onchain BTC. Pay the returned hold invoice. + func createReverseSwap(amountSat: UInt64, claimAddress: String) async throws -> ReverseSwapResponse { + let (mnemonic, passphrase) = try credentials() + let response = try await ServiceQueue.background(.core) { + try await boltzCreateReverseSwap( + network: Self.boltzNetwork, + electrumUrl: Env.electrumServerUrl, + amountSat: amountSat, + claimAddress: claimAddress, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + Logger.info("Created Boltz reverse swap \(response.id)", context: "BoltzService") + return response + } + + // MARK: - Query + + func listSwaps() async throws -> [BoltzSwap] { + try await ServiceQueue.background(.core) { + try await boltzListSwaps() + } + } + + func listPendingSwaps() async throws -> [BoltzSwap] { + try await ServiceQueue.background(.core) { + try await boltzListPendingSwaps() + } + } + + func getSwap(id: String) async throws -> BoltzSwap? { + try await ServiceQueue.background(.core) { + try await boltzGetSwap(swapId: id) + } + } + + // MARK: - Manual claim / refund + + func claimReverseSwap(id: String, feeRateSatPerVb: Double? = nil) async throws -> String { + let (mnemonic, passphrase) = try credentials() + return try await ServiceQueue.background(.core) { + try await boltzClaimReverseSwap( + swapId: id, + mnemonic: mnemonic, + bip39Passphrase: passphrase, + feeRateSatPerVb: feeRateSatPerVb + ) + } + } + + func refundSubmarineSwap(id: String, refundAddress: String, feeRateSatPerVb: Double? = nil) async throws -> String { + let (mnemonic, passphrase) = try credentials() + return try await ServiceQueue.background(.core) { + try await boltzRefundSubmarineSwap( + swapId: id, + refundAddress: refundAddress, + mnemonic: mnemonic, + bip39Passphrase: passphrase, + feeRateSatPerVb: feeRateSatPerVb + ) + } + } + + // MARK: - Updates stream + + /// Open the Boltz updates WebSocket, subscribe all pending swaps and auto-claim + /// confirmed reverse swaps. `feeRateSatPerVb` is the rate used for those + /// auto-claims (Bitkit owns fee estimation). Replaces any running stream. + func startUpdates(feeRateSatPerVb: Double?) async throws { + let (mnemonic, passphrase) = try credentials() + try await ServiceQueue.background(.core) { + try await boltzStartSwapUpdates( + network: Self.boltzNetwork, + listener: self.listener, + mnemonic: mnemonic, + bip39Passphrase: passphrase, + feeRateSatPerVb: feeRateSatPerVb + ) + } + Logger.info("Started Boltz updates stream on \(Self.boltzNetwork)", context: "BoltzService") + } + + func stopUpdates() async { + await boltzStopSwapUpdates() + Logger.info("Stopped Boltz updates stream", context: "BoltzService") + } + + // MARK: - Helpers + + /// The Boltz network matching the app's configured network. + static var boltzNetwork: BoltzNetwork { + switch Env.network { + case .bitcoin: return .mainnet + case .testnet: return .testnet + case .regtest: return .regtest + // Boltz does not operate on signet; fall back to testnet for development. + default: return .testnet + } + } + + private func credentials() throws -> (mnemonic: String, passphrase: String?) { + let walletIndex = LightningService.shared.currentWalletIndex + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphraseRaw = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + let passphrase = passphraseRaw?.isEmpty == true ? nil : passphraseRaw + return (mnemonic, passphrase) + } +} + +/// Bridges the UniFFI foreign callback to the service's event streams. +private final class EventForwarder: BoltzEventListener { + private let handler: @Sendable (BoltzSwapEvent) -> Void + + init(handler: @escaping @Sendable (BoltzSwapEvent) -> Void) { + self.handler = handler + } + + func onEvent(event: BoltzSwapEvent) { + handler(event) + } +} + +extension BoltzSwap { + /// Whether a manual claim can succeed for this swap: reverse direction, lockup funds + /// visible on-chain (mempool or confirmed), and no claim broadcast yet. Freshly created, + /// expired, failed, and refunded swaps have nothing to claim. + var isClaimable: Bool { + guard swapType == .reverse, claimTxId == nil else { return false } + switch status { + case .transactionMempool, .transactionConfirmed, .transactionClaimPending: + return true + default: + return false + } + } +} diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 374b4d817..07411efbe 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -106,6 +106,8 @@ enum Route: Hashable { case probingTool case legacyRnRecovery case orders + case swaps + case swapDetail(id: String) case logs case trezor } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index b68ab74f5..f00a8727a 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -112,6 +112,12 @@ class TransferViewModel: ObservableObject { @Published var channelsToClose: [ChannelDetails] = [] @Published var transferUnavailable = false + /// How the LN -> onchain "transfer to savings" is executed. Swapping funds out + /// (default) keeps channels open; closing a channel is the fallback the user can + /// pick to drain a whole channel on-chain. + @Published var savingsTransferMode: SavingsTransferMode = .swap + @Published var savingsSwapState = SavingsSwapState() + /// Hardware-wallet transfer-to-spending state. @Published var hwSpending = HwSpendingState() /// Bumped when a hardware funding tx is signed + broadcast, so the Sign screen advances. @@ -142,6 +148,16 @@ class TransferViewModel: ObservableObject { private let giveUpInterval: TimeInterval = 30 * 60 // 30 min private var coopCloseRetryTask: Task? + private let boltzService: BoltzService = .shared + /// The amount (sat) that will actually be swapped out; adjustable via the confirm slider. + private var pendingSwapAmountSat: UInt64 = 0 + /// Cached swap limits so the slider can re-price locally without hitting the network. + private var reverseSwapLimits: BoltzPairInfo? + /// How long the confirm/progress flow waits for the on-chain claim before backgrounding it. + private let swapClaimTimeout: TimeInterval = 60 + /// Minimum sats held back from a swap to cover Lightning routing fees. + private static let minLnRoutingFeeReserveSats: UInt64 = 10 + init( coreService: CoreService = .shared, lightningService: LightningService = .shared, @@ -1140,6 +1156,155 @@ class TransferViewModel: ObservableObject { return trustedChannels.count } + + // MARK: - Savings Swap (Boltz reverse swap) + + /// Fetch swap limits, derive the adjustable amount range, and publish an initial fee quote + /// (defaulting to the maximum transferable) so the user sees the cost before confirming. + /// The confirm slider then re-prices locally via `onSwapAmountChange`. Errors surface in state. + func loadSavingsSwapQuote(requestedSat: UInt64, spendableSats: UInt64) async { + savingsSwapState = SavingsSwapState(isLoading: true) + + let limits: BoltzPairInfo + do { + limits = try await boltzService.reverseLimits() + } catch { + Logger.error("Failed to load reverse swap limits", context: error.localizedDescription) + reverseSwapLimits = nil + savingsSwapState = SavingsSwapState(errorMessage: error.localizedDescription) + return + } + reverseSwapLimits = limits + + // Reserve headroom for Lightning routing fees. Paying an invoice for 100% of + // outbound capacity leaves nothing for fees and fails to route, so cap the + // swap at outbound minus ~1% (with a small floor). + let routingReserve = max(spendableSats / 100, Self.minLnRoutingFeeReserveSats) + let sendable = spendableSats > routingReserve ? spendableSats - routingReserve : 0 + let maxSat = min(requestedSat, limits.maximalSat, sendable) + let minSat = limits.minimalSat + + guard maxSat >= minSat, maxSat > 0 else { + pendingSwapAmountSat = 0 + savingsSwapState = SavingsSwapState(errorMessage: t("lightning__savings_confirm__amount_too_low")) + return + } + + // Default to transferring as much as possible; the slider can lower it. + pendingSwapAmountSat = maxSat + savingsSwapState = SavingsSwapState( + quote: SavingsSwapQuote.build(amountSat: maxSat, limits: limits), + minSat: minSat, + maxSat: maxSat + ) + } + + /// Re-price the swap for a slider-selected amount, clamped to the allowed range. + func onSwapAmountChange(_ sat: UInt64) { + guard let limits = reverseSwapLimits, savingsSwapState.quote != nil, savingsSwapState.maxSat >= savingsSwapState.minSat else { + return + } + let amount = min(max(sat, savingsSwapState.minSat), savingsSwapState.maxSat) + pendingSwapAmountSat = amount + savingsSwapState.quote = SavingsSwapQuote.build(amountSat: amount, limits: limits) + } + + /// Execute the LN -> onchain swap: derive a fresh claim address, create the swap, + /// pay the returned hold invoice over Lightning, then wait for the on-chain claim. + /// The claim is auto-broadcast by the updates stream once the lockup confirms, so a + /// timeout here is not a failure; the swap completes in the background. + func executeSavingsSwap() async -> SavingsSwapResult { + let amount = pendingSwapAmountSat + guard amount > 0 else { + return .failure(message: t("lightning__savings_confirm__amount_too_low")) + } + + do { + let claimAddress = try await lightningService.newAddress() + let swap = try await boltzService.createReverseSwap(amountSat: amount, claimAddress: claimAddress) + Logger.info("Created savings transfer swap \(swap.id)", context: "TransferViewModel") + + // Subscribe before paying so a claim settling faster than the payment + // call returns cannot be missed (events buffer from stream creation). + let events = boltzService.events() + + // Pay the hold invoice (amount is encoded). It stays pending until Boltz + // locks funds on-chain and we claim them, which is the expected happy path. + _ = try await lightningService.send(bolt11: swap.invoice) + + let result = await awaitSwapClaim(swapId: swap.id, events: events) + await onBalanceRefresh?() + return result + } catch { + Logger.error("Savings transfer swap failed", context: error.localizedDescription) + return .failure(message: error.localizedDescription) + } + } + + private func awaitSwapClaim(swapId: String, events: AsyncStream) async -> SavingsSwapResult { + let waitTask = Task { + for await event in events { + switch event { + case let .claimed(swapId: id, txid: txid) where id == swapId: + return SavingsSwapResult.success(txid: txid) + case let .error(swapId: id, message: message) where id == swapId: + return SavingsSwapResult.failure(message: message) + default: + continue + } + } + // No terminal event before cancellation; the claim completes in the background. + return SavingsSwapResult.pending + } + + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(swapClaimTimeout * 1_000_000_000)) + waitTask.cancel() + } + + let result = await waitTask.value + timeoutTask.cancel() + return result + } +} + +/// Whether a transfer to savings swaps funds out (default) or closes a channel. +enum SavingsTransferMode { + case swap + case close +} + +struct SavingsSwapQuote: Equatable { + let amountSat: UInt64 + let networkFeeSat: UInt64 + let swapFeeSat: UInt64 + let receiveSat: UInt64 + + /// Estimate the fee breakdown for swapping `amountSat` out under the given pair limits. + static func build(amountSat: UInt64, limits: BoltzPairInfo) -> SavingsSwapQuote { + let swapFee = UInt64(max(0, (Double(amountSat) * limits.feePercentage / 100.0).rounded())) + let networkFee = limits.minerFeesSat + let totalFees = swapFee + networkFee + let receive = amountSat > totalFees ? amountSat - totalFees : 0 + return SavingsSwapQuote(amountSat: amountSat, networkFeeSat: networkFee, swapFeeSat: swapFee, receiveSat: receive) + } +} + +struct SavingsSwapState { + var isLoading = false + var quote: SavingsSwapQuote? + /// Inclusive adjustable range for the confirm slider (sat). Equal/zero when unavailable. + var minSat: UInt64 = 0 + var maxSat: UInt64 = 0 + var errorMessage: String? +} + +enum SavingsSwapResult: Equatable { + /// Funds landed on-chain during the flow. + case success(txid: String) + /// Swap created and invoice paid; the claim completes in the background. + case pending + case failure(message: String) } /// Actor to safely capture channel data from channel pending events diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 5475460e9..66585cb33 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -413,6 +413,8 @@ class WalletViewModel: ObservableObject { func stopLightningNode(clearEventCallback: Bool = false) async throws { nodeLifecycleState = .stopping + // Stop the swap updates stream with the node; it restarts on the next wallet start. + await BoltzService.shared.stopUpdates() try await lightningService.stop(clearEventCallback: clearEventCallback) nodeLifecycleState = .stopped probeOutcomes.removeAll() diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 3a8332ace..a6f52f5b1 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -53,6 +53,10 @@ struct DevSettingsView: View { SettingsRow(title: "Orders") } + NavigationLink(value: Route.swaps) { + SettingsRow(title: "Swaps") + } + NavigationLink(value: Route.trezor) { SettingsRow(title: "Trezor Hardware Wallet") } diff --git a/Bitkit/Views/Settings/SwapsListView.swift b/Bitkit/Views/Settings/SwapsListView.swift new file mode 100644 index 000000000..1df1c56d1 --- /dev/null +++ b/Bitkit/Views/Settings/SwapsListView.swift @@ -0,0 +1,231 @@ +import BitkitCore +import SwiftUI + +struct SwapsListView: View { + @State private var swaps: [BoltzSwap] = [] + @State private var errorMessage: String? + @State private var isLoading = true + + var body: some View { + List { + if let errorMessage { + Text("Error: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + } + + if swaps.isEmpty, !isLoading, errorMessage == nil { + Text("No swaps found") + .font(.caption) + .foregroundColor(.gray) + } else { + ForEach(swaps, id: \.id) { swap in + NavigationLink(value: Route.swapDetail(id: swap.id)) { + SwapRow(swap: swap) + } + } + } + } + .navigationTitle("Swaps") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await refresh() + } + .task { + await refresh() + } + } + + private func refresh() async { + do { + let list = try await BoltzService.shared.listSwaps() + swaps = list.sorted { $0.createdAt > $1.createdAt } + errorMessage = nil + } catch { + Logger.error("Failed to list swaps", context: error.localizedDescription) + errorMessage = error.localizedDescription + } + isLoading = false + } +} + +private struct SwapRow: View { + let swap: BoltzSwap + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(swap.id) + .font(.system(size: swap.id.count > 20 ? 10 : 12, design: .monospaced)) + .lineLimit(1) + Spacer() + Text(String(describing: swap.status)) + .font(.caption) + .padding(4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + } + + HStack { + VStack(alignment: .leading) { + Text("Type") + .font(.caption) + .foregroundColor(.gray) + Text(String(describing: swap.swapType)) + .font(.subheadline) + } + Spacer() + VStack(alignment: .trailing) { + Text("Amount") + .font(.caption) + .foregroundColor(.gray) + Text("\(swap.amountSat) sats") + .font(.subheadline) + } + } + + HStack { + VStack(alignment: .leading) { + Text("Receives") + .font(.caption) + .foregroundColor(.gray) + Text(swap.onchainAmountSat.map { "\($0) sats" } ?? "-") + .font(.subheadline) + } + Spacer() + VStack(alignment: .trailing) { + Text("Created") + .font(.caption) + .foregroundColor(.gray) + Text(formatEpochSeconds(swap.createdAt)) + .font(.subheadline) + } + } + } + .padding(.vertical, 4) + } +} + +struct SwapDetailView: View { + let swapId: String + + @EnvironmentObject var app: AppViewModel + @State private var swap: BoltzSwap? + @State private var isClaiming = false + + var body: some View { + List { + if let swap { + Section("Overview") { + SwapDetailRow(label: "ID", value: swap.id) + SwapDetailRow(label: "Type", value: String(describing: swap.swapType)) + SwapDetailRow(label: "Status", value: String(describing: swap.status)) + SwapDetailRow(label: "Network", value: String(describing: swap.network)) + } + + Section("Amounts") { + SwapDetailRow(label: "Amount", value: "\(swap.amountSat) sats") + SwapDetailRow(label: "Onchain amount", value: swap.onchainAmountSat.map { "\($0) sats" } ?? "-") + } + + Section("Addresses") { + SwapDetailRow(label: "Lockup", value: swap.lockupAddress ?? "-") + SwapDetailRow(label: "Claim / onchain", value: swap.onchainAddress ?? "-") + } + + if let invoice = swap.invoice { + Section("Lightning") { + SwapDetailRow(label: "Invoice", value: invoice) + } + } + + Section("Transactions") { + SwapDetailRow(label: "Claim txid", value: swap.claimTxId ?? "-") + SwapDetailRow(label: "Refund txid", value: swap.refundTxId ?? "-") + } + + Section("Recovery") { + SwapDetailRow(label: "Swap index", value: String(swap.swapIndex)) + SwapDetailRow(label: "Timeout block", value: String(swap.timeoutBlockHeight)) + } + + Section("Timestamps") { + SwapDetailRow(label: "Created", value: formatEpochSeconds(swap.createdAt)) + } + + if swap.isClaimable { + Section { + Button { + claim() + } label: { + if isClaiming { + ProgressView() + } else { + Text("Claim now") + } + } + .disabled(isClaiming) + } + } + } else { + Text("Loading...") + .font(.caption) + .foregroundColor(.gray) + } + } + .navigationTitle("Swap Details") + .navigationBarTitleDisplayMode(.inline) + .task { + await refresh() + } + } + + private func refresh() async { + do { + swap = try await BoltzService.shared.getSwap(id: swapId) + } catch { + Logger.error("Failed to load swap '\(swapId)'", context: error.localizedDescription) + } + } + + /// Manually broadcast the claim for a reverse swap (recovery when auto-claim didn't fire). + private func claim() { + isClaiming = true + Task { + do { + let txid = try await BoltzService.shared.claimReverseSwap(id: swapId) + app.toast(type: .success, title: "Claim broadcast", description: txid) + await refresh() + } catch { + Logger.error("Manual claim failed for '\(swapId)'", context: error.localizedDescription) + app.toast(type: .error, title: "Claim failed", description: error.localizedDescription) + } + isClaiming = false + } + } +} + +private struct SwapDetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundColor(.gray) + Spacer() + CopyableText(text: value) + } + } +} + +private let epochFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter +}() + +private func formatEpochSeconds(_ seconds: UInt64) -> String { + epochFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(seconds))) +} diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index bdde774eb..8ee232b34 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -34,6 +34,14 @@ struct SavingsConfirmView: View { channels.reduce(0) { $0 + $1.balanceOnCloseSats } } + private var swapState: SavingsSwapState { + transfer.savingsSwapState + } + + private var headlineSats: UInt64 { + swapState.quote?.amountSat ?? totalSats + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__transfer__nav_title")) @@ -46,7 +54,14 @@ struct SavingsConfirmView: View { .padding(.top, 32) .padding(.bottom, 16) - MoneyText(sats: Int(totalSats), size: .display, symbol: true) + MoneyText(sats: Int(headlineSats), size: .display, symbol: true) + + if let quote = swapState.quote { + quoteSection(quote) + } else if let errorMessage = swapState.errorMessage { + BodySText(errorMessage) + .padding(.top, 16) + } if hasMultipleChannels { HStack(spacing: 16) { @@ -66,12 +81,19 @@ struct SavingsConfirmView: View { Spacer() - // Piggybank image - Image("piggybank-right") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity, alignment: .center) + // Flexible middle: the piggybank shrinks when the fees/slider are shown and + // gives way to a spinner while the quote loads. + if swapState.quote == nil, swapState.isLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 32) + } else { + Image("piggybank-right") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 256, maxHeight: 256) + .frame(maxWidth: .infinity, alignment: .center) + } Spacer() @@ -80,8 +102,11 @@ struct SavingsConfirmView: View { title: t("lightning__transfer__swipe"), accentColor: .brandAccent ) { + // Swapping funds out is the default; it only fires once the fee quote is ready. + guard swapState.quote != nil else { return } + do { - // Process transfer to savings action + transfer.savingsTransferMode = .swap transfer.onTransferToSavingsConfirm(channels: channels) try await Task.sleep(nanoseconds: 300_000_000) @@ -96,11 +121,61 @@ struct SavingsConfirmView: View { } } } + + // Fallback: drain a whole channel on-chain by closing it instead of swapping. + CustomButton(title: t("lightning__savings_confirm__close_instead"), variant: .tertiary) { + transfer.savingsTransferMode = .close + transfer.onTransferToSavingsConfirm(channels: channels) + navigation.navigate(.savingsProgress) + } + .padding(.top, 12) } .navigationBarHidden(true) .padding(.horizontal, 16) .bottomSafeAreaPadding() .offlineOverlay(title: t("lightning__transfer__nav_title")) + .task(id: totalSats) { + // Pull the latest node balances so a just-received payment is reflected, then + // present the swap fee before the user commits. Recomputed when the amount changes. + await wallet.syncStateAsync() + guard totalSats > 0 else { return } + await transfer.loadSavingsSwapQuote( + requestedSat: totalSats, + spendableSats: UInt64(max(0, wallet.maxSendLightningSats)) + ) + } + } + + @ViewBuilder + private func quoteSection(_ quote: SavingsSwapQuote) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 16) { + FeeDisplayRow(label: t("lightning__savings_confirm__network_fee"), amount: quote.networkFeeSat) + .frame(maxWidth: .infinity, alignment: .leading) + FeeDisplayRow(label: t("lightning__savings_confirm__service_fee"), amount: quote.swapFeeSat) + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack(alignment: .top, spacing: 16) { + FeeDisplayRow(label: t("lightning__savings_confirm__amount"), amount: quote.amountSat) + .frame(maxWidth: .infinity, alignment: .leading) + FeeDisplayRow(label: t("lightning__savings_confirm__receive"), amount: quote.receiveSat) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Adjust how much to move to savings, bounded to a payable range. + if swapState.maxSat > swapState.minSat { + AmountSlider( + value: Binding( + get: { quote.amountSat }, + set: { transfer.onSwapAmountChange($0) } + ), + minValue: swapState.minSat, + maxValue: swapState.maxSat + ) + .padding(.top, 16) + } + } + .padding(.top, 24) } } @@ -111,6 +186,7 @@ struct SavingsConfirmView: View { .environmentObject(AppViewModel()) .environmentObject(CurrencyViewModel()) .environmentObject(TransferViewModel()) + .environmentObject(NavigationViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index 8abeaa670..bc5afce1c 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -123,6 +123,7 @@ struct SavingsProgressView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var transfer: TransferViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel @State private var progressState: SavingsProgressState = .inProgress var body: some View { @@ -131,42 +132,11 @@ struct SavingsProgressView: View { // Disable screen timeout while this view is active UIApplication.shared.isIdleTimerDisabled = true - do { - try await Task.sleep(nanoseconds: 2_000_000_000) - - let channelsFailedToCoopClose = try await transfer.closeSelectedChannels() - - if channelsFailedToCoopClose.isEmpty { - // Re-enable screen timeout when we're done - UIApplication.shared.isIdleTimerDisabled = false - - withAnimation { - progressState = .success - } - } else { - // Check if any channels can be retried (filter out trusted peers) - let (_, nonTrustedChannels) = LightningService.shared.separateTrustedChannels(channelsFailedToCoopClose) - - if nonTrustedChannels.isEmpty { - // All channels are trusted peers - show error and navigate back - UIApplication.shared.isIdleTimerDisabled = false - app.toast( - type: .error, - title: t("lightning__close_error"), - description: t("lightning__close_error_msg") - ) - navigation.reset() - } else { - withAnimation { - progressState = .failed - } - - // Start retrying the cooperative close for non-trusted channels - transfer.startCoopCloseRetries(channels: nonTrustedChannels) - } - } - } catch { - app.toast(error) + switch transfer.savingsTransferMode { + case .swap: + await runSavingsSwap() + case .close: + await runChannelClose() } } .onDisappear { @@ -184,6 +154,69 @@ struct SavingsProgressView: View { } } } + + /// Swaps spending funds out to on-chain savings. A pending claim is treated as success + /// because the updates stream auto-claims it in the background. + private func runSavingsSwap() async { + let result = await transfer.executeSavingsSwap() + UIApplication.shared.isIdleTimerDisabled = false + + switch result { + case .success, .pending: + await wallet.syncStateAsync() + withAnimation { + progressState = .success + } + case let .failure(message): + app.toast( + type: .error, + title: t("common__error"), + description: message + ) + navigation.reset() + } + } + + /// Legacy path: cooperatively close the selected channel(s), retrying on failure. + private func runChannelClose() async { + do { + try await Task.sleep(nanoseconds: 2_000_000_000) + + let channelsFailedToCoopClose = try await transfer.closeSelectedChannels() + + if channelsFailedToCoopClose.isEmpty { + // Re-enable screen timeout when we're done + UIApplication.shared.isIdleTimerDisabled = false + + withAnimation { + progressState = .success + } + } else { + // Check if any channels can be retried (filter out trusted peers) + let (_, nonTrustedChannels) = LightningService.shared.separateTrustedChannels(channelsFailedToCoopClose) + + if nonTrustedChannels.isEmpty { + // All channels are trusted peers - show error and navigate back + UIApplication.shared.isIdleTimerDisabled = false + app.toast( + type: .error, + title: t("lightning__close_error"), + description: t("lightning__close_error_msg") + ) + navigation.reset() + } else { + withAnimation { + progressState = .failed + } + + // Start retrying the cooperative close for non-trusted channels + transfer.startCoopCloseRetries(channels: nonTrustedChannels) + } + } + } catch { + app.toast(error) + } + } } #Preview("In Progress") { diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift new file mode 100644 index 000000000..b581ce0bd --- /dev/null +++ b/BitkitTests/SavingsSwapTests.swift @@ -0,0 +1,81 @@ +import BitkitCore +import XCTest + +@testable import Bitkit + +final class SavingsSwapTests: XCTestCase { + // MARK: - Quote math + + func testQuoteBuildsFeeBreakdown() { + let quote = SavingsSwapQuote.build(amountSat: 148_500, limits: limits()) + + XCTAssertEqual(quote.amountSat, 148_500) + XCTAssertEqual(quote.networkFeeSat, 300) + // 0.5% of 148_500 = 742.5, rounded to 743 + XCTAssertEqual(quote.swapFeeSat, 743) + XCTAssertEqual(quote.receiveSat, 148_500 - 743 - 300) + } + + func testQuoteClampsReceiveAtZeroWhenFeesExceedAmount() { + let quote = SavingsSwapQuote.build(amountSat: 100, limits: limits(minerFeesSat: 500)) + + XCTAssertEqual(quote.receiveSat, 0) + } + + // MARK: - Claim gating + + func testIsClaimableOnlyWhileReverseLockupIsOnchainAndUnclaimed() { + XCTAssertTrue(swap(status: .transactionMempool).isClaimable) + XCTAssertTrue(swap(status: .transactionConfirmed).isClaimable) + XCTAssertTrue(swap(status: .transactionClaimPending).isClaimable) + + XCTAssertFalse(swap(status: .swapCreated).isClaimable) + XCTAssertFalse(swap(status: .swapExpired).isClaimable) + XCTAssertFalse(swap(status: .transactionFailed).isClaimable) + XCTAssertFalse(swap(status: .transactionRefunded).isClaimable) + XCTAssertFalse(swap(status: .invoiceSettled).isClaimable) + XCTAssertFalse(swap(status: .transactionConfirmed, claimTxId: "txid1").isClaimable) + XCTAssertFalse(swap(swapType: .submarine, status: .transactionConfirmed).isClaimable) + } + + // MARK: - Fixtures + + private func limits( + minimalSat: UInt64 = 25000, + maximalSat: UInt64 = 1_000_000, + feePercentage: Double = 0.5, + minerFeesSat: UInt64 = 300 + ) -> BoltzPairInfo { + BoltzPairInfo( + hash: "hash", + rate: 1.0, + minimalSat: minimalSat, + maximalSat: maximalSat, + feePercentage: feePercentage, + minerFeesSat: minerFeesSat + ) + } + + private func swap( + swapType: BoltzSwapType = .reverse, + status: BoltzSwapStatus = .transactionConfirmed, + claimTxId: String? = nil + ) -> BoltzSwap { + BoltzSwap( + id: "swap1", + swapType: swapType, + status: status, + network: .regtest, + swapIndex: 0, + amountSat: 100_000, + onchainAmountSat: 99000, + invoice: nil, + lockupAddress: nil, + onchainAddress: nil, + timeoutBlockHeight: 800, + createdAt: 0, + claimTxId: claimTxId, + refundTxId: nil + ) + } +} diff --git a/changelog.d/next/632.added.md b/changelog.d/next/632.added.md new file mode 100644 index 000000000..006f64a1f --- /dev/null +++ b/changelog.d/next/632.added.md @@ -0,0 +1 @@ +Transfer to savings can now swap Lightning funds on-chain through Boltz, keeping channels open instead of closing them. From b1098344456eb13568b5fd6a4e1d6f11eec37105 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 20 Jul 2026 12:28:07 -0400 Subject: [PATCH 02/12] feat: align savings swap with android boltz refinements Bring the iOS savings-swap flow to parity with bitkit-android #1081: - Bump bitkit-core 0.5.1 -> 0.5.2 and pass acceptZeroConf so reverse swaps claim once the lockup hits the mempool - Make BoltzSwap.isClaimable permissive so the manual-claim recovery tool stays reachable when the updates stream stalls - Add a settling progress state instead of showing success while the on-chain claim is still landing - Move the updates-stream orchestration into WalletViewModel with retry/backoff and ensure it is running before starting a swap - Shorten the claim wait to 30s and let the swipe close the channel when the amount is below the swap minimum --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/AppScene.swift | 32 +-------- .../Localization/en.lproj/Localizable.strings | 2 + Bitkit/Services/BoltzService.swift | 35 +++++++--- Bitkit/ViewModels/TransferViewModel.swift | 8 ++- Bitkit/ViewModels/WalletViewModel.swift | 68 ++++++++++++++++++- .../Views/Transfer/SavingsConfirmView.swift | 24 ++++--- .../Views/Transfer/SavingsProgressView.swift | 34 ++++++++-- BitkitTests/SavingsSwapTests.swift | 14 +++- 10 files changed, 162 insertions(+), 61 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index b1f0b4c2c..8a82efc1f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1201,7 +1201,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.5.1; + version = 0.5.2; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c40e90715..97ade53c4 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "158caf08177c7cbaf8dd3e8d23830ee6f51ec8a8", - "version" : "0.5.1" + "revision" : "fdc234141d718c765f0bd1fdaf41be970f13f7b1", + "version" : "0.5.2" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 06e224999..048bcec57 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -42,8 +42,6 @@ struct AppScene: View { @State private var walletInitShouldFinish = false @State private var isPinVerified: Bool = false @State private var showRecoveryScreen = false - /// Long-lived collector that refreshes balances when a Boltz swap claim lands on-chain. - @State private var swapEventsTask: Task? /// Check if there's a critical update available private var hasCriticalUpdate: Bool { @@ -468,8 +466,8 @@ struct AppScene: View { await blocktank.startWatchingPendingOrders(transferViewModel: transfer) // Open the swap updates stream so any pending LN -> onchain swaps resume - // and auto-claim once their lockup confirms. - await startSwapUpdates() + // and auto-claim once their lockup confirms. Retries until the stream starts. + wallet.ensureSwapUpdatesRunning() // Schedule full backup after wallet create/restore to prevent epoch dates in backup status await BackupService.shared.scheduleFullBackup() @@ -486,32 +484,6 @@ struct AppScene: View { } } - /// Start the Boltz updates stream using the wallet's current fee rate for auto-claims, - /// and refresh balances whenever a swap lands on-chain so savings reflect it without - /// a manual sync. - private func startSwapUpdates() async { - if swapEventsTask == nil { - swapEventsTask = Task { - for await event in BoltzService.shared.events() { - if case let .claimed(swapId, _) = event { - Logger.info("Savings swap claimed: \(swapId)", context: "AppScene") - await wallet.syncStateAsync() - } - } - } - } - - do { - var feeRate: Double? - if let rates = await feeEstimatesManager.getEstimates() { - feeRate = Double(SettingsViewModel.shared.defaultTransactionSpeed.getFeeRate(from: rates)) - } - try await BoltzService.shared.startUpdates(feeRateSatPerVb: feeRate) - } catch { - Logger.error(error, context: "Failed to start swap updates") - } - } - /// Handle orphaned keychain entries from previous app installs. /// If the installation marker doesn't exist but keychain has data, the app was reinstalled /// and the keychain data is orphaned (corresponding wallet data was deleted with the app). diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 2ed69f7ce..14498e7ab 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -231,6 +231,8 @@ "lightning__savings_advanced__total" = "Total selected"; "lightning__savings_progress__title" = "Funds\nin transfer"; "lightning__savings_progress__text" = "Please wait, your funds transfer is in progress. This should take ±10 seconds."; +"lightning__savings_settling__title" = "Transfer\non the way"; +"lightning__savings_settling__text" = "Your transfer has been initiated and is settling on-chain. Your savings balance will update automatically once it completes."; "lightning__savings_interrupted__nav_title" = "Transfer\ninterrupted"; "lightning__savings_interrupted__title" = "Keep Bitkit\nopen"; "lightning__savings_interrupted__text" = "Funds were not transferred yet. Bitkit will try to initiate the transfer during the next 30 minutes. Please keep your app open."; diff --git a/Bitkit/Services/BoltzService.swift b/Bitkit/Services/BoltzService.swift index ccdc55971..e7164543b 100644 --- a/Bitkit/Services/BoltzService.swift +++ b/Bitkit/Services/BoltzService.swift @@ -155,9 +155,10 @@ class BoltzService { // MARK: - Updates stream /// Open the Boltz updates WebSocket, subscribe all pending swaps and auto-claim - /// confirmed reverse swaps. `feeRateSatPerVb` is the rate used for those - /// auto-claims (Bitkit owns fee estimation). Replaces any running stream. - func startUpdates(feeRateSatPerVb: Double?) async throws { + /// reverse swaps. `feeRateSatPerVb` is the rate used for those auto-claims (Bitkit + /// owns fee estimation). `acceptZeroConf` claims a reverse swap as soon as its lockup + /// hits the mempool instead of waiting for its confirmation. Replaces any running stream. + func startUpdates(feeRateSatPerVb: Double?, acceptZeroConf: Bool = true) async throws { let (mnemonic, passphrase) = try credentials() try await ServiceQueue.background(.core) { try await boltzStartSwapUpdates( @@ -165,7 +166,8 @@ class BoltzService { listener: self.listener, mnemonic: mnemonic, bip39Passphrase: passphrase, - feeRateSatPerVb: feeRateSatPerVb + feeRateSatPerVb: feeRateSatPerVb, + acceptZeroConf: acceptZeroConf ) } Logger.info("Started Boltz updates stream on \(Self.boltzNetwork)", context: "BoltzService") @@ -214,16 +216,29 @@ private final class EventForwarder: BoltzEventListener { } extension BoltzSwap { - /// Whether a manual claim can succeed for this swap: reverse direction, lockup funds - /// visible on-chain (mempool or confirmed), and no claim broadcast yet. Freshly created, - /// expired, failed, and refunded swaps have nothing to claim. + /// Whether a manual claim is worth attempting: a reverse swap with no claim broadcast yet + /// that has not reached a terminal state. + /// + /// Deliberately permissive about `status`. The persisted status only advances while the + /// updates stream is delivering events, so gating on it hides the recovery tool in exactly + /// the case it exists for: a stalled stream leaves the swap at `.swapCreated` locally even + /// after Boltz has locked up on-chain and the funds are claimable. The chain, not the cached + /// status, is the source of truth here, so offer the claim and let `boltzClaimReverseSwap` + /// decide; when there is nothing to claim it fails harmlessly and the error surfaces. var isClaimable: Bool { guard swapType == .reverse, claimTxId == nil else { return false } switch status { - case .transactionMempool, .transactionConfirmed, .transactionClaimPending: - return true - default: + case .invoiceExpired, + .invoiceFailedToPay, + .invoiceSettled, + .swapExpired, + .transactionClaimed, + .transactionFailed, + .transactionLockupFailed, + .transactionRefunded: return false + default: + return true } } } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index f00a8727a..970b9a9e4 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -154,7 +154,7 @@ class TransferViewModel: ObservableObject { /// Cached swap limits so the slider can re-price locally without hitting the network. private var reverseSwapLimits: BoltzPairInfo? /// How long the confirm/progress flow waits for the on-chain claim before backgrounding it. - private let swapClaimTimeout: TimeInterval = 60 + private let swapClaimTimeout: TimeInterval = 30 /// Minimum sats held back from a swap to cover Lightning routing fees. private static let minLnRoutingFeeReserveSats: UInt64 = 10 @@ -1185,8 +1185,10 @@ class TransferViewModel: ObservableObject { let minSat = limits.minimalSat guard maxSat >= minSat, maxSat > 0 else { + // Below the swap minimum: revert to the pre-swap view where the swipe closes + // the channel instead. No error text or extra close action is shown. pendingSwapAmountSat = 0 - savingsSwapState = SavingsSwapState(errorMessage: t("lightning__savings_confirm__amount_too_low")) + savingsSwapState = SavingsSwapState(amountTooLow: true) return } @@ -1297,6 +1299,8 @@ struct SavingsSwapState { var minSat: UInt64 = 0 var maxSat: UInt64 = 0 var errorMessage: String? + /// Swap amount is below the swap minimum; the screen falls back to closing the channel. + var amountTooLow = false } enum SavingsSwapResult: Equatable { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 66585cb33..8d476d91d 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -414,13 +414,79 @@ class WalletViewModel: ObservableObject { func stopLightningNode(clearEventCallback: Bool = false) async throws { nodeLifecycleState = .stopping // Stop the swap updates stream with the node; it restarts on the next wallet start. - await BoltzService.shared.stopUpdates() + await stopSwapUpdates() try await lightningService.stop(clearEventCallback: clearEventCallback) nodeLifecycleState = .stopped probeOutcomes.removeAll() syncState() } + // MARK: - Boltz swap updates stream + + /// Base backoff between swap updates stream attempts; scales linearly per attempt. + private static let swapUpdatesRetryDelay: TimeInterval = 5 + /// Upper bound for the backoff between swap updates stream attempts. + private static let swapUpdatesRetryCap: TimeInterval = 60 + + private var swapUpdatesTask: Task? + private var swapEventsTask: Task? + private var swapUpdatesRunning = false + + /// Ensure the swap updates stream is running so pending LN -> onchain swaps are tracked and + /// auto-claimed. A live stream is left untouched: restarting it would abort bitkit-core's + /// background tasks and could race an in-flight claim. Safe to call repeatedly. + func ensureSwapUpdatesRunning() { + collectSwapEventsOnce() + guard !swapUpdatesRunning, swapUpdatesTask == nil else { return } + swapUpdatesTask = Task { [weak self] in + await self?.startSwapUpdatesWithRetry() + } + } + + /// Open the swap updates stream so any pending LN -> onchain swaps resume and auto-claim. + /// Uses the wallet's current fee rate for the claim tx. Retries until started: without the + /// stream a paid swap has nothing to broadcast its claim. Once started, bitkit-core keeps + /// the WebSocket alive with its own reconnect loop. + private func startSwapUpdatesWithRetry() async { + var attempt = 0 + while !Task.isCancelled { + do { + var feeRate: Double? + if let rates = await feeEstimatesManager.getEstimates() { + feeRate = Double(SettingsViewModel.shared.defaultTransactionSpeed.getFeeRate(from: rates)) + } + try await BoltzService.shared.startUpdates(feeRateSatPerVb: feeRate, acceptZeroConf: true) + swapUpdatesRunning = true + return + } catch { + attempt += 1 + Logger.warn("Failed to start swap updates, attempt \(attempt)", context: "WalletViewModel") + let delay = min(Self.swapUpdatesRetryDelay * Double(attempt), Self.swapUpdatesRetryCap) + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + } + + /// Refresh balances when a swap lands on-chain so savings reflect it without a manual sync. + private func collectSwapEventsOnce() { + guard swapEventsTask == nil else { return } + swapEventsTask = Task { [weak self] in + for await event in BoltzService.shared.events() { + if case let .claimed(swapId, _) = event { + Logger.info("Savings swap claimed: \(swapId)", context: "WalletViewModel") + await self?.syncStateAsync() + } + } + } + } + + private func stopSwapUpdates() async { + swapUpdatesTask?.cancel() + swapUpdatesTask = nil + swapUpdatesRunning = false + await BoltzService.shared.stopUpdates() + } + func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { let finalExpirySecs = expirySecs ?? 60 * 60 * 24 let invoice = try await lightningService.receive(amountSats: amountSats, description: note, expirySecs: finalExpirySecs) diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index 8ee232b34..3b3dbb010 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -103,10 +103,16 @@ struct SavingsConfirmView: View { accentColor: .brandAccent ) { // Swapping funds out is the default; it only fires once the fee quote is ready. - guard swapState.quote != nil else { return } + // Below the swap minimum we revert to the pre-swap behaviour: the swipe closes + // the channel and the extra "close instead" action is hidden. + if swapState.amountTooLow { + transfer.savingsTransferMode = .close + } else { + guard swapState.quote != nil else { return } + transfer.savingsTransferMode = .swap + } do { - transfer.savingsTransferMode = .swap transfer.onTransferToSavingsConfirm(channels: channels) try await Task.sleep(nanoseconds: 300_000_000) @@ -122,13 +128,15 @@ struct SavingsConfirmView: View { } } - // Fallback: drain a whole channel on-chain by closing it instead of swapping. - CustomButton(title: t("lightning__savings_confirm__close_instead"), variant: .tertiary) { - transfer.savingsTransferMode = .close - transfer.onTransferToSavingsConfirm(channels: channels) - navigation.navigate(.savingsProgress) + if !swapState.amountTooLow { + // Fallback: drain a whole channel on-chain by closing it instead of swapping. + CustomButton(title: t("lightning__savings_confirm__close_instead"), variant: .tertiary) { + transfer.savingsTransferMode = .close + transfer.onTransferToSavingsConfirm(channels: channels) + navigation.navigate(.savingsProgress) + } + .padding(.top, 12) } - .padding(.top, 12) } .navigationBarHidden(true) .padding(.horizontal, 16) diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index bc5afce1c..f4c0b7f15 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -2,6 +2,9 @@ import SwiftUI enum SavingsProgressState { case inProgress + /// Swap hold invoice is paid but the on-chain claim has not landed within the wait window. + /// The claim auto-broadcasts once the lockup appears, so the transfer is committed and settling. + case settling case success case failed } @@ -18,7 +21,7 @@ struct SavingsProgressContentView: View { var navTitle: String { switch progressState { - case .inProgress: return t("lightning__transfer__nav_title") + case .inProgress, .settling: return t("lightning__transfer__nav_title") case .failed: return t("lightning__savings_interrupted__nav_title") case .success: return t("lightning__transfer__nav_title") } @@ -27,6 +30,7 @@ struct SavingsProgressContentView: View { var title: String { switch progressState { case .inProgress: return t("lightning__savings_progress__title") + case .settling: return t("lightning__savings_settling__title") case .failed: return t("lightning__savings_interrupted__title") case .success: return t("lightning__transfer_success__title_savings") } @@ -35,6 +39,7 @@ struct SavingsProgressContentView: View { var text: String { switch progressState { case .inProgress: return t("lightning__savings_progress__text") + case .settling: return t("lightning__savings_settling__text") case .failed: return t("lightning__savings_interrupted__text") case .success: return t("lightning__transfer_success__text_savings") } @@ -52,7 +57,7 @@ struct SavingsProgressContentView: View { Spacer() - if progressState == .inProgress { + if progressState == .inProgress || progressState == .settling { ZStack(alignment: .center) { // Outer ellipse Image("ellipse-outer-brand") @@ -155,18 +160,28 @@ struct SavingsProgressView: View { } } - /// Swaps spending funds out to on-chain savings. A pending claim is treated as success - /// because the updates stream auto-claims it in the background. + /// Swaps spending funds out to on-chain savings. A pending claim is shown as "settling" + /// rather than success: the hold invoice is paid and the updates stream auto-claims it in + /// the background, so the transfer is committed but not yet landed on-chain. private func runSavingsSwap() async { + // Ensure the updates stream is running so the new swap is tracked and auto-claimed + // once its lockup appears, even if the launch-time start had not yet succeeded. + wallet.ensureSwapUpdatesRunning() + let result = await transfer.executeSavingsSwap() UIApplication.shared.isIdleTimerDisabled = false switch result { - case .success, .pending: + case .success: await wallet.syncStateAsync() withAnimation { progressState = .success } + case .pending: + await wallet.syncStateAsync() + withAnimation { + progressState = .settling + } case let .failure(message): app.toast( type: .error, @@ -228,6 +243,15 @@ struct SavingsProgressView: View { .preferredColorScheme(.dark) } +#Preview("Settling") { + NavigationStack { + SavingsProgressContentView(progressState: .settling) + .environmentObject(AppViewModel()) + .environmentObject(TransferViewModel()) + } + .preferredColorScheme(.dark) +} + #Preview("Success") { NavigationStack { SavingsProgressContentView(progressState: .success) diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift index b581ce0bd..3b5a722f6 100644 --- a/BitkitTests/SavingsSwapTests.swift +++ b/BitkitTests/SavingsSwapTests.swift @@ -24,16 +24,26 @@ final class SavingsSwapTests: XCTestCase { // MARK: - Claim gating - func testIsClaimableOnlyWhileReverseLockupIsOnchainAndUnclaimed() { + func testIsClaimableWhileReverseSwapIsUnclaimedAndNotTerminal() { XCTAssertTrue(swap(status: .transactionMempool).isClaimable) XCTAssertTrue(swap(status: .transactionConfirmed).isClaimable) XCTAssertTrue(swap(status: .transactionClaimPending).isClaimable) + XCTAssertTrue(swap(status: .invoicePending).isClaimable) - XCTAssertFalse(swap(status: .swapCreated).isClaimable) + // A stalled updates stream leaves the swap at swapCreated locally even once Boltz has + // locked up on-chain, so the claim must stay reachable: this is the recovery case. + XCTAssertTrue(swap(status: .swapCreated).isClaimable) + } + + func testIsClaimableFalseForTerminalAlreadyClaimedAndSubmarineSwaps() { XCTAssertFalse(swap(status: .swapExpired).isClaimable) XCTAssertFalse(swap(status: .transactionFailed).isClaimable) + XCTAssertFalse(swap(status: .transactionLockupFailed).isClaimable) XCTAssertFalse(swap(status: .transactionRefunded).isClaimable) + XCTAssertFalse(swap(status: .transactionClaimed).isClaimable) XCTAssertFalse(swap(status: .invoiceSettled).isClaimable) + XCTAssertFalse(swap(status: .invoiceExpired).isClaimable) + XCTAssertFalse(swap(status: .invoiceFailedToPay).isClaimable) XCTAssertFalse(swap(status: .transactionConfirmed, claimTxId: "txid1").isClaimable) XCTAssertFalse(swap(swapType: .submarine, status: .transactionConfirmed).isClaimable) } From 2f0a6fcc50c0c27c6f47d28f70565fabbbe97780 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 20 Jul 2026 13:50:19 -0400 Subject: [PATCH 03/12] fix: do not gate savings swap claim timeout behind the payment Paying a Boltz hold invoice only settles once the swap is claimed on-chain, so awaiting the payment before starting the claim timeout could leave the progress screen spinning when a lockup is delayed. Run the hold-invoice payment in a background task and drive the outcome off the bounded claim wait, which now runs concurrently. A timeout means the transfer is settling in the background; a payment that fails to initiate is still surfaced. --- Bitkit/ViewModels/TransferViewModel.swift | 27 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 970b9a9e4..05684354c 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -1226,15 +1226,32 @@ class TransferViewModel: ObservableObject { let swap = try await boltzService.createReverseSwap(amountSat: amount, claimAddress: claimAddress) Logger.info("Created savings transfer swap \(swap.id)", context: "TransferViewModel") - // Subscribe before paying so a claim settling faster than the payment - // call returns cannot be missed (events buffer from stream creation). + // Subscribe before paying so no claim is missed (the events stream buffers from creation). let events = boltzService.events() - // Pay the hold invoice (amount is encoded). It stays pending until Boltz - // locks funds on-chain and we claim them, which is the expected happy path. - _ = try await lightningService.send(bolt11: swap.invoice) + // Pay the hold invoice in the background. A hold invoice only settles once Boltz locks + // up on-chain and we claim, so the payment await must not gate the claim timeout: the + // bounded wait below drives the screen, and this task keeps settling afterwards. We only + // read its result to surface a payment that failed to even initiate. + let payment = Task { () -> Error? in + do { + _ = try await self.lightningService.send(bolt11: swap.invoice) + return nil + } catch { + return error + } + } + // Drive the outcome off the bounded claim wait, which runs concurrently with the payment. + // A timeout means the transfer is committed and settling in the background, not stuck. let result = await awaitSwapClaim(swapId: swap.id, events: events) + + // If the claim did not land and the payment could not be initiated, report the failure + // instead of a false "settling" state. + if case .pending = result, let paymentError = await payment.value { + return .failure(message: paymentError.localizedDescription) + } + await onBalanceRefresh?() return result } catch { From 98fb3ab1c56b9488251ebc00e1ef0cf0d37ba979 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 20 Jul 2026 13:59:53 -0400 Subject: [PATCH 04/12] refactor: simplify savings swap payment wait and document non-blocking send LDK returns a payment id as soon as a bolt11 payment is initiated; it does not block until a hold invoice settles, so awaiting the payment before the bounded claim wait does not gate the claim timeout. Restore the direct structure so a payment that fails to initiate surfaces immediately instead of after the claim timeout, and document the semantics so the ordering is not mistaken for a stuck flow. --- Bitkit/ViewModels/TransferViewModel.swift | 32 +++++++---------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 05684354c..4168c0b2a 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -1226,32 +1226,20 @@ class TransferViewModel: ObservableObject { let swap = try await boltzService.createReverseSwap(amountSat: amount, claimAddress: claimAddress) Logger.info("Created savings transfer swap \(swap.id)", context: "TransferViewModel") - // Subscribe before paying so no claim is missed (the events stream buffers from creation). + // Subscribe before paying so a claim settling faster than the payment call returns is + // not missed (events buffer from stream creation). let events = boltzService.events() - // Pay the hold invoice in the background. A hold invoice only settles once Boltz locks - // up on-chain and we claim, so the payment await must not gate the claim timeout: the - // bounded wait below drives the screen, and this task keeps settling afterwards. We only - // read its result to surface a payment that failed to even initiate. - let payment = Task { () -> Error? in - do { - _ = try await self.lightningService.send(bolt11: swap.invoice) - return nil - } catch { - return error - } - } + // Pay the hold invoice. LDK returns a payment id as soon as the payment is initiated; it + // does not block until the invoice settles (settlement follows the on-chain claim, which + // the updates stream performs), so awaiting it here does not gate the claim timeout below. + // A failure to initiate the payment throws and is surfaced immediately as a failure. + _ = try await lightningService.send(bolt11: swap.invoice) - // Drive the outcome off the bounded claim wait, which runs concurrently with the payment. - // A timeout means the transfer is committed and settling in the background, not stuck. + // Wait (bounded) for the on-chain claim. A timeout is not a failure: the claim is + // auto-broadcast by the updates stream once the lockup appears, so the transfer is + // committed and settling in the background. let result = await awaitSwapClaim(swapId: swap.id, events: events) - - // If the claim did not land and the payment could not be initiated, report the failure - // instead of a false "settling" state. - if case .pending = result, let paymentError = await payment.value { - return .failure(message: paymentError.localizedDescription) - } - await onBalanceRefresh?() return result } catch { From cb326e19922b7a15b66330c0b4d17d9b603eb426 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 22 Jul 2026 09:45:33 -0400 Subject: [PATCH 05/12] fix: fall back to closing a channel when swaps are unavailable Ports the latest bitkit-android #1081 refinement. Closing a channel is now the default and a priced quote is the only thing that upgrades a transfer to a swap, so the swipe always commits the transfer instead of going inert when no quote is available. Gate swaps to mainnet: Boltz's testnet deployment is deprecated and regtest expects a local backend, so on every other network the confirm screen shows no quote, fees, slider, or close-instead action and the swipe closes the channel exactly as it did before swaps existed. Skip the limits fetch entirely where swaps are unsupported, bound it to 15s elsewhere so a hanging Boltz request cannot leave the swipe stuck loading, and drop the quote error and amount-too-low state now that every failure simply leaves the quote nil. Also skip starting the swap updates stream on unsupported networks, and add an optional loading state to the swipe button so it cannot be swiped past a quote that is still being fetched. --- Bitkit/Components/SwipeButton.swift | 16 +++-- Bitkit/Constants/Env.swift | 5 ++ Bitkit/Services/BoltzService.swift | 3 + Bitkit/ViewModels/TransferViewModel.swift | 62 +++++++++++++------ Bitkit/ViewModels/WalletViewModel.swift | 1 + .../Views/Transfer/SavingsConfirmView.swift | 24 +++---- BitkitTests/SavingsSwapTests.swift | 39 ++++++++++++ 7 files changed, 109 insertions(+), 41 deletions(-) diff --git a/Bitkit/Components/SwipeButton.swift b/Bitkit/Components/SwipeButton.swift index 3078cf756..2190ec9bc 100644 --- a/Bitkit/Components/SwipeButton.swift +++ b/Bitkit/Components/SwipeButton.swift @@ -3,12 +3,16 @@ import SwiftUI struct SwipeButton: View { let title: String let accentColor: Color + /// Blocks the swipe and shows the knob spinner while a prerequisite is still loading. + var isLoading = false /// Optional binding for swipe progress (0...1), e.g. to drive animations in the parent. var swipeProgress: Binding? let onComplete: () async throws -> Void @State private var offset: CGFloat = 0 - @State private var isLoading = false + @State private var isSubmitting = false + + private var isBusy: Bool { isLoading || isSubmitting } private let buttonHeight: CGFloat = 76 private let innerPadding: CGFloat = 16 @@ -49,7 +53,7 @@ struct SwipeButton: View { .frame(width: buttonHeight - innerPadding, height: buttonHeight - innerPadding) .overlay( ZStack { - if isLoading { + if isBusy { ActivityIndicator(theme: .dark) } else { Image("arrow-right") @@ -72,21 +76,21 @@ struct SwipeButton: View { .gesture( DragGesture() .onChanged { value in - guard !isLoading else { return } + guard !isBusy else { return } withAnimation(.interactiveSpring()) { offset = value.translation.width swipeProgress?.wrappedValue = max(0, min(1, offset / maxOffset)) } } .onEnded { _ in - guard !isLoading else { return } + guard !isBusy else { return } withAnimation(.spring()) { let threshold = geometry.size.width * 0.7 if offset > threshold { Haptics.play(.medium) offset = geometry.size.width - buttonHeight swipeProgress?.wrappedValue = 1 - isLoading = true + isSubmitting = true Task { @MainActor in do { try await onComplete() @@ -99,7 +103,7 @@ struct SwipeButton: View { // Adjust the delay to match animation duration DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isLoading = false + isSubmitting = false } } } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index c0dfbc4b6..54d037750 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -129,6 +129,11 @@ enum Env { return .bitcoin }() + /// Whether LN -> onchain swaps can reach a Boltz backend. Boltz only serves a public API on + /// mainnet: its testnet deployment is deprecated and regtest resolves to a local backend that + /// no build of ours can reach. Elsewhere the transfer to savings closes a channel instead. + static var isSwapSupported: Bool { network == .bitcoin } + static let ldkLogLevel = LDKNode.LogLevel.trace static let walletSyncIntervalSecs: UInt64 = 10 // TODO: play around with this diff --git a/Bitkit/Services/BoltzService.swift b/Bitkit/Services/BoltzService.swift index e7164543b..47d2ff229 100644 --- a/Bitkit/Services/BoltzService.swift +++ b/Bitkit/Services/BoltzService.swift @@ -180,6 +180,9 @@ class BoltzService { // MARK: - Helpers + /// Whether the configured network has a reachable Boltz backend. See `Env.isSwapSupported`. + var isSwapSupported: Bool { Env.isSwapSupported } + /// The Boltz network matching the app's configured network. static var boltzNetwork: BoltzNetwork { switch Env.network { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 4168c0b2a..42f93886a 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -112,10 +112,10 @@ class TransferViewModel: ObservableObject { @Published var channelsToClose: [ChannelDetails] = [] @Published var transferUnavailable = false - /// How the LN -> onchain "transfer to savings" is executed. Swapping funds out - /// (default) keeps channels open; closing a channel is the fallback the user can - /// pick to drain a whole channel on-chain. - @Published var savingsTransferMode: SavingsTransferMode = .swap + /// How the LN -> onchain "transfer to savings" is executed. Closing a channel is the + /// default because it always works; swapping funds out keeps channels open and is used + /// whenever a priced quote is available. + @Published var savingsTransferMode: SavingsTransferMode = .close @Published var savingsSwapState = SavingsSwapState() /// Hardware-wallet transfer-to-spending state. @@ -155,6 +155,8 @@ class TransferViewModel: ObservableObject { private var reverseSwapLimits: BoltzPairInfo? /// How long the confirm/progress flow waits for the on-chain claim before backgrounding it. private let swapClaimTimeout: TimeInterval = 30 + /// Upper bound for fetching swap limits before the confirm screen gives up on a quote. + private let swapQuoteTimeout: TimeInterval = 15 /// Minimum sats held back from a swap to cover Lightning routing fees. private static let minLnRoutingFeeReserveSats: UInt64 = 10 @@ -963,11 +965,19 @@ class TransferViewModel: ObservableObject { selectedChannelIds = ids } - func onTransferToSavingsConfirm(channels: [ChannelDetails]) { + /// Commit the transfer and pick how it runs. A swap needs a priced quote, so without one + /// (swaps unsupported on this network, Boltz unreachable, or an amount below the swap + /// minimum) the transfer closes a channel exactly as it did before swaps existed. + func onTransferToSavingsConfirm(channels: [ChannelDetails], mode: SavingsTransferMode? = nil) { + savingsTransferMode = mode ?? resolvedSavingsTransferMode selectedChannelIds = [] channelsToClose = channels } + private var resolvedSavingsTransferMode: SavingsTransferMode { + savingsSwapState.quote != nil ? .swap : .close + } + func closeSelectedChannels() async throws -> [ChannelDetails] { return try await closeChannels(channels: channelsToClose) } @@ -1161,17 +1171,16 @@ class TransferViewModel: ObservableObject { /// Fetch swap limits, derive the adjustable amount range, and publish an initial fee quote /// (defaulting to the maximum transferable) so the user sees the cost before confirming. - /// The confirm slider then re-prices locally via `onSwapAmountChange`. Errors surface in state. + /// The confirm slider then re-prices locally via `onSwapAmountChange`. A quote is the only + /// thing that unlocks the swap, so every failure simply leaves it nil and the transfer + /// falls back to closing a channel. func loadSavingsSwapQuote(requestedSat: UInt64, spendableSats: UInt64) async { + guard boltzService.isSwapSupported else { return } savingsSwapState = SavingsSwapState(isLoading: true) - let limits: BoltzPairInfo - do { - limits = try await boltzService.reverseLimits() - } catch { - Logger.error("Failed to load reverse swap limits", context: error.localizedDescription) + guard let limits = await fetchReverseSwapLimits() else { reverseSwapLimits = nil - savingsSwapState = SavingsSwapState(errorMessage: error.localizedDescription) + savingsSwapState = SavingsSwapState() return } reverseSwapLimits = limits @@ -1186,9 +1195,9 @@ class TransferViewModel: ObservableObject { guard maxSat >= minSat, maxSat > 0 else { // Below the swap minimum: revert to the pre-swap view where the swipe closes - // the channel instead. No error text or extra close action is shown. + // the channel instead. No fees, slider, or extra close action are shown. pendingSwapAmountSat = 0 - savingsSwapState = SavingsSwapState(amountTooLow: true) + savingsSwapState = SavingsSwapState() return } @@ -1201,6 +1210,26 @@ class TransferViewModel: ObservableObject { ) } + /// Bounded so a hanging Boltz request cannot leave the confirm swipe stuck loading. + private func fetchReverseSwapLimits() async -> BoltzPairInfo? { + await withTaskGroup(of: BoltzPairInfo?.self) { group in + group.addTask { + do { + return try await self.boltzService.reverseLimits() + } catch { + Logger.error("Failed to load reverse swap limits", context: error.localizedDescription) + return nil + } + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(self.swapQuoteTimeout * 1_000_000_000)) + return nil + } + defer { group.cancelAll() } + return await group.next() ?? nil + } + } + /// Re-price the swap for a slider-selected amount, clamped to the allowed range. func onSwapAmountChange(_ sat: UInt64) { guard let limits = reverseSwapLimits, savingsSwapState.quote != nil, savingsSwapState.maxSat >= savingsSwapState.minSat else { @@ -1275,7 +1304,7 @@ class TransferViewModel: ObservableObject { } } -/// Whether a transfer to savings swaps funds out (default) or closes a channel. +/// Whether a transfer to savings swaps funds out or closes a channel (default). enum SavingsTransferMode { case swap case close @@ -1303,9 +1332,6 @@ struct SavingsSwapState { /// Inclusive adjustable range for the confirm slider (sat). Equal/zero when unavailable. var minSat: UInt64 = 0 var maxSat: UInt64 = 0 - var errorMessage: String? - /// Swap amount is below the swap minimum; the screen falls back to closing the channel. - var amountTooLow = false } enum SavingsSwapResult: Equatable { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 8d476d91d..221e07618 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -436,6 +436,7 @@ class WalletViewModel: ObservableObject { /// auto-claimed. A live stream is left untouched: restarting it would abort bitkit-core's /// background tasks and could race an in-flight claim. Safe to call repeatedly. func ensureSwapUpdatesRunning() { + guard BoltzService.shared.isSwapSupported else { return } collectSwapEventsOnce() guard !swapUpdatesRunning, swapUpdatesTask == nil else { return } swapUpdatesTask = Task { [weak self] in diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index 3b3dbb010..d58171b91 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -58,9 +58,6 @@ struct SavingsConfirmView: View { if let quote = swapState.quote { quoteSection(quote) - } else if let errorMessage = swapState.errorMessage { - BodySText(errorMessage) - .padding(.top, 16) } if hasMultipleChannels { @@ -100,18 +97,12 @@ struct SavingsConfirmView: View { if !hideSwipeButton { SwipeButton( title: t("lightning__transfer__swipe"), - accentColor: .brandAccent + accentColor: .brandAccent, + isLoading: swapState.quote == nil && swapState.isLoading ) { - // Swapping funds out is the default; it only fires once the fee quote is ready. - // Below the swap minimum we revert to the pre-swap behaviour: the swipe closes - // the channel and the extra "close instead" action is hidden. - if swapState.amountTooLow { - transfer.savingsTransferMode = .close - } else { - guard swapState.quote != nil else { return } - transfer.savingsTransferMode = .swap - } - + // The swipe always commits the transfer: it swaps when a quote is ready and + // otherwise falls back to the pre-swap behaviour of closing the channel, so it + // is never inert. do { transfer.onTransferToSavingsConfirm(channels: channels) @@ -128,11 +119,10 @@ struct SavingsConfirmView: View { } } - if !swapState.amountTooLow { + if swapState.quote != nil { // Fallback: drain a whole channel on-chain by closing it instead of swapping. CustomButton(title: t("lightning__savings_confirm__close_instead"), variant: .tertiary) { - transfer.savingsTransferMode = .close - transfer.onTransferToSavingsConfirm(channels: channels) + transfer.onTransferToSavingsConfirm(channels: channels, mode: .close) navigation.navigate(.savingsProgress) } .padding(.top, 12) diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift index 3b5a722f6..8762303a4 100644 --- a/BitkitTests/SavingsSwapTests.swift +++ b/BitkitTests/SavingsSwapTests.swift @@ -22,6 +22,45 @@ final class SavingsSwapTests: XCTestCase { XCTAssertEqual(quote.receiveSat, 0) } + // MARK: - Network support + + func testSwapsAreUnsupportedOffMainnet() { + // Unit tests run on regtest, where Boltz resolves to a local backend no build can reach. + XCTAssertEqual(Env.network, .regtest) + XCTAssertFalse(Env.isSwapSupported) + XCTAssertFalse(BoltzService.shared.isSwapSupported) + } + + // MARK: - Transfer mode + + @MainActor + func testTransferToSavingsFallsBackToCloseWithoutAQuote() async { + let transfer = TransferViewModel() + // Swaps are unsupported here, so no quote can be published and the swipe must still commit. + await transfer.loadSavingsSwapQuote(requestedSat: 100_000, spendableSats: 100_000) + XCTAssertNil(transfer.savingsSwapState.quote) + + transfer.onTransferToSavingsConfirm(channels: []) + + XCTAssertEqual(transfer.savingsTransferMode, .close) + } + + @MainActor + func testTransferToSavingsSwapsWithAQuoteAndClosesWhenTheUserOptsOut() { + let transfer = TransferViewModel() + transfer.savingsSwapState = SavingsSwapState( + quote: SavingsSwapQuote.build(amountSat: 100_000, limits: limits()), + minSat: 25000, + maxSat: 100_000 + ) + + transfer.onTransferToSavingsConfirm(channels: []) + XCTAssertEqual(transfer.savingsTransferMode, .swap) + + transfer.onTransferToSavingsConfirm(channels: [], mode: .close) + XCTAssertEqual(transfer.savingsTransferMode, .close) + } + // MARK: - Claim gating func testIsClaimableWhileReverseSwapIsUnclaimedAndNotTerminal() { From ad93ba38b38894f8bd0dba69c83af39c12f2b23b Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 22 Jul 2026 10:18:01 -0400 Subject: [PATCH 06/12] feat: gate savings swap behind dev flag Ports the bitkit-android #1081 dev gate. The savings swap UI is not final yet, so the flow now stays off until "Enable Savings Swap" is switched on under Dev Settings. With it off the confirm screen fetches no quote and resolves to the channel-close path, and the swap updates stream never starts, so the journey is exactly what shipped before swaps existed. Groups the toggle with the existing swaps list under a new Swaps section in Dev Settings, and drops the changelog fragment now that the flow is not user-facing. --- Bitkit/Services/BoltzService.swift | 9 +++++++++ Bitkit/ViewModels/TransferViewModel.swift | 5 +++-- Bitkit/ViewModels/WalletViewModel.swift | 5 +++-- Bitkit/Views/Settings/DevSettingsView.swift | 19 +++++++++++++++---- BitkitTests/SavingsSwapTests.swift | 20 ++++++++++++++++++++ changelog.d/next/632.added.md | 1 - 6 files changed, 50 insertions(+), 9 deletions(-) delete mode 100644 changelog.d/next/632.added.md diff --git a/Bitkit/Services/BoltzService.swift b/Bitkit/Services/BoltzService.swift index 47d2ff229..a59b88774 100644 --- a/Bitkit/Services/BoltzService.swift +++ b/Bitkit/Services/BoltzService.swift @@ -180,9 +180,18 @@ class BoltzService { // MARK: - Helpers + /// Dev settings key for the savings swap gate. + static let savingsSwapEnabledKey = "savingsSwapEnabled" + /// Whether the configured network has a reachable Boltz backend. See `Env.isSwapSupported`. var isSwapSupported: Bool { Env.isSwapSupported } + /// Whether swaps may run: the network needs a reachable Boltz backend and the savings swap + /// flow must be switched on in dev settings, since its UI is not final yet. + var isSwapEnabled: Bool { + isSwapSupported && UserDefaults.standard.bool(forKey: Self.savingsSwapEnabledKey) + } + /// The Boltz network matching the app's configured network. static var boltzNetwork: BoltzNetwork { switch Env.network { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 42f93886a..1aa1a709d 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -1173,9 +1173,10 @@ class TransferViewModel: ObservableObject { /// (defaulting to the maximum transferable) so the user sees the cost before confirming. /// The confirm slider then re-prices locally via `onSwapAmountChange`. A quote is the only /// thing that unlocks the swap, so every failure simply leaves it nil and the transfer - /// falls back to closing a channel. + /// falls back to closing a channel. Skipped entirely where swaps are unsupported or the + /// flow is switched off in dev settings, see `BoltzService.isSwapEnabled`. func loadSavingsSwapQuote(requestedSat: UInt64, spendableSats: UInt64) async { - guard boltzService.isSwapSupported else { return } + guard boltzService.isSwapEnabled else { return } savingsSwapState = SavingsSwapState(isLoading: true) guard let limits = await fetchReverseSwapLimits() else { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 221e07618..14e2c42cc 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -434,9 +434,10 @@ class WalletViewModel: ObservableObject { /// Ensure the swap updates stream is running so pending LN -> onchain swaps are tracked and /// auto-claimed. A live stream is left untouched: restarting it would abort bitkit-core's - /// background tasks and could race an in-flight claim. Safe to call repeatedly. + /// background tasks and could race an in-flight claim. Safe to call repeatedly. Runs only + /// where swaps are supported and enabled in dev settings, see `BoltzService.isSwapEnabled`. func ensureSwapUpdatesRunning() { - guard BoltzService.shared.isSwapSupported else { return } + guard BoltzService.shared.isSwapEnabled else { return } collectSwapEventsOnce() guard !swapUpdatesRunning, swapUpdatesTask == nil else { return } swapUpdatesTask = Task { [weak self] in diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index a6f52f5b1..9cd81efc9 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -6,6 +6,7 @@ struct DevSettingsView: View { @AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false + @AppStorage(BoltzService.savingsSwapEnabledKey) private var isSavingsSwapEnabled = false @EnvironmentObject var app: AppViewModel @EnvironmentObject var activity: ActivityListViewModel @@ -53,15 +54,25 @@ struct DevSettingsView: View { SettingsRow(title: "Orders") } - NavigationLink(value: Route.swaps) { - SettingsRow(title: "Swaps") - } - NavigationLink(value: Route.trezor) { SettingsRow(title: "Trezor Hardware Wallet") } .accessibilityIdentifier("Trezor") + SettingsSectionHeader("SWAPS") + .padding(.top, 16) + + NavigationLink(value: Route.swaps) { + SettingsRow(title: "Swaps") + } + + SettingsRow( + title: "Enable Savings Swap", + rightIcon: nil, + toggle: $isSavingsSwapEnabled, + testIdentifier: "SavingsSwapToggle" + ) + SettingsSectionHeader("RECOVERY") .padding(.top, 16) diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift index 8762303a4..b4a1629d3 100644 --- a/BitkitTests/SavingsSwapTests.swift +++ b/BitkitTests/SavingsSwapTests.swift @@ -31,6 +31,18 @@ final class SavingsSwapTests: XCTestCase { XCTAssertFalse(BoltzService.shared.isSwapSupported) } + func testSwapsStayDisabledWithoutTheDevFlag() { + withSavingsSwapDevFlag(false) { + XCTAssertFalse(BoltzService.shared.isSwapEnabled) + } + } + + func testDevFlagAloneDoesNotEnableSwapsOffMainnet() { + withSavingsSwapDevFlag(true) { + XCTAssertFalse(BoltzService.shared.isSwapEnabled) + } + } + // MARK: - Transfer mode @MainActor @@ -89,6 +101,14 @@ final class SavingsSwapTests: XCTestCase { // MARK: - Fixtures + private func withSavingsSwapDevFlag(_ enabled: Bool, _ body: () -> Void) { + let defaults = UserDefaults.standard + let previous = defaults.bool(forKey: BoltzService.savingsSwapEnabledKey) + defaults.set(enabled, forKey: BoltzService.savingsSwapEnabledKey) + defer { defaults.set(previous, forKey: BoltzService.savingsSwapEnabledKey) } + body() + } + private func limits( minimalSat: UInt64 = 25000, maximalSat: UInt64 = 1_000_000, diff --git a/changelog.d/next/632.added.md b/changelog.d/next/632.added.md deleted file mode 100644 index 006f64a1f..000000000 --- a/changelog.d/next/632.added.md +++ /dev/null @@ -1 +0,0 @@ -Transfer to savings can now swap Lightning funds on-chain through Boltz, keeping channels open instead of closing them. From e92dfcaea6713ad802fe91e03856d382d4c661f8 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 24 Jul 2026 11:04:01 -0400 Subject: [PATCH 07/12] fix: surface unroutable savings swap payments Ports bitkit-android #1081. Paying the Boltz hold invoice returns as soon as the HTLC is dispatched, and a hold invoice never settles until Boltz claims on-chain, so a payment that fails to route went unnoticed: the swap idled into the claim timeout and reported a settling transfer that never completes. Watch the node's payment-failed event for the paid invoice alongside the on-chain claim so an unroutable payment resolves as a failure instead. The claim, a Boltz error, the routing failure, and the bounded timeout now race, and the payment id is matched against both the event's payment id and hash so a route-not-found failure is caught. Also cap the swap updates stream start retries at 20 attempts instead of retrying forever; the next node start or swap flow re-triggers it. --- Bitkit/ViewModels/TransferViewModel.swift | 133 +++++++++++++----- Bitkit/ViewModels/WalletViewModel.swift | 15 +- .../Views/Transfer/SavingsProgressView.swift | 5 +- 3 files changed, 114 insertions(+), 39 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 1aa1a709d..68b363e2a 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -1241,11 +1241,16 @@ class TransferViewModel: ObservableObject { savingsSwapState.quote = SavingsSwapQuote.build(amountSat: amount, limits: limits) } - /// Execute the LN -> onchain swap: derive a fresh claim address, create the swap, - /// pay the returned hold invoice over Lightning, then wait for the on-chain claim. - /// The claim is auto-broadcast by the updates stream once the lockup confirms, so a - /// timeout here is not a failure; the swap completes in the background. - func executeSavingsSwap() async -> SavingsSwapResult { + /// Execute the LN -> onchain swap: derive a fresh claim address, create the swap, pay the + /// returned hold invoice over Lightning, then wait for whichever resolves first: the on-chain + /// claim, a Boltz error, or a Lightning routing failure on the payment. A timeout is not a + /// failure; the claim is auto-broadcast by the updates stream once the lockup confirms, so the + /// swap completes in the background. `onEvent`/`removeEvent` register a node event listener so + /// an unroutable payment can be observed (see `awaitSwapOutcome`). + func executeSavingsSwap( + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void + ) async -> SavingsSwapResult { let amount = pendingSwapAmountSat guard amount > 0 else { return .failure(message: t("lightning__savings_confirm__amount_too_low")) @@ -1260,16 +1265,18 @@ class TransferViewModel: ObservableObject { // not missed (events buffer from stream creation). let events = boltzService.events() - // Pay the hold invoice. LDK returns a payment id as soon as the payment is initiated; it - // does not block until the invoice settles (settlement follows the on-chain claim, which - // the updates stream performs), so awaiting it here does not gate the claim timeout below. - // A failure to initiate the payment throws and is surfaced immediately as a failure. - _ = try await lightningService.send(bolt11: swap.invoice) - - // Wait (bounded) for the on-chain claim. A timeout is not a failure: the claim is - // auto-broadcast by the updates stream once the lockup appears, so the transfer is - // committed and settling in the background. - let result = await awaitSwapClaim(swapId: swap.id, events: events) + // Pay the hold invoice. `send` returns a payment id as soon as the HTLC is dispatched; + // a hold invoice never settles until Boltz claims on-chain, so this does not block on + // the claim. A failure to dispatch the payment throws and is surfaced immediately. + let paymentId = try await lightningService.send(bolt11: swap.invoice) + + let result = await awaitSwapOutcome( + swapId: swap.id, + paymentId: paymentId, + events: events, + onEvent: onEvent, + removeEvent: removeEvent + ) await onBalanceRefresh?() return result } catch { @@ -1278,30 +1285,88 @@ class TransferViewModel: ObservableObject { } } - private func awaitSwapClaim(swapId: String, events: AsyncStream) async -> SavingsSwapResult { - let waitTask = Task { - for await event in events { - switch event { - case let .claimed(swapId: id, txid: txid) where id == swapId: - return SavingsSwapResult.success(txid: txid) - case let .error(swapId: id, message: message) where id == swapId: - return SavingsSwapResult.failure(message: message) - default: - continue + /// Wait for whichever swap outcome resolves first. `send` returning does not mean the payment + /// routed, and a paid hold invoice never settles until the on-chain claim, so an unroutable + /// payment would otherwise idle into the claim timeout and read as a settling transfer that + /// never completes. Watching the node's payment-failed event alongside the claim surfaces it + /// as a failure instead. A timeout still resolves to `.pending`: the claim settles later. + private func awaitSwapOutcome( + swapId: String, + paymentId: String, + events: AsyncStream, + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void + ) async -> SavingsSwapResult { + let eventId = "savings-swap-\(swapId)" + let paymentFailure = SwapPaymentFailureCapture() + + // LDK events are dispatched on the main actor, so this handler runs there; it records a + // routing failure for the paid hold invoice for the poll below to surface. The returned + // payment id may land in either the event's payment id or its payment hash field. + onEvent(eventId) { event in + guard case let .paymentFailed(eventPaymentId, eventPaymentHash, _) = event else { return } + guard [eventPaymentId, eventPaymentHash].compactMap({ $0 }).contains(paymentId) else { return } + Task { await paymentFailure.markFailed() } + } + + // Capture main-actor state locally so the detached group tasks stay Sendable. + let claimTimeout = swapClaimTimeout + let paymentFailedMessage = t("wallet__toast_payment_failed_description") + + let outcome = await withTaskGroup(of: SavingsSwapResult?.self) { group in + // On-chain claim or a Boltz-side error. + group.addTask { + for await event in events { + switch event { + case let .claimed(swapId: id, txid: txid) where id == swapId: + return .success(txid: txid) + case let .error(swapId: id, message: message) where id == swapId: + return .failure(message: message) + default: + continue + } } + return nil + } + // Lightning routing failure on the paid hold invoice. + group.addTask { + while !Task.isCancelled { + if await paymentFailure.hasFailed { + return .failure(message: paymentFailedMessage) + } + try? await Task.sleep(nanoseconds: 200_000_000) + } + return nil + } + // Bounded wait: a timeout is not a failure, the claim settles in the background. + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(claimTimeout * 1_000_000_000)) + return .pending } - // No terminal event before cancellation; the claim completes in the background. - return SavingsSwapResult.pending - } - let timeoutTask = Task { - try? await Task.sleep(nanoseconds: UInt64(swapClaimTimeout * 1_000_000_000)) - waitTask.cancel() + var result: SavingsSwapResult = .pending + for await value in group { + if let value { + result = value + break + } + } + group.cancelAll() + return result } - let result = await waitTask.value - timeoutTask.cancel() - return result + removeEvent(eventId) + return outcome + } +} + +/// Captures a Lightning routing failure for the savings swap's hold-invoice payment. The node +/// event handler runs on the main actor; this actor lets the racing wait poll for the outcome. +actor SwapPaymentFailureCapture { + private(set) var hasFailed = false + + func markFailed() { + hasFailed = true } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 14e2c42cc..bd14946b4 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -427,6 +427,9 @@ class WalletViewModel: ObservableObject { private static let swapUpdatesRetryDelay: TimeInterval = 5 /// Upper bound for the backoff between swap updates stream attempts. private static let swapUpdatesRetryCap: TimeInterval = 60 + /// Ceiling on swap updates stream start attempts per run (~14 min of backoff). Giving up is + /// safe: the stream is retried on the next node start and when entering a swap flow. + private static let swapUpdatesMaxAttempts = 20 private var swapUpdatesTask: Task? private var swapEventsTask: Task? @@ -446,12 +449,13 @@ class WalletViewModel: ObservableObject { } /// Open the swap updates stream so any pending LN -> onchain swaps resume and auto-claim. - /// Uses the wallet's current fee rate for the claim tx. Retries until started: without the - /// stream a paid swap has nothing to broadcast its claim. Once started, bitkit-core keeps - /// the WebSocket alive with its own reconnect loop. + /// Uses the wallet's current fee rate for the claim tx. Retries up to a ceiling: without the + /// stream a paid swap has nothing to broadcast its claim, so give up only after + /// `swapUpdatesMaxAttempts` and leave the next trigger to retry. Once started, bitkit-core + /// keeps the WebSocket alive with its own reconnect loop. private func startSwapUpdatesWithRetry() async { var attempt = 0 - while !Task.isCancelled { + while !Task.isCancelled, attempt < Self.swapUpdatesMaxAttempts { do { var feeRate: Double? if let rates = await feeEstimatesManager.getEstimates() { @@ -467,6 +471,9 @@ class WalletViewModel: ObservableObject { try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } } + if !Task.isCancelled { + Logger.warn("Gave up starting swap updates after \(attempt) attempts", context: "WalletViewModel") + } } /// Refresh balances when a swap lands on-chain so savings reflect it without a manual sync. diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index f4c0b7f15..7aef3f6d5 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -168,7 +168,10 @@ struct SavingsProgressView: View { // once its lockup appears, even if the launch-time start had not yet succeeded. wallet.ensureSwapUpdatesRunning() - let result = await transfer.executeSavingsSwap() + let result = await transfer.executeSavingsSwap( + onEvent: { id, handler in wallet.addOnEvent(id: id, handler: handler) }, + removeEvent: { id in wallet.removeOnEvent(id: id) } + ) UIApplication.shared.isIdleTimerDisabled = false switch result { From 737918d296a81887bd00e99c62fe8f554fab0312 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 27 Jul 2026 14:05:09 -0400 Subject: [PATCH 08/12] fix: decode xpub locally instead of stranded bitkit-core symbol BitkitCore.serializedExtendedPubkey only exists in bitkit-core v0.4.2, a tag cut from a branch that never merged to bitkit-core master, so no release carries both it and the Boltz module this branch needs. Replace the FFI call with a local base58check decode plus a secp256k1 point check on the trailing compressed key, byte-exact against bitkit-core's own test vectors. New regression tests cover the vectors and rejection cases, including an off-curve key. A TODO marks the revert once bitkit-core lands the symbol on master alongside Boltz. --- Bitkit/Services/WatchOnlyAccountService.swift | 85 +++++++++++++++++-- .../WatchOnlyAccountServiceTests.swift | 60 +++++++++++++ 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 0d13db020..65d10ea25 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -3,6 +3,7 @@ import Combine import CryptoKit import Foundation import LDKNode +import secp256k1 enum WatchOnlyAccountSetupState: String, Codable { case pendingDelivery @@ -904,19 +905,91 @@ enum WatchOnlyAccountClaimCodec { return claim } + /// Decode a BIP32 extended public key into its canonical 78-byte payload. + /// + /// This used to call `BitkitCore.serializedExtendedPubkey`, but that symbol only ever shipped + /// in bitkit-core v0.4.2, a tag cut from a branch that never merged to bitkit-core `master`, + /// so no later release (0.4.3, any 0.5.x, or master) contains it. Depending on it pinned this + /// repo to a dead-end tag and blocked every bitkit-core upgrade. Decoding locally removes that + /// constraint. + /// + /// TODO: revert to `BitkitCore.serializedExtendedPubkey` once bitkit-core lands + /// `dd99b46d "feat: expose serialized extended public keys"` on `master` and cuts a release + /// carrying both it and the Boltz module. Delete this decoder and its helpers at that point; + /// the `testSerializedXpub*` cases pin the behaviour either way. static func serializedXpub(_ xpub: String) throws -> Data { guard xpub.count > 4 else { throw WatchOnlyAccountError.invalidExtendedPublicKey } - do { - let serialized = try BitkitCore.serializedExtendedPubkey(xpub: xpub) - guard serialized.count == serializedXpubLength else { + let payload = try base58CheckDecode(xpub) + guard payload.count == serializedXpubLength else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + // A BIP32 xpub is version(4) ‖ depth(1) ‖ fingerprint(4) ‖ child(4) ‖ chaincode(32) ‖ key(33). + // Reject anything whose key is not a point on the curve, matching rust-bitcoin's Xpub parse. + let compressedKey = payload.suffix(33) + guard isValidCompressedPublicKey(compressedKey) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + return payload + } + + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + + private static func base58CheckDecode(_ string: String) throws -> Data { + let decoded = try base58Decode(string) + guard decoded.count > 4 else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + let payload = decoded.prefix(decoded.count - 4) + let checksum = decoded.suffix(4) + let expected = Data(SHA256.hash(data: Data(SHA256.hash(data: payload)))).prefix(4) + guard checksum.elementsEqual(expected) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + return Data(payload) + } + + private static func base58Decode(_ string: String) throws -> Data { + var bytes: [UInt8] = [0] + for character in string.utf8 { + guard let digit = base58Alphabet.firstIndex(of: character) else { throw WatchOnlyAccountError.invalidExtendedPublicKey } - return serialized - } catch { - throw WatchOnlyAccountError.invalidExtendedPublicKey + + var carry = digit + for index in bytes.indices.reversed() { + carry += 58 * Int(bytes[index]) + bytes[index] = UInt8(carry & 0xFF) + carry >>= 8 + } + while carry > 0 { + bytes.insert(UInt8(carry & 0xFF), at: 0) + carry >>= 8 + } + } + + // Each leading '1' encodes a leading zero byte that the arithmetic above cannot represent. + let leadingZeros = string.utf8.prefix { $0 == base58Alphabet[0] }.count + let significant = bytes.drop { $0 == 0 } + return Data(repeating: 0, count: leadingZeros) + Data(significant) + } + + private static func isValidCompressedPublicKey(_ key: Data) -> Bool { + guard key.count == 33, let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_NONE)) else { + return false + } + defer { secp256k1_context_destroy(context) } + + var parsed = secp256k1_pubkey() + return Array(key).withUnsafeBufferPointer { buffer in + guard let baseAddress = buffer.baseAddress else { return false } + return secp256k1_ec_pubkey_parse(context, &parsed, baseAddress, buffer.count) == 1 } } } diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index 3ab0460be..49ea105f7 100644 --- a/BitkitTests/WatchOnlyAccountServiceTests.swift +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -11,6 +11,16 @@ private let alternateTestXpub = private let testSerializedXpubHex = "043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" +private let mainnetTestXpub = + "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8" + + "YtGqsefD265TMg7usUDFdp6W1EGMcet8" +private let mainnetSerializedXpubHex = + "0488b21e000000000000000000873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508" + + "0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2" +/// Base58check over version(4) ‖ 74 zero bytes: decodes cleanly, but its key is not on the curve. +private let offCurveKeyXpub = + "xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6LBpB85b3D2yc8sfvZU" + + "521AAwdZafEz7mnzBBsz4wKY5e4cp9LB" final class WatchOnlyAccountServiceTests: XCTestCase { func testUnsignedClaimContainsExactAccountMetadata() throws { @@ -37,6 +47,56 @@ final class WatchOnlyAccountServiceTests: XCTestCase { } } + // MARK: - serializedXpub + + /// Vectors below are bitkit-core's own, from `src/modules/onchain/extended_pubkey.rs` @ v0.4.2, + /// so the local decode stays byte-compatible with the FFI it replaces. + func testSerializedXpubMatchesCoreVectorForMainnetXpub() throws { + let serialized = try WatchOnlyAccountClaimCodec.serializedXpub(mainnetTestXpub) + + XCTAssertEqual(serialized.count, WatchOnlyAccountClaimCodec.serializedXpubLength) + XCTAssertEqual(serialized, mainnetSerializedXpubHex.hexaData) + } + + func testSerializedXpubMatchesCoreVectorForTestnetTpub() throws { + let serialized = try WatchOnlyAccountClaimCodec.serializedXpub(testXpub) + + XCTAssertEqual(serialized.count, WatchOnlyAccountClaimCodec.serializedXpubLength) + XCTAssertEqual(serialized, testSerializedXpubHex.hexaData) + } + + func testSerializedXpubRejectsInvalidBase58Character() throws { + // '0' is not in the base58 alphabet. + let invalidXpub = "0" + mainnetTestXpub.dropFirst() + + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(invalidXpub)) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + + func testSerializedXpubRejectsInvalidChecksum() throws { + let invalidXpub = String(mainnetTestXpub.dropLast()) + "1" + + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(invalidXpub)) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + + func testSerializedXpubRejectsWellFormedPayloadWithKeyOffTheCurve() throws { + // Valid base58check over a 78-byte payload whose 33-byte key is all zeros, so it decodes + // cleanly but is not a point on secp256k1. Guards the parse step, not just the checksum. + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub(offCurveKeyXpub)) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + + func testSerializedXpubRejectsPayloadOfTheWrongLength() throws { + // Base58check over 4 bytes: correct checksum, but nowhere near 78 bytes. + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.serializedXpub("kz9795HmHu")) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + func testRestorePreservesAllocatorHighWaterMarkWhileClearingUnrestoredReservation() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) From 27d7da6567ae1093647103ebe9b1923f3ccc9139 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 28 Jul 2026 10:03:28 -0400 Subject: [PATCH 09/12] fix: harden savings swap flow from review findings Run at most one swap per confirm commit: the execution now lives on the view model as a memoized run, so re-entering the progress screen joins the in-flight swap instead of creating and paying a second one. Free the swap updates task slot when the retry loop gives up so a later trigger can start the stream again. Mark the quote as loading before the confirm screen's balance sync so the swipe cannot briefly land in the channel-close fallback, and drop a superseded quote load's state writes once its task is cancelled. Surface the node's payment failure reason in the failure message, mirroring Android's mapping, with new English strings for the three reasons that had none. Replace the 200 ms failure poll with a continuation resumed by the event handler. --- .../PaymentFailureReason+UserMessage.swift | 21 ++++ .../Localization/en.lproj/Localizable.strings | 3 + Bitkit/ViewModels/TransferViewModel.swift | 101 +++++++++++++++--- Bitkit/ViewModels/WalletViewModel.swift | 3 + .../Views/Transfer/SavingsConfirmView.swift | 4 +- BitkitTests/SavingsSwapTests.swift | 83 ++++++++++++++ 6 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 Bitkit/Extensions/PaymentFailureReason+UserMessage.swift diff --git a/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift b/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift new file mode 100644 index 000000000..6ebf71829 --- /dev/null +++ b/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift @@ -0,0 +1,21 @@ +import LDKNode + +extension PaymentFailureReason { + /// User-facing message for a failed payment, mirroring Android's + /// `PaymentFailureReason.toUserMessage`; reasons without a dedicated string fall back to + /// the generic payment-failed description. + static func userMessage(for reason: PaymentFailureReason?) -> String { + switch reason { + case .recipientRejected: + return t("wallet__toast_payment_failed_recipient_rejected") + case .retriesExhausted: + return t("wallet__toast_payment_failed_retries_exhausted") + case .routeNotFound: + return t("wallet__toast_payment_failed_route_not_found") + case .paymentExpired: + return t("wallet__toast_payment_failed_timeout") + default: + return t("wallet__toast_payment_failed_description") + } + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 6c8d0b2eb..5e59eae0b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1305,6 +1305,9 @@ "wallet__toast_payment_failed_title" = "Payment Failed"; "wallet__toast_payment_failed_description" = "Your instant payment failed. Please try again."; "wallet__toast_payment_failed_timeout" = "Payment timed out. Please try again."; +"wallet__toast_payment_failed_recipient_rejected" = "The recipient rejected this payment. Try a different amount."; +"wallet__toast_payment_failed_retries_exhausted" = "Could not find a route with sufficient liquidity. Try a smaller amount or wait and try again."; +"wallet__toast_payment_failed_route_not_found" = "Could not find a payment path to the recipient."; "wallet__toast_received_transaction_replaced_title" = "Received Transaction Replaced"; "wallet__toast_received_transaction_replaced_description" = "Your received transaction was replaced by a fee bump"; "wallet__toast_transaction_replaced_title" = "Transaction Replaced"; diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 68b363e2a..3384af7de 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -149,6 +149,10 @@ class TransferViewModel: ObservableObject { private var coopCloseRetryTask: Task? private let boltzService: BoltzService = .shared + /// The in-flight or completed swap execution for the current transfer commit. Held here so + /// SwiftUI cancelling and re-running the progress screen's `.task` neither cancels the swap + /// nor creates and pays a second one: re-entry awaits the same run and its memoized result. + private var savingsSwapRun: Task? /// The amount (sat) that will actually be swapped out; adjustable via the confirm slider. private var pendingSwapAmountSat: UInt64 = 0 /// Cached swap limits so the slider can re-price locally without hitting the network. @@ -972,6 +976,8 @@ class TransferViewModel: ObservableObject { savingsTransferMode = mode ?? resolvedSavingsTransferMode selectedChannelIds = [] channelsToClose = channels + // Each swipe commit is a new transfer; the previous run's memo must not satisfy it. + savingsSwapRun = nil } private var resolvedSavingsTransferMode: SavingsTransferMode { @@ -1175,11 +1181,28 @@ class TransferViewModel: ObservableObject { /// thing that unlocks the swap, so every failure simply leaves it nil and the transfer /// falls back to closing a channel. Skipped entirely where swaps are unsupported or the /// flow is switched off in dev settings, see `BoltzService.isSwapEnabled`. + /// Show the quote's loading state ahead of `loadSavingsSwapQuote`, covering work that runs + /// before the fetch (the confirm screen's balance sync) so the swipe cannot briefly land in + /// the channel-close fallback while a quote is still on its way. + func beginSavingsSwapQuoteLoad() { + guard boltzService.isSwapEnabled else { return } + savingsSwapState = SavingsSwapState(isLoading: true) + } + func loadSavingsSwapQuote(requestedSat: UInt64, spendableSats: UInt64) async { guard boltzService.isSwapEnabled else { return } + guard requestedSat > 0 else { + savingsSwapState = SavingsSwapState() + return + } savingsSwapState = SavingsSwapState(isLoading: true) - guard let limits = await fetchReverseSwapLimits() else { + let fetchedLimits = await fetchReverseSwapLimits() + // The confirm screen restarts this whenever the amount changes; a superseded invocation + // must not clobber the state its replacement is about to publish. + guard !Task.isCancelled else { return } + + guard let limits = fetchedLimits else { reverseSwapLimits = nil savingsSwapState = SavingsSwapState() return @@ -1247,9 +1270,25 @@ class TransferViewModel: ObservableObject { /// failure; the claim is auto-broadcast by the updates stream once the lockup confirms, so the /// swap completes in the background. `onEvent`/`removeEvent` register a node event listener so /// an unroutable payment can be observed (see `awaitSwapOutcome`). + /// + /// At most one swap runs per confirm commit: the run is owned by the view model, so the + /// progress screen's `.task` being cancelled and re-entered joins the same run instead of + /// creating and paying a second swap. func executeSavingsSwap( onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, removeEvent: @escaping (String) -> Void + ) async -> SavingsSwapResult { + if let run = savingsSwapRun { + return await run.value + } + let run = Task { await performSavingsSwap(onEvent: onEvent, removeEvent: removeEvent) } + savingsSwapRun = run + return await run.value + } + + private func performSavingsSwap( + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void ) async -> SavingsSwapResult { let amount = pendingSwapAmountSat guard amount > 0 else { @@ -1301,17 +1340,16 @@ class TransferViewModel: ObservableObject { let paymentFailure = SwapPaymentFailureCapture() // LDK events are dispatched on the main actor, so this handler runs there; it records a - // routing failure for the paid hold invoice for the poll below to surface. The returned - // payment id may land in either the event's payment id or its payment hash field. + // routing failure for the paid hold invoice and resumes the failure wait below. The + // returned payment id may land in either the event's payment id or its payment hash field. onEvent(eventId) { event in - guard case let .paymentFailed(eventPaymentId, eventPaymentHash, _) = event else { return } + guard case let .paymentFailed(eventPaymentId, eventPaymentHash, reason) = event else { return } guard [eventPaymentId, eventPaymentHash].compactMap({ $0 }).contains(paymentId) else { return } - Task { await paymentFailure.markFailed() } + Task { await paymentFailure.markFailed(reason: reason) } } // Capture main-actor state locally so the detached group tasks stay Sendable. let claimTimeout = swapClaimTimeout - let paymentFailedMessage = t("wallet__toast_payment_failed_description") let outcome = await withTaskGroup(of: SavingsSwapResult?.self) { group in // On-chain claim or a Boltz-side error. @@ -1328,15 +1366,16 @@ class TransferViewModel: ObservableObject { } return nil } - // Lightning routing failure on the paid hold invoice. + // Lightning routing failure on the paid hold invoice, surfaced the moment the node + // reports it, with the failure reason mapped to a user-facing message. group.addTask { - while !Task.isCancelled { - if await paymentFailure.hasFailed { - return .failure(message: paymentFailedMessage) - } - try? await Task.sleep(nanoseconds: 200_000_000) + let wait = await withTaskCancellationHandler { + await paymentFailure.waitForFailure() + } onCancel: { + Task { await paymentFailure.cancelWaits() } } - return nil + guard case let .failed(reason) = wait else { return nil } + return .failure(message: PaymentFailureReason.userMessage(for: reason)) } // Bounded wait: a timeout is not a failure, the claim settles in the background. group.addTask { @@ -1361,12 +1400,40 @@ class TransferViewModel: ObservableObject { } /// Captures a Lightning routing failure for the savings swap's hold-invoice payment. The node -/// event handler runs on the main actor; this actor lets the racing wait poll for the outcome. +/// event handler runs on the main actor and resumes the waiting continuation, so the racing +/// wait in `awaitSwapOutcome` surfaces the failure the moment it is reported instead of polling. actor SwapPaymentFailureCapture { - private(set) var hasFailed = false + enum Wait: Equatable { + case failed(reason: PaymentFailureReason?) + case cancelled + } + + private var outcome: Wait? + private var waiters: [CheckedContinuation] = [] - func markFailed() { - hasFailed = true + func markFailed(reason: PaymentFailureReason?) { + finish(with: .failed(reason: reason)) + } + + /// Unblocks the wait when its surrounding task is cancelled (another branch of the race + /// won); a continuation left pending would keep the task group from ever finishing. + func cancelWaits() { + finish(with: .cancelled) + } + + func waitForFailure() async -> Wait { + if let outcome { return outcome } + return await withCheckedContinuation { waiters.append($0) } + } + + private func finish(with wait: Wait) { + guard outcome == nil else { return } + outcome = wait + let pending = waiters + waiters = [] + for waiter in pending { + waiter.resume(returning: wait) + } } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 807ab4939..de489bb24 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -473,6 +473,9 @@ class WalletViewModel: ObservableObject { } if !Task.isCancelled { Logger.warn("Gave up starting swap updates after \(attempt) attempts", context: "WalletViewModel") + // Free the slot so the next trigger (node start or entering a swap flow) can retry; + // a parked spent task would keep ensureSwapUpdatesRunning from ever starting one. + swapUpdatesTask = nil } } diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index d58171b91..cf29c1824 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -135,8 +135,10 @@ struct SavingsConfirmView: View { .task(id: totalSats) { // Pull the latest node balances so a just-received payment is reflected, then // present the swap fee before the user commits. Recomputed when the amount changes. + // The quote reads as loading across the sync too, so the swipe cannot land in the + // channel-close fallback while a quote is still on its way. + transfer.beginSavingsSwapQuoteLoad() await wallet.syncStateAsync() - guard totalSats > 0 else { return } await transfer.loadSavingsSwapQuote( requestedSat: totalSats, spendableSats: UInt64(max(0, wallet.maxSendLightningSats)) diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift index b4a1629d3..bf21fb7f3 100644 --- a/BitkitTests/SavingsSwapTests.swift +++ b/BitkitTests/SavingsSwapTests.swift @@ -1,4 +1,5 @@ import BitkitCore +import LDKNode import XCTest @testable import Bitkit @@ -73,6 +74,88 @@ final class SavingsSwapTests: XCTestCase { XCTAssertEqual(transfer.savingsTransferMode, .close) } + // MARK: - Quote loading state + + @MainActor + func testBeginSavingsSwapQuoteLoadIsANoOpWhereSwapsAreUnavailable() { + // Unit tests run on regtest, so the swap gate is closed and the confirm screen's + // pre-sync loading marker must leave the state idle: the swipe stays immediately + // usable on the unchanged channel-close path. + let transfer = TransferViewModel() + + transfer.beginSavingsSwapQuoteLoad() + + XCTAssertFalse(transfer.savingsSwapState.isLoading) + XCTAssertNil(transfer.savingsSwapState.quote) + } + + // MARK: - Payment failure messages + + func testPaymentFailureReasonsMapToTheirUserMessages() { + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .recipientRejected), + t("wallet__toast_payment_failed_recipient_rejected") + ) + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .retriesExhausted), + t("wallet__toast_payment_failed_retries_exhausted") + ) + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .routeNotFound), + t("wallet__toast_payment_failed_route_not_found") + ) + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .paymentExpired), + t("wallet__toast_payment_failed_timeout") + ) + } + + func testUnmappedAndAbsentPaymentFailureReasonsFallBackToTheGenericMessage() { + XCTAssertEqual( + PaymentFailureReason.userMessage(for: .unexpectedError), + t("wallet__toast_payment_failed_description") + ) + XCTAssertEqual( + PaymentFailureReason.userMessage(for: nil), + t("wallet__toast_payment_failed_description") + ) + } + + // MARK: - Payment failure capture + + func testWaitForFailureResumesWithTheReportedReason() async { + let capture = SwapPaymentFailureCapture() + + async let wait = capture.waitForFailure() + await capture.markFailed(reason: .routeNotFound) + + let outcome = await wait + XCTAssertEqual(outcome, .failed(reason: .routeNotFound)) + } + + func testWaitForFailureReturnsTheMemoizedOutcomeToLateWaiters() async { + let capture = SwapPaymentFailureCapture() + await capture.markFailed(reason: nil) + + let outcome = await capture.waitForFailure() + + XCTAssertEqual(outcome, .failed(reason: nil)) + } + + func testCancelWaitsUnblocksTheWaitAndAppliesToLaterWaiters() async { + let capture = SwapPaymentFailureCapture() + + async let wait = capture.waitForFailure() + await capture.cancelWaits() + let outcome = await wait + XCTAssertEqual(outcome, .cancelled) + + // The race is over; a failure arriving afterwards must not reopen it. + await capture.markFailed(reason: .routeNotFound) + let after = await capture.waitForFailure() + XCTAssertEqual(after, .cancelled) + } + // MARK: - Claim gating func testIsClaimableWhileReverseSwapIsUnclaimedAndNotTerminal() { From 24ddd45275dd3445dd856315d9dd8c8bdb20bcca Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 28 Jul 2026 10:03:34 -0400 Subject: [PATCH 10/12] chore: bump bitkit-core to 0.5.3 Carries the Boltz review fixes: response validation binding the script hashlock and invoice amount, recovery keyed on local completion state, serialized updates stream replacement, and claim address plus fee rate validation at the entry points. The iOS bindings are byte-identical between 0.5.2 and 0.5.3, so no app code changes. --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 0babee3aa..bd8ec1f76 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1207,7 +1207,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.5.2; + version = 0.5.3; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a380e4a15..40c876eee 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "fdc234141d718c765f0bd1fdaf41be970f13f7b1", - "version" : "0.5.2" + "revision" : "4c859d3f2c0022fda25baa62a029e2c03d1892a7", + "version" : "0.5.3" } }, { From ee07c988eeedd8b28d1ecc295c95b7a072866b33 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 28 Jul 2026 10:10:19 -0400 Subject: [PATCH 11/12] fix: keep manual claim for settled swaps missing a local claim txid Align the claim gating with bitkit-core 0.5.3 recovery semantics: a cooperative claim can disclose the preimage before the broadcast lands, so Boltz settles the invoice while the funds still sit in the lockup. Core now keeps that swap pending and retries the claim, so the manual claim in Dev Settings stays reachable for it as well. A settled swap with a recorded claim txid remains hidden. --- Bitkit/Services/BoltzService.swift | 6 +++++- BitkitTests/SavingsSwapTests.swift | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/BoltzService.swift b/Bitkit/Services/BoltzService.swift index a59b88774..6fdc02d7a 100644 --- a/Bitkit/Services/BoltzService.swift +++ b/Bitkit/Services/BoltzService.swift @@ -237,12 +237,16 @@ extension BoltzSwap { /// after Boltz has locked up on-chain and the funds are claimable. The chain, not the cached /// status, is the source of truth here, so offer the claim and let `boltzClaimReverseSwap` /// decide; when there is nothing to claim it fails harmlessly and the error surfaces. + /// + /// `.invoiceSettled` is not terminal without a local claim txid: a cooperative claim can + /// disclose the preimage before the broadcast lands, so Boltz settles the invoice while the + /// funds still sit in the lockup. bitkit-core 0.5.3 keeps that swap pending and retries the + /// claim; the manual claim stays reachable for it too. var isClaimable: Bool { guard swapType == .reverse, claimTxId == nil else { return false } switch status { case .invoiceExpired, .invoiceFailedToPay, - .invoiceSettled, .swapExpired, .transactionClaimed, .transactionFailed, diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift index bf21fb7f3..0f7aed361 100644 --- a/BitkitTests/SavingsSwapTests.swift +++ b/BitkitTests/SavingsSwapTests.swift @@ -167,6 +167,11 @@ final class SavingsSwapTests: XCTestCase { // A stalled updates stream leaves the swap at swapCreated locally even once Boltz has // locked up on-chain, so the claim must stay reachable: this is the recovery case. XCTAssertTrue(swap(status: .swapCreated).isClaimable) + + // A cooperative claim can disclose the preimage before the broadcast lands, so Boltz + // settles the invoice while the funds still sit in the lockup. bitkit-core 0.5.3 keeps + // that swap pending and retries the claim; the manual claim stays reachable too. + XCTAssertTrue(swap(status: .invoiceSettled).isClaimable) } func testIsClaimableFalseForTerminalAlreadyClaimedAndSubmarineSwaps() { @@ -175,10 +180,10 @@ final class SavingsSwapTests: XCTestCase { XCTAssertFalse(swap(status: .transactionLockupFailed).isClaimable) XCTAssertFalse(swap(status: .transactionRefunded).isClaimable) XCTAssertFalse(swap(status: .transactionClaimed).isClaimable) - XCTAssertFalse(swap(status: .invoiceSettled).isClaimable) XCTAssertFalse(swap(status: .invoiceExpired).isClaimable) XCTAssertFalse(swap(status: .invoiceFailedToPay).isClaimable) XCTAssertFalse(swap(status: .transactionConfirmed, claimTxId: "txid1").isClaimable) + XCTAssertFalse(swap(status: .invoiceSettled, claimTxId: "txid1").isClaimable) XCTAssertFalse(swap(swapType: .submarine, status: .transactionConfirmed).isClaimable) } From d5df68a69e1d12021b26422cfd9baa7aa02400e9 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 28 Jul 2026 11:29:40 -0400 Subject: [PATCH 12/12] test: accept n-prefixed legacy addresses on regtest Regtest P2PKH addresses start with m or n depending on the hash, and the assertion message already said so, but the check only accepted m, so testNewAddressMatchesTypeFormat failed for roughly half of freshly generated legacy addresses in CI. --- BitkitTests/AddressTypeIntegrationTests.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift index ed106c44d..3e09d7846 100644 --- a/BitkitTests/AddressTypeIntegrationTests.swift +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -309,17 +309,18 @@ final class AddressTypeIntegrationTests: XCTestCase { XCTAssertTrue(success, "Enabling \(type.stringValue) monitoring should succeed") } - let expectations: [(LDKNode.AddressType, String, String)] = [ - (.legacy, "m", "Legacy address should start with m or n on regtest"), - (.nestedSegwit, "2", "Nested SegWit address should start with 2 on regtest"), - (.nativeSegwit, "bcrt1q", "Native SegWit address should start with bcrt1q on regtest"), - (.taproot, "bcrt1p", "Taproot address should start with bcrt1p on regtest"), + // Regtest P2PKH addresses start with m or n depending on the hash, so legacy accepts both. + let expectations: [(LDKNode.AddressType, [String], String)] = [ + (.legacy, ["m", "n"], "Legacy address should start with m or n on regtest"), + (.nestedSegwit, ["2"], "Nested SegWit address should start with 2 on regtest"), + (.nativeSegwit, ["bcrt1q"], "Native SegWit address should start with bcrt1q on regtest"), + (.taproot, ["bcrt1p"], "Taproot address should start with bcrt1p on regtest"), ] - for (type, prefix, message) in expectations { + for (type, prefixes, message) in expectations { let address = try await settings.lightningService.newAddressForType(type) Logger.test("\(type.stringValue) address: \(address)", context: "AddressTypeIntegrationTests") - XCTAssertTrue(address.hasPrefix(prefix), "\(message), got: \(address)") + XCTAssertTrue(prefixes.contains(where: address.hasPrefix), "\(message), got: \(address)") } } }