From 03a771bba90f43c90fbe4d0c9f0d52a97f7584c9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 02:40:03 +0100 Subject: [PATCH 001/378] feat: spark emergency exit Moves the Spark balance on-chain without the Spark operators: quote, fee funding from Savings or another wallet, swipe to start, then a driver that builds, broadcasts and checks the SDK's recovery until it lands in Savings, after restarts and from background tasks too. Lost leaves are given up, fee funding left over is swept to Savings, and Spark stops sending, receiving and optimizing leaves while the exit runs. --- Bitkit/AppScene.swift | 4 + Bitkit/BitkitApp.swift | 1 + Bitkit/Constants/Env.swift | 7 + Bitkit/Info.plist | 10 + Bitkit/MainNavView.swift | 5 + Bitkit/Managers/BalanceManager.swift | 5 + Bitkit/Managers/SparkExitManager.swift | 599 ++++++ Bitkit/Managers/SparkManager.swift | 190 +- Bitkit/Models/TransferType.swift | 4 +- .../Localization/ar.lproj/Localizable.strings | 64 + .../Localization/ca.lproj/Localizable.strings | 64 + .../Localization/cs.lproj/Localizable.strings | 64 + .../Localization/de.lproj/Localizable.strings | 64 + .../Localization/el.lproj/Localizable.strings | 64 + .../Localization/en.lproj/Localizable.strings | 64 + .../es-419.lproj/Localizable.strings | 64 + .../Localization/es.lproj/Localizable.strings | 64 + .../Localization/fr.lproj/Localizable.strings | 64 + .../Localization/it.lproj/Localizable.strings | 64 + .../Localization/nl.lproj/Localizable.strings | 64 + .../Localization/pl.lproj/Localizable.strings | 64 + .../pt-BR.lproj/Localizable.strings | 64 + .../Localization/pt.lproj/Localizable.strings | 64 + .../Localization/ru.lproj/Localizable.strings | 64 + .../SparkExit/SparkExitBackgroundRun.swift | 151 ++ .../SparkExit/SparkExitBroadcaster.swift | 262 +++ .../Services/SparkExit/SparkExitDriver.swift | 643 +++++++ .../Services/SparkExit/SparkExitFunding.swift | 349 ++++ .../Services/SparkExit/SparkExitRecord.swift | 663 +++++++ .../Services/SparkExit/SparkExitSweep.swift | 226 +++ Bitkit/Services/TransferStorage.swift | 23 + Bitkit/ViewModels/NavigationViewModel.swift | 5 + Bitkit/ViewModels/TransferViewModel.swift | 5 +- Bitkit/ViewModels/WalletViewModel.swift | 10 +- .../Advanced/SparkConnectionViews.swift | 21 +- .../Settings/Advanced/SparkExitViews.swift | 706 +++++++ .../Views/Transfer/SavingsAdvancedView.swift | 2 +- .../Views/Transfer/SavingsConfirmView.swift | 2 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 2 +- BitkitTests/SparkConfigTests.swift | 4 +- BitkitTests/SparkExitRegtestTests.swift | 174 ++ BitkitTests/SparkExitSweepTests.swift | 341 ++++ BitkitTests/SparkExitTests.swift | 1646 +++++++++++++++++ 43 files changed, 7004 insertions(+), 16 deletions(-) create mode 100644 Bitkit/Managers/SparkExitManager.swift create mode 100644 Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift create mode 100644 Bitkit/Services/SparkExit/SparkExitBroadcaster.swift create mode 100644 Bitkit/Services/SparkExit/SparkExitDriver.swift create mode 100644 Bitkit/Services/SparkExit/SparkExitFunding.swift create mode 100644 Bitkit/Services/SparkExit/SparkExitRecord.swift create mode 100644 Bitkit/Services/SparkExit/SparkExitSweep.swift create mode 100644 Bitkit/Views/Settings/Advanced/SparkExitViews.swift create mode 100644 BitkitTests/SparkExitRegtestTests.swift create mode 100644 BitkitTests/SparkExitSweepTests.swift create mode 100644 BitkitTests/SparkExitTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 7ed764826..5bc352ac3 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -609,6 +609,10 @@ struct AppScene: View { await pubkyProfile.retrySessionRestoration() } .task(id: [scenePhase == .active, network.isConnected, wipeCoordinator.isGateUp]) { await pollIncomingPaykitPaymentRequests() } + .task(id: [scenePhase == .active, SparkExitManager.shared.needsDriving]) { + guard scenePhase == .active else { return } + await SparkExitManager.shared.runWhileActive() + } .task(id: foregroundContactSession) { shopContactImport?.task.cancel() shopContactImport = nil diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index 03273d8aa..b627cab19 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -73,6 +73,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self + SparkExitManager.registerBackgroundTask() // Check notification authorization status at launch and re-register with APN if granted UNUserNotificationCenter.current().getNotificationSettings { settings in diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4cdd9ea87..c2b1135a3 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -286,6 +286,13 @@ enum Env { .appendingPathComponent("wallet\(walletIndex)/spark-exit-restore.json") } + /// The running Spark emergency exit, if any: its signed recovery and how far it got. + static func sparkExitRecord(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark-exit.json") + } + /// Breez SDK API key, set with the `BREEZ_API_KEY` build setting. Spark on mainnet needs it; its regtest does not. static var breezApiKey: String? { infoPlistValue("BREEZ_API_KEY") diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index e6bac9837..84f203398 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -2,6 +2,16 @@ + BGTaskSchedulerPermittedIdentifiers + + to.bitkit.spark-exit + to.bitkit.spark-exit.processing + + UIBackgroundModes + + fetch + processing + UIDesignRequiresCompatibility USDT_BRIDGE_NETWORKS diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 0b7cf1540..415848e00 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -705,6 +705,11 @@ struct MainNavView: View { case .connections: LightningConnectionsView() case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId) case .sparkConnectionDetail: SparkConnectionDetailView() + case .sparkExitIntro: SparkExitIntroView() + case .sparkExitQuote: SparkExitQuoteView() + case .sparkExitFunding: SparkExitFundingView() + case .sparkExitConfirm: SparkExitConfirmView() + case .sparkExitProgress: SparkExitProgressView() case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() diff --git a/Bitkit/Managers/BalanceManager.swift b/Bitkit/Managers/BalanceManager.swift index e54b635cf..8fa29f60e 100644 --- a/Bitkit/Managers/BalanceManager.swift +++ b/Bitkit/Managers/BalanceManager.swift @@ -144,6 +144,11 @@ class BalanceManager { var pendingAmount: UInt64 = 0 for transfer in transfers.filter({ $0.type.isToSavings() }) { + // A Spark exit has no channel: it is in transfer until its sweep confirms and the exit settles it. + if transfer.type == .sparkExit { + pendingAmount += transfer.amountSats + continue + } guard let channelId = transfer.channelId else { continue } let balanceFromLdk = balanceDetails.lightningBalances diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift new file mode 100644 index 000000000..5e8fbd210 --- /dev/null +++ b/Bitkit/Managers/SparkExitManager.swift @@ -0,0 +1,599 @@ +import BackgroundTasks +import BitkitCore +import BreezSdkSpark +import Foundation +import Observation +import UserNotifications + +/// Runs a Spark emergency exit: the quote, the fee funding, then the driver until every step is on-chain. The exit +/// survives restarts through `SparkExitStore`, and is driven while Bitkit is open and by background runs while it +/// is closed (`BGAppRefreshTask` and `BGProcessingTask`, Spark SDK only). Reminders, scheduled ahead of each step, +/// bring the user back when those runs do not get to a step in time. +@Observable @MainActor +final class SparkExitManager { + static let shared = SparkExitManager() + nonisolated static let backgroundTaskId = "to.bitkit.spark-exit" + nonisolated static let processingTaskId = "to.bitkit.spark-exit.processing" + private nonisolated static let notificationPrefix = "spark-exit" + /// Reminder ids with their fire dates, so one that fired is not posted again on the next run. + private nonisolated static let remindersKey = "sparkExitReminders" + private nonisolated static let walletIndexKey = "sparkExitWalletIndex" + private static let foregroundTickSeconds: UInt64 = 60 + + private(set) var record: SparkExitRecord? + private(set) var quote: SparkExitQuote? + private(set) var quoteError: String? + private(set) var isQuoting = false + private(set) var tipHeight: UInt32? + /// Confirmed or not, what reached the funding address and is not a funding input yet. + private(set) var fundingSeenSats: UInt64 = 0 + /// What the running exit shows about its fee funding while it lacks some. + private(set) var fundingState: SparkExitFundingState? + private(set) var fundingAddress: String? + private(set) var lastError: String? + + /// The funding choice made on the funding screen, used when the exit starts. + @ObservationIgnored var fundFromSavings = false + @ObservationIgnored var savingsFeeRate: UInt32 = 0 + @ObservationIgnored var savingsFeeSats: UInt64 = 0 + @ObservationIgnored private var walletIndex = 0 + @ObservationIgnored private var isTicking = false + @ObservationIgnored private var pendingDestination: String? + @ObservationIgnored private var pendingFeeRate: UInt64? + @ObservationIgnored private var pendingFundingIndex: UInt32? + + private init() {} + + var isExiting: Bool { + record?.isActive == true + } + + /// The exit runs, or its leftover fee funding is still on its way to savings. + var needsDriving: Bool { + record?.needsDriving == true + } + + /// Emergency exit opens this exit's progress or result instead of a new exit. + var showsResult: Bool { + record?.showsResult == true + } + + private var store: SparkExitStore { + SparkExitStore(walletIndex: walletIndex) + } + + private var chain: SparkExitChain? { + guard let network = SparkManager.exitNetwork else { return nil } + return EsploraSparkExitChain(hosts: EsploraSparkExitChain.defaultHosts(for: network)) + } + + // MARK: Lifecycle + + func load(walletIndex: Int) { + self.walletIndex = walletIndex + do { + record = try store.load() + } catch { + // Kept aside for support, so a new exit never overwrites it; a new exit prepares from chain state. + Logger.error("Failed to read the Spark exit record: \(error)", context: "SparkExitManager") + try? store.setAside() + record = nil + } + if isExiting { + SparkManager.shared.detachForExit() + } + let record = record + Task { + if let record { + await settleIfFinished(record) + } + await settleOrphanedTransfers(record) + } + } + + /// Forgets the exit of a wallet being wiped. + func wipe() { + try? store.delete() + record = nil + quote = nil + pendingFundingIndex = nil + Self.clearReminders() + } + + /// Stops an exit that has not built anything and whose funding address received nothing, so leaving it + /// costs nothing: Spark comes back as it was. + var canCancel: Bool { + guard let record, record.recovery == nil, record.fundedSats == 0, fundingSeenSats == 0 else { return false } + if case .awaitingFunding = record.phase { + return true + } + return false + } + + /// Notes that the user opened the result of an exit that failed or lost leaves, so Emergency exit starts a new + /// one next time. + func markResultSeen() { + guard var record, record.hasUnseenResult else { return } + record.isResultSeen = true + do { + try store.save(record) + self.record = record + } catch { + Logger.error("Failed to save the seen Spark exit result: \(error)", context: "SparkExitManager") + } + } + + func cancel() async { + guard canCancel, let record else { return } + try? store.delete() + self.record = nil + Self.clearReminders() + if let transferId = record.transferId { + try? await TransferService(lightningService: .shared).markSettled(id: transferId) + } + await reattachSpark() + Logger.info("Cancelled the Spark exit before it was funded", context: "SparkExitManager") + } + + /// Ticks every minute while the app is active and an exit runs. Each step of an exit needs Bitkit to publish it. + func runWhileActive() async { + while !Task.isCancelled, needsDriving { + await tick() + try? await Task.sleep(nanoseconds: Self.foregroundTickSeconds * 1_000_000_000) + } + } + + func tick() async { + // Every tick asks for the next background run, so one failed or skipped tick never ends the chain. + defer { + if needsDriving { + Self.scheduleBackgroundRuns() + } + } + guard !isTicking, let current = record, current.needsDriving, let chain else { return } + isTicking = true + defer { isTicking = false } + let store = store + let walletIndex = walletIndex + let driver = SparkExitDriver( + chain: chain, + recovering: SparkManager.shared.exitRecovering, + makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, + save: { try store.save($0) } + ) + do { + tipHeight = try? await chain.tipHeight() + await refreshFundingSeen(chain: chain, record: current) + var updated = try await driver.tick(current) + // The result may have been opened while this tick ran. + if record?.isResultSeen == true, updated.isResultSeen != true { + updated.isResultSeen = true + try? store.save(updated) + } + record = updated + lastError = nil + await afterTick(updated, wasActive: current.isActive) + } catch { + lastError = error.localizedDescription + Logger.error("Spark exit tick failed: \(error)", context: "SparkExitManager") + await scheduleReminders(current) + } + } + + private func afterTick(_ record: SparkExitRecord, wasActive: Bool) async { + if let transferId = record.transferId { + try? TransferStorage.shared.updateClaimableAtHeight(id: transferId, claimableAtHeight: record.claimableAtHeight) + } + await settleIfFinished(record) + await scheduleReminders(record) + if wasActive, !record.isActive { + await reattachSpark() + } + } + + /// Ends the exit's transfer once the sweep reaches savings, or once the exit ended, so its amount is never + /// counted both as in transfer and in the on-chain balance. Runs on every tick and launch until it holds. + private func settleIfFinished(_ record: SparkExitRecord) async { + guard let transferId = record.transferId, + let transfer = try? TransferStorage.shared.getById(transferId), !transfer.isSettled + else { return } + let activity = CoreService.shared.activity + var isFinished = !record.isActive + if let sweep = record.recovery?.response.transactions.first(where: { $0.kind == .sweep }), + await activity.hasOnchainActivityForTxid(txid: sweep.txid) + { + await activity.markOnchainActivityAsTransfer(txId: sweep.txid) + isFinished = true + } + guard isFinished else { return } + try? await TransferService(lightningService: .shared).markSettled(id: transferId) + } + + /// Settles exit transfers no running exit owns, so their amount never stays in transfer to savings for good. + private func settleOrphanedTransfers(_ record: SparkExitRecord?) async { + guard let active = try? TransferStorage.shared.getActiveTransfers() else { return } + for id in SparkExitMath.orphanedTransferIds(active, record: record) { + Logger.warn("Settling Spark exit transfer \(id) with no running exit", context: "SparkExitManager") + try? await TransferService(lightningService: .shared).markSettled(id: id) + } + } + + private func reattachSpark() async { + let spark = SparkManager.shared + await spark.reconnectForExit() + if LightningService.shared.channelFreeRail == nil, spark.isReady { + LightningService.shared.channelFreeRail = spark + } + spark.onStateChanged?() + } + + /// Only new funding counts: the adopted inputs and the CPFP change at the address are already part of the exit. + private func refreshFundingSeen(chain: SparkExitChain, record: SparkExitRecord) async { + guard let address = record.funding.first?.address else { return } + fundingAddress = address + guard record.fundingShortfallSats != nil else { + fundingState = nil + return + } + let utxos = try? await chain.utxos(address: address) + if let utxos { + fundingSeenSats = SparkExitMath.newFunding(utxos, record: record).reduce(0) { $0 + $1.value } + } + fundingState = SparkExitMath.fundingState(record, utxos: utxos) + } + + // MARK: Quote + + func loadQuote() async { + isQuoting = true + quoteError = nil + defer { isQuoting = false } + do { + guard let recovering = SparkManager.shared.exitRecovering else { + throw AppError(message: "Spark is not connected", debugMessage: nil) + } + // Value an interrupted leaf optimization still owes the wallet would otherwise stay out of the quote. + await SparkManager.shared.settleForExit() + let feeRate = try await SparkManager.shared.exitFeeRate() + let destination: String = if let pendingDestination { + pendingDestination + } else { + try await LightningService.shared.newAddress() + } + let prepared = try await recovering.prepare(PrepareRecoverFundsRequest( + feeRateSatPerVbyte: feeRate, + fundingKind: .p2wpkh, + destination: destination, + selection: .all + )) + let fundingIndex = pendingFundingIndex ?? nextFundingIndex() + pendingDestination = destination + pendingFeeRate = feeRate + pendingFundingIndex = fundingIndex + quote = SparkExitQuote(prepared: prepared) + fundingAddress = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex).address + } catch { + Logger.error("Failed to quote the Spark exit: \(error)", context: "SparkExitManager") + quote = nil + quoteError = t("lightning__spark_exit__quote_error") + } + } + + /// Watches the funding address while the user funds it from another wallet. + func watchFundingAddress() async { + guard let chain, let fundingAddress else { return } + while !Task.isCancelled { + if let utxos = try? await chain.utxos(address: fundingAddress) { + fundingSeenSats = utxos.reduce(0) { $0 + $1.value } + } + try? await Task.sleep(nanoseconds: 15 * 1_000_000_000) + } + } + + // MARK: Start + + /// Starts the exit for the current quote: writes its record, moves the Spark balance into transfer to savings + /// and, with `fundFromSavings`, pays the fee funding from savings at `savingsFeeRate`. + func start() async throws { + guard !isExiting, let quote, let destination = pendingDestination, let feeRate = pendingFeeRate, + let fundingIndex = pendingFundingIndex, let network = SparkManager.exitNetwork + else { + throw AppError(message: t("lightning__spark_exit__quote_error"), debugMessage: "No quote to start from") + } + let key = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex) + var record = SparkExitRecord( + network: "\(network)", + createdAt: Date(), + phase: .awaitingFunding(requiredSats: quote.fundingSats), + feeRateSatPerVbyte: feeRate, + destination: destination, + leafIds: quote.leafIds, + leafTotalSats: quote.leafTotalSats, + funding: [.init(index: fundingIndex, address: key.address, pubkey: key.pubkey)], + expectedSats: quote.incomingSats, + skippedLeafCount: quote.prepared.skipped.count + ) + // Saved before the transfer exists, so a failed save never leaves a transfer counted with no exit behind it. + try store.save(record) + record.transferId = try await TransferService(lightningService: .shared).createTransfer(type: .sparkExit, amountSats: quote.incomingSats) + try store.save(record) + UserDefaults.standard.set(walletIndex, forKey: Self.walletIndexKey) + UserDefaults.standard.set(Int(fundingIndex) + 1, forKey: fundingIndexKey) + pendingFundingIndex = nil + self.record = record + SparkManager.shared.detachForExit() + // Reconnecting cuts an optimization short, which would leave its counter-swap unclaimed for a while. + await SparkManager.shared.waitForOptimization() + await SparkManager.shared.reconnectForExit() + Logger.info("Started Spark exit of \(quote.exitSats) sats over \(quote.leafIds.count) leaves", context: "SparkExitManager") + + if fundFromSavings, quote.fundingSats > 0 { + let txid = try await payFunding(address: key.address, sats: quote.fundingSats, satsPerVbyte: savingsFeeRate, feeSats: savingsFeeSats) + await notePaidFunding(txid: txid) + } + Task { await tick() } + } + + /// Keeps the savings payment in the record, so the exit waits for it instead of asking for the funding again. + /// Written between ticks, so a tick in flight never saves over it. + private func notePaidFunding(txid: String) async { + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + guard var record else { return } + record.fundingPaymentTxid = txid + do { + try store.save(record) + } catch { + Logger.error("Failed to save the Spark exit funding payment: \(error)", context: "SparkExitManager") + } + self.record = record + fundingState = SparkExitMath.fundingState(record, utxos: nil) + } + + /// The funding key index of the next exit: one no exit of this wallet used, from the counter the last start + /// left and the stored record, so leftover of an earlier exit never mixes into a new one. + private func nextFundingIndex() -> UInt32 { + SparkExitMath.nextFundingIndex(counter: UserDefaults.standard.integer(forKey: fundingIndexKey), record: record) + } + + private var fundingIndexKey: String { + "sparkExitNextFundingIndex-\(Env.network)-\(walletIndex)" + } + + /// Pays the fee funding from savings. It shows as an ordinary payment, not as a transfer to spending. + @discardableResult + func payFunding(address: String, sats: UInt64, satsPerVbyte: UInt32, feeSats: UInt64) async throws -> String { + let lightning = LightningService.shared + let attempts = OnchainSendAttemptService.shared + let result = try await attempts.send( + using: lightning, + address: address, + amountSats: sats, + satsPerVbyte: satsPerVbyte, + utxosToSpend: nil, + isMaxAmount: false, + followupContext: OnchainSendFollowupContext( + feeSats: feeSats, + feeRate: satsPerVbyte, + tags: [], + contact: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + ) + guard case let .accepted(txid) = result else { + throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark exit funding outcome: \(result)") + } + _ = try? await attempts.resumeAcceptedOrdinarySend( + walletId: OnchainSendAttemptService.walletId(index: lightning.currentWalletIndex), + observedTxid: txid + ) + Logger.info("Funded the Spark exit with \(sats) sats in \(txid)", context: "SparkExitManager") + return txid + } + + // MARK: Keys + + private nonisolated static func fundingKey(walletIndex: Int, index: UInt32) throws -> SparkExitFundingKey.Derived { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + return try SparkExitFundingKey.derive(index: index, mnemonic: mnemonic, passphrase: passphrase, network: Env.bitkitCoreNetwork) + } + + private nonisolated static func signer(walletIndex: Int, index: UInt32) throws -> CpfpSigner { + try singleKeyCpfpSigner(secretKeyBytes: fundingKey(walletIndex: walletIndex, index: index).secretKey) + } + + // MARK: Background + + /// One background run from a cold launch or a suspended app, with no UI: the keyless pass, then the Spark SDK + /// only (never the node) for the check, the signing and the broadcast, then the reminders and the next runs. + /// Waits for a foreground tick in flight instead of skipping, so the run never reports work it did not do. + @discardableResult + func runInBackground(deadline: Date) async -> SparkExitBackgroundRun.Outcome { + defer { + if needsDriving { + Self.scheduleBackgroundRuns() + } + } + if record == nil { + load(walletIndex: UserDefaults.standard.integer(forKey: Self.walletIndexKey)) + } + while isTicking, !Task.isCancelled, Date() < deadline { + try? await Task.sleep(nanoseconds: 250_000_000) + } + guard !isTicking, let chain else { return .init(record: record) } + let wasActive = record?.isActive == true + isTicking = true + defer { isTicking = false } + let store = store + let walletIndex = walletIndex + let run = SparkExitBackgroundRun( + load: { try store.load() }, + makeDriver: { chain, recovering in + SparkExitDriver( + chain: chain, + recovering: recovering, + makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, + save: { try store.save($0) } + ) + }, + chain: chain, + connected: SparkManager.shared.exitRecovering, + connect: { timeout in await SparkManager.shared.connectForExit(walletIndex: walletIndex, timeout: timeout) }, + deadline: deadline + ) + tipHeight = try? await chain.tipHeight() + let outcome = await run.run() + let seconds = SparkExitBackgroundRun.Step.allCases.compactMap { step in + outcome.seconds[step].map { "\(step.rawValue)=\(String(format: "%.2f", $0))s" } + } + Logger.info( + "Spark exit background run: connected=\(outcome.isConnected) phase=\(String(describing: outcome.record?.phase)) " + + "\(seconds.joined(separator: " ")) footprint=\(Self.footprintMegabytes())MB", + context: "SparkExitManager" + ) + if let updated = outcome.record { + record = updated + await afterTick(updated, wasActive: wasActive) + } + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.lastBackgroundRunKey) + return outcome + } + + private nonisolated static let lastBackgroundRunKey = "sparkExitLastBackgroundRun" + + /// The process's physical footprint, as `footprint` and Xcode's memory gauge report it. + nonisolated static func footprintMegabytes() -> Int { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) } + } + return result == KERN_SUCCESS ? Int(info.phys_footprint / 1_048_576) : -1 + } + + /// Registers the background runs that drive an exit while Bitkit is closed. Must run before launch finishes. + nonisolated static func registerBackgroundTask() { + // The OS ends a refresh after about 30 seconds and a processing task after a few minutes. + register(backgroundTaskId, seconds: 25) + register(processingTaskId, seconds: 4 * 60) + } + + private nonisolated static func register(_ identifier: String, seconds: TimeInterval) { + BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in + let completion = SparkExitTaskCompletion(task) + let work = Task { @MainActor in + await SparkExitManager.shared.runInBackground(deadline: Date(timeIntervalSinceNow: seconds)) + completion.complete(success: !Task.isCancelled) + } + // The driver stops before its next SDK call or broadcast and has saved every broadcast, so the record on + // disk is whole whenever the OS suspends the app. + task.expirationHandler = { + work.cancel() + completion.complete(success: false) + } + } + } + + nonisolated static func scheduleBackgroundRuns() { + // The refresh moves out on every run; the processing task is left alone while pending, or runs ticking more + // often than its floor would keep it from ever coming due. + BGTaskScheduler.shared.getPendingTaskRequests { pending in + let refresh = BGAppRefreshTaskRequest(identifier: backgroundTaskId) + refresh.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + var requests: [BGTaskRequest] = [refresh] + if !pending.contains(where: { $0.identifier == processingTaskId }) { + let processing = BGProcessingTaskRequest(identifier: processingTaskId) + processing.requiresNetworkConnectivity = true + processing.requiresExternalPower = false + processing.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) + requests.append(processing) + } + for request in requests { + do { + try BGTaskScheduler.shared.submit(request) + } catch { + Logger.warn("Failed to schedule Spark exit run \(request.identifier): \(error)", context: "SparkExitManager") + } + } + } + } + + // MARK: Reminders + + /// Schedules a reminder ahead of each step, so it fires even when no background run gets to that step: at each + /// timelock's maturity plus a grace well inside the ~50 blocks after which the operators may publish their own + /// version, after a grace for steps ready to publish now, and at once when the exit needs more fee funding or a + /// step keeps being rejected. Each run (foreground or background) recomputes them, which drops the reminders of + /// steps it published or that confirmed, and of an exit that ended. + private func scheduleReminders(_ record: SparkExitRecord) async { + let center = UNUserNotificationCenter.current() + let now = Date() + let remembered = Self.rememberedReminders() + let planned = SparkExitMath.reminders(record, tip: tipHeight, now: now, network: Env.network) + var kept: [String: TimeInterval] = [:] + var requests: [UNNotificationRequest] = [] + for reminder in planned { + let id = "\(Self.notificationPrefix)-\(reminder.id)" + // A reminder that fired is not posted again. One still ahead keeps its earliest date, so runs that do not + // publish its step never push it out. + let rememberedAt = remembered[id].map(Date.init(timeIntervalSince1970:)) + if let rememberedAt, rememberedAt <= now { + kept[id] = rememberedAt.timeIntervalSince1970 + continue + } + let fireAt = max(now.addingTimeInterval(1), min(reminder.fireAt, rememberedAt ?? reminder.fireAt)) + kept[id] = fireAt.timeIntervalSince1970 + let content = UNMutableNotificationContent() + content.title = t("lightning__spark_exit__notification_title") + content.body = if reminder.isLostLeaves { + t("lightning__spark_exit__notification_lost", variables: ["amount": "₿ \(CurrencyFormatter.formatSats(record.lostSats))"]) + } else if reminder.isFailed { + t("lightning__spark_exit__notification_failed") + } else { + t(reminder.isFunding ? "lightning__spark_exit__notification_funding" : "lightning__spark_exit__notification_body") + } + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: fireAt.timeIntervalSince(now), repeats: false) + requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger)) + } + let pending = await center.pendingNotificationRequests().map(\.identifier).filter { $0.hasPrefix(Self.notificationPrefix) } + center.removePendingNotificationRequests(withIdentifiers: pending) + for request in requests { + try? await center.add(request) + } + UserDefaults.standard.set(kept, forKey: Self.remindersKey) + } + + private nonisolated static func rememberedReminders() -> [String: TimeInterval] { + UserDefaults.standard.dictionary(forKey: remindersKey) as? [String: TimeInterval] ?? [:] + } + + private nonisolated static func clearReminders() { + UNUserNotificationCenter.current().getPendingNotificationRequests { requests in + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: requests.map(\.identifier).filter { $0.hasPrefix(notificationPrefix) }) + } + UserDefaults.standard.removeObject(forKey: remindersKey) + } +} + +/// Completes a background task once, whichever comes first: the run finishing or the OS expiring it. +private final class SparkExitTaskCompletion: @unchecked Sendable { + private let lock = NSLock() + private var task: BGTask? + + init(_ task: BGTask) { + self.task = task + } + + func complete(success: Bool) { + let task = lock.withLock { + defer { self.task = nil } + return self.task + } + task?.setTaskCompleted(success: success) + } +} diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 332c17997..ba0eccae3 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -24,6 +24,7 @@ final class SparkManager: ChannelFreeLightningRail { private nonisolated static let depositClaimFeePpm: UInt32 = 2000 private(set) var isReady = false + private(set) var walletIndex = 0 private(set) var balanceSats: UInt64 = 0 /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. private(set) var pendingDepositSats: UInt64 = 0 @@ -47,6 +48,17 @@ final class SparkManager: ChannelFreeLightningRail { @ObservationIgnored private var sentWithdrawalTxIds: Set = [] @ObservationIgnored private var listenerId: String? @ObservationIgnored private var connectTask: Task? + /// A leaf optimization is running, as its events tell. + @ObservationIgnored private var isOptimizing = false + /// Waiting before an exit quote for value an interrupted leaf optimization still owes the wallet. + private(set) var isSettlingForExit = false + /// When a leaf optimization last moved without completing, so a counter-swap it left may still be unclaimed. + private nonisolated static let optimizationActivityKey = "sparkOptimizationActivityAt" + /// The SDK claims a counter-swap left by an interrupted optimization only after this long + /// (spark-wallet `COUNTER_SWAP_CLAIM_GRACE_PERIOD_SECS`), plus a margin for the round that created it. + static let counterSwapClaimGraceSeconds: TimeInterval = 300 + 30 + /// The longest an exit waits for a running optimization before going ahead. + private static let optimizationWaitSeconds: TimeInterval = 120 private init() {} @@ -58,6 +70,148 @@ final class SparkManager: ChannelFreeLightningRail { sdk != nil } + /// An emergency exit is moving the Spark balance on-chain: Spark then neither sends nor receives, and its + /// balance counts as in transfer to savings. + var isExiting: Bool { + SparkExitManager.shared.isExiting + } + + /// Takes Spark off the Lightning rail for an emergency exit, so no payment moves leaves the exit is spending. + func detachForExit() { + if LightningService.shared.channelFreeRail === self { + LightningService.shared.channelFreeRail = nil + } + } + + /// Connects Spark again so its leaf optimization follows whether an exit runs: off when one starts, back on + /// once it ends. + func reconnectForExit() async { + guard let network = Self.sparkNetwork else { return } + noteOptimizationCutShort() + if let sdk { + if let listenerId { + _ = await sdk.removeEventListener(id: listenerId) + } + try? await sdk.disconnect() + } + sdk = nil + listenerId = nil + do { + try await connect(walletIndex: walletIndex, network: network) + try await refresh() + } catch { + Logger.error("Failed to reconnect Spark for the exit: \(error)", context: "SparkManager") + } + } + + /// Connects the Spark SDK alone for a background exit run, never the node: no refresh, no activity sync and no + /// Lightning rail. Returns nil when it does not connect within `timeout`; the connect keeps going, and a later + /// `start` picks it up. + func connectForExit(walletIndex: Int, timeout: TimeInterval) async -> SparkExitRecovering? { + guard let network = Self.sparkNetwork else { return nil } + if sdk == nil { + self.walletIndex = walletIndex + if connectTask == nil { + connectTask = Task { try await connect(walletIndex: walletIndex, network: network) } + } + let connecting = connectTask + // nil while still connecting at the timeout. + let didConnect: Bool? = await withTaskGroup(of: Bool?.self) { group in + group.addTask { await (try? connecting?.value) != nil } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + // A finished connect is forgotten, so a failed one is tried again; one still running is kept for `start`. + if didConnect != nil { + connectTask = nil + } + guard didConnect == true else { + Logger.warn("Spark did not connect within \(Int(timeout))s for the exit", context: "SparkManager") + return nil + } + } + return exitRecovering + } + + /// Waits until no leaf optimization runs and no counter-swap one left behind is still inside the SDK's claim + /// grace, then syncs, which claims it. An optimization swaps leaves with the operators; one cut short (by a + /// disconnect or the app closing) leaves its counter-swap unclaimed for 5 minutes, and a quote taken meanwhile + /// would leave that value out of the exit. + func settleForExit() async { + isSettlingForExit = true + defer { isSettlingForExit = false } + await waitForOptimization() + let activity = UserDefaults.standard.object(forKey: Self.optimizationActivityKey) as? TimeInterval + if let activity { + let wait = Self.counterSwapClaimGraceSeconds - Date().timeIntervalSince1970 + activity + if wait > 0 { + Logger.info("Waiting \(Int(wait))s for an interrupted Spark optimization before the exit quote", context: "SparkManager") + try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) + } + } + guard !Task.isCancelled, let sdk else { return } + do { + _ = try await sdk.syncWallet(request: SyncWalletRequest()) + if activity != nil, !isOptimizing { + UserDefaults.standard.removeObject(forKey: Self.optimizationActivityKey) + } + } catch { + Logger.warn("Failed to sync Spark before the exit quote: \(error)", context: "SparkManager") + } + await refreshState() + } + + /// Waits for a running leaf optimization to finish, for up to two minutes. + func waitForOptimization() async { + let deadline = Date().addingTimeInterval(Self.optimizationWaitSeconds) + while isOptimizing, !Task.isCancelled, Date() < deadline { + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + + private func noteOptimization(_ event: AutoOptimizationEvent) { + switch event { + case .started, .roundCompleted: + isOptimizing = true + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.optimizationActivityKey) + case .cancelled, .failed: + isOptimizing = false + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.optimizationActivityKey) + case .completed, .skipped: + isOptimizing = false + UserDefaults.standard.removeObject(forKey: Self.optimizationActivityKey) + } + } + + private func noteOptimizationCutShort() { + guard isOptimizing else { return } + isOptimizing = false + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.optimizationActivityKey) + } + + /// The SDK calls of an emergency exit, or nil while Spark is not connected. + var exitRecovering: SparkExitRecovering? { + sdk.map(BreezSparkExitRecovering.init) + } + + /// The fee rate an exit is built at: the fastest the SDK's chain service recommends, at least 1 sat/vB. + func exitFeeRate() async throws -> UInt64 { + try await max(1, requireSdk().recommendedFees().fastestFee) + } + + nonisolated static var exitNetwork: BreezSdkSpark.Network? { + switch Env.network { + case .bitcoin: .mainnet + case .regtest: .regtest + default: nil + } + } + /// Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a /// Spark transfer, which lets two dev builds pay each other's invoices. nonisolated static var prefersSpark: Bool { @@ -80,6 +234,8 @@ final class SparkManager: ChannelFreeLightningRail { func start(walletIndex: Int) async { guard let network = Self.sparkNetwork else { return } + self.walletIndex = walletIndex + SparkExitManager.shared.load(walletIndex: walletIndex) do { if sdk == nil { if connectTask == nil { @@ -89,8 +245,10 @@ final class SparkManager: ChannelFreeLightningRail { connectTask = nil } // Registered before the first refresh marks Spark ready, so Lightning calls never see it ready while they - // still go to LDK, even when that refresh or the activity sync fails. - LightningService.shared.channelFreeRail = self + // still go to LDK, even when that refresh or the activity sync fails. An emergency exit keeps it out. + if !isExiting { + LightningService.shared.channelFreeRail = self + } try await refresh() try await syncActivities() } catch { @@ -104,7 +262,7 @@ final class SparkManager: ChannelFreeLightningRail { throw CustomServiceError.mnemonicNotFound } let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) - let config = Self.sdkConfig(network: network, apiKey: Env.breezApiKey) + let config = Self.sdkConfig(network: network, apiKey: Env.breezApiKey, isExiting: isExiting) let storageDir = Env.sparkStorage(walletIndex: walletIndex) try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) let connected = try await BreezSdkSpark.connect(request: ConnectRequest( @@ -122,7 +280,7 @@ final class SparkManager: ChannelFreeLightningRail { } /// The SDK config Bitkit connects with. - nonisolated static func sdkConfig(network: BreezSdkSpark.Network, apiKey: String?) -> BreezSdkSpark.Config { + nonisolated static func sdkConfig(network: BreezSdkSpark.Network, apiKey: String?, isExiting: Bool) -> BreezSdkSpark.Config { var config = defaultConfig(network: network) config.apiKey = apiKey // No Breez data sync server and no Lightning address server: Bitkit uses neither. @@ -134,10 +292,15 @@ final class SparkManager: ChannelFreeLightningRail { onchain: .networkRecommended(leewaySatPerVbyte: depositClaimFeeLeewaySatPerVbyte), proportionalPpm: nil ) + // Optimizing swaps leaves with the operators, which would hand the leaves an exit is spending to someone + // else, whose refunds then confirm first. Off for as long as an emergency exit runs. + config.leafOptimizationConfig.autoEnabled = !isExiting return config } func wipe(walletIndex: Int) async { + UserDefaults.standard.removeObject(forKey: Self.optimizationActivityKey) + isOptimizing = false if LightningService.shared.channelFreeRail === self { LightningService.shared.channelFreeRail = nil } @@ -150,6 +313,7 @@ final class SparkManager: ChannelFreeLightningRail { sdk = nil listenerId = nil isReady = false + SparkExitManager.shared.wipe() balanceSats = 0 pendingDepositSats = 0 recoverableFundsSats = 0 @@ -541,6 +705,8 @@ final class SparkManager: ChannelFreeLightningRail { await recordActivity(payment) case .unilateralExitStateChanged: Self.exitStateChanged.send() + case let .autoOptimization(optimizationEvent): + noteOptimization(optimizationEvent) default: break } @@ -725,6 +891,22 @@ struct SparkWithdrawal: Identifiable, Equatable { } } +struct BreezSparkExitRecovering: SparkExitRecovering { + let sdk: BreezSdk + + func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse { + try await sdk.prepareRecoverFunds(request: request) + } + + func recover(_ request: RecoverFundsRequest, signer: CpfpSigner?) async throws -> RecoverFundsResponse { + try await sdk.recoverFunds(request: request, signer: signer) + } + + func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { + try await sdk.checkRecoverFunds(request: CheckRecoverFundsRequest(recovery: recovery)) + } +} + private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked Sendable { private let handler: @Sendable (SdkEvent) async -> Void diff --git a/Bitkit/Models/TransferType.swift b/Bitkit/Models/TransferType.swift index 909481edb..f8f416be9 100644 --- a/Bitkit/Models/TransferType.swift +++ b/Bitkit/Models/TransferType.swift @@ -6,10 +6,12 @@ enum TransferType: String, Codable { case manualSetup = "MANUAL_SETUP" case forceClose = "FORCE_CLOSE" case coopClose = "COOP_CLOSE" + /// The Spark balance leaving on-chain without the Spark operators. + case sparkExit = "SPARK_EXIT" func isToSavings() -> Bool { switch self { - case .toSavings, .coopClose, .forceClose: + case .toSavings, .coopClose, .forceClose, .sparkExit: return true default: return false diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 2ba6f3041..8a3bdeba3 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -319,6 +319,70 @@ "lightning__spark_provider_value" = "Spark عبر Breez"; "lightning__spark_receiving_unlimited" = "بلا حد"; "lightning__spark_trust" = "يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 78a612fe0..ecf7ed116 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -356,6 +356,70 @@ "lightning__spark_provider_value" = "Spark mitjançant Breez"; "lightning__spark_receiving_unlimited" = "Sense límit"; "lightning__spark_trust" = "Aquest saldo es guarda a Spark, gestionat per Lightspark, Breez i Flashnet. Està segur mentre almenys un d’ells actuï amb honestedat. Poden pausar l’accés, i sortir-ne sense ells requereix una sortida on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 755de70ef..d0b878581 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -372,6 +372,70 @@ "lightning__spark_provider_value" = "Spark přes Breez"; "lightning__spark_receiving_unlimited" = "Bez limitu"; "lightning__spark_trust" = "Tento zůstatek je uložen na Spark, který provozují Lightspark, Breez a Flashnet. Je v bezpečí, dokud alespoň jeden z nich jedná poctivě. Mohou pozastavit přístup a odchod bez nich vyžaduje on-chain výstup."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 41d543636..038541fb5 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -370,6 +370,70 @@ "lightning__spark_provider_value" = "Spark über Breez"; "lightning__spark_receiving_unlimited" = "Kein Limit"; "lightning__spark_trust" = "Dieses Guthaben liegt auf Spark, betrieben von Lightspark, Breez und Flashnet. Es ist sicher, solange mindestens einer von ihnen ehrlich handelt. Sie können den Zugang sperren, und ein Ausstieg ohne sie erfordert einen On-Chain-Exit."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 3bcdf21e1..567df30b4 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -343,6 +343,70 @@ "lightning__spark_provider_value" = "Spark μέσω Breez"; "lightning__spark_receiving_unlimited" = "Χωρίς όριο"; "lightning__spark_trust" = "Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9f299e68b..ac8e5a67b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -365,6 +365,70 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "No limit"; "lightning__spark_trust" = "This balance is held on Spark, run by Lightspark, Breez and Flashnet. It is safe while at least one of them acts honestly. They can pause access, and leaving without them takes an on-chain exit."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 7de3e658a..086463eb8 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -373,6 +373,70 @@ "lightning__spark_provider_value" = "Spark mediante Breez"; "lightning__spark_receiving_unlimited" = "Sin límite"; "lightning__spark_trust" = "Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 510f68918..1ecb364c7 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -357,6 +357,70 @@ "lightning__spark_provider_value" = "Spark mediante Breez"; "lightning__spark_receiving_unlimited" = "Sin límite"; "lightning__spark_trust" = "Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 4074d833e..648e43d09 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -373,6 +373,70 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "Sans limite"; "lightning__spark_trust" = "Ce solde est détenu sur Spark, géré par Lightspark, Breez et Flashnet. Il est en sécurité tant qu’au moins l’un d’eux agit honnêtement. Ils peuvent suspendre l’accès, et en sortir sans eux nécessite une sortie on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index ff531fab8..312ad4d0a 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -366,6 +366,70 @@ "lightning__spark_provider_value" = "Spark tramite Breez"; "lightning__spark_receiving_unlimited" = "Nessun limite"; "lightning__spark_trust" = "Questo saldo è custodito su Spark, gestito da Lightspark, Breez e Flashnet. È al sicuro finché almeno uno di loro agisce onestamente. Possono sospendere l’accesso e uscire senza di loro richiede un’uscita on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 087be4fee..3a362c060 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -370,6 +370,70 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "Geen limiet"; "lightning__spark_trust" = "Dit saldo staat op Spark, beheerd door Lightspark, Breez en Flashnet. Het is veilig zolang ten minste één van hen eerlijk handelt. Zij kunnen de toegang pauzeren, en vertrekken zonder hen vereist een on-chain exit."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 1cd7e31c2..5fda39a31 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -372,6 +372,70 @@ "lightning__spark_provider_value" = "Spark przez Breez"; "lightning__spark_receiving_unlimited" = "Bez limitu"; "lightning__spark_trust" = "To saldo jest przechowywane na Spark, prowadzonym przez Lightspark, Breez i Flashnet. Jest bezpieczne, dopóki co najmniej jeden z nich działa uczciwie. Mogą wstrzymać dostęp, a wyjście bez nich wymaga wyjścia on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index bd54df0b4..09166b0f0 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -372,6 +372,70 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "Sem limite"; "lightning__spark_trust" = "Este saldo fica na Spark, operada pela Lightspark, Breez e Flashnet. Ele está seguro enquanto pelo menos um deles agir com honestidade. Eles podem pausar o acesso, e sair sem eles exige uma saída on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 5ab90bafd..87b1615f2 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -370,6 +370,70 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "Sem limite"; "lightning__spark_trust" = "Este saldo é mantido na Spark, operada pela Lightspark, Breez e Flashnet. Está seguro enquanto pelo menos um deles agir com honestidade. Podem suspender o acesso, e sair sem eles exige uma saída on-chain."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index d2df40a5b..08600d84f 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -367,6 +367,70 @@ "lightning__spark_provider_value" = "Spark через Breez"; "lightning__spark_receiving_unlimited" = "Без ограничений"; "lightning__spark_trust" = "Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода."; +"lightning__spark_exit__button" = "Emergency exit"; +"lightning__spark_exit__nav_title" = "Emergency Exit"; +"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; +"lightning__spark_exit__quote_title" = "Exit\nquote"; +"lightning__spark_exit__amount" = "Amount to exit"; +"lightning__spark_exit__fees" = "Network fees"; +"lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__funding" = "Fee funding needed"; +"lightning__spark_exit__left_behind" = "Stays on Spark"; +"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; +"lightning__spark_exit__quote_loading" = "Getting a quote…"; +"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; +"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; +"lightning__spark_exit__nothing" = "Nothing can be exited right now."; +"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; +"lightning__spark_exit__funding_title" = "Fund the\nfees"; +"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; +"lightning__spark_exit__fund_savings" = "Pay from savings"; +"lightning__spark_exit__fund_external" = "Fund from another wallet"; +"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; +"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; +"lightning__spark_exit__fund_seen" = "Payment seen."; +"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; +"lightning__spark_exit__confirm_title" = "Start\nexit?"; +"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; +"lightning__spark_exit__swipe" = "Swipe To Exit"; +"lightning__spark_exit__progress_title" = "Exit in\nprogress"; +"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__step_funding" = "Fee funding confirms"; +"lightning__spark_exit__step_publish" = "Publishing exit transactions"; +"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; +"lightning__spark_exit__step_sweep" = "Sending to savings"; +"lightning__spark_exit__ready_in" = "Ready in {duration}"; +"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; +"lightning__spark_exit__done_title" = "Exit\ncomplete"; +"lightning__spark_exit__done_text" = "Your funds are in savings."; +"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; +"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; +"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; +"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; +"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; +"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; +"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; +"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; +"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; +"lightning__spark_exit__failed_title" = "Exit\nstopped"; +"lightning__spark_exit__cancel" = "Cancel exit"; +"lightning__spark_exit__failed_text" = "Nothing could be recovered."; +"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; +"lightning__spark_exit__notification_title" = "Emergency exit"; +"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; +"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; +"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; +"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift new file mode 100644 index 000000000..ebfc54f38 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift @@ -0,0 +1,151 @@ +import BreezSdkSpark +import Foundation +import os + +/// One background run of an exit, from a cold launch with no UI: loads the record, sends what is ready from the +/// stored, already signed transactions without the SDK, then starts the Spark SDK only (never the node) and ticks +/// again with it, which checks the exit against the chain, signs a build or rebuild, and broadcasts. An ended exit +/// whose leftover fee funding still has to reach savings gets the keyless pass alone, which sweeps it. The driver +/// saves the record before and after each broadcast, so a run that expires mid-step leaves a whole record and the +/// next run carries on from it: a step that does not fit one run (connect and sign, say) finishes in the next. +struct SparkExitBackgroundRun { + enum Step: String, CaseIterable { + case load, keyless, connect, check, sign, broadcast, total + } + + struct Outcome { + var record: SparkExitRecord? + var isConnected = false + var seconds: [Step: TimeInterval] = [:] + } + + let load: () throws -> SparkExitRecord? + /// A driver over the given SDK, or over none for the keyless pass. The run times its SDK and broadcast calls. + let makeDriver: (_ chain: SparkExitChain, _ recovering: SparkExitRecovering?) -> SparkExitDriver + let chain: SparkExitChain + /// The SDK when it is already connected, so the keyless pass is skipped. + let connected: SparkExitRecovering? + /// Starts the Spark SDK only; nil when it does not connect within the given seconds. + let connect: (_ timeout: TimeInterval) async -> SparkExitRecovering? + /// When the run must have saved and returned; the OS ends a refresh after about 30 seconds. + let deadline: Date + var now: () -> Date = Date.init + + /// Time kept back from the connect for the SDK tick and the final save. + static let reserveSeconds: TimeInterval = 8 + + func run() async -> Outcome { + let started = now() + var outcome = await steps() + outcome.seconds[.total] = now().timeIntervalSince(started) + return outcome + } + + private func steps() async -> Outcome { + let timings = SparkExitTimings() + var outcome = Outcome() + let loadStarted = now() + do { + outcome.record = try load() + } catch { + Logger.error("Spark exit background run could not read the record: \(error)", context: "SparkExitBackgroundRun") + } + timings.add(.load, now().timeIntervalSince(loadStarted)) + guard var record = outcome.record, record.needsDriving else { + outcome.seconds = timings.seconds + return outcome + } + let chain = TimedSparkExitChain(base: chain, timings: timings) + + var recovering = connected + if recovering == nil { + let keylessStarted = now() + if let ticked = try? await makeDriver(chain, nil).tick(record) { + record = ticked + } + timings.add(.keyless, now().timeIntervalSince(keylessStarted)) + + let budget = deadline.timeIntervalSince(now()) - Self.reserveSeconds + // Sweeping the leftover of an ended exit needs no SDK. + if !Task.isCancelled, budget > 0, record.isActive { + let connectStarted = now() + recovering = await connect(budget) + timings.add(.connect, now().timeIntervalSince(connectStarted)) + } + } + if let recovering, !Task.isCancelled { + outcome.isConnected = true + do { + record = try await makeDriver(chain, TimedSparkExitRecovering(base: recovering, timings: timings)).tick(record) + } catch { + Logger.error("Spark exit background tick failed: \(error)", context: "SparkExitBackgroundRun") + } + } + outcome.record = record + outcome.seconds = timings.seconds + return outcome + } +} + +/// Wall time per step of a background run, also marked as signpost intervals for Instruments. +final class SparkExitTimings: @unchecked Sendable { + static let signposter = OSSignposter(subsystem: "to.bitkit", category: "SparkExitBackground") + private let lock = NSLock() + private var values: [SparkExitBackgroundRun.Step: TimeInterval] = [:] + + var seconds: [SparkExitBackgroundRun.Step: TimeInterval] { + lock.withLock { values } + } + + func add(_ step: SparkExitBackgroundRun.Step, _ seconds: TimeInterval) { + lock.withLock { values[step, default: 0] += seconds } + } + + func time(_ step: SparkExitBackgroundRun.Step, _ body: () async throws -> T) async rethrows -> T { + let state = Self.signposter.beginInterval("step", "\(step.rawValue)") + let started = Date() + defer { + add(step, Date().timeIntervalSince(started)) + Self.signposter.endInterval("step", state) + } + return try await body() + } +} + +private struct TimedSparkExitRecovering: SparkExitRecovering { + let base: SparkExitRecovering + let timings: SparkExitTimings + + func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse { + try await timings.time(.sign) { try await base.prepare(request) } + } + + func recover(_ request: RecoverFundsRequest, signer: CpfpSigner?) async throws -> RecoverFundsResponse { + try await timings.time(.sign) { try await base.recover(request, signer: signer) } + } + + func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { + try await timings.time(.check) { try await base.check(recovery) } + } +} + +private struct TimedSparkExitChain: SparkExitChain { + let base: SparkExitChain + let timings: SparkExitTimings + + func tipHeight() async throws -> UInt32 { + try await base.tipHeight() + } + + func utxos(address: String) async throws -> [EsploraUtxo] { + try await base.utxos(address: address) + } + + func txStatus(txid: String) async throws -> EsploraTxStatus { + try await base.txStatus(txid: txid) + } + + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { + await timings.time(.broadcast) { await base.broadcast(tx) } + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift new file mode 100644 index 000000000..5819cbabf --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -0,0 +1,262 @@ +import BreezSdkSpark +import Foundation + +struct EsploraTxStatus: Codable, Equatable { + let confirmed: Bool + var blockHeight: UInt32? + + enum CodingKeys: String, CodingKey { + case confirmed + case blockHeight = "block_height" + } +} + +struct EsploraUtxo: Codable, Equatable { + let txid: String + let vout: UInt32 + let value: UInt64 + let status: EsploraTxStatus +} + +/// What a broadcast came to. Accepted includes a transaction the node already had. +enum SparkExitBroadcastOutcome: Equatable { + case accepted + /// Its relative timelock has not matured (`non-BIP68-final`): the next tick tries again. Never a rejection. + case notMature(String) + /// An input is missing or already spent: its parent is not visible yet, or another transaction spent it. + case inputsMissing(String) + /// The host or node could not take it now: the next tick tries again. + case retryLater(String) + /// Refused for good at this fee or policy; a later check or rebuild replaces it. + case rejected(String) + + var summary: String { + switch self { + case .accepted: "accepted" + case let .notMature(reason): "not mature: \(reason)" + case let .inputsMissing(reason): "inputs missing: \(reason)" + case let .retryLater(reason): "retry: \(reason)" + case let .rejected(reason): "rejected: \(reason)" + } + } +} + +/// The chain the Spark SDK reads, for what an exit does outside the SDK: broadcasting and the funding address. +protocol SparkExitChain: Sendable { + func tipHeight() async throws -> UInt32 + func utxos(address: String) async throws -> [EsploraUtxo] + func txStatus(txid: String) async throws -> EsploraTxStatus + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome +} + +protocol SparkExitHTTP: Sendable { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) +} + +struct URLSessionSparkExitHTTP: SparkExitHTTP { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } + return (data, http) + } +} + +/// esplora and mempool.space REST, on the hosts the Spark SDK uses by default: a transaction with a CPFP child goes +/// out as a package through `/v1/txs/package` (JSON array of hex), one without as a single `/tx`. +struct EsploraSparkExitChain: SparkExitChain { + let hosts: [URL] + var http: SparkExitHTTP = URLSessionSparkExitHTTP() + + /// The Spark SDK's default chain service for each network (breez spark-sdk `default_rest_chain_services`). + static func defaultHosts(for network: BreezSdkSpark.Network) -> [URL] { + switch network { + case .mainnet: [URL(string: "https://mempool.space/api")!, URL(string: "https://blockstream.info/api")!] + case .regtest: [URL(string: "https://regtest-mempool.us-west-2.sparkinfra.net/api")!] + default: [] + } + } + + func tipHeight() async throws -> UInt32 { + let data = try await get("/blocks/tip/height") + guard let height = UInt32(String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw URLError(.cannotParseResponse) + } + return height + } + + func utxos(address: String) async throws -> [EsploraUtxo] { + try await JSONDecoder().decode([EsploraUtxo].self, from: get("/address/\(address)/utxo")) + } + + func txStatus(txid: String) async throws -> EsploraTxStatus { + try await JSONDecoder().decode(EsploraTxStatus.self, from: get("/tx/\(txid)/status")) + } + + /// Tries each host until one accepts. Otherwise a node's refusal wins over a host that was only unavailable. + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { + var outcomes: [SparkExitBroadcastOutcome] = [] + for host in hosts { + let attempt: SparkExitBroadcastOutcome? = if let child = tx.cpfpTxHex { + await submitPackage(host: host, hexes: [tx.txHex, child], txid: tx.txid) + } else { + await submitSingle(host: host, hex: tx.txHex) + } + // nil: this host cannot take it (no package endpoint, unreachable). + guard let attempt else { continue } + if attempt == .accepted { + return attempt + } + outcomes.append(attempt) + } + return outcomes.first { + if case .rejected = $0 { + true + } else { + false + } + } ?? outcomes.first ?? .retryLater("no chain host reachable") + } + + private func submitPackage(host: URL, hexes: [String], txid: String) async -> SparkExitBroadcastOutcome? { + for path in ["/v1/txs/package", "/txs/package"] { + var request = URLRequest(url: host.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONEncoder().encode(hexes) + request.timeoutInterval = 30 + guard let (data, response) = try? await http.send(request) else { return nil } + if response.statusCode == 404 || response.statusCode == 405 { + continue + } + return SparkExitBroadcastClassifier.package(status: response.statusCode, body: data, txid: txid) + } + return nil + } + + private func submitSingle(host: URL, hex: String) async -> SparkExitBroadcastOutcome? { + var request = URLRequest(url: host.appendingPathComponent("/tx")) + request.httpMethod = "POST" + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + request.httpBody = Data(hex.utf8) + request.timeoutInterval = 30 + guard let (data, response) = try? await http.send(request) else { return nil } + if response.statusCode == 404 || response.statusCode == 405 { + return nil + } + return SparkExitBroadcastClassifier.single(status: response.statusCode, body: data) + } + + private func get(_ path: String) async throws -> Data { + var lastError: Error = URLError(.cannotFindHost) + for host in hosts { + var request = URLRequest(url: host.appendingPathComponent(path)) + request.timeoutInterval = 20 + do { + let (data, response) = try await http.send(request) + guard (200 ..< 300).contains(response.statusCode) else { throw URLError(.badServerResponse) } + return data + } catch { + lastError = error + } + } + throw lastError + } +} + +/// Reads node responses: mempool returns the RPC code only (`sendrawtransaction RPC error: {"code":-25}`), +/// esplora the message too, and a package the per-transaction errors. +enum SparkExitBroadcastClassifier { + private static let knownMessages = [ + "txn-already-in-mempool", + "txn-already-known", + "txn-same-nonwitness-data-in-mempool", + "already in block chain", + ] + private static let notMatureMessages = ["non-BIP68-final", "non-final"] + private static let missingInputMessages = ["bad-txns-inputs-missingorspent", "missing-inputs", "missingorspent"] + + static func single(status: Int, body: Data) -> SparkExitBroadcastOutcome { + let text = String(decoding: body, as: UTF8.self) + if (200 ..< 300).contains(status) { + return .accepted + } + if let unavailable = unavailable(status) { + return unavailable + } + return classify(error: text) + } + + /// `txid` is the package's parent, the step itself: an error on it decides the outcome. Missing inputs count as + /// such only when the step reports them; the CPFP child's own missing input (its funding or chained change) is + /// retried. + static func package(status: Int, body: Data, txid: String? = nil) -> SparkExitBroadcastOutcome { + let text = String(decoding: body, as: UTF8.self) + if let unavailable = unavailable(status) { + return unavailable + } + guard (200 ..< 300).contains(status), + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] + else { + return classify(error: text) + } + if json["package_msg"] as? String == "success" { + return .accepted + } + let results = (json["tx-results"] as? [String: Any])?.values.compactMap { $0 as? [String: Any] } ?? [] + let errors = results.compactMap { result in (result["error"] as? String).map { (txid: result["txid"] as? String, error: $0) } } + if errors.isEmpty { + return results.isEmpty ? .retryLater(text) : .accepted + } + // The step's own error decides, whatever its child reports and in whatever order the results come. + if let txid, let own = errors.first(where: { $0.txid == txid }) { + let outcome = classify(error: own.error) + if outcome != .accepted { + return outcome + } + } + let outcomes = errors.map { result in + let outcome = classify(error: result.error) + if case .inputsMissing = outcome, let txid, let resultTxid = result.txid, resultTxid != txid { + return SparkExitBroadcastOutcome.retryLater(result.error) + } + return outcome + } + if outcomes.allSatisfy({ $0 == .accepted }) { + return .accepted + } + // A child of a parent not mature yet can fail with any error, so the timelock wins over them all. + let precedence: (SparkExitBroadcastOutcome) -> Int = { + switch $0 { + case .notMature: 0 + case .rejected: 1 + case .inputsMissing: 2 + case .retryLater: 3 + case .accepted: 4 + } + } + return outcomes.min { precedence($0) < precedence($1) } ?? .accepted + } + + /// A host that is down, rate limiting or behind a challenge page says nothing about the transaction. + private static func unavailable(_ status: Int) -> SparkExitBroadcastOutcome? { + status >= 500 || status == 429 || status == 403 ? .retryLater("HTTP \(status)") : nil + } + + static func classify(error text: String) -> SparkExitBroadcastOutcome { + if knownMessages.contains(where: text.contains) || hasRpcCode(-27, in: text) { + return .accepted + } + if notMatureMessages.contains(where: text.contains) { + return .notMature(text) + } + if missingInputMessages.contains(where: text.contains) || hasRpcCode(-25, in: text) { + return .inputsMissing(text) + } + return .rejected(text) + } + + /// The RPC code, also when the node's error sits JSON-escaped inside mempool's own error object. + private static func hasRpcCode(_ code: Int, in text: String) -> Bool { + text.range(of: #"code\\?"\s*:\s*\#(code)\b"#, options: .regularExpression) != nil + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift new file mode 100644 index 000000000..d04a65958 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -0,0 +1,643 @@ +import BreezSdkSpark +import CryptoKit +import Foundation + +/// The SDK calls an exit makes. `BreezSdk` in the app, a fake in tests. +protocol SparkExitRecovering: Sendable { + func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse + func recover(_ request: RecoverFundsRequest, signer: CpfpSigner?) async throws -> RecoverFundsResponse + func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse +} + +/// Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready, +/// rebuilds when the chain diverged, and finishes when everything confirmed. Without the SDK (a background launch, +/// or operators down at connect) it still broadcasts the stored, already signed transactions whose parents +/// confirmed. Every built recovery is saved before anything of it is broadcast, and every broadcast is saved as +/// soon as its outcome is known. Once the task running it is cancelled (a background run expiring) it starts no +/// SDK call and no broadcast, and saves what it did, so the next run resumes without sending anything twice. +struct SparkExitDriver { + let chain: SparkExitChain + /// nil while the Spark SDK is not connected. + let recovering: SparkExitRecovering? + /// The signer of the funding key at the record's index. + let makeSigner: (UInt32) throws -> CpfpSigner + let save: (SparkExitRecord) throws -> Void + var now: () -> Date = Date.init + + /// A ready transaction accepted in this generation is sent again after this long, in case a node dropped it. + static let rebroadcastInterval: TimeInterval = 600 + /// After this many rejections of one transaction the progress screen shows the error. + static let rejectionsToSurface = 3 + /// The least time between two builds, so a divergence that persists does not rebuild every tick. + static let rebuildInterval: TimeInterval = 600 + /// Chain host reads in flight at once. + static let concurrentReads = 8 + /// Rebuilds in a row that reproduce a step whose input another transaction spent before its leaf counts as lost: + /// an older state of the wallet claimed it, and every rebuild would send the same refund again. + static let strikesToLoseLeaf = 2 + + @discardableResult + func tick(_ record: SparkExitRecord) async throws -> SparkExitRecord { + var record = record + switch record.phase { + case .done, .failed: + guard record.isLeftoverSweepPending, !Task.isCancelled else { return record } + try await sweepLeftover(&record) + try save(record) + return record + case .awaitingFunding, .needsFunding: + guard let recovering, !Task.isCancelled else { return record } + // Nothing to fund when every leaf is recovered cooperatively. + if let shortfall = record.fundingShortfallSats { + guard try await adoptFunding(&record, requiredSats: shortfall) else { return record } + } + try await build(&record, recovering: recovering) + case .running where record.isRebuilding: + // The diverged generation is never broadcast again; only a rebuild moves on from here. + guard let recovering, !Task.isCancelled, isRebuildDue(record) else { return record } + try await build(&record, recovering: recovering) + case .running: + if Task.isCancelled { + return record + } + if let recovering { + try await check(&record, recovering: recovering) + } else { + try await broadcastWithoutSdk(&record) + } + } + record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response) } + try save(record) + return record + } + + // MARK: Funding + + /// Adds every confirmed new UTXO at the funding address as a funding input once together they cover + /// `requiredSats`. + private func adoptFunding(_ record: inout SparkExitRecord, requiredSats: UInt64) async throws -> Bool { + guard let address = record.funding.first?.address, let pubkey = record.funding.first?.pubkey else { return false } + let utxos = try await chain.utxos(address: address) + guard let topUp = SparkExitMath.fundingTopUp(SparkExitMath.newFunding(utxos, record: record), requiredSats: requiredSats) else { + return false + } + let index = record.fundingIndex + if record.funding.count == 1, record.funding[0].txid == nil { + record.funding = [] + } + record.funding += topUp.map { + SparkExitRecord.Funding(index: index, address: address, pubkey: pubkey, txid: $0.txid, vout: $0.vout, valueSats: $0.value) + } + return true + } + + // MARK: Build + + private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + guard let prepared = try await prepare(&record, recovering: recovering) else { + // The last leaf lost on a tick that could not read the funding address ends here, still as lost. + await finish(&record, phase: .failed(reason: record.isEveryLeafLost ? SparkExitRecord.lostReason : "No leaf can be exited")) + return + } + guard !prepared.leaves.isEmpty else { + // A leaf the chain could not be read for comes back on a later tick; finished leaves are not listed. + if prepared.skipped.contains(where: { $0.reason == .unverified }) { + return + } + record.skippedLeafCount = max(record.skippedLeafCount, prepared.skipped.count) + await finish(&record, phase: record.recovery == nil ? .failed(reason: "Nothing left to recover") : .done) + return + } + let built: RecoverFundsResponse + do { + built = try await recovering.recover( + RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs), + signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex) + ) + } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { + // Built at the same fee rate, a leaf can still fail here: leave it behind and rebuild at once. + if let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) { + leaveBehind([leafId], reason: message, in: &record) + } + record.isRebuilding = record.recovery != nil + record.lastBuildAt = nil + return + } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { + // The SDK names the total all inputs must hold, not what is missing. + var utxos: [EsploraUtxo]? + if let address = record.funding.first?.address { + utxos = try? await chain.utxos(address: address) + } + record.phase = .needsFunding(requiredSats: SparkExitMath.neededFunding(requiredSats: requiredSats, record: record, utxos: utxos)) + return + } + guard !built.transactions.isEmpty else { + // Cooperative leaves the operators could not co-sign now are tried again on the next tick. + if !built.failed.isEmpty { + record.failedLeafCount = built.failed.count + return + } + await finish(&record, phase: .failed(reason: "Nothing to recover")) + return + } + let strikes = record.recovery == nil ? [:] : record.strikes(rebuiltInto: built) + let lost = lostLeaves(built, strikes: strikes, record: record) + if !lost.isEmpty { + Logger.warn("Spark exit leaves claimed by an older state: \(lost.map(\.leafId))", context: "SparkExitDriver") + record.lostLeaves = (record.lostLeaves ?? []) + lost + record.spentStepStrikes = nil + // Not adopted: the next tick rebuilds at once without the lost leaves. + record.isRebuilding = true + record.lastBuildAt = nil + if record.exitLeafIds.isEmpty { + await finish(&record, phase: .failed(reason: SparkExitRecord.lostReason)) + } + return + } + record.adopt(built, at: now()) + record.spentStepStrikes = strikes.isEmpty ? nil : strikes + // Saved before any of it is broadcast, so a crash never leaves broadcast transactions without their record. + try save(record) + try await broadcastReady(&record) + } + + /// Prepares the exit's leaves. `Specific` fails as a whole when one fee-ladder leaf cannot pay its refund's fee: + /// that leaf is left behind and the rest prepared again, and when the error names no leaf, `RecoverableOnly` + /// prepares what it can and the leaves it leaves out stay behind. Nil once no leaf can be exited. + private func prepare(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws -> PrepareRecoverFundsResponse? { + var isRecoverableOnly = false + while !record.exitLeafIds.isEmpty { + do { + let prepared = try await recovering.prepare(PrepareRecoverFundsRequest( + feeRateSatPerVbyte: record.feeRateSatPerVbyte, + fundingKind: .p2wpkh, + destination: record.destination, + selection: isRecoverableOnly ? .recoverableOnly : .specific(leafIds: record.exitLeafIds) + )) + if isRecoverableOnly { + let kept = Set(prepared.leaves.map(\.leafId)) + leaveBehind(record.exitLeafIds.filter { !kept.contains($0) }, reason: "Not recoverable at this fee rate", in: &record) + } + return prepared + } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { + if let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) { + leaveBehind([leafId], reason: message, in: &record) + } else if isRecoverableOnly { + throw SdkError.SparkError(message) + } else { + isRecoverableOnly = true + } + } + } + return nil + } + + private func leaveBehind(_ leafIds: [String], reason: String, in record: inout SparkExitRecord) { + guard !leafIds.isEmpty else { return } + let values = Dictionary((record.recovery?.response.leaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first }) + Logger.warn("Spark exit leaves left behind: \(leafIds): \(reason)", context: "SparkExitDriver") + record.leftBehindLeaves = (record.leftBehindLeaves ?? []) + leafIds.map { + SparkExitRecord.LeftBehindLeaf(leafId: $0, valueSats: values[$0] ?? 0, reason: reason) + } + } + + /// The leaves whose refund a rebuild reproduced after its input was spent by another transaction in each of the + /// last generations. A refund's node id is its leaf. + private func lostLeaves(_ built: RecoverFundsResponse, strikes: [String: Int], record: SparkExitRecord) -> [SparkExitRecord.LostLeaf] { + let values = Dictionary( + ((record.recovery?.response.leaves ?? []) + built.leaves).map { ($0.leafId, $0.valueSats) }, + uniquingKeysWith: { first, _ in first } + ) + var lost: [SparkExitRecord.LostLeaf] = [] + for tx in built.transactions where tx.kind == .refund && strikes[tx.txid, default: 0] >= Self.strikesToLoseLeaf { + guard let leafId = tx.nodeId, record.exitLeafIds.contains(leafId), !lost.contains(where: { $0.leafId == leafId }) else { continue } + lost.append(.init(leafId: leafId, valueSats: values[leafId] ?? 0)) + } + return lost + } + + // MARK: Check + + private func check(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + guard let stored = record.recovery?.response else { + record.phase = .awaitingFunding(requiredSats: 0) + return + } + let checked = try await recovering.check(withConfirmations(stored)) + record.recovery = StoredRecovery(checked.recovery) + switch checked.verdict { + case .valid: + try await broadcastReady(&record) + case .done: + await finish(&record, phase: .done) + case .redo: + Logger.warn("Spark exit diverged on-chain, rebuilding generation \(record.generation + 1)", context: "SparkExitDriver") + record.isRebuilding = true + try save(record) + guard !Task.isCancelled, isRebuildDue(record) else { return } + try await build(&record, recovering: recovering) + } + } + + /// Marks the next steps the chain host shows in a block as confirmed. The SDK asks the chain about a step only + /// once the caller saw it in a block; otherwise it looks for who spent its input, which an Electrum-backed + /// mempool, such as Spark's regtest, does not report. + private func withConfirmations(_ recovery: RecoverFundsResponse) async -> RecoverFundsResponse { + var recovery = recovery + var confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + var asked = Set() + // One round per depth, each read at once: a 30-transaction exit takes a few rounds instead of 30 reads in a row. + while !Task.isCancelled { + let next = recovery.transactions.indices.filter { i in + let tx = recovery.transactions[i] + return !confirmed.contains(tx.txid) && !asked.contains(tx.txid) && tx.dependsOn.allSatisfy(confirmed.contains) + } + guard !next.isEmpty else { break } + let statuses = await statuses(next.map { recovery.transactions[$0].txid }) + asked.formUnion(next.map { recovery.transactions[$0].txid }) + for i in next { + guard let status = statuses[recovery.transactions[i].txid], status.confirmed else { continue } + confirmed.insert(recovery.transactions[i].txid) + recovery.transactions[i].status = .confirmed(blockHeight: status.blockHeight) + } + } + return recovery + } + + /// The chain host's status of each transaction, read concurrently; one it could not answer is left out. + private func statuses(_ txids: [String]) async -> [String: EsploraTxStatus] { + await withTaskGroup(of: (String, EsploraTxStatus?).self) { group in + var results: [String: EsploraTxStatus] = [:] + var pending = Set(txids).makeIterator() + func addNext() -> Bool { + guard let txid = pending.next() else { return false } + group.addTask { [chain] in await (txid, try? chain.txStatus(txid: txid)) } + return true + } + for _ in 0 ..< Self.concurrentReads { + guard addNext() else { break } + } + while let (txid, status) = await group.next() { + results[txid] = status + _ = addNext() + } + return results + } + } + + private func isRebuildDue(_ record: SparkExitRecord) -> Bool { + guard let last = record.lastBuildAt else { return true } + return now().timeIntervalSince(last) >= Self.rebuildInterval + } + + /// Ends the exit and notes what is left at the funding address, which the next step sweeps to savings. While the + /// address cannot be read the exit keeps its phase: one ended without its leftover read would never sweep it, so + /// the next tick ends it instead. + private func finish(_ record: inout SparkExitRecord, phase: SparkExitRecord.Phase) async { + guard let address = record.funding.first?.address else { + record.phase = phase + return + } + guard let utxos = try? await chain.utxos(address: address) else { return } + record.phase = phase + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + if record.isLeftoverSweepPending, !Task.isCancelled { + try? await sweepLeftover(&record) + } + } + + // MARK: Leftover + + /// Sends the confirmed leftover at the funding address to the exit's savings address, signed with the funding + /// key. Built and saved once before it is sent, then sent again on later ticks until it confirms. Waits while + /// the leftover is unconfirmed, and skips it when the fee would leave less than dust. + private func sweepLeftover(_ record: inout SparkExitRecord) async throws { + if record.leftoverSweep == nil { + guard let address = record.funding.first?.address else { return } + let utxos = try await chain.utxos(address: address) + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + let confirmed = utxos.filter(\.status.confirmed) + guard !confirmed.isEmpty, confirmed.count == utxos.count else { return } + let built = try await SparkExitLeftoverSweep.build( + utxos: confirmed, + fundingAddress: address, + destination: record.destination, + feeRateSatPerVbyte: record.feeRateSatPerVbyte, + signer: makeSigner(record.fundingIndex) + ) + record.leftoverSweep = SparkExitRecord.LeftoverSweep(txid: built?.txid, txHex: built?.txHex, valueSats: built?.valueSats ?? 0) + try save(record) + } + guard let sweep = record.leftoverSweep, let txid = sweep.txid, let hex = sweep.txHex, sweep.rejection == nil, + sweep.isConfirmed != true, !Task.isCancelled + else { return } + if sweep.isAccepted, let status = try? await chain.txStatus(txid: txid), status.confirmed { + record.leftoverSweep?.isConfirmed = true + return + } + let outcome = await chain.broadcast(RecoveryTransaction( + kind: .sweep, + nodeId: nil, + txid: txid, + txHex: hex, + cpfpTxHex: nil, + csvTimelockBlocks: nil, + dependsOn: [], + status: .ready + )) + Logger.info("Spark exit leftover sweep \(txid): \(outcome.summary)", context: "SparkExitDriver") + switch outcome { + case .accepted: record.leftoverSweep?.isAccepted = true + // Once accepted, a later refusal is a node that already has it or a conflict with it confirming. + case let .rejected(reason) where !sweep.isAccepted: record.leftoverSweep?.rejection = reason + default: break + } + } + + // MARK: Broadcast + + private func broadcastReady(_ record: inout SparkExitRecord) async throws { + guard let recovery = record.recovery?.response else { return } + let confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + // Unverified: the SDK could not read its parent from the chain; nodes refuse it harmlessly if it is not final. + for tx in recovery.transactions where tx.status == .ready || tx.status == .unverified { + guard !Task.isCancelled else { return } + guard isDue(tx.txid, in: record) else { continue } + let hasConfirmedParents = tx.status == .ready && tx.dependsOn.allSatisfy(confirmed.contains) + await broadcast(tx, into: &record, hasConfirmedParents: hasConfirmedParents) + try save(record) + } + } + + /// Without the SDK: each unconfirmed transaction whose parents are confirmed on the chain host and whose relative + /// timelocks matured, counted from the confirmation height of the parent each input spends. A step whose input + /// someone else spent, the operators' version of it, is not sent again; the next SDK check rebuilds. A stale one + /// is refused by nodes as a conflict, and only the current generation is stored. + private func broadcastWithoutSdk(_ record: inout SparkExitRecord) async throws { + guard var recovery = record.recovery?.response else { return } + let tip = try await chain.tipHeight() + let listed = Set(recovery.transactions.map(\.txid)) + var confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + var heights: [String: UInt32] = [:] + for tx in recovery.transactions { + if case let .confirmed(height?) = tx.status { + heights[tx.txid] = height + } + } + let unknown = recovery.transactions.filter { !$0.isConfirmed }.map(\.txid) + + Set(recovery.transactions.flatMap(\.dependsOn)).subtracting(listed).subtracting(confirmed) + let statuses = await statuses(unknown) + for (txid, status) in statuses where status.confirmed { + confirmed.insert(txid) + heights[txid] = status.blockHeight + } + for (i, tx) in recovery.transactions.enumerated() { + guard !Task.isCancelled else { break } + if tx.isConfirmed { + continue + } + if let status = statuses[tx.txid], status.confirmed { + recovery.transactions[i].status = .confirmed(blockHeight: status.blockHeight) + continue + } + guard tx.dependsOn.allSatisfy(confirmed.contains), isDue(tx.txid, in: record, withoutSdk: true) else { continue } + if case let .waitingForTimelock(height?) = tx.status, !SparkExitTimelock.isMature(height, tip: tip) { + continue + } + let locks = SparkExitTimelock.locks(of: tx) + for parent in Set(locks.map(\.parent)).subtracting(heights.keys) { + if let status = try? await chain.txStatus(txid: parent), status.confirmed, let height = status.blockHeight { + heights[parent] = height + } + } + switch SparkExitTimelock.maturity(of: tx, locks: locks, heights: heights) { + case .unknown: + continue + case let .at(height) where !SparkExitTimelock.isMature(height, tip: tip): + recovery.transactions[i].status = .waitingForTimelock(spendableAtHeight: height) + continue + case .none, .at: + break + } + await broadcast(tx, into: &record, hasConfirmedParents: true) + record.recovery = StoredRecovery(recovery) + try save(record) + } + record.recovery = StoredRecovery(recovery) + } + + /// Without the SDK, a step whose input someone else spent is never due: only a rebuild moves on from it. + private func isDue(_ txid: String, in record: SparkExitRecord, withoutSdk: Bool = false) -> Bool { + guard let last = record.broadcastLog.first(where: { $0.generation == record.generation && $0.txid == txid }) else { return true } + if withoutSdk, last.isInputSpent == true { + return false + } + return !last.isAccepted || now().timeIntervalSince(last.lastAttemptAt) >= Self.rebroadcastInterval + } + + /// With every parent of the step confirmed, missing inputs mean another transaction spent one. + private func broadcast(_ tx: RecoveryTransaction, into record: inout SparkExitRecord, hasConfirmedParents: Bool) async { + let outcome = await chain.broadcast(tx) + Logger.info("Spark exit broadcast \(tx.kind) \(tx.txid): \(outcome.summary)", context: "SparkExitDriver") + let isAccepted = outcome == .accepted + let isInputSpent: Bool? = if hasConfirmedParents, case .inputsMissing = outcome { + true + } else { + nil + } + let isRejected = if case .rejected = outcome { + true + } else { + false + } + if let i = record.broadcastLog.firstIndex(where: { $0.generation == record.generation && $0.txid == tx.txid }) { + record.broadcastLog[i].attempts += 1 + record.broadcastLog[i].rejections += isRejected ? 1 : 0 + record.broadcastLog[i].lastAttemptAt = now() + record.broadcastLog[i].lastOutcome = outcome.summary + record.broadcastLog[i].isAccepted = isAccepted + record.broadcastLog[i].isInputSpent = isInputSpent + } else { + record.broadcastLog.append(.init( + generation: record.generation, + txid: tx.txid, + attempts: 1, + rejections: isRejected ? 1 : 0, + lastAttemptAt: now(), + lastOutcome: outcome.summary, + isAccepted: isAccepted, + isInputSpent: isInputSpent + )) + } + } +} + +private extension RecoveryTransaction { + var isConfirmed: Bool { + if case .confirmed = status { + return true + } + return false + } +} + +extension SparkExitRecord { + /// The rejection to show on the progress screen once a transaction was refused several times. + var surfacedRejection: String? { + broadcastLog.first { $0.generation == generation && $0.rejections >= SparkExitDriver.rejectionsToSurface && !$0.isAccepted }?.lastOutcome + } +} + +/// When a step's relative timelocks mature, as the SDK's `spendable_at_height` computes it: each input's BIP68 block +/// lock counts from the confirmation height of the transaction that input spends. +enum SparkExitTimelock { + struct Lock: Equatable { + let parent: String + let blocks: UInt32 + } + + enum Maturity: Equatable { + /// No relative timelock. + case none + /// Spendable from this height. + case at(UInt32) + /// A parent's confirmation height is not known yet. + case unknown + } + + /// The block locks of the step's inputs, from its hex; from `csvTimelockBlocks` over its parents when the hex + /// does not decode. Empty when it has no timelock. + static func locks(of tx: RecoveryTransaction) -> [Lock] { + guard let csv = tx.csvTimelockBlocks else { return [] } + return SparkExitTxid.relativeBlockLocks(hex: tx.txHex) ?? tx.dependsOn.map { Lock(parent: $0, blocks: csv) } + } + + static func maturity(of tx: RecoveryTransaction, locks: [Lock], heights: [String: UInt32]) -> Maturity { + guard tx.csvTimelockBlocks != nil else { return .none } + // The SDK leaves a step with a timelock but no lock it can count as unknown, and so does this. + guard !locks.isEmpty else { return .unknown } + var spendable: UInt32 = 0 + for lock in locks { + guard let height = heights[lock.parent] else { return .unknown } + spendable = max(spendable, height.addingReportingOverflow(lock.blocks).partialValue) + } + return .at(spendable) + } + + /// A transaction is accepted while it would be valid in the next block. + static func isMature(_ spendableAtHeight: UInt32, tip: UInt32) -> Bool { + UInt64(tip) + 1 >= UInt64(spendableAtHeight) + } +} + +/// Transaction ids from raw hex, for the CPFP children the SDK returns without one. +enum SparkExitTxid { + static func cpfpChildTxids(_ recovery: RecoverFundsResponse) -> [String] { + recovery.transactions.compactMap { $0.cpfpTxHex.flatMap(txid(hex:)) } + } + + /// Each input's BIP68 block lock with the txid it spends; nil when the hex does not decode. Version 1 + /// transactions and inputs whose sequence disables the lock or counts time carry none. + static func relativeBlockLocks(hex: String) -> [SparkExitTimelock.Lock]? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + guard let parsed = reader.inputs() else { return nil } + guard parsed.version >= 2 else { return [] } + return parsed.inputs.compactMap { input in + let sequence = input.sequence + let isDisabled = sequence & (1 << 31) != 0 + let isTime = sequence & (1 << 22) != 0 + let blocks = sequence & 0xFFFF + guard !isDisabled, !isTime, blocks > 0 else { return nil } + return SparkExitTimelock.Lock(parent: input.txid, blocks: blocks) + } + } + + /// The txid of a serialized transaction: double SHA-256 of it without witness data, byte-reversed. + static func txid(hex: String) -> String? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + guard let stripped = reader.strippedOfWitness() else { return nil } + let hash = Data(SHA256.hash(data: Data(SHA256.hash(data: stripped)))) + return Data(hash.reversed()).hex + } + + private struct Reader { + let bytes: [UInt8] + var position = 0 + + mutating func take(_ count: Int) -> [UInt8]? { + guard count >= 0, position + count <= bytes.count else { return nil } + defer { position += count } + return Array(bytes[position ..< position + count]) + } + + mutating func varInt() -> (value: Int, raw: [UInt8])? { + guard let first = take(1)?.first else { return nil } + let width = switch first { + case 0xFD: 2 + case 0xFE: 4 + case 0xFF: 8 + default: 0 + } + guard width > 0 else { return (Int(first), [first]) } + guard let rest = take(width) else { return nil } + let value = rest.enumerated().reduce(0) { $0 | Int($1.element) << (8 * $1.offset) } + return (value, [first] + rest) + } + + mutating func inputs() -> (version: Int32, inputs: [(txid: String, sequence: UInt32)])? { + guard let versionBytes = take(4) else { return nil } + let version = Int32(bitPattern: littleEndian(versionBytes)) + if bytes.count > 5, bytes[4] == 0, bytes[5] == 1 { + position += 2 + } + guard let count = varInt() else { return nil } + var inputs: [(txid: String, sequence: UInt32)] = [] + for _ in 0 ..< count.value { + guard let txid = take(32), take(4) != nil, let script = varInt(), take(script.value) != nil, let sequence = take(4) else { + return nil + } + inputs.append((Data(txid.reversed()).hex, littleEndian(sequence))) + } + return (version, inputs) + } + + private func littleEndian(_ bytes: [UInt8]) -> UInt32 { + bytes.enumerated().reduce(0) { $0 | UInt32($1.element) << (8 * $1.offset) } + } + + mutating func strippedOfWitness() -> Data? { + guard let version = take(4) else { return nil } + var out = version + let isSegwit = bytes.count > 5 && bytes[4] == 0 && bytes[5] == 1 + if isSegwit { + position += 2 + } + guard let inputs = varInt() else { return nil } + out += inputs.raw + for _ in 0 ..< inputs.value { + guard let outpoint = take(36), let script = varInt(), let scriptBytes = take(script.value), let sequence = take(4) else { return nil } + out += outpoint + script.raw + scriptBytes + sequence + } + guard let outputs = varInt() else { return nil } + out += outputs.raw + for _ in 0 ..< outputs.value { + guard let value = take(8), let script = varInt(), let scriptBytes = take(script.value) else { return nil } + out += value + script.raw + scriptBytes + } + if isSegwit { + for _ in 0 ..< inputs.value { + guard let items = varInt() else { return nil } + for _ in 0 ..< items.value { + guard let item = varInt(), take(item.value) != nil else { return nil } + } + } + } + guard let lockTime = take(4) else { return nil } + return Data(out + lockTime) + } + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift new file mode 100644 index 000000000..63465193f --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -0,0 +1,349 @@ +import BitkitCore +import BreezSdkSpark +import CryptoKit +import Foundation +import LDKNode + +/// The key that pays a Spark exit's CPFP fees. It derives from the wallet seed on an account no wallet of Bitkit's +/// derives, so ldk-node never spends the funding UTXO or the CPFP change chained back to it. +enum SparkExitFundingKey { + static let account: UInt32 = 7797 + + struct Derived { + let address: String + /// Compressed public key, hex, as `CpfpInput.p2wpkh` takes it. + let pubkey: String + let secretKey: Data + } + + enum KeyError: Error, Equatable { + case invalidWif + } + + static func path(index: UInt32, network: BitkitCore.Network) -> String { + let coin = network == .bitcoin ? 0 : 1 + return "m/84'/\(coin)'/\(account)'/0/\(index)" + } + + static func derive(index: UInt32, mnemonic: String, passphrase: String?, network: BitkitCore.Network) throws -> Derived { + let path = path(index: index, network: network) + let address = try deriveBitcoinAddress(mnemonicPhrase: mnemonic, derivationPathStr: path, network: network, bip39Passphrase: passphrase) + let wif = try derivePrivateKey(mnemonicPhrase: mnemonic, derivationPathStr: path, network: network, bip39Passphrase: passphrase) + return try Derived(address: address.address, pubkey: address.publicKey, secretKey: secretKey(fromWif: wif)) + } + + /// The 32-byte secret of a compressed-key WIF: base58check, version byte first, `0x01` compression flag last. + static func secretKey(fromWif wif: String) throws -> Data { + guard let decoded = Base58.decode(wif), decoded.count == 38 else { throw KeyError.invalidWif } + let payload = decoded.prefix(34) + let checksum = Data(SHA256.hash(data: Data(SHA256.hash(data: payload)))).prefix(4) + guard Data(decoded.suffix(4)) == checksum, decoded[decoded.startIndex + 33] == 0x01 else { throw KeyError.invalidWif } + return Data(payload.dropFirst().prefix(32)) + } +} + +enum Base58 { + private static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + + static func decode(_ string: String) -> Data? { + var bytes: [UInt8] = [] + for character in string { + guard let digit = alphabet.firstIndex(of: character) else { return nil } + var carry = digit + for i in bytes.indices.reversed() { + carry += Int(bytes[i]) * 58 + bytes[i] = UInt8(carry & 0xFF) + carry >>= 8 + } + while carry > 0 { + bytes.insert(UInt8(carry & 0xFF), at: 0) + carry >>= 8 + } + } + let leadingZeros = string.prefix { $0 == "1" }.count + return Data(repeating: 0, count: leadingZeros) + Data(bytes) + } +} + +/// What an exit costs and returns, from a `prepareRecoverFunds` quote. +struct SparkExitQuote: Equatable { + let prepared: PrepareRecoverFundsResponse + + /// The value of the leaves the exit takes on-chain. + var exitSats: UInt64 { + prepared.recoverableValueSats + } + + var networkFeeSats: UInt64 { + prepared.totalFeeSats + } + + /// What savings gains overall: the exited value less every fee, those paid from the fee funding included. + var receiveSats: UInt64 { + exitSats.saturatingSub(networkFeeSats) + } + + /// The on-chain payment that funds the CPFP fees, or 0 when every leaf is recovered cooperatively. + var fundingSats: UInt64 { + prepared.funding?.singleUtxoSats ?? 0 + } + + /// What the sweep and the cooperative recoveries pay to savings: `receiveSats` plus the fee funding the exit + /// did not spend, which the sweep returns. + var incomingSats: UInt64 { + receiveSats + fundingSats + } + + var leftBehindSats: UInt64 { + prepared.skipped.reduce(0) { $0 + $1.valueSats } + } + + var leafIds: [String] { + prepared.leaves.map(\.leafId) + } + + var leafTotalSats: UInt64 { + prepared.leaves.reduce(0) { $0 + $1.valueSats } + } + + /// Breez's rule: only exit when the value is more than its fees. + var isWorthExiting: Bool { + !prepared.leaves.isEmpty && exitSats > networkFeeSats + } +} + +/// A local notification that brings the user back to an exit; `id` is stable across runs for the same step. +struct SparkExitReminder: Equatable { + let id: String + let fireAt: Date + var isFunding = false + var isLostLeaves = false + var isFailed = false +} + +/// What the exit shows about its fee funding while it lacks some. +enum SparkExitFundingState: Equatable { + /// Ask for `sats` more at the funding address, with its QR. `receivedSats` is the new funding already there. + case ask(sats: UInt64, receivedSats: UInt64 = 0) + /// Bitkit paid it from savings in `txid`, which has not confirmed yet. + case paid(txid: String) + /// A payment covering the shortfall reached the address, and the exit waits for it to confirm or to build. + case seen(txid: String) +} + +enum SparkExitMath { + /// The countdown target: the latest block at which a timelocked step of the exit can confirm. + static func claimableAtHeight(_ recovery: RecoverFundsResponse) -> UInt32? { + recovery.transactions.compactMap { tx -> UInt32? in + if case let .waitingForTimelock(height) = tx.status { + return height + } + return nil + }.max() + } + + /// Blocks after a step matures before the operators' watchtower may publish their own version. + static let operatorWindowBlocks: UInt32 = 50 + /// Blocks after a refund matures before an older state of the wallet, with a refund 100 blocks shorter, can + /// claim the leaf while the operators are down. + static let olderStateLeadBlocks: UInt32 = 100 + /// Blocks after a step matures before its reminder fires: well inside the smaller of the two windows above, and + /// time for a background run to publish it first. + static let reminderGraceBlocks: UInt32 = 12 + /// Below the OS's 64 pending notifications per app. + static let maxTimelockReminders = 20 + + /// The reminders an exit needs now. Each timelocked step not yet published gets one at its maturity plus a + /// grace; a step ready to publish gets one after the grace; needing fee funding or a step rejected repeatedly + /// reminds at once. A step published or confirmed needs none, so a background run that publishes a step drops + /// its reminder before it fires. + static func reminders(_ record: SparkExitRecord, tip: UInt32?, now: Date, network: LDKNode.Network) -> [SparkExitReminder] { + let lost = record.lostLeaves ?? [] + var lostReminder = lost.isEmpty ? [] : [SparkExitReminder(id: "lost-\(lost.count)", fireAt: now, isLostLeaves: true)] + // An exit stopped because every leaf was lost is told by the lost reminder alone. + if case .failed = record.phase, !record.isEveryLeafLost { + lostReminder.append(SparkExitReminder(id: "failed", fireAt: now, isFailed: true)) + } + if case let .needsFunding(requiredSats) = record.phase { + return lostReminder + [SparkExitReminder(id: "funding-\(requiredSats)", fireAt: now, isFunding: true)] + } + guard case .running = record.phase, let recovery = record.recovery?.response else { return lostReminder } + var reminders = lostReminder + let generation = record.generation + if let rejected = record.broadcastLog.first(where: { + $0.generation == generation && $0.rejections >= SparkExitDriver.rejectionsToSurface && !$0.isAccepted + }) { + reminders.append(SparkExitReminder(id: "rejected-\(generation)-\(rejected.txid)", fireAt: now)) + } + let accepted = Set(record.broadcastLog.filter { $0.generation == generation && $0.isAccepted }.map(\.txid)) + let blockSeconds = secondsUntil(height: 1, tip: 0, network: network) + let grace = TimeInterval(reminderGraceBlocks) * blockSeconds + var heights = Set() + var readyTxid: String? + for tx in recovery.transactions where !accepted.contains(tx.txid) { + switch tx.status { + case let .waitingForTimelock(height?): + heights.insert(height) + case .ready, .unverified: + readyTxid = readyTxid ?? tx.txid + default: + break + } + } + for height in heights.sorted().prefix(maxTimelockReminders) { + let wait = tip.map { secondsUntil(height: height, tip: $0, network: network) } ?? reminderSeconds(network: network) + reminders.append(SparkExitReminder(id: "\(generation)-h\(height)", fireAt: now.addingTimeInterval(wait + grace))) + } + if let readyTxid { + reminders.append(SparkExitReminder(id: "\(generation)-ready-\(readyTxid)", fireAt: now.addingTimeInterval(grace))) + } + // Steps waiting for parents have no height yet, which only a run learns: remind once progress stalls. Any + // confirmation or accepted broadcast changes the id, which starts the wait again. + let confirmed = confirmedCount(recovery) + let isAhead = heights.contains { height in tip.map { height > $0 } ?? true } + if !isAhead, confirmed < recovery.transactions.count { + let id = "\(generation)-idle-\(confirmed)-\(accepted.count)" + reminders.append(SparkExitReminder(id: id, fireAt: now.addingTimeInterval(reminderSeconds(network: network)))) + } + return reminders + } + + /// Whether every refund's timelock is behind it: none still waits for its parents or its height. + static func areTimelocksMatured(_ transactions: [RecoveryTransaction]) -> Bool { + let refunds = transactions.filter { $0.kind == .refund } + return !refunds.isEmpty && !refunds.contains { + switch $0.status { + case .waitingForDependencies, .waitingForTimelock: true + default: false + } + } + } + + /// The active exit transfers no running exit owns: a record set aside as unreadable, or one written without its + /// transfer id before a crash, would otherwise count its amount as in transfer to savings for good. + static func orphanedTransferIds(_ activeTransfers: [Transfer], record: SparkExitRecord?) -> [String] { + let owned = record?.isActive == true ? record?.transferId : nil + return activeTransfers.filter { $0.type == .sparkExit && $0.id != owned }.map(\.id) + } + + /// The steps already in a block, for progress. + static func confirmedCount(_ recovery: RecoverFundsResponse) -> Int { + recovery.transactions.filter { + if case .confirmed = $0.status { + return true + } + return false + }.count + } + + /// UTXOs at the funding address that are new fee funding: neither a funding input yet nor CPFP change any + /// generation of the exit chained there. + static func newFunding(_ utxos: [EsploraUtxo], record: SparkExitRecord) -> [EsploraUtxo] { + let known = Set(record.funding.compactMap { funding in funding.txid.map { "\($0):\(funding.vout ?? 0)" } }) + let chained = Set((record.recovery.map { SparkExitTxid.cpfpChildTxids($0.response) } ?? []) + (record.earlierCpfpTxids ?? [])) + return utxos.filter { !known.contains("\($0.txid):\($0.vout)") && !chained.contains($0.txid) } + } + + /// The confirmed new funding, all of it, once it covers `requiredSats` together: the SDK spends every input it is + /// given, so several small payments fund the exit as well as one. + static func fundingTopUp(_ newFunding: [EsploraUtxo], requiredSats: UInt64) -> [EsploraUtxo]? { + let confirmed = newFunding.filter(\.status.confirmed) + let total = confirmed.reduce(0) { $0 + $1.value } + return !confirmed.isEmpty && total >= requiredSats ? confirmed : nil + } + + /// The funding total a rebuild asks for once the SDK refused it with `InsufficientCpfpFunds(requiredSats)`. + /// `requiredSats` is what all the given inputs must hold together now, and what they hold now is the confirmed + /// value at the funding address apart from new funding (spent inputs live on as chained CPFP change there). + /// The phase counts against `fundedSats`, so the shortfall it shows is `requiredSats` less what the inputs hold. + static func neededFunding(requiredSats: UInt64, record: SparkExitRecord, utxos: [EsploraUtxo]?) -> UInt64 { + guard let utxos else { return max(requiredSats, record.fundedSats) } + let fresh = Set(newFunding(utxos, record: record).map { "\($0.txid):\($0.vout)" }) + let held = utxos.filter { $0.status.confirmed && !fresh.contains("\($0.txid):\($0.vout)") }.reduce(0) { $0 + $1.value } + return record.fundedSats + max(requiredSats.saturatingSub(held), 1) + } + + /// The funding state while the exit lacks fee funding, nil once it lacks none. A payment Bitkit made from savings + /// or one seen unconfirmed is waited for rather than asked for again; only what they leave short is asked for. + static func fundingState(_ record: SparkExitRecord, utxos: [EsploraUtxo]?) -> SparkExitFundingState? { + guard let shortfall = record.fundingShortfallSats else { return nil } + // Paid until it confirms or the exit adopted it; adopted, a later rebuild may have spent it already. + if let paid = record.fundingPaymentTxid, !record.funding.contains(where: { $0.txid == paid }), + !(utxos ?? []).contains(where: { $0.txid == paid && $0.status.confirmed }) + { + return .paid(txid: paid) + } + let fresh = utxos.map { newFunding($0, record: record) } ?? [] + let seenSats = fresh.reduce(0) { $0 + $1.value } + // Covered by confirmed funding alone, the next tick builds. + if seenSats >= shortfall, let pending = fresh.first(where: { !$0.status.confirmed }) ?? fresh.first { + return .seen(txid: pending.txid) + } + return .ask(sats: max(shortfall.saturatingSub(seenSats), fundingDustSats), receivedSats: seenSats) + } + + /// The least a P2WPKH funding output can hold: nodes refuse a payment below it. + static let fundingDustSats: UInt64 = 294 + + /// What a partial payment leaves to send, at least the dust limit: nil before any new funding arrives and once + /// it covers `requiredSats`. + static func partialFundingSats(receivedSats: UInt64, requiredSats: UInt64) -> UInt64? { + receivedSats > 0 && receivedSats < requiredSats ? max(requiredSats - receivedSats, fundingDustSats) : nil + } + + /// The funding key index of a new exit: past the counter the last start left and every index the stored record + /// used. Records from before per-exit indexes used 0. + static func nextFundingIndex(counter: Int, record: SparkExitRecord?) -> UInt32 { + let afterRecord = record?.funding.map(\.index).max().map { $0 + 1 } ?? 0 + return max(UInt32(clamping: max(0, counter)), afterRecord) + } + + /// The Spark balance a quote cannot exit: the leaves it skips, and what the balance holds beyond every leaf the + /// SDK lists, such as a counter-swap still unclaimed. Nil when there is none. + static func unexitableSats(sparkBalanceSats: UInt64, quote: SparkExitQuote) -> UInt64? { + let listed = quote.leafTotalSats + quote.leftBehindSats + let unexitable = quote.leftBehindSats + sparkBalanceSats.saturatingSub(listed) + return unexitable > 0 ? unexitable : nil + } + + /// Whether the SDK refused a `Specific` selection because a leaf in it cannot be exited. + static func isUnexitableLeafError(_ message: String) -> Bool { + message.contains("cannot be exited") + } + + /// The leaf an untyped "Leaf X cannot be exited: …" error names, if it names one. + static func unexitableLeafId(_ message: String) -> String? { + guard let match = message.firstMatch(of: #/Leaf ([^\s:]+) cannot be exited/#) else { return nil } + return String(match.1) + } + + /// What the Spark balance holds beyond the exit's leaves: value that reached Spark after the quote, such as a + /// counter-swap claimed late, which this exit leaves on Spark. Nil when nothing is beyond them. + static func outsideExitSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { + guard record.isActive else { return nil } + return staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) + } + + /// What the Spark balance holds beyond the exit's leaves, in any phase: what a failed exit leaves on Spark. + static func staysOnSparkSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { + guard let leafTotal = record.leafTotalSats, sparkBalanceSats > leafTotal else { return nil } + return sparkBalanceSats - leafTotal + } + + /// About how long until `height`: 10-minute blocks, and Spark's regtest mines one every ~30 seconds. + static func secondsUntil(height: UInt32, tip: UInt32, network: LDKNode.Network) -> TimeInterval { + let blockSeconds: TimeInterval = network == .regtest ? 30 : 600 + return TimeInterval(BlockTimeHelpers.blocksRemaining(until: height, currentHeight: tip)) * blockSeconds + } + + /// How far out to remind about a timelocked step when the tip could not be read, and about an exit whose steps + /// wait for parents with no progress. + static func reminderSeconds(network: LDKNode.Network) -> TimeInterval { + network == .regtest ? 10 * 60 : 4 * 60 * 60 + } + + /// Savings can pay the fee funding when its spendable balance covers the funding and the payment's own fee. + static func canFundFromSavings(spendableSats: UInt64, fundingSats: UInt64, paymentFeeSats: UInt64) -> Bool { + fundingSats > 0 && spendableSats >= fundingSats.saturatingAdd(paymentFeeSats) + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift new file mode 100644 index 000000000..23ab2f4f7 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -0,0 +1,663 @@ +import BreezSdkSpark +import Foundation + +/// Everything a Spark emergency exit needs to continue after a restart. The SDK keeps none of it: the signed +/// recovery, the request it came from, how its fees were funded and what this device already broadcast. +struct SparkExitRecord: Codable, Equatable { + static let currentVersion = 1 + + enum Phase: Codable, Equatable { + /// Waiting for confirmed fee funding of `requiredSats` in all at the funding address before the first build. + case awaitingFunding(requiredSats: UInt64) + /// Built and being driven to the chain. + case running + /// A rebuild needs fee funding of `requiredSats` in all, more than the funding address received so far. + case needsFunding(requiredSats: UInt64) + case done + case failed(reason: String) + + var requiredFundingSats: UInt64? { + switch self { + case let .awaitingFunding(requiredSats), let .needsFunding(requiredSats): requiredSats + default: nil + } + } + } + + struct Funding: Codable, Equatable { + /// The last path component under `SparkExitFundingKey.basePath`. + let index: UInt32 + let address: String + let pubkey: String + var txid: String? + var vout: UInt32? + var valueSats: UInt64? + + var input: CpfpInput? { + guard let txid, let vout, let valueSats else { return nil } + return .p2wpkh(txid: txid, vout: vout, valueSats: valueSats, pubkey: pubkey) + } + } + + struct BroadcastAttempt: Codable, Equatable { + let generation: Int + let txid: String + var attempts: Int + var rejections: Int + var lastAttemptAt: Date + var lastOutcome: String + var isAccepted: Bool + /// Its input was spent by another transaction, the operators' version of the step: the keyless pass stops + /// sending it and the next SDK check rebuilds. + var isInputSpent: Bool? + } + + /// A leaf an older state of the wallet claimed on-chain first, which this exit cannot recover. + struct LostLeaf: Codable, Equatable { + let leafId: String + let valueSats: UInt64 + } + + /// A leaf the SDK cannot exit at the exit's fee rate, such as a fee-ladder leaf whose refund cannot pay its fee. + struct LeftBehindLeaf: Codable, Equatable { + let leafId: String + let valueSats: UInt64 + let reason: String + } + + struct Leftover: Codable, Equatable { + let txid: String + let vout: UInt32 + let valueSats: UInt64 + } + + /// The transaction that sends the leftover fee funding to savings once the exit ended. + struct LeftoverSweep: Codable, Equatable { + /// nil when what is left is under the dust limit after the sweep's fee, so nothing is swept. + var txid: String? + var txHex: String? + /// What reaches savings. + var valueSats: UInt64 = 0 + var isAccepted = false + /// The node's refusal; the sweep is not sent again. + var rejection: String? + /// Set once the chain host shows the accepted sweep in a block. + var isConfirmed: Bool? + } + + var version: Int = Self.currentVersion + let network: String + let createdAt: Date + var phase: Phase + var feeRateSatPerVbyte: UInt64 + let destination: String + /// The leaves of the quote the user confirmed; every rebuild selects exactly these. + let leafIds: [String] + /// What the quote's leaves hold together, for telling how much of the Spark balance is outside the exit. + var leafTotalSats: UInt64? + var funding: [Funding] + /// Bumped on every rebuild. Broadcasts are logged per generation and a rebuilt recovery replaces the old one + /// entirely, so older hex is never sent again. + var generation: Int = 0 + var recovery: StoredRecovery? + var broadcastLog: [BroadcastAttempt] = [] + var transferId: String? + /// The fee funding payment Bitkit broadcast from savings, so the exit shows it as paid instead of asking again. + var fundingPaymentTxid: String? + /// What reaches savings: the exited value less the fees paid out of it. + let expectedSats: UInt64 + var claimableAtHeight: UInt32? + var leftover: [Leftover] = [] + var leftoverSweep: LeftoverSweep? + /// The CPFP children of earlier generations, whose change at the funding address is not new funding. + var earlierCpfpTxids: [String]? + var skippedLeafCount: Int = 0 + /// Cooperative leaves the last build could not recover. + var failedLeafCount: Int = 0 + /// Per step txid, how many rebuilds in a row reproduced it after another transaction spent its input. + var spentStepStrikes: [String: Int]? + var lostLeaves: [LostLeaf]? + var leftBehindLeaves: [LeftBehindLeaf]? + /// Set once the user opened the result of an exit that failed or lost leaves. + var isResultSeen: Bool? + /// Set and saved before a rebuild, so nothing of the diverged generation is broadcast while it is replaced. + var isRebuilding = false + var lastBuildAt: Date? + + var isActive: Bool { + switch phase { + case .awaitingFunding, .running, .needsFunding: true + case .done, .failed: false + } + } + + /// An ended exit whose leftover fee funding still has to reach savings: not swept yet, or sent but not + /// confirmed. + var isLeftoverSweepPending: Bool { + guard !isActive, !leftover.isEmpty else { return false } + guard let leftoverSweep else { return true } + return leftoverSweep.txid != nil && leftoverSweep.rejection == nil && leftoverSweep.isConfirmed != true + } + + static let lostReason = "Claimed by an older state" + + var isFailed: Bool { + if case .failed = phase { + return true + } + return false + } + + /// An ended exit that failed or lost leaves, whose result the user has not opened yet. + var hasUnseenResult: Bool { + !isActive && (isFailed || lostSats > 0) && isResultSeen != true + } + + /// Whether Emergency exit opens this exit rather than a new one: it runs, its leftover is still on its way to + /// savings, the leftover sweep was refused and the leftover waits at the funding address, or it failed or lost + /// leaves and the user has not seen that yet. + var showsResult: Bool { + needsDriving || (!leftover.isEmpty && leftoverSweep?.rejection != nil) || hasUnseenResult + } + + /// The leaves builds select: the quote's, less those an older state claimed and those left behind. + var exitLeafIds: [String] { + let dropped = Set((lostLeaves ?? []).map(\.leafId) + (leftBehindLeaves ?? []).map(\.leafId)) + return leafIds.filter { !dropped.contains($0) } + } + + var leftBehindSats: UInt64 { + (leftBehindLeaves ?? []).reduce(0) { $0 + $1.valueSats } + } + + /// Every leaf of the exit was claimed by an older state, none left behind. + var isEveryLeafLost: Bool { + let lost = Set((lostLeaves ?? []).map(\.leafId)) + return !lost.isEmpty && leafIds.allSatisfy(lost.contains) + } + + var lostSats: UInt64 { + (lostLeaves ?? []).reduce(0) { $0 + $1.valueSats } + } + + /// The strikes of the steps a rebuild into `built` reproduces after their input was spent in this generation. + func strikes(rebuiltInto built: RecoverFundsResponse) -> [String: Int] { + let spent = Set(broadcastLog.filter { $0.generation == generation && $0.isInputSpent == true }.map(\.txid)) + var strikes: [String: Int] = [:] + for tx in built.transactions where spent.contains(tx.txid) { + strikes[tx.txid] = (spentStepStrikes?[tx.txid] ?? 0) + 1 + } + return strikes + } + + /// The funding key's index, which every funding entry of one exit shares. + var fundingIndex: UInt32 { + funding.first?.index ?? 0 + } + + /// Whether the driver still has work: the exit runs, or its leftover fee funding is on its way to savings. + var needsDriving: Bool { + isActive || isLeftoverSweepPending + } + + var fundingInputs: [CpfpInput] { + funding.compactMap(\.input) + } + + var fundedSats: UInt64 { + funding.reduce(0) { $0 + ($1.valueSats ?? 0) } + } + + /// What the funding address still has to receive before the exit can build, or nil when nothing is missing. + var fundingShortfallSats: UInt64? { + guard let required = phase.requiredFundingSats else { return nil } + let shortfall = required.saturatingSub(fundedSats) + return shortfall > 0 ? shortfall : nil + } + + /// Starts a new generation from a fresh build, forgetting what was broadcast for the previous one. + mutating func adopt(_ built: RecoverFundsResponse, at date: Date = Date()) { + if let recovery { + let txids = SparkExitTxid.cpfpChildTxids(recovery.response) + if !txids.isEmpty { + earlierCpfpTxids = (earlierCpfpTxids ?? []) + txids + } + } + generation += 1 + recovery = StoredRecovery(built) + broadcastLog = [] + phase = .running + isRebuilding = false + lastBuildAt = date + failedLeafCount = built.failed.count + } +} + +// MARK: - Persistence + +/// Reads and writes the exit record of one wallet. Writes are atomic and readable after the first unlock, so the +/// background task can drive the exit while the device is locked. +struct SparkExitStore { + let url: URL + + init(url: URL) { + self.url = url + } + + init(walletIndex: Int) { + url = Env.sparkExitRecord(walletIndex: walletIndex) + } + + func load() throws -> SparkExitRecord? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return try SparkExitRecordCoder.decode(Data(contentsOf: url)) + } + + /// Moves an unreadable record out of the way, keeping it for support. + func setAside() throws { + let aside = url.deletingPathExtension().appendingPathExtension("unreadable-\(Int(Date().timeIntervalSince1970)).json") + try FileManager.default.moveItem(at: url, to: aside) + } + + func save(_ record: SparkExitRecord) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try SparkExitRecordCoder.encode(record).write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + func delete() throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } +} + +enum SparkExitRecordCoder { + struct UnsupportedVersion: Error, Equatable { + let version: Int + } + + private struct VersionProbe: Decodable { + let version: Int + } + + static func encode(_ record: SparkExitRecord) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .millisecondsSince1970 + return try encoder.encode(record) + } + + static func decode(_ data: Data) throws -> SparkExitRecord { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + let version = try decoder.decode(VersionProbe.self, from: data).version + guard version == SparkExitRecord.currentVersion else { throw UnsupportedVersion(version: version) } + return try decoder.decode(SparkExitRecord.self, from: data) + } +} + +// MARK: - RecoverFundsResponse in spark-sdk's serde shape + +/// `RecoverFundsResponse` as spark-sdk 0.27.2 serializes it with serde: snake_case fields and externally tagged +/// enums (`"Ready"`, `{"Confirmed":{"block_height":120}}`). A stored recovery is then readable by the Breez CLI's +/// `check-recover-funds` and shared with Android as one format. The SDK's Swift types are not `Codable`. +struct StoredRecovery: Codable, Equatable { + let response: RecoverFundsResponse + + init(_ response: RecoverFundsResponse) { + self.response = response + } + + private enum CodingKeys: String, CodingKey { + case recoverableValueSats = "recoverable_value_sats" + case totalFeeSats = "total_fee_sats" + case cooperativeFeeSats = "cooperative_fee_sats" + case cpfpFeeSats = "cpfp_fee_sats" + case fanoutFeeSats = "fanout_fee_sats" + case refundFeeSats = "refund_fee_sats" + case sweepFeeSats = "sweep_fee_sats" + case leaves + case failed + case transactions + case fundingInputs = "funding_inputs" + case feeRateSatPerVbyte = "fee_rate_sat_per_vbyte" + case destination + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + response = try RecoverFundsResponse( + recoverableValueSats: c.decode(UInt64.self, forKey: .recoverableValueSats), + totalFeeSats: c.decode(UInt64.self, forKey: .totalFeeSats), + cooperativeFeeSats: c.decode(UInt64.self, forKey: .cooperativeFeeSats), + cpfpFeeSats: c.decode(UInt64.self, forKey: .cpfpFeeSats), + fanoutFeeSats: c.decode(UInt64.self, forKey: .fanoutFeeSats), + refundFeeSats: c.decode(UInt64.self, forKey: .refundFeeSats), + sweepFeeSats: c.decode(UInt64.self, forKey: .sweepFeeSats), + leaves: c.decode([StoredLeaf].self, forKey: .leaves).map(\.value), + failed: c.decode([StoredFailure].self, forKey: .failed).map(\.value), + transactions: c.decode([StoredTransaction].self, forKey: .transactions).map(\.value), + fundingInputs: c.decode([StoredCpfpInput].self, forKey: .fundingInputs).map(\.value), + feeRateSatPerVbyte: c.decode(UInt64.self, forKey: .feeRateSatPerVbyte), + destination: c.decode(String.self, forKey: .destination) + ) + } + + func encode(to encoder: Encoder) throws { + let r = response + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(r.recoverableValueSats, forKey: .recoverableValueSats) + try c.encode(r.totalFeeSats, forKey: .totalFeeSats) + try c.encode(r.cooperativeFeeSats, forKey: .cooperativeFeeSats) + try c.encode(r.cpfpFeeSats, forKey: .cpfpFeeSats) + try c.encode(r.fanoutFeeSats, forKey: .fanoutFeeSats) + try c.encode(r.refundFeeSats, forKey: .refundFeeSats) + try c.encode(r.sweepFeeSats, forKey: .sweepFeeSats) + try c.encode(r.leaves.map(StoredLeaf.init), forKey: .leaves) + try c.encode(r.failed.map(StoredFailure.init), forKey: .failed) + try c.encode(r.transactions.map(StoredTransaction.init), forKey: .transactions) + try c.encode(r.fundingInputs.map(StoredCpfpInput.init), forKey: .fundingInputs) + try c.encode(r.feeRateSatPerVbyte, forKey: .feeRateSatPerVbyte) + try c.encode(r.destination, forKey: .destination) + } +} + +private struct StoredLeaf: Codable { + let value: RecoverFundsLeaf + + init(_ value: RecoverFundsLeaf) { + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case leafId = "leaf_id" + case valueSats = "value_sats" + case method + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let method: RecoveryMethod = switch try c.decode(String.self, forKey: .method) { + case "Cooperative": .cooperative + case "Unilateral": .unilateral + case let other: throw DecodingError.dataCorruptedError(forKey: .method, in: c, debugDescription: "Unknown method \(other)") + } + value = try RecoverFundsLeaf( + leafId: c.decode(String.self, forKey: .leafId), + valueSats: c.decode(UInt64.self, forKey: .valueSats), + method: method + ) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(value.leafId, forKey: .leafId) + try c.encode(value.valueSats, forKey: .valueSats) + try c.encode(value.method == .cooperative ? "Cooperative" : "Unilateral", forKey: .method) + } +} + +private struct StoredFailure: Codable { + let value: CooperativeRecoveryFailure + + init(_ value: CooperativeRecoveryFailure) { + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case leafId = "leaf_id" + case outputTxid = "output_txid" + case outputVout = "output_vout" + case error + } + + private enum ErrorKeys: String, CodingKey { + case requiredFeeSats = "required_fee_sats" + case requiredFeeRateSatPerVbyte = "required_fee_rate_sat_per_vbyte" + case message + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let error = try ExternallyTagged.decode(from: c.superDecoder(forKey: .error)) { tag, fields -> CooperativeRecoveryError in + let f = try fields?.container(keyedBy: ErrorKeys.self) + switch tag { + case "ReplacementFeeTooLow": + return try .replacementFeeTooLow( + requiredFeeSats: ExternallyTagged.require(f).decode(UInt64.self, forKey: .requiredFeeSats), + requiredFeeRateSatPerVbyte: ExternallyTagged.require(f).decode(UInt64.self, forKey: .requiredFeeRateSatPerVbyte) + ) + case "OperatorsUnavailable": + return try .operatorsUnavailable(message: ExternallyTagged.require(f).decode(String.self, forKey: .message)) + case "Generic": + return try .generic(message: ExternallyTagged.require(f).decode(String.self, forKey: .message)) + default: + throw ExternallyTagged.unknown(tag) + } + } + value = try CooperativeRecoveryFailure( + leafId: c.decode(String.self, forKey: .leafId), + outputTxid: c.decode(String.self, forKey: .outputTxid), + outputVout: c.decode(UInt32.self, forKey: .outputVout), + error: error + ) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(value.leafId, forKey: .leafId) + try c.encode(value.outputTxid, forKey: .outputTxid) + try c.encode(value.outputVout, forKey: .outputVout) + let errorEncoder = c.superEncoder(forKey: .error) + switch value.error { + case let .replacementFeeTooLow(requiredFeeSats, requiredFeeRateSatPerVbyte): + var f = ExternallyTagged.fields(errorEncoder, tag: "ReplacementFeeTooLow", keyedBy: ErrorKeys.self) + try f.encode(requiredFeeSats, forKey: .requiredFeeSats) + try f.encode(requiredFeeRateSatPerVbyte, forKey: .requiredFeeRateSatPerVbyte) + case let .operatorsUnavailable(message): + var f = ExternallyTagged.fields(errorEncoder, tag: "OperatorsUnavailable", keyedBy: ErrorKeys.self) + try f.encode(message, forKey: .message) + case let .generic(message): + var f = ExternallyTagged.fields(errorEncoder, tag: "Generic", keyedBy: ErrorKeys.self) + try f.encode(message, forKey: .message) + } + } +} + +private struct StoredTransaction: Codable { + let value: RecoveryTransaction + + init(_ value: RecoveryTransaction) { + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case kind + case nodeId = "node_id" + case txid + case txHex = "tx_hex" + case cpfpTxHex = "cpfp_tx_hex" + case csvTimelockBlocks = "csv_timelock_blocks" + case dependsOn = "depends_on" + case status + } + + private enum StatusKeys: String, CodingKey { + case blockHeight = "block_height" + case spendableAtHeight = "spendable_at_height" + } + + private static let kinds: [(RecoveryTxKind, String)] = [ + (.cooperative, "Cooperative"), (.fanOut, "FanOut"), (.node, "Node"), (.refund, "Refund"), (.sweep, "Sweep"), + ] + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let kindName = try c.decode(String.self, forKey: .kind) + guard let kind = Self.kinds.first(where: { $0.1 == kindName })?.0 else { throw ExternallyTagged.unknown(kindName) } + let status = try ExternallyTagged.decode(from: c.superDecoder(forKey: .status)) { tag, fields -> ExitTransactionStatus in + let f = try fields?.container(keyedBy: StatusKeys.self) + switch tag { + case "Confirmed": return try .confirmed(blockHeight: ExternallyTagged.require(f).decodeIfPresent(UInt32.self, forKey: .blockHeight)) + case "Ready": return .ready + case "WaitingForDependencies": return .waitingForDependencies + case "WaitingForTimelock": + return try .waitingForTimelock(spendableAtHeight: ExternallyTagged.require(f).decodeIfPresent( + UInt32.self, + forKey: .spendableAtHeight + )) + case "Unverified": return .unverified + default: throw ExternallyTagged.unknown(tag) + } + } + value = try RecoveryTransaction( + kind: kind, + nodeId: c.decodeIfPresent(String.self, forKey: .nodeId), + txid: c.decode(String.self, forKey: .txid), + txHex: c.decode(String.self, forKey: .txHex), + cpfpTxHex: c.decodeIfPresent(String.self, forKey: .cpfpTxHex), + csvTimelockBlocks: c.decodeIfPresent(UInt32.self, forKey: .csvTimelockBlocks), + dependsOn: c.decode([String].self, forKey: .dependsOn), + status: status + ) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(Self.kinds.first { $0.0 == value.kind }?.1, forKey: .kind) + try c.encode(value.nodeId, forKey: .nodeId) + try c.encode(value.txid, forKey: .txid) + try c.encode(value.txHex, forKey: .txHex) + try c.encode(value.cpfpTxHex, forKey: .cpfpTxHex) + try c.encode(value.csvTimelockBlocks, forKey: .csvTimelockBlocks) + try c.encode(value.dependsOn, forKey: .dependsOn) + let statusEncoder = c.superEncoder(forKey: .status) + switch value.status { + case let .confirmed(blockHeight): + var f = ExternallyTagged.fields(statusEncoder, tag: "Confirmed", keyedBy: StatusKeys.self) + try f.encode(blockHeight, forKey: .blockHeight) + case .ready: + try ExternallyTagged.unit(statusEncoder, tag: "Ready") + case .waitingForDependencies: + try ExternallyTagged.unit(statusEncoder, tag: "WaitingForDependencies") + case let .waitingForTimelock(spendableAtHeight): + var f = ExternallyTagged.fields(statusEncoder, tag: "WaitingForTimelock", keyedBy: StatusKeys.self) + try f.encode(spendableAtHeight, forKey: .spendableAtHeight) + case .unverified: + try ExternallyTagged.unit(statusEncoder, tag: "Unverified") + } + } +} + +private struct StoredCpfpInput: Codable { + let value: CpfpInput + + init(_ value: CpfpInput) { + self.value = value + } + + private enum Keys: String, CodingKey { + case txid + case vout + case valueSats = "value_sats" + case pubkey + case scriptPubkeyHex = "script_pubkey_hex" + case signedInputWeight = "signed_input_weight" + } + + init(from decoder: Decoder) throws { + value = try ExternallyTagged.decode(from: decoder) { tag, fields -> CpfpInput in + let f = try ExternallyTagged.require(fields?.container(keyedBy: Keys.self)) + let txid = try f.decode(String.self, forKey: .txid) + let vout = try f.decode(UInt32.self, forKey: .vout) + let valueSats = try f.decode(UInt64.self, forKey: .valueSats) + switch tag { + case "P2wpkh": return try .p2wpkh(txid: txid, vout: vout, valueSats: valueSats, pubkey: f.decode(String.self, forKey: .pubkey)) + case "P2tr": return try .p2tr(txid: txid, vout: vout, valueSats: valueSats, pubkey: f.decode(String.self, forKey: .pubkey)) + case "Custom": + return try .custom( + txid: txid, + vout: vout, + valueSats: valueSats, + scriptPubkeyHex: f.decode(String.self, forKey: .scriptPubkeyHex), + signedInputWeight: f.decode(UInt64.self, forKey: .signedInputWeight) + ) + default: throw ExternallyTagged.unknown(tag) + } + } + } + + func encode(to encoder: Encoder) throws { + switch value { + case let .p2wpkh(txid, vout, valueSats, pubkey), let .p2tr(txid, vout, valueSats, pubkey): + var f = ExternallyTagged.fields(encoder, tag: { + if case .p2wpkh = value { + "P2wpkh" + } else { + "P2tr" + } + }(), keyedBy: Keys.self) + try f.encode(txid, forKey: .txid) + try f.encode(vout, forKey: .vout) + try f.encode(valueSats, forKey: .valueSats) + try f.encode(pubkey, forKey: .pubkey) + case let .custom(txid, vout, valueSats, scriptPubkeyHex, signedInputWeight): + var f = ExternallyTagged.fields(encoder, tag: "Custom", keyedBy: Keys.self) + try f.encode(txid, forKey: .txid) + try f.encode(vout, forKey: .vout) + try f.encode(valueSats, forKey: .valueSats) + try f.encode(scriptPubkeyHex, forKey: .scriptPubkeyHex) + try f.encode(signedInputWeight, forKey: .signedInputWeight) + } + } +} + +/// serde's default enum representation: a unit variant is its name as a string, a variant with fields is an object +/// with the name as its only key. +private enum ExternallyTagged { + struct TagKey: CodingKey { + let stringValue: String + var intValue: Int? { + nil + } + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue _: Int) { + nil + } + } + + static func decode(from decoder: Decoder, variant: (String, Decoder?) throws -> T) throws -> T { + if let tag = try? decoder.singleValueContainer().decode(String.self) { + return try variant(tag, nil) + } + let c = try decoder.container(keyedBy: TagKey.self) + guard c.allKeys.count == 1, let key = c.allKeys.first else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Expected one variant key")) + } + return try variant(key.stringValue, c.superDecoder(forKey: key)) + } + + static func fields(_ encoder: Encoder, tag: String, keyedBy _: Keys.Type) -> KeyedEncodingContainer { + var c = encoder.container(keyedBy: TagKey.self) + return c.nestedContainer(keyedBy: Keys.self, forKey: TagKey(stringValue: tag)) + } + + static func unit(_ encoder: Encoder, tag: String) throws { + var c = encoder.singleValueContainer() + try c.encode(tag) + } + + static func require(_ value: T?) throws -> T { + guard let value else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Variant is missing its fields")) + } + return value + } + + static func unknown(_ tag: String) -> Error { + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Unknown variant \(tag)")) + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitSweep.swift b/Bitkit/Services/SparkExit/SparkExitSweep.swift new file mode 100644 index 000000000..b7ab0c272 --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitSweep.swift @@ -0,0 +1,226 @@ +import BreezSdkSpark +import CryptoKit +import Foundation + +/// Sends what an ended exit left at its funding address (CPFP change, or fee funding no build spent) to savings in +/// one transaction. The funding key's `CpfpSigner` signs it, the same signer the exit's CPFP children use. +enum SparkExitLeftoverSweep { + struct Built: Equatable { + let txid: String + let txHex: String + /// What reaches savings: the swept value less the fee. + let valueSats: UInt64 + } + + enum SweepError: Error, Equatable { + case unsupportedAddress(String) + case unsigned + } + + /// Spends every given UTXO of the funding address to `destination` at `feeRateSatPerVbyte`. Nil when what is left + /// after the fee is under the destination's dust limit. + static func build( + utxos: [EsploraUtxo], + fundingAddress: String, + destination: String, + feeRateSatPerVbyte: UInt64, + signer: CpfpSigner + ) async throws -> Built? { + guard !utxos.isEmpty else { return nil } + let inputScript = try SparkExitAddress.scriptPubKey(fundingAddress) + let outputScript = try SparkExitAddress.scriptPubKey(destination) + let total = utxos.reduce(0) { $0 + $1.value } + let fee = feeSats(inputs: utxos.count, outputScriptLength: outputScript.count, feeRateSatPerVbyte: feeRateSatPerVbyte) + guard total > fee, total - fee >= dustLimit(outputScript) else { return nil } + let value = total - fee + + var inputs = Data() + inputs += varInt(utxos.count) + for utxo in utxos { + inputs += Data(utxo.txid.hexaBytes.reversed()) + le32(utxo.vout) + [0x00] + le32(0xFFFF_FFFD) + } + let outputs = varInt(1) + le64(value) + varInt(outputScript.count) + outputScript + let version = le32(2) + let lockTime = le32(0) + + var psbt = Data("psbt".utf8) + [0xFF] + let unsigned = version + inputs + outputs + lockTime + psbt += varInt(1) + [0x00] + varInt(unsigned.count) + unsigned + [0x00] + for utxo in utxos { + let witnessUtxo = le64(utxo.value) + varInt(inputScript.count) + inputScript + psbt += varInt(1) + [0x01] + varInt(witnessUtxo.count) + witnessUtxo + [0x00] + } + psbt += [0x00] + + let signed = try await signer.signPsbt(psbtBytes: psbt) + guard let witnesses = finalWitnesses(signed, inputs: utxos.count) else { throw SweepError.unsigned } + let tx = version + [0x00, 0x01] + inputs + outputs + witnesses.reduce(Data(), +) + lockTime + let hex = tx.hex + guard let txid = SparkExitTxid.txid(hex: hex) else { throw SweepError.unsigned } + return Built(txid: txid, txHex: hex, valueSats: value) + } + + /// The fee for P2WPKH inputs and one output, sized for 72-byte signatures. + static func feeSats(inputs: Int, outputScriptLength: Int, feeRateSatPerVbyte: UInt64) -> UInt64 { + let base = 4 + 1 + 41 * inputs + 1 + 8 + 1 + outputScriptLength + 4 + let witness = 2 + 108 * inputs + let vbytes = (4 * base + witness + 3) / 4 + return UInt64(vbytes) * feeRateSatPerVbyte + } + + /// Bitcoin Core's dust limit at its default 3 sat/vB: 294 for P2WPKH, 330 for P2TR, 540 for P2SH, 546 for P2PKH. + static func dustLimit(_ script: Data) -> UInt64 { + let isWitness = script.count >= 4 && (script[script.startIndex] == 0x00 || (0x51 ... 0x60).contains(script[script.startIndex])) + return UInt64(3 * (8 + 1 + script.count + (isWitness ? 67 : 148))) + } + + /// Each input's `final_script_witness` (PSBT input key 0x08), already in the transaction's witness encoding. + static func finalWitnesses(_ psbt: Data, inputs: Int) -> [Data]? { + var reader = PsbtReader(bytes: [UInt8](psbt)) + guard reader.take(5) == Array("psbt".utf8) + [0xFF], reader.skipMap() else { return nil } + var witnesses: [Data] = [] + for _ in 0 ..< inputs { + guard let map = reader.readMap(), let witness = map[0x08] else { return nil } + witnesses.append(Data(witness)) + } + return witnesses + } + + private static func varInt(_ value: Int) -> Data { + switch value { + case ..<0xFD: Data([UInt8(value)]) + case ...0xFFFF: Data([0xFD]) + le16(UInt16(value)) + default: Data([0xFE]) + le32(UInt32(value)) + } + } + + private static func le16(_ value: UInt16) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + private static func le32(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + private static func le64(_ value: UInt64) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + private struct PsbtReader { + let bytes: [UInt8] + var position = 0 + + mutating func take(_ count: Int) -> [UInt8]? { + guard count >= 0, position + count <= bytes.count else { return nil } + defer { position += count } + return Array(bytes[position ..< position + count]) + } + + mutating func varInt() -> Int? { + guard let first = take(1)?.first else { return nil } + let width = switch first { + case 0xFD: 2 + case 0xFE: 4 + case 0xFF: 8 + default: 0 + } + guard width > 0 else { return Int(first) } + guard let rest = take(width) else { return nil } + return rest.enumerated().reduce(0) { $0 | Int($1.element) << (8 * $1.offset) } + } + + /// One key-value map up to its separator, by key type; keys with data after the type are kept by type too. + mutating func readMap() -> [UInt8: [UInt8]]? { + var map: [UInt8: [UInt8]] = [:] + while true { + guard let keyLength = varInt() else { return nil } + if keyLength == 0 { + return map + } + guard let key = take(keyLength), let valueLength = varInt(), let value = take(valueLength) else { return nil } + map[key[0]] = value + } + } + + mutating func skipMap() -> Bool { + readMap() != nil + } + } +} + +/// The output script of a Bitcoin address: bech32 and bech32m segwit, base58 P2PKH and P2SH, on any network. +enum SparkExitAddress { + private static let charset = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") + + static func scriptPubKey(_ address: String) throws -> Data { + if let script = segwitScript(address) ?? base58Script(address) { + return script + } + throw SparkExitLeftoverSweep.SweepError.unsupportedAddress(address) + } + + private static func segwitScript(_ address: String) -> Data? { + let lower = address.lowercased() + guard lower == address || address.uppercased() == address, let separator = lower.lastIndex(of: "1") else { return nil } + let hrp = lower[..= 7 else { return nil } + let checksum = polymod(hrp.unicodeScalars.map { UInt8($0.value >> 5) } + [0] + hrp.unicodeScalars.map { UInt8($0.value & 31) } + data) + let version = data[0] + guard version <= 16, checksum == (version == 0 ? 1 : 0x2BC8_30A3), + let program = convertBits(Array(data[1 ..< data.count - 6])), + (2 ... 40).contains(program.count), version != 0 || program.count == 20 || program.count == 32 + else { return nil } + return Data([version == 0 ? 0x00 : 0x50 + version, UInt8(program.count)] + program) + } + + private static func base58Script(_ address: String) -> Data? { + guard let decoded = Base58.decode(address), decoded.count == 25 else { return nil } + let bytes = [UInt8](decoded) + let checksum = [UInt8](Data(SHA256Twice.hash(Data(bytes[0 ..< 21]))).prefix(4)) + guard Array(bytes[21...]) == checksum else { return nil } + let hash = Array(bytes[1 ..< 21]) + switch bytes[0] { + case 0x00, 0x6F: return Data([0x76, 0xA9, 0x14] + hash + [0x88, 0xAC]) + case 0x05, 0xC4: return Data([0xA9, 0x14] + hash + [0x87]) + default: return nil + } + } + + private static func polymod(_ values: [UInt8]) -> UInt32 { + let generator: [UInt32] = [0x3B6A_57B2, 0x2650_8E6D, 0x1EA1_19FA, 0x3D42_33DD, 0x2A14_62B3] + var checksum: UInt32 = 1 + for value in values { + let top = checksum >> 25 + checksum = (checksum & 0x1FFFFFF) << 5 ^ UInt32(value) + for i in 0 ..< 5 where (top >> i) & 1 == 1 { + checksum ^= generator[i] + } + } + return checksum + } + + /// 5-bit groups to bytes, without padding. + private static func convertBits(_ data: [UInt8]) -> [UInt8]? { + var accumulator = 0 + var bits = 0 + var result: [UInt8] = [] + for value in data { + accumulator = (accumulator << 5) | Int(value) + bits += 5 + while bits >= 8 { + bits -= 8 + result.append(UInt8((accumulator >> bits) & 0xFF)) + } + } + guard bits < 5, (accumulator << (8 - bits)) & 0xFF == 0 else { return nil } + return result + } +} + +private enum SHA256Twice { + static func hash(_ data: Data) -> Data { + Data(SHA256.hash(data: Data(SHA256.hash(data: data)))) + } +} diff --git a/Bitkit/Services/TransferStorage.swift b/Bitkit/Services/TransferStorage.swift index dc73fc283..59c6b701d 100644 --- a/Bitkit/Services/TransferStorage.swift +++ b/Bitkit/Services/TransferStorage.swift @@ -99,6 +99,29 @@ class TransferStorage { } } + /// Moves a transfer's countdown target, as a Spark exit learns when its timelocks mature. + func updateClaimableAtHeight(id: String, claimableAtHeight: UInt32?) throws { + var transfers = try getAll() + guard let index = transfers.firstIndex(where: { $0.id == id }), transfers[index].claimableAtHeight != claimableAtHeight else { return } + let transfer = transfers[index] + transfers[index] = Transfer( + id: transfer.id, + type: transfer.type, + amountSats: transfer.amountSats, + channelId: transfer.channelId, + fundingTxId: transfer.fundingTxId, + lspOrderId: transfer.lspOrderId, + isSettled: transfer.isSettled, + createdAt: transfer.createdAt, + settledAt: transfer.settledAt, + claimableAtHeight: claimableAtHeight, + txTotalSats: transfer.txTotalSats, + preTransferOnchainSats: transfer.preTransferOnchainSats + ) + try save(transfers) + transfersChangedSubject.send() + } + /// Delete old settled transfers func deleteOldSettled(expirationTimestamp: UInt64) throws { var transfers = try getAll() diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 5bae41280..21093e4ff 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -104,6 +104,11 @@ enum Route: Hashable { case connections case connectionDetail(channelId: String) case sparkConnectionDetail + case sparkExitIntro + case sparkExitQuote + case sparkExitFunding + case sparkExitConfirm + case sparkExitProgress case closeConnection(channel: ChannelDetails) case node case electrumSettings diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 18f6346d8..b29c4790c 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -366,7 +366,7 @@ class TransferViewModel: ObservableObject { sparkFeeTask = nil isPricingSparkWithdrawal = false let spark = SparkManager.shared - guard spark.balanceSats > 0 else { return } + guard spark.balanceSats > 0, !spark.isExiting else { return } let address: String = if let sparkWithdrawAddress { sparkWithdrawAddress } else { @@ -844,7 +844,8 @@ struct SavingsSelection: Equatable { channelIds.isEmpty && !withdrawsSpark } - /// Without a pick (`selectedChannelIds` nil) every ready channel moves. Spark moves when picked and holding a balance. + /// Without a pick (`selectedChannelIds` nil) every ready channel moves. Spark moves when picked and holding a + /// balance, which is 0 during an emergency exit. static func resolve( readyChannelIds: [String], selectedChannelIds: Set?, diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1c36919dd..940f0c212 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1411,13 +1411,15 @@ class WalletViewModel: ObservableObject { let spark = SparkManager.shared balanceInTransferToSavings = Int(state.balanceInTransferToSavings) balanceInTransferToSpending = Int(state.balanceInTransferToSpending + spark.pendingDepositSats) + // During an emergency exit the Spark balance is on its way to savings and counted there instead. + let sparkBalanceSats = spark.isExiting ? 0 : spark.balanceSats // Update display values with adjusted balances totalOnchainSats = Int(state.totalOnchainSats) - totalLightningSats = Int(state.totalLightningSats + spark.balanceSats) - totalBalanceSats = Int(state.totalBalanceSats + spark.balanceSats + spark.pendingDepositSats) + totalLightningSats = Int(state.totalLightningSats + sparkBalanceSats) + totalBalanceSats = Int(state.totalBalanceSats + sparkBalanceSats + spark.pendingDepositSats) // A payment goes over one rail, so the larger of the two balances caps it. - maxSendLightningSats = Int(max(state.maxSendLightningSats, spark.sendableSats)) + maxSendLightningSats = Int(max(state.maxSendLightningSats, spark.isExiting ? 0 : spark.sendableSats)) // Update channel fundable balance (excludes Legacy UTXOs and accounts for funding tx fee) let (selectedType, monitoredTypes) = LightningService.addressTypeStateFromUserDefaults() @@ -1432,6 +1434,8 @@ class WalletViewModel: ObservableObject { let activeTransfers = try? transferService.getActiveTransfers() let forceCloseTransfer = activeTransfers?.first { $0.type == .forceClose && !$0.isSettled + } ?? activeTransfers?.first { + $0.type == .sparkExit && !$0.isSettled } forceCloseClaimableAtHeight = forceCloseTransfer?.claimableAtHeight } catch { diff --git a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift index e673479a9..d19c8f6e6 100644 --- a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift @@ -9,7 +9,10 @@ struct SparkConnectionRow: View { static var isVisible: Bool { let spark = SparkManager.shared - return spark.isReady && (spark.balanceSats > 0 || !spark.deposits.isEmpty || Env.network == .regtest) + // An exit's progress and result stay reachable while Spark is not ready, such as with the operators down. + let hasExit = spark.isExiting || spark.recoverableFundsSats > 0 + return SparkExitManager.shared.showsResult + || spark.isReady && (spark.balanceSats > 0 || !spark.deposits.isEmpty || hasExit || Env.network == .regtest) } var body: some View { @@ -112,6 +115,12 @@ struct SparkConnectionDetailView: View { private let spark = SparkManager.shared + /// The emergency exit is offered while Spark holds funds, and leads to the exit's progress or result while it + /// runs or its leftover fee funding is still on its way to savings. + private var showsExit: Bool { + spark.balanceSats > 0 || spark.recoverableFundsSats > 0 || SparkExitManager.shared.showsResult + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__spark")) @@ -198,8 +207,16 @@ struct SparkConnectionDetailView: View { navigation.navigate(.savingsIntro) } } - .disabled(spark.balanceSats == 0) + .disabled(spark.balanceSats == 0 || spark.isExiting) .accessibilityIdentifier("SparkTransferToSavings") + + if showsExit { + CustomButton(title: t("lightning__spark_exit__button"), variant: .tertiary) { + navigation.navigate(SparkExitManager.shared.showsResult ? .sparkExitProgress : .sparkExitIntro) + } + .padding(.top, 8) + .accessibilityIdentifier("SparkEmergencyExit") + } } .navigationBarHidden(true) .padding(.horizontal, 16) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift new file mode 100644 index 000000000..59ca12c2d --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -0,0 +1,706 @@ +import BreezSdkSpark +import SwiftUI + +/// When to use an emergency exit, what it costs and how long it takes. +struct SparkExitIntroView: View { + @EnvironmentObject var navigation: NavigationViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__spark_exit__intro_title"), accentColor: .yellowAccent) + + BodyMText(t("lightning__spark_exit__intro_text"), accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + + Spacer() + + Image("exclamation-mark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 256, maxHeight: 256) + .frame(maxWidth: .infinity) + + Spacer() + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + navigation.navigateBack() + } + + CustomButton(title: t("common__continue")) { + navigation.navigate(.sparkExitQuote) + } + .accessibilityIdentifier("SparkExitIntroContinue") + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } +} + +/// What the exit takes on-chain, what it costs, and what stays behind. +struct SparkExitQuoteView: View { + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + + private let exit = SparkExitManager.shared + + private var blockedReason: String? { + guard let quote = exit.quote else { return exit.quoteError } + if quote.prepared.leaves.isEmpty { + return t("lightning__spark_exit__nothing") + } + if !quote.isWorthExiting { + return t("lightning__spark_exit__uneconomic") + } + return nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_exit__quote_title"), accentColor: .yellowAccent) + .padding(.bottom, 32) + + if let quote = exit.quote { + SparkExitQuoteRows(quote: quote) + + if let unexitableSats = SparkExitMath.unexitableSats(sparkBalanceSats: SparkManager.shared.balanceSats, quote: quote) { + BodySText(t( + "lightning__spark_exit__quote_outside", + variables: ["amount": currency.primaryAmountText(sats: unexitableSats)] + )) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitQuoteOutside") + } + } else if exit.isQuoting { + let key = SparkManager.shared.isSettlingForExit ? "lightning__spark_exit__quote_settling" : "lightning__spark_exit__quote_loading" + BodyMText(t(key)) + .fixedSize(horizontal: false, vertical: true) + } + + if let blockedReason { + BodyMText(blockedReason, textColor: .redAccent) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitQuoteBlocked") + } + } + .padding(.bottom, 16) + } + + CustomButton(title: t("common__continue"), isLoading: exit.isQuoting) { + guard let quote = exit.quote else { return } + navigation.navigate(quote.fundingSats > 0 ? .sparkExitFunding : .sparkExitConfirm) + } + .disabled(exit.quote == nil || blockedReason != nil) + .accessibilityIdentifier("SparkExitQuoteContinue") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await exit.loadQuote() + } + } +} + +struct SparkExitQuoteRows: View { + let quote: SparkExitQuote + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SparkExitAmountRow(label: t("lightning__spark_exit__amount"), sats: quote.exitSats) + SparkExitAmountRow(label: t("lightning__spark_exit__fees"), sats: quote.networkFeeSats) + SparkExitAmountRow(label: t("lightning__spark_exit__receive"), sats: quote.receiveSats) + if quote.fundingSats > 0 { + SparkExitAmountRow(label: t("lightning__spark_exit__funding"), sats: quote.fundingSats) + } + if quote.leftBehindSats > 0 { + SparkExitAmountRow(label: t("lightning__spark_exit__left_behind"), sats: quote.leftBehindSats) + } + } + } +} + +struct SparkExitAmountRow: View { + let label: String + let sats: UInt64 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + MoneyText(sats: Int(sats), size: .captionB, symbol: true) + } + .frame(height: 50) + CustomDivider() + } + } +} + +/// Pays the CPFP fee funding from savings, or shows the funding address to pay from another wallet. +struct SparkExitFundingView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var savingsFeeSats: UInt64? + /// Savings can't pay the funding, usually because it doesn't hold enough. + @State private var hasSavingsFeeFailed = false + @State private var showsAddress = false + + private let exit = SparkExitManager.shared + + private var fundingSats: UInt64 { + exit.quote?.fundingSats ?? 0 + } + + private var canFundFromSavings: Bool { + guard let savingsFeeSats else { return false } + return SparkExitMath.canFundFromSavings( + spendableSats: UInt64(max(0, wallet.spendableOnchainBalanceSats)), + fundingSats: fundingSats, + paymentFeeSats: savingsFeeSats + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_exit__funding_title"), accentColor: .yellowAccent) + + BodyMText( + t("lightning__spark_exit__funding_text", variables: ["amount": currency.primaryAmountText(sats: fundingSats)]), + accentFont: Fonts.bold + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + + if showsAddress, let address = exit.fundingAddress { + SparkExitFundingAddress( + address: address, + requiredSats: fundingSats, + receivedSats: exit.fundingSeenSats, + targetSats: fundingSats + ) + .padding(.top, 24) + } + } + .padding(.bottom, 16) + } + + if showsAddress { + CustomButton(title: t("common__continue")) { + exit.fundFromSavings = false + navigation.navigate(.sparkExitConfirm) + } + .disabled(exit.fundingSeenSats < fundingSats) + .accessibilityIdentifier("SparkExitFundingContinue") + } else { + if hasSavingsFeeFailed || (savingsFeeSats != nil && !canFundFromSavings) { + BodySText(t("lightning__spark_exit__fund_insufficient")) + .padding(.bottom, 16) + } + + CustomButton(title: t("lightning__spark_exit__fund_savings"), isLoading: savingsFeeSats == nil && !hasSavingsFeeFailed) { + exit.fundFromSavings = true + navigation.navigate(.sparkExitConfirm) + } + .disabled(!canFundFromSavings) + .accessibilityIdentifier("SparkExitFundSavings") + + CustomButton(title: t("lightning__spark_exit__fund_external"), variant: .secondary) { + showsAddress = true + } + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFundExternal") + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await estimateSavingsFee() + } + .task(id: showsAddress) { + guard showsAddress else { return } + await exit.watchFundingAddress() + } + } + + private func estimateSavingsFee() async { + guard let address = exit.fundingAddress, fundingSats > 0, + let feeRates = await feeEstimatesManager.getEstimates(refresh: true) + else { return } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + do { + let fee = try await wallet.calculateTotalFee(address: address, amountSats: fundingSats, satsPerVByte: rate) + exit.savingsFeeRate = rate + exit.savingsFeeSats = fee + savingsFeeSats = fee + } catch { + Logger.warn("Failed to price the Spark exit funding from savings: \(error)", context: "SparkExitFundingView") + hasSavingsFeeFailed = true + } + } +} + +struct SparkExitFundingAddress: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + + let address: String + /// What the address asks for. + let requiredSats: UInt64 + /// New funding already at the address, and the total it counts towards. + let receivedSats: UInt64 + let targetSats: UInt64 + /// Off where the caller shows what was seen itself. + var showsSeen = true + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + QR(content: address) + .frame(maxWidth: 200) + .frame(maxWidth: .infinity) + + BodySText(t("lightning__spark_exit__fund_external_text", variables: ["amount": currency.primaryAmountText(sats: requiredSats)])) + + Button { + UIPasteboard.general.string = address + Haptics.play(.copiedToClipboard) + app.toast(type: .success, title: t("common__copied"), description: address) + } label: { + BodySText(address, textColor: .textPrimary) + .lineLimit(2) + .truncationMode(.middle) + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("SparkExitFundingAddress") + + if let missingSats = SparkExitMath.partialFundingSats(receivedSats: receivedSats, requiredSats: targetSats) { + BodySText(t("lightning__spark_exit__fund_partial", variables: [ + "received": currency.primaryAmountText(sats: receivedSats), + "required": currency.primaryAmountText(sats: targetSats), + "missing": currency.primaryAmountText(sats: missingSats), + ])) + .accessibilityIdentifier("SparkExitFundingPartial") + } else if showsSeen, receivedSats >= targetSats, targetSats > 0 { + BodySText(t("lightning__spark_exit__fund_seen"), textColor: .greenAccent) + } + } + } +} + +/// A fee funding payment on its way: no QR and no amount to send, only what is awaited and its transaction. +struct SparkExitWaitingPayment: View { + let text: String + let txid: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + BodySText(text, textColor: .greenAccent) + BodySText(t("lightning__spark_exit__funding_txid", variables: ["txid": txid])) + .lineLimit(2) + .truncationMode(.middle) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SparkExitFundingWaiting") + } +} + +/// Where the ended exit's leftover fee funding is: on its way to savings, sent, or too small to send. +struct SparkExitLeftoverStatus: View { + @EnvironmentObject var currency: CurrencyViewModel + + let record: SparkExitRecord + let address: String + + private var leftoverSats: UInt64 { + record.leftover.reduce(0) { $0 + $1.valueSats } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let sweep = record.leftoverSweep, sweep.rejection == nil { + if let txid = sweep.txid { + let key = sweep.isAccepted ? "lightning__spark_exit__leftover_swept" : "lightning__spark_exit__leftover_sending" + BodySText(t(key, variables: ["amount": currency.primaryAmountText(sats: sweep.valueSats)])) + if sweep.isAccepted { + BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": txid])) + .lineLimit(2) + .truncationMode(.middle) + } + } else { + BodySText(t( + "lightning__spark_exit__leftover_dust", + variables: ["amount": currency.primaryAmountText(sats: leftoverSats), "address": address] + )) + } + } else { + ForEach(Array(record.leftover.enumerated()), id: \.offset) { _, leftover in + BodySText(t( + "lightning__spark_exit__leftover", + variables: ["amount": currency.primaryAmountText(sats: leftover.valueSats), "address": address] + )) + } + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SparkExitLeftover") + } +} + +/// The last look before the exit starts: what arrives and for how long the funds are out of reach. +struct SparkExitConfirmView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + private let exit = SparkExitManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_exit__confirm_title"), accentColor: .yellowAccent) + + BodyMText(t("lightning__spark_exit__confirm_text"), accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .padding(.bottom, 32) + + if let quote = exit.quote { + SparkExitQuoteRows(quote: quote) + } + + SparkExitBackgroundNotes() + .padding(.top, 16) + } + .padding(.bottom, 16) + } + + SwipeButton(title: t("lightning__spark_exit__swipe"), accentColor: .yellowAccent) { + do { + try await exit.start() + navigation.navigate(.sparkExitProgress) + } catch { + app.toast(error) + // The exit started but paying its fee funding failed: the progress screen shows where to fund it. + if exit.isExiting { + navigation.navigate(.sparkExitProgress) + return + } + throw error + } + } + .accessibilityIdentifier("SparkExitSwipe") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + // Asked before the swipe: the reminders are how Bitkit reaches the user when a step needs the app. + PushNotificationManager.shared.requestPermissionIfUndecided() + } + } +} + +/// The running exit's steps and countdown, and its result once it ends. +struct SparkExitProgressView: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var currency: CurrencyViewModel + + private let exit = SparkExitManager.shared + + private var record: SparkExitRecord? { + exit.record + } + + private var fundingState: SparkExitFundingState? { + guard let record, record.fundingShortfallSats != nil else { return nil } + return exit.fundingState ?? SparkExitMath.fundingState(record, utxos: nil) + } + + private var lostText: String? { + guard let record, record.lostSats > 0 else { return nil } + return t("lightning__spark_exit__lost_leaves", variables: ["amount": currency.primaryAmountText(sats: record.lostSats)]) + } + + /// Lost leaves are gone, so the text claims only what Spark still holds. + private var failedText: String { + let staysSats = record.flatMap { SparkExitMath.staysOnSparkSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: $0) } + return [ + t("lightning__spark_exit__failed_text"), + staysSats.map { t("lightning__spark_exit__failed_stays", variables: ["amount": currency.primaryAmountText(sats: $0)]) }, + ].compactMap { $0 }.joined(separator: " ") + } + + private var title: String { + switch record?.phase { + case .done: t("lightning__spark_exit__done_title") + case .failed: t("lightning__spark_exit__failed_title") + case .needsFunding: t("lightning__spark_exit__needs_funding_title") + default: t("lightning__spark_exit__progress_title") + } + } + + private var text: String { + switch record?.phase { + case .done: + [ + t("lightning__spark_exit__done_text"), + (record?.skippedLeafCount ?? 0) > 0 + ? t("lightning__spark_exit__partial_text", variables: ["count": "\(record?.skippedLeafCount ?? 0)"]) : nil, + (record?.failedLeafCount ?? 0) > 0 + ? t("lightning__spark_exit__failed_leaves", variables: ["count": "\(record?.failedLeafCount ?? 0)"]) : nil, + ].compactMap { $0 }.joined(separator: " ") + case .failed: failedText + case .needsFunding: + t( + "lightning__spark_exit__needs_funding_text", + variables: ["amount": currency.primaryAmountText(sats: askSats ?? record?.fundingShortfallSats ?? 0)] + ) + default: t("lightning__spark_exit__progress_text") + } + } + + private var askSats: UInt64? { + if case let .ask(sats, _) = fundingState { + return sats + } + return nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + DisplayText(title, accentColor: .yellowAccent) + .padding(.bottom, 16) + + BodyMText(text, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 24) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + if let record { + SparkExitSteps(record: record, tipHeight: exit.tipHeight) + + if let address = record.funding.first?.address { + switch fundingState { + case let .ask(sats, receivedSats): + SparkExitFundingAddress( + address: address, + requiredSats: sats, + receivedSats: receivedSats, + targetSats: record.fundingShortfallSats ?? sats + receivedSats, + showsSeen: false + ) + .padding(.top, 24) + case let .paid(txid): + SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_paid"), txid: txid) + .padding(.top, 24) + case let .seen(txid): + SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_pending"), txid: txid) + .padding(.top, 24) + case nil: + EmptyView() + } + } + + if record.leftBehindSats > 0 { + BodySText(t( + "lightning__spark_exit__left_behind_leaves", + variables: ["amount": currency.primaryAmountText(sats: record.leftBehindSats)] + )) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLeftBehind") + } + + if let lostText { + BodySText(lostText, textColor: .redAccent) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLostLeaves") + } + + if let rejection = record.surfacedRejection { + BodySText(t("lightning__spark_exit__errors", variables: ["error": rejection]), textColor: .redAccent) + .padding(.top, 16) + } + + if let outsideSats = SparkExitMath.outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) { + BodySText(t( + "lightning__spark_exit__outside_exit", + variables: ["amount": currency.primaryAmountText(sats: outsideSats)] + )) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitOutsideExit") + } + + if record.isActive { + SparkExitBackgroundNotes() + .padding(.top, 16) + } + + if !record.isActive, !record.leftover.isEmpty, let address = record.funding.first?.address { + SparkExitLeftoverStatus(record: record, address: address) + .padding(.top, 16) + } + } + } + } + + if exit.canCancel { + CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { + Task { + await exit.cancel() + navigation.reset() + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitCancel") + } + + CustomButton(title: t("common__ok")) { + navigation.reset() + } + .accessibilityIdentifier("SparkExitProgressOk") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .refreshable { + await SparkManager.shared.refreshState() + await exit.tick() + } + .task { + await SparkManager.shared.refreshState() + await exit.tick() + } + .task(id: record?.hasUnseenResult) { + exit.markResultSeen() + } + } +} + +/// What keeps the exit from moving or from reaching the user while Bitkit is closed: Background App Refresh off, +/// Low Power Mode, or notifications denied. Each note shows only while its setting is in the way. +struct SparkExitBackgroundNotes: View { + @ObservedObject private var notifications = PushNotificationManager.shared + @State private var refreshStatus = UIApplication.shared.backgroundRefreshStatus + @State private var isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled + + private var notes: [String] { + [ + refreshStatus == .available ? nil : t("lightning__spark_exit__bg_refresh_off"), + isLowPower ? t("lightning__spark_exit__low_power") : nil, + notifications.authorizationStatus == .denied ? t("lightning__spark_exit__notifications_off") : nil, + ].compactMap { $0 } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(notes, id: \.self) { note in + BodySText(note, textColor: .yellowAccent) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SparkExitBackgroundNotes") + .onAppear(perform: refresh) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.backgroundRefreshStatusDidChangeNotification)) { _ in refresh() } + .onReceive(NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)) { _ in refresh() } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in refresh() } + } + + private func refresh() { + DispatchQueue.main.async { + refreshStatus = UIApplication.shared.backgroundRefreshStatus + isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled + } + notifications.updateNotificationPermission() + } +} + +struct SparkExitSteps: View { + let record: SparkExitRecord + let tipHeight: UInt32? + + private var transactions: [RecoveryTransaction] { + record.recovery?.response.transactions ?? [] + } + + private var isFunded: Bool { + if case .awaitingFunding = record.phase { + return false + } + return true + } + + private var remainingDuration: String? { + guard let target = record.claimableAtHeight, let tipHeight else { return nil } + let blocks = BlockTimeHelpers.blocksRemaining(until: target, currentHeight: tipHeight) + guard blocks > 0 else { return nil } + return BlockTimeHelpers.getDurationForBlocks(blocks) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + step(t("lightning__spark_exit__step_funding"), isDone: isFunded) + step( + t("lightning__spark_exit__step_publish"), + detail: record.recovery.map { "\(SparkExitMath.confirmedCount($0.response))/\(transactions.count)" }, + isDone: record.phase == .done + ) + step( + t("lightning__spark_exit__step_timelock"), + detail: remainingDuration.map { t("lightning__spark_exit__ready_in", variables: ["duration": $0]) }, + isDone: record.phase == .done || SparkExitMath.areTimelocksMatured(transactions) + ) + step(t("lightning__spark_exit__step_sweep"), isDone: record.phase == .done) + } + } + + private func step(_ title: String, detail: String? = nil, isDone: Bool) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 12) { + Image(isDone ? "check-mark" : "hourglass") + .resizable() + .foregroundColor(isDone ? .greenAccent : .textSecondary) + .frame(width: 20, height: 20) + CaptionBText(title, textColor: .textPrimary) + Spacer() + if let detail { + CaptionBText(detail) + } + } + .frame(height: 50) + CustomDivider() + } + } +} diff --git a/Bitkit/Views/Transfer/SavingsAdvancedView.swift b/Bitkit/Views/Transfer/SavingsAdvancedView.swift index 2e5c60a8a..b0216bbf5 100644 --- a/Bitkit/Views/Transfer/SavingsAdvancedView.swift +++ b/Bitkit/Views/Transfer/SavingsAdvancedView.swift @@ -13,7 +13,7 @@ struct SavingsAdvancedView: View { } private var sparkSats: UInt64 { - SparkManager.shared.balanceSats + SparkManager.shared.isExiting ? 0 : SparkManager.shared.balanceSats } private var selection: SavingsSelection { diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index f8705aee7..0674c448d 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -15,7 +15,7 @@ struct SavingsConfirmView: View { } private var sparkSats: UInt64 { - SparkManager.shared.balanceSats + SparkManager.shared.isExiting ? 0 : SparkManager.shared.balanceSats } /// Each ready channel and a Spark balance is a row of the Advanced list. diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 8132cbfd9..a443c2400 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -330,7 +330,7 @@ struct SendSheet: View { private var sparkCanPayScannedInvoice: Bool { let spark = SparkManager.shared let amountSats = wallet.sendAmountSats ?? app.scannedLightningInvoice?.amountSatoshis ?? 0 - return spark.isReady && amountSats > 0 && spark.balanceSats >= amountSats + return spark.isReady && !spark.isExiting && amountSats > 0 && spark.balanceSats >= amountSats } /// Identity for the sync timeout task. The timer only counts down while the overlay is visible diff --git a/BitkitTests/SparkConfigTests.swift b/BitkitTests/SparkConfigTests.swift index 267e1600b..23ad31c3b 100644 --- a/BitkitTests/SparkConfigTests.swift +++ b/BitkitTests/SparkConfigTests.swift @@ -5,11 +5,13 @@ import XCTest final class SparkConfigTests: XCTestCase { func testConnectsWithoutBreezDataSyncOrLightningAddressServer() { for network in [BreezSdkSpark.Network.mainnet, .regtest] { - let config = SparkManager.sdkConfig(network: network, apiKey: "test-key") + let config = SparkManager.sdkConfig(network: network, apiKey: "test-key", isExiting: false) XCTAssertNil(config.realTimeSyncServerUrl, "\(network)") XCTAssertNil(config.lnurlDomain, "\(network)") XCTAssertEqual(config.apiKey, "test-key") + XCTAssertTrue(config.leafOptimizationConfig.autoEnabled) } + XCTAssertFalse(SparkManager.sdkConfig(network: .regtest, apiKey: nil, isExiting: true).leafOptimizationConfig.autoEnabled) } } diff --git a/BitkitTests/SparkExitRegtestTests.swift b/BitkitTests/SparkExitRegtestTests.swift new file mode 100644 index 000000000..69db04e73 --- /dev/null +++ b/BitkitTests/SparkExitRegtestTests.swift @@ -0,0 +1,174 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +/// Runs the exit's SDK and chain calls against Spark's hosted regtest. It needs the network, so it only runs with +/// `TEST_RUNNER_SPARK_EXIT_REGTEST=1` in the environment of `xcodebuild test`. +final class SparkExitRegtestTests: XCTestCase { + private var storageDir: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + guard ProcessInfo.processInfo.environment["SPARK_EXIT_REGTEST"] == "1" else { + throw XCTSkip("Set TEST_RUNNER_SPARK_EXIT_REGTEST=1 to run against Spark's regtest") + } + storageDir = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-regtest-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) + } + + override func tearDown() { + if let storageDir { + try? FileManager.default.removeItem(at: storageDir) + } + super.tearDown() + } + + func testQuotesAnEmptyWalletAndReadsTheChain() async throws { + let mnemonic = try generateMnemonic(wordCount: .words12) + let sdk = try await BreezSdkSpark.connect(request: ConnectRequest( + config: defaultConfig(network: .regtest), + seed: .mnemonic(mnemonic: mnemonic, passphrase: nil), + storageDir: storageDir.path + )) + defer { Task { try? await sdk.disconnect() } } + let funding = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .regtest) + let recovering = BreezSparkExitRecovering(sdk: sdk) + let feeRate = try await max(1, sdk.recommendedFees().fastestFee) + + let prepared = try await recovering.prepare(PrepareRecoverFundsRequest( + feeRateSatPerVbyte: feeRate, + fundingKind: .p2wpkh, + destination: funding.address, + selection: .all + )) + + XCTAssertTrue(prepared.leaves.isEmpty) + XCTAssertFalse(SparkExitQuote(prepared: prepared).isWorthExiting) + + let chain = EsploraSparkExitChain(hosts: EsploraSparkExitChain.defaultHosts(for: .regtest)) + let tip = try await chain.tipHeight() + XCTAssertGreaterThan(tip, 0) + let utxos = try await chain.utxos(address: funding.address) + XCTAssertTrue(utxos.isEmpty) + } + + /// A parent spending a missing input with its child, as a package: the live node reads the body and answers + /// per transaction, which the classifier reads as retry later rather than a rejection. + func testLiveNodeAnswersAPackageTheClassifierRetries() async { + let parent = SparkExitFixtures.parentHex + let child = "0200000001" + Data(SparkExitFixtures.parentTxid.hexaBytes.reversed()).hex + + "0000000000ffffffff01e803000000000000160014222222222222222222222222222222222222222200000000" + let tx = RecoveryTransaction( + kind: .node, + nodeId: "n", + txid: SparkExitFixtures.parentTxid, + txHex: parent, + cpfpTxHex: child, + csvTimelockBlocks: nil, + dependsOn: [], + status: .ready + ) + let chain = EsploraSparkExitChain(hosts: EsploraSparkExitChain.defaultHosts(for: .regtest)) + + let outcome = await chain.broadcast(tx) + + guard case let .retryLater(reason) = outcome else { return XCTFail("Expected retry later, got \(outcome)") } + XCTAssertTrue(reason.contains("missingorspent"), reason) + } + + /// The background tasks' entry point from a cold start, in a process with no UI and no node: the keyless pass + /// sends a stored package to the live node, then the Spark SDK connects, checks the stored exit and rebuilds it. + /// Logs the wall time per step and the process footprint, for the refresh and processing budgets. + @MainActor + func testBackgroundRunFromAColdStart() async throws { + let walletIndex = 7 + let mnemonic = try generateMnemonic(wordCount: .words12) + try Keychain.upsert(key: .bip39Mnemonic(index: walletIndex), data: Data(mnemonic.utf8)) + let store = SparkExitStore(walletIndex: walletIndex) + defer { + try? Keychain.delete(key: .bip39Mnemonic(index: walletIndex)) + try? store.delete() + try? FileManager.default.removeItem(at: Env.sparkStorage(walletIndex: walletIndex)) + } + let funding = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .regtest) + var record = SparkExitRecord( + network: "regtest", + createdAt: Date(), + phase: .running, + feeRateSatPerVbyte: 1, + destination: funding.address, + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: funding.address, pubkey: funding.pubkey)], + expectedSats: 1000 + ) + let child = "0200000001" + Data(SparkExitFixtures.parentTxid.hexaBytes.reversed()).hex + + "0000000000ffffffff01e803000000000000160014222222222222222222222222222222222222222200000000" + // The shape of the live regtest exit: a fan-out, 24 node packages, 4 refunds and the sweep. Only the fan-out is + // a real transaction; the rest wait for parents the chain host has never seen. + func txid(_ i: Int) -> String { + String(repeating: String(format: "%02x", i), count: 32) + } + let nodes = (1 ... 24).map { SparkExitFixtures.tx( + txid($0), + cpfp: "03c\($0)", + dependsOn: [SparkExitFixtures.parentTxid], + status: .waitingForDependencies + ) } + let refunds = (25 ... 28).map { SparkExitFixtures.tx(txid($0), kind: .refund, dependsOn: [txid($0 - 24)], status: .waitingForDependencies) } + let sweep = SparkExitFixtures.tx(txid(29), kind: .sweep, dependsOn: (25 ... 28).map(txid), status: .waitingForDependencies) + var stored = SparkExitFixtures.recovery([SparkExitFixtures.tx(SparkExitFixtures.parentTxid, cpfp: child)] + nodes + refunds + [sweep]) + stored.transactions[0].txHex = SparkExitFixtures.parentHex + record.adopt(stored, at: Date(timeIntervalSinceNow: -3600)) + try store.save(record) + UserDefaults.standard.set(walletIndex, forKey: "sparkExitWalletIndex") + + let baseline = SparkExitManager.footprintMegabytes() + let peak = PeakFootprint() + let sampler = Task.detached { + while !Task.isCancelled { + peak.sample() + try? await Task.sleep(nanoseconds: 20_000_000) + } + } + let cold = await SparkExitManager.shared.runInBackground(deadline: Date(timeIntervalSinceNow: 25)) + let warm = await SparkExitManager.shared.runInBackground(deadline: Date(timeIntervalSinceNow: 25)) + let prepareStarted = Date() + let prepared = try await XCTUnwrap(SparkManager.shared.exitRecovering).prepare(PrepareRecoverFundsRequest( + feeRateSatPerVbyte: 1, + fundingKind: .p2wpkh, + destination: funding.address, + selection: .all + )) + let prepareSeconds = Date().timeIntervalSince(prepareStarted) + sampler.cancel() + peak.sample() + + func line(_ outcome: SparkExitBackgroundRun.Outcome) -> String { + SparkExitBackgroundRun.Step.allCases.compactMap { step in + outcome.seconds[step].map { "\(step.rawValue)=\(String(format: "%.2f", $0))s" } + }.joined(separator: " ") + } + print("SPARK_EXIT_BG cold: \(line(cold)) phase=\(String(describing: cold.record?.phase))") + print("SPARK_EXIT_BG warm: \(line(warm)) phase=\(String(describing: warm.record?.phase))") + print("SPARK_EXIT_BG prepare(all): \(String(format: "%.2f", prepareSeconds))s leaves=\(prepared.leaves.count)") + print("SPARK_EXIT_BG footprint: baseline=\(baseline)MB peak=\(peak.value)MB end=\(SparkExitManager.footprintMegabytes())MB") + XCTAssertTrue(cold.isConnected) + XCTAssertNotNil(cold.seconds[.broadcast]) + XCTAssertFalse(LightningService.shared.hasNode) + } +} + +private final class PeakFootprint: @unchecked Sendable { + private let lock = NSLock() + private var peak = 0 + + var value: Int { + lock.withLock { peak } + } + + func sample() { + let now = SparkExitManager.footprintMegabytes() + lock.withLock { peak = max(peak, now) } + } +} diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift new file mode 100644 index 000000000..e790ac5f7 --- /dev/null +++ b/BitkitTests/SparkExitSweepTests.swift @@ -0,0 +1,341 @@ +@testable import Bitkit +import BreezSdkSpark +import CryptoKit +import secp256k1 +import XCTest + +final class SparkExitSweepTests: XCTestCase { + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + private var key: SparkExitFundingKey.Derived! + private var destination: String! + private var chain: FakeSparkExitChain! + private var saved: [SparkExitRecord] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + key = try SparkExitFundingKey.derive(index: 0, mnemonic: Self.mnemonic, passphrase: nil, network: .regtest) + destination = try SparkExitFundingKey.derive(index: 1, mnemonic: Self.mnemonic, passphrase: nil, network: .regtest).address + chain = FakeSparkExitChain() + saved = [] + } + + private func signer() throws -> CpfpSigner { + try singleKeyCpfpSigner(secretKeyBytes: key.secretKey) + } + + private func driver() -> SparkExitDriver { + SparkExitDriver( + chain: chain, + recovering: nil, + makeSigner: { [unowned self] _ in try signer() }, + save: { [unowned self] in saved.append($0) } + ) + } + + private func endedRecord(phase: SparkExitRecord.Phase = .done) -> SparkExitRecord { + SparkExitRecord( + network: "regtest", + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + phase: phase, + feeRateSatPerVbyte: 2, + destination: destination, + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: key.address, pubkey: key.pubkey, txid: "f0", vout: 1, valueSats: 9000)], + expectedSats: 90000, + leftover: [.init(txid: String(repeating: "a", count: 64), vout: 0, valueSats: 5035)] + ) + } + + // MARK: Building + + func testSignsEveryLeftoverInputForTheFundingKeyAndPaysSavingsLessTheFee() async throws { + let utxos = [ + SparkExitFixtures.utxo(String(repeating: "a", count: 64), vout: 0, value: 5035), + SparkExitFixtures.utxo(String(repeating: "b", count: 64), vout: 3, value: 1200), + ] + + let sweep = try await SparkExitLeftoverSweep.build( + utxos: utxos, + fundingAddress: key.address, + destination: destination, + feeRateSatPerVbyte: 2, + signer: signer() + ) + let built = try XCTUnwrap(sweep) + + let fee = SparkExitLeftoverSweep.feeSats(inputs: 2, outputScriptLength: 22, feeRateSatPerVbyte: 2) + XCTAssertEqual(fee, 2 * 178) + XCTAssertEqual(built.valueSats, 6235 - fee) + XCTAssertEqual(SparkExitTxid.txid(hex: built.txHex), built.txid) + try assertValidP2wpkhSpend(built, utxos: utxos) + } + + func testSkipsALeftoverThatIsDustAfterTheFee() async throws { + let fee = SparkExitLeftoverSweep.feeSats(inputs: 1, outputScriptLength: 22, feeRateSatPerVbyte: 2) + + let dust = try await SparkExitLeftoverSweep.build( + utxos: [SparkExitFixtures.utxo("aa", value: fee + 293)], + fundingAddress: key.address, + destination: destination, + feeRateSatPerVbyte: 2, + signer: signer() + ) + let enough = try await SparkExitLeftoverSweep.build( + utxos: [SparkExitFixtures.utxo(String(repeating: "c", count: 64), value: fee + 294)], + fundingAddress: key.address, + destination: destination, + feeRateSatPerVbyte: 2, + signer: signer() + ) + + XCTAssertNil(dust) + XCTAssertEqual(enough?.valueSats, 294) + } + + func testReadsTheOutputScriptOfEachAddressKind() throws { + let cases = [ + "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4": "0014751e76e8199196d454941c45d1b3a323f1433bd6", + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0": + "512079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2": "76a91477bff20c60e522dfaa3350c39b030a5d004e839a88ac", + "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy": "a914b472a266d0bd89c13706a4132ccfb16f7c3b9fcb87", + ] + for (address, script) in cases { + XCTAssertEqual(try SparkExitAddress.scriptPubKey(address).hex, script, address) + } + XCTAssertEqual(SparkExitLeftoverSweep.dustLimit(Data(hex: "0014751e76e8199196d454941c45d1b3a323f1433bd6")), 294) + XCTAssertEqual(SparkExitLeftoverSweep.dustLimit(Data(hex: "a914b472a266d0bd89c13706a4132ccfb16f7c3b9fcb87")), 540) + // Bad checksum, and a version 0 program sent as bech32m. + XCTAssertThrowsError(try SparkExitAddress.scriptPubKey("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5")) + XCTAssertThrowsError(try SparkExitAddress.scriptPubKey("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh")) + } + + // MARK: Driving + + func testAnEndedExitSweepsItsLeftoverToSavingsOnceANodeAcceptsIt() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + chain.outcome = .retryLater("HTTP 503") + + var record = try await driver().tick(endedRecord()) + let sweepTxid = try XCTUnwrap(record.leftoverSweep?.txid) + XCTAssertTrue(record.isLeftoverSweepPending) + XCTAssertTrue(record.needsDriving) + XCTAssertEqual(saved.first?.leftoverSweep?.txid, sweepTxid, "saved before it is sent") + + chain.outcome = .accepted + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + XCTAssertTrue(record.needsDriving, "driven until the sweep confirms") + XCTAssertTrue(record.showsResult) + + chain.confirmedTxids = [sweepTxid] + record = try await driver().tick(record) + + XCTAssertEqual(chain.broadcasts.map(\.txid), [sweepTxid, sweepTxid]) + XCTAssertEqual(record.leftoverSweep?.isConfirmed, true) + XCTAssertFalse(record.needsDriving) + XCTAssertFalse(record.showsResult, "a new exit may start") + XCTAssertEqual(record.phase, .done) + } + + /// qa-03: Done keeps the result reachable while the sweep is unconfirmed or refused, and sends an accepted sweep + /// again until it confirms, in case a node dropped it. + func testTheResultStaysReachableUntilTheSweepConfirms() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + var record = try await driver().tick(endedRecord()) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + + record = try await driver().tick(record) + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.count, 3) + XCTAssertTrue(record.showsResult) + + var refused = endedRecord() + refused.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "min relay fee not met") + XCTAssertFalse(refused.needsDriving) + XCTAssertTrue(refused.showsResult, "the leftover waits at the funding address") + + var dust = endedRecord() + dust.leftoverSweep = .init() + XCTAssertFalse(dust.showsResult) + } + + /// qa-03: after a Redo that needed no funding, 3,514 sats of generation 1's change in five UTXOs stayed at the + /// funding address. Done sweeps them all in one transaction. + func testDoneAfterARedoWithoutFundingSweepsEveryLeftoverOfTheFirstGeneration() async throws { + var record = endedRecord(phase: .running) + record.leftover = [] + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c1")]) + record.adopt(first, at: .distantPast) + let second = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)]) + let sdk = FakeSparkExitRecovering() + sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil) + sdk.builds = [second] + sdk.checks = [ + CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged)), + CheckRecoverFundsResponse( + recovery: SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 50))]), + verdict: .done + ), + ] + let utxos = [1200, 900, 700, 414, 300].enumerated().map { i, value in + SparkExitFixtures.utxo(String(repeating: "\(i + 1)", count: 64), vout: UInt32(i), value: UInt64(value)) + } + chain.utxosByAddress[key.address] = utxos + let driver = SparkExitDriver(chain: chain, recovering: sdk, makeSigner: { [unowned self] _ in try signer() }, save: { _ in }) + + record = try await driver.tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, []) + + record = try await driver.tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.leftover.count, 5) + let sweep = try XCTUnwrap(record.leftoverSweep) + let fee = SparkExitLeftoverSweep.feeSats(inputs: 5, outputScriptLength: 22, feeRateSatPerVbyte: 2) + XCTAssertEqual(sweep.valueSats, 3514 - fee) + XCTAssertEqual(sweep.isAccepted, true) + try assertValidP2wpkhSpend(.init(txid: XCTUnwrap(sweep.txid), txHex: XCTUnwrap(sweep.txHex), valueSats: sweep.valueSats), utxos: utxos) + XCTAssertTrue(record.showsResult) + } + + /// Each exit signs with the key at its own funding index, so a later exit never spends an earlier one's address. + func testSignsWithTheKeyAtTheRecordsFundingIndex() async throws { + let second = try SparkExitFundingKey.derive(index: 4, mnemonic: Self.mnemonic, passphrase: nil, network: .regtest) + XCTAssertNotEqual(second.address, key.address) + var record = endedRecord() + record.funding = [.init(index: 4, address: second.address, pubkey: second.pubkey, txid: "f0", vout: 1, valueSats: 9000)] + chain.utxosByAddress[second.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + var indexes: [UInt32] = [] + let driver = SparkExitDriver(chain: chain, recovering: nil, makeSigner: { index in + indexes.append(index) + return try singleKeyCpfpSigner(secretKeyBytes: second.secretKey) + }, save: { _ in }) + + record = try await driver.tick(record) + + XCTAssertEqual(indexes, [4]) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + } + + func testWaitsForTheLeftoverToConfirmAndLeavesDustAlone() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035, confirmed: false)] + + var record = try await driver().tick(endedRecord()) + XCTAssertNil(record.leftoverSweep) + XCTAssertTrue(chain.broadcasts.isEmpty) + + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 400)] + record = try await driver().tick(record) + + XCTAssertEqual(record.leftoverSweep, SparkExitRecord.LeftoverSweep()) + XCTAssertFalse(record.needsDriving) + XCTAssertFalse(record.showsResult, "dust is kept, and a new exit may start") + XCTAssertTrue(chain.broadcasts.isEmpty) + } + + func testFinishingAnExitSweepsTheLeftoverInTheSameTick() async throws { + var record = endedRecord(phase: .running) + record.leftover = [] + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)])) + let done = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 50))]) + let sdk = FakeSparkExitRecovering() + sdk.checks = [CheckRecoverFundsResponse(recovery: done, verdict: .done)] + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + let driver = SparkExitDriver(chain: chain, recovering: sdk, makeSigner: { [unowned self] _ in try signer() }, save: { _ in }) + + record = try await driver.tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + XCTAssertEqual(chain.broadcasts.map(\.txid), [record.leftoverSweep?.txid]) + } + + func testABackgroundRunSweepsAnEndedExitWithoutStartingSpark() async throws { + let store = SparkExitStore(url: FileManager.default.temporaryDirectory + .appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json")) + defer { try? store.delete() } + try store.save(endedRecord()) + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + var connects = 0 + let run = SparkExitBackgroundRun( + load: { try store.load() }, + makeDriver: { [unowned self] chain, recovering in + SparkExitDriver(chain: chain, recovering: recovering, makeSigner: { _ in try self.signer() }, save: { try store.save($0) }) + }, + chain: chain, + connected: nil, + connect: { _ in + connects += 1 + return nil + }, + deadline: Date(timeIntervalSinceNow: 25) + ) + + let outcome = await run.run() + + XCTAssertEqual(connects, 0) + XCTAssertEqual(outcome.record?.leftoverSweep?.isAccepted, true) + XCTAssertEqual(try store.load()?.leftoverSweep?.isAccepted, true) + } + + // MARK: Signature check + + /// Checks each input's witness is a valid BIP143 signature by the funding key over the sweep. + private func assertValidP2wpkhSpend(_ built: SparkExitLeftoverSweep.Built, utxos: [EsploraUtxo]) throws { + let tx = [UInt8](Data(hex: built.txHex)) + let outputScript = try [UInt8](SparkExitAddress.scriptPubKey(destination)) + let pubkeyHash = try [UInt8](SparkExitAddress.scriptPubKey(key.address))[2...] + let outpoints = utxos.map { [UInt8]($0.txid.hexaBytes.reversed()) + le32($0.vout) } + let sequence = le32(0xFFFF_FFFD) + let outputs = le64(built.valueSats) + [UInt8(outputScript.count)] + outputScript + var offset = 4 + 2 + 1 + 41 * utxos.count + 1 + outputs.count + XCTAssertEqual(Array(tx[(offset - outputs.count) ..< offset]), outputs) + + let context = try XCTUnwrap(secp256k1_context_create(UInt32(SECP256K1_CONTEXT_VERIFY))) + defer { secp256k1_context_destroy(context) } + var pubkey = secp256k1_pubkey() + let pubkeyBytes = key.pubkey.hexaBytes + XCTAssertEqual(secp256k1_ec_pubkey_parse(context, &pubkey, pubkeyBytes, pubkeyBytes.count), 1) + + for (i, utxo) in utxos.enumerated() { + XCTAssertEqual(tx[offset], 2) + let signatureLength = Int(tx[offset + 1]) + let signature = Array(tx[(offset + 2) ..< (offset + 2 + signatureLength)]) + offset += 2 + signatureLength + XCTAssertEqual(Array(tx[(offset + 1) ..< (offset + 34)]), pubkeyBytes) + offset += 34 + XCTAssertEqual(signature.last, 0x01, "SIGHASH_ALL") + + let preimage = le32(2) + dsha(outpoints.flatMap { $0 }) + dsha(Array(repeating: sequence, count: utxos.count).flatMap { $0 }) + + outpoints[i] + [0x19, 0x76, 0xA9, 0x14] + pubkeyHash + [0x88, 0xAC] + le64(utxo.value) + sequence + + dsha(outputs) + le32(0) + le32(1) + let digest = dsha(preimage) + var parsed = secp256k1_ecdsa_signature() + let der = Array(signature.dropLast()) + XCTAssertEqual(secp256k1_ecdsa_signature_parse_der(context, &parsed, der, der.count), 1) + XCTAssertEqual(secp256k1_ecdsa_verify(context, &parsed, digest, &pubkey), 1, "input \(i)") + } + XCTAssertEqual(Array(tx[offset...]), le32(0)) + } + + private func dsha(_ bytes: [UInt8]) -> [UInt8] { + [UInt8](Data(SHA256.hash(data: Data(SHA256.hash(data: Data(bytes)))))) + } + + private func le32(_ value: UInt32) -> [UInt8] { + withUnsafeBytes(of: value.littleEndian) { [UInt8]($0) } + } + + private func le64(_ value: UInt64) -> [UInt8] { + withUnsafeBytes(of: value.littleEndian) { [UInt8]($0) } + } +} + +private extension Data { + init(hex: String) { + self.init(hex.hexaBytes) + } +} diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift new file mode 100644 index 000000000..6c30dca75 --- /dev/null +++ b/BitkitTests/SparkExitTests.swift @@ -0,0 +1,1646 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +// MARK: - Fixtures + +enum SparkExitFixtures { + static let parentHex = "020000000111111111111111111111111111111111111111111111111111111111111111110000000000ffffffff01e803000000000000160014222222222222222222222222222222222222222200000000" + /// The id Spark's regtest node reported for `parentHex`. + static let parentTxid = "b91b720103788b9ae50dbff918bc375d185df91025b9f6e861efe208909b6adf" + + static func tx( + _ txid: String, + kind: RecoveryTxKind = .node, + cpfp: String? = nil, + dependsOn: [String] = [], + status: ExitTransactionStatus = .ready, + csv: UInt32? = nil + ) -> RecoveryTransaction { + RecoveryTransaction( + kind: kind, + nodeId: kind == .sweep ? nil : "node-\(txid)", + txid: txid, + txHex: "02\(txid)", + cpfpTxHex: cpfp, + csvTimelockBlocks: csv, + dependsOn: dependsOn, + status: status + ) + } + + static func recovery(_ transactions: [RecoveryTransaction], failed: [CooperativeRecoveryFailure] = []) -> RecoverFundsResponse { + RecoverFundsResponse( + recoverableValueSats: 100_000, + totalFeeSats: 9000, + cooperativeFeeSats: 0, + cpfpFeeSats: 7000, + fanoutFeeSats: 500, + refundFeeSats: 1000, + sweepFeeSats: 500, + leaves: [RecoverFundsLeaf(leafId: "leaf-1", valueSats: 100_000, method: .unilateral)], + failed: failed, + transactions: transactions, + fundingInputs: [.p2wpkh(txid: "f0", vout: 1, valueSats: 20000, pubkey: "02ab")], + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination" + ) + } + + static func prepared(funding: UInt64? = 20000, leaves: [String] = ["leaf-1"]) -> PrepareRecoverFundsResponse { + PrepareRecoverFundsResponse( + leaves: leaves.map { RecoverFundsLeaf(leafId: $0, valueSats: 100_000, method: .unilateral) }, + skipped: [SkippedLeaf(leafId: "dust", valueSats: 300, reason: .feeExceedsValue)], + recoverableValueSats: 100_000, + totalFeeSats: 9000, + cooperativeFeeSats: 0, + cpfpFeeSats: 7000, + fanoutFeeSats: 500, + refundFeeSats: 1000, + sweepFeeSats: 500, + funding: funding.map { RecoveryFunding(singleUtxoSats: $0, perBranch: []) }, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + exitChainState: ExitChainState( + confirmedNodes: [], + refunds: [], + stoppedLeafIds: [], + unverifiedNodeIds: [], + unverifiableConfirmedNodeIds: [] + ) + ) + } + + static func record(phase: SparkExitRecord.Phase = .awaitingFunding(requiredSats: 20000)) -> SparkExitRecord { + SparkExitRecord( + network: "regtest", + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + phase: phase, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab")], + transferId: "transfer-1", + expectedSats: 111_000 + ) + } + + /// A version 3 transaction with one input spending `csvParentTxid`:0 under a 10-block BIP68 lock. + static let csvParentTxid = String(repeating: "11", count: 32) + static let csvHex = "03000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "0a000000" + + "01" + "e803000000000000" + "16" + "0014" + String(repeating: "22", count: 20) + "00000000" + + static func refund(_ txid: String, leafId: String, dependsOn: [String], status: ExitTransactionStatus = .ready) -> RecoveryTransaction { + RecoveryTransaction( + kind: .refund, + nodeId: leafId, + txid: txid, + txHex: "02\(txid)", + cpfpTxHex: "03\(txid)", + csvTimelockBlocks: nil, + dependsOn: dependsOn, + status: status + ) + } + + static func utxo(_ txid: String, vout: UInt32 = 0, value: UInt64, confirmed: Bool = true) -> EsploraUtxo { + EsploraUtxo(txid: txid, vout: vout, value: value, status: EsploraTxStatus(confirmed: confirmed, blockHeight: confirmed ? 100 : nil)) + } +} + +final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { + private let lock = NSLock() + var tip: UInt32 = 1000 + var utxosByAddress: [String: [EsploraUtxo]] = [:] + var confirmedTxids: Set = [] + var outcome: SparkExitBroadcastOutcome = .accepted + var isUtxosUnreadable = false + private(set) var broadcasts: [RecoveryTransaction] = [] + var onBroadcast: (() -> Void)? + + func tipHeight() async throws -> UInt32 { + tip + } + + func utxos(address: String) async throws -> [EsploraUtxo] { + if isUtxosUnreadable { + throw URLError(.timedOut) + } + return lock.withLock { utxosByAddress[address] ?? [] } + } + + func txStatus(txid: String) async throws -> EsploraTxStatus { + lock.withLock { EsploraTxStatus(confirmed: confirmedTxids.contains(txid), blockHeight: confirmedTxids.contains(txid) ? 900 : nil) } + } + + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { + onBroadcast?() + return lock.withLock { + broadcasts.append(tx) + return outcome + } + } +} + +final class FakeSparkExitRecovering: SparkExitRecovering, @unchecked Sendable { + var preparedResponse = SparkExitFixtures.prepared() + var builds: [RecoverFundsResponse] = [] + var checks: [CheckRecoverFundsResponse] = [] + var recoverError: Error? + /// Thrown by the next prepares, in order. + var prepareErrors: [Error] = [] + var preparedByRequest: ((PrepareRecoverFundsRequest) -> PrepareRecoverFundsResponse)? + private(set) var prepareRequests: [PrepareRecoverFundsRequest] = [] + private(set) var recoverRequests: [RecoverFundsRequest] = [] + private(set) var checkedRecoveries: [RecoverFundsResponse] = [] + + func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse { + prepareRequests.append(request) + if !prepareErrors.isEmpty { + throw prepareErrors.removeFirst() + } + return preparedByRequest?(request) ?? preparedResponse + } + + func recover(_ request: RecoverFundsRequest, signer _: CpfpSigner?) async throws -> RecoverFundsResponse { + recoverRequests.append(request) + if let recoverError { + throw recoverError + } + return builds.removeFirst() + } + + func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { + checkedRecoveries.append(recovery) + return checks.removeFirst() + } +} + +final class StubCpfpSigner: CpfpSigner, @unchecked Sendable { + func signPsbt(psbtBytes: Data) async throws -> Data { + psbtBytes + } +} + +// MARK: - Serializer + +final class SparkExitRecordTests: XCTestCase { + func testRecoveryRoundTripsThroughTheRecord() throws { + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery( + [ + SparkExitFixtures.tx("fan", kind: .fanOut, status: .confirmed(blockHeight: 120)), + SparkExitFixtures.tx("node", cpfp: "03cpfp", dependsOn: ["fan"], status: .ready), + SparkExitFixtures.tx( + "refund", + kind: .refund, + cpfp: "03cpfp2", + dependsOn: ["node"], + status: .waitingForTimelock(spendableAtHeight: 2100), + csv: 2000 + ), + SparkExitFixtures.tx("wait", dependsOn: ["node"], status: .waitingForDependencies), + SparkExitFixtures.tx("unknown", status: .unverified), + SparkExitFixtures.tx("sweep", kind: .sweep, dependsOn: ["refund"], status: .confirmed(blockHeight: nil)), + ], + failed: [ + CooperativeRecoveryFailure( + leafId: "a", + outputTxid: "o1", + outputVout: 0, + error: .replacementFeeTooLow(requiredFeeSats: 5, requiredFeeRateSatPerVbyte: 6) + ), + CooperativeRecoveryFailure(leafId: "b", outputTxid: "o2", outputVout: 1, error: .operatorsUnavailable(message: "down")), + CooperativeRecoveryFailure(leafId: "c", outputTxid: "o3", outputVout: 2, error: .generic(message: "boom")), + ] + ) + record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) + record.broadcastLog = [.init( + generation: 1, + txid: "node", + attempts: 2, + rejections: 0, + lastAttemptAt: Date(timeIntervalSince1970: 1_700_000_100), + lastOutcome: "accepted", + isAccepted: true + )] + record.phase = .needsFunding(requiredSats: 4321) + + let decoded = try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)) + + XCTAssertEqual(decoded, record) + XCTAssertEqual(decoded.recovery?.response, recovery) + } + + /// A record written before the qa-03 fields reads as before; the new fields round-trip. + func testReadsARecordWithoutTheNewFieldsAndRoundTripsThem() throws { + let json = #""" + {"version":1,"network":"regtest","createdAt":1700000000000,"phase":{"done":{}},"feeRateSatPerVbyte":2, + "destination":"bcrt1qdestination","leafIds":["leaf-1"],"funding":[{"index":0,"address":"bcrt1qfunding","pubkey":"02ab"}], + "generation":1,"broadcastLog":[{"generation":1,"txid":"n1","attempts":1,"rejections":0,"lastAttemptAt":1700000000000, + "lastOutcome":"accepted","isAccepted":true}],"expectedSats":1000,"leftover":[{"txid":"c","vout":0,"valueSats":700}], + "leftoverSweep":{"txid":"t","txHex":"02","valueSats":400,"isAccepted":true},"skippedLeafCount":0,"failedLeafCount":0, + "isRebuilding":false} + """# + + var record = try SparkExitRecordCoder.decode(Data(json.utf8)) + + XCTAssertEqual(record.fundingIndex, 0) + XCTAssertNil(record.broadcastLog[0].isInputSpent) + XCTAssertNil(record.leftoverSweep?.isConfirmed) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertTrue(record.showsResult, "an accepted sweep not known to be confirmed keeps the result") + + record.broadcastLog[0].isInputSpent = true + record.leftoverSweep?.isConfirmed = true + record.fundingPaymentTxid = "pay" + record.spentStepStrikes = ["r1": 1] + record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 5000)] + XCTAssertEqual(try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)), record) + } + + func testRecoveryUsesSparkSdkSerdeShape() throws { + let recovery = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("a", cpfp: "03c", status: .confirmed(blockHeight: 7)), + SparkExitFixtures.tx("b", kind: .fanOut, status: .ready), + SparkExitFixtures.tx("c", status: .waitingForTimelock(spendableAtHeight: nil)), + ]) + + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(StoredRecovery(recovery))) as? [String: Any]) + + XCTAssertEqual(json["fee_rate_sat_per_vbyte"] as? Int, 2) + let transactions = try XCTUnwrap(json["transactions"] as? [[String: Any]]) + XCTAssertEqual(transactions[0]["kind"] as? String, "Node") + XCTAssertEqual(transactions[0]["cpfp_tx_hex"] as? String, "03c") + XCTAssertEqual((transactions[0]["status"] as? [String: [String: Int]])?["Confirmed"]?["block_height"], 7) + XCTAssertEqual(transactions[1]["kind"] as? String, "FanOut") + XCTAssertEqual(transactions[1]["status"] as? String, "Ready") + XCTAssertTrue(transactions[1]["cpfp_tx_hex"] is NSNull) + let inputs = try XCTUnwrap(json["funding_inputs"] as? [[String: [String: Any]]]) + XCTAssertEqual(inputs[0]["P2wpkh"]?["value_sats"] as? Int, 20000) + } + + /// A recovery as the Breez CLI's `recover-funds --output-file` writes it (serde_json, spark-sdk 0.27.2). + func testReadsARecoveryWrittenBySparkSdk() throws { + let json = """ + {"recoverable_value_sats":90112,"total_fee_sats":8832,"cooperative_fee_sats":0,"cpfp_fee_sats":8388, + "fanout_fee_sats":0,"refund_fee_sats":0,"sweep_fee_sats":444, + "leaves":[{"leaf_id":"l1","value_sats":90112,"method":"Unilateral"}], + "failed":[{"leaf_id":"l2","output_txid":"t","output_vout":3,"error":{"OperatorsUnavailable":{"message":"timeout"}}}], + "transactions":[ + {"kind":"Node","node_id":"n1","txid":"aa","tx_hex":"02aa","cpfp_tx_hex":"03bb","csv_timelock_blocks":null, + "depends_on":[],"status":"Ready"}, + {"kind":"Refund","node_id":"l1","txid":"cc","tx_hex":"02cc","cpfp_tx_hex":"03dd","csv_timelock_blocks":2000, + "depends_on":["aa"],"status":{"WaitingForTimelock":{"spendable_at_height":3100}}}, + {"kind":"Sweep","node_id":null,"txid":"ee","tx_hex":"02ee","cpfp_tx_hex":null,"csv_timelock_blocks":null, + "depends_on":["cc"],"status":"WaitingForDependencies"}], + "funding_inputs":[{"P2wpkh":{"txid":"ff","vout":0,"value_sats":10000,"pubkey":"02cd"}}], + "fee_rate_sat_per_vbyte":1,"destination":"bc1qexample"} + """ + + let recovery = try JSONDecoder().decode(StoredRecovery.self, from: Data(json.utf8)).response + + XCTAssertEqual(recovery.transactions.map(\.kind), [.node, .refund, .sweep]) + XCTAssertEqual(recovery.transactions[1].status, .waitingForTimelock(spendableAtHeight: 3100)) + XCTAssertEqual(recovery.transactions[1].csvTimelockBlocks, 2000) + XCTAssertNil(recovery.transactions[2].nodeId) + XCTAssertEqual(recovery.failed[0].error, .operatorsUnavailable(message: "timeout")) + XCTAssertEqual(recovery.fundingInputs, [.p2wpkh(txid: "ff", vout: 0, valueSats: 10000, pubkey: "02cd")]) + } + + func testRejectsAnUnknownRecordVersion() throws { + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: SparkExitRecordCoder.encode(SparkExitFixtures.record())) as? [String: Any]) + object["version"] = 2 + let data = try JSONSerialization.data(withJSONObject: object) + + XCTAssertThrowsError(try SparkExitRecordCoder.decode(data)) { error in + XCTAssertEqual(error as? SparkExitRecordCoder.UnsupportedVersion, .init(version: 2)) + } + } + + func testStoreSavesAndLoads() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json") + let store = SparkExitStore(url: url) + defer { try? store.delete() } + XCTAssertNil(try store.load()) + + try store.save(SparkExitFixtures.record()) + + XCTAssertEqual(try store.load(), SparkExitFixtures.record()) + } +} + +// MARK: - Driver + +final class SparkExitDriverTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + private var saved: [SparkExitRecord] = [] + private var clock = Date(timeIntervalSince1970: 1_800_000_000) + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + saved = [] + } + + private func driver(withSdk: Bool = true) -> SparkExitDriver { + SparkExitDriver( + chain: chain, + recovering: withSdk ? sdk : nil, + makeSigner: { _ in StubCpfpSigner() }, + save: { [unowned self] in saved.append($0) }, + now: { [unowned self] in clock } + ) + } + + func testWaitsForAConfirmedFundingUtxo() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", value: 20000, confirmed: false)] + + let record = try await driver().tick(SparkExitFixtures.record()) + + XCTAssertEqual(record.phase, .awaitingFunding(requiredSats: 20000)) + XCTAssertTrue(sdk.prepareRequests.isEmpty) + } + + func testBuildsOnceFundedAndSavesBeforeBroadcasting() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)] + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: "03c1"), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .waitingForDependencies), + ])] + var savesAtFirstBroadcast: Int? + chain.onBroadcast = { [unowned self] in savesAtFirstBroadcast = savesAtFirstBroadcast ?? saved.count } + + let record = try await driver().tick(SparkExitFixtures.record()) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(savesAtFirstBroadcast, 1) + XCTAssertEqual(saved.first?.recovery, record.recovery) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: "f0", vout: 1, valueSats: 25000, pubkey: "02ab")]) + guard case let .specific(leafIds) = sdk.prepareRequests.first?.selection else { return XCTFail("Expected a specific selection") } + XCTAssertEqual(leafIds, ["leaf-1"]) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + XCTAssertEqual(record.broadcastLog.map(\.txid), ["n1"]) + } + + func testValidBroadcastsReadyStepsOnceUntilTheyAreDue() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + let checked = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"]), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n2"], status: .waitingForTimelock(spendableAtHeight: 3000)), + ]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: checked, verdict: .valid), count: 3) + + record = try await driver().tick(record) + record = try await driver().tick(record) + clock = clock.addingTimeInterval(SparkExitDriver.rebroadcastInterval) + record = try await driver().tick(record) + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2", "n2"]) + XCTAssertEqual(record.broadcastLog.first?.attempts, 2) + XCTAssertEqual(record.claimableAtHeight, 3000) + XCTAssertEqual(record.recovery?.response, checked) + } + + func testRetriesARejectedStepAndSurfacesItAfterRepeatedRejections() async throws { + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.outcome = .rejected("min relay fee not met") + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 3) + + for _ in 0 ..< 3 { + record = try await driver().tick(record) + } + + XCTAssertEqual(chain.broadcasts.count, 3) + XCTAssertEqual(record.surfacedRejection, "rejected: min relay fee not met") + } + + func testDoneFinishesAndReadsLeftoverFunding() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)])) + let done = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 50))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: done, verdict: .done)] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("change", value: 700)] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertFalse(record.isActive) + XCTAssertEqual(record.leftover, [.init(txid: "change", vout: 0, valueSats: 700)]) + XCTAssertTrue(chain.broadcasts.isEmpty) + XCTAssertEqual(saved.last, record) + } + + func testDoneWaitsForTheFundingAddressToBeReadBeforeEnding() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)])) + let done = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 50))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: done, verdict: .done), count: 2) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("change", value: 700)] + chain.isUtxosUnreadable = true + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertTrue(record.leftover.isEmpty) + + chain.isUtxosUnreadable = false + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.leftover, [.init(txid: "change", vout: 0, valueSats: 700)]) + } + + func testRedoRebuildsAsANewGenerationAndNeverSendsTheOldOne() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + let old = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(old) + record.broadcastLog = [.init( + generation: 1, + txid: "old", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "accepted", + isAccepted: true + )] + sdk.checks = [CheckRecoverFundsResponse(recovery: old, verdict: .redo(reason: .onChainStateDiverged))] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new", cpfp: "03n")])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["new"]) + XCTAssertEqual(record.broadcastLog.map(\.txid), ["new"]) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["new"]) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: "f0", vout: 1, valueSats: 20000, pubkey: "02ab")]) + } + + func testRebuildThatNeedsMoreFeesAsksForFunding() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")])) + sdk.checks = [CheckRecoverFundsResponse(recovery: SparkExitFixtures.recovery([]), verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3000)) + XCTAssertTrue(record.isActive) + } + + /// The headless regtest run: a quote for 8,541, five payments of 9,035 together, and the SDK asking for 9,085 + /// in all once it priced the five inputs. Only the 50 missing is asked for. + func testInsufficientFundsAsksOnlyForWhatTheInputsLack() async throws { + let values: [UInt64] = [5000, 2000, 1000, 535, 500] + chain.utxosByAddress["bcrt1qfunding"] = values.enumerated().map { SparkExitFixtures.utxo("u\($0.offset)", value: $0.element) } + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 9085, docsUrl: "") + + var record = try await driver().tick(SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 8541))) + + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 5) + XCTAssertEqual(record.fundedSats, 9035) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 9085)) + XCTAssertEqual(record.fundingShortfallSats, 50) + + chain.utxosByAddress["bcrt1qfunding", default: []] += [SparkExitFixtures.utxo("t1", value: 30), SparkExitFixtures.utxo("t2", value: 30)] + sdk.recoverError = nil + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(sdk.recoverRequests.last?.fundingInputs.count, 7) + } + + /// After a Redo the first inputs are partly spent: what they hold now is the CPFP change at the address. + func testRebuildShortfallCountsWhatTheSpentInputsStillHold() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + let old = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: SparkExitFixtures.parentHex)]) + record.adopt(old) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo(SparkExitFixtures.parentTxid, value: 13000)] + sdk.checks = [CheckRecoverFundsResponse(recovery: old, verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 15000, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 22000)) + XCTAssertEqual(record.fundingShortfallSats, 2000) + } + + /// Gen 1's change outlives a rebuild that needed no funding; a later shortfall still counts it as held. + func testChangeOfAnEarlierGenerationIsNotNewFunding() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: SparkExitFixtures.parentHex)])) + let second = SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")]) + record.adopt(second) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo(SparkExitFixtures.parentTxid, value: 13000)] + sdk.checks = [CheckRecoverFundsResponse(recovery: second, verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 15000, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertEqual(record.earlierCpfpTxids, [SparkExitFixtures.parentTxid]) + XCTAssertEqual(record.fundingShortfallSats, 2000) + record = try await driver().tick(record) + XCTAssertEqual(record.funding.count, 1, "the change is not adopted as a top-up") + } + + func testTopUpIgnoresCpfpChangeChainedToTheFundingAddress() async throws { + var record = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 23000)) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.recovery = StoredRecovery(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: SparkExitFixtures.parentHex)])) + chain.utxosByAddress["bcrt1qfunding"] = [ + SparkExitFixtures.utxo("f0", vout: 1, value: 20000), + SparkExitFixtures.utxo(SparkExitFixtures.parentTxid, value: 9000), + ] + + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 23000)) + XCTAssertEqual(record.fundingShortfallSats, 3000) + XCTAssertTrue(sdk.prepareRequests.isEmpty) + + chain.utxosByAddress["bcrt1qfunding", default: []].append(SparkExitFixtures.utxo("topup", value: 3000)) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 2) + } + + func testRedoIsSavedBeforeRebuildingSoTheOldGenerationIsNeverSentWithoutTheSdk() async throws { + var record = SparkExitFixtures.record(phase: .running) + let old = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(old, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: old, verdict: .redo(reason: .onChainStateDiverged))] + + // Built a minute ago: the rebuild waits, but the diverged state is already saved. + clock = clock.addingTimeInterval(60) + record = try await driver().tick(record) + XCTAssertTrue(record.isRebuilding) + XCTAssertTrue(saved.first?.isRebuilding == true) + XCTAssertTrue(sdk.recoverRequests.isEmpty) + + record = try await driver(withSdk: false).tick(record) + XCTAssertTrue(chain.broadcasts.isEmpty) + + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + record = try await driver().tick(record) + XCTAssertFalse(record.isRebuilding) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["new"]) + } + + func testCooperativeLeavesTheOperatorsCouldNotSignAreTriedAgain() async throws { + sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil) + sdk.builds = [SparkExitFixtures.recovery( + [], + failed: [CooperativeRecoveryFailure(leafId: "leaf-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down"))] + )] + + let record = try await driver().tick(SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 0))) + + XCTAssertEqual(record.phase, .awaitingFunding(requiredSats: 0)) + XCTAssertEqual(record.failedLeafCount, 1) + XCTAssertNil(record.recovery) + } + + func testRebuildWithNothingLeftEndsTheExit() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]), at: .distantPast) + record.isRebuilding = true + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: []) + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + } + + func testRebuildWaitsWhileALeafIsUnverified() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]), at: .distantPast) + record.isRebuilding = true + var prepared = SparkExitFixtures.prepared(leaves: []) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertTrue(record.isRebuilding) + } + + func testResumesAfterARestartWithoutRepeatingBroadcasts() async throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json") + let store = SparkExitStore(url: url) + defer { try? store.delete() } + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", value: 20000)] + let built = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + sdk.builds = [built] + let persisting = SparkExitDriver( + chain: chain, + recovering: sdk, + makeSigner: { _ in StubCpfpSigner() }, + save: { try store.save($0) }, + now: { [unowned self] in clock } + ) + try await persisting.tick(SparkExitFixtures.record()) + + let reloaded = try XCTUnwrap(store.load()) + sdk.checks = [CheckRecoverFundsResponse(recovery: built, verdict: .valid)] + try await persisting.tick(reloaded) + + XCTAssertEqual(reloaded.phase, .running) + XCTAssertEqual(reloaded.generation, 1) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + XCTAssertEqual(sdk.recoverRequests.count, 1) + } + + func testWithoutTheSdkBroadcastsStepsWhoseParentsConfirmedAndTimelocksMatured() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .ready), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"], status: .waitingForDependencies), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n2"], status: .waitingForTimelock(spendableAtHeight: 990)), + SparkExitFixtures.tx("r2", kind: .refund, dependsOn: ["n1"], status: .waitingForTimelock(spendableAtHeight: 1500)), + ])) + chain.tip = 1000 + chain.confirmedTxids = ["n1"] + + record = try await driver(withSdk: false).tick(record) + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(record.recovery?.response.transactions.first?.status, .confirmed(blockHeight: 900)) + XCTAssertEqual(record.phase, .running) + } + + func testCheckSeesStepsTheChainHostShowsInABlock() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("fanout", status: .ready), + SparkExitFixtures.tx("n1", cpfp: "03c1", dependsOn: ["fanout"], status: .waitingForDependencies), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"], status: .waitingForDependencies), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n3"], status: .waitingForDependencies), + ])) + chain.confirmedTxids = ["fanout", "n1", "r1"] + let stored = try XCTUnwrap(record.recovery?.response) + sdk.checks = [CheckRecoverFundsResponse(recovery: stored, verdict: .valid)] + + _ = try await driver().tick(record) + + let statuses = sdk.checkedRecoveries.first?.transactions.map(\.status) + XCTAssertEqual(statuses, [ + .confirmed(blockHeight: 900), + .confirmed(blockHeight: 900), + .waitingForDependencies, + .waitingForDependencies, + ]) + } + + // MARK: qa-03 + + /// The keyless pass counts a relative timelock from the confirmation height of the parent each input spends, + /// and sends a step only once it would be valid in the next block. + func testWithoutTheSdkWaitsForEachRelativeTimelockFromItsParentsHeight() async throws { + var record = SparkExitFixtures.record(phase: .running) + let fromHex = RecoveryTransaction( + kind: .refund, + nodeId: "leaf-1", + txid: "r1", + txHex: SparkExitFixtures.csvHex, + cpfpTxHex: nil, + csvTimelockBlocks: 10, + dependsOn: [SparkExitFixtures.csvParentTxid], + status: .waitingForDependencies + ) + let fallback = SparkExitFixtures.tx("r2", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies, csv: 5) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 995)), + fromHex, + fallback, + ])) + chain.confirmedTxids = [SparkExitFixtures.csvParentTxid] + + chain.tip = 908 + record = try await driver(withSdk: false).tick(record) + XCTAssertTrue(chain.broadcasts.isEmpty) + XCTAssertEqual(record.recovery?.response.transactions[1].status, .waitingForTimelock(spendableAtHeight: 910)) + XCTAssertEqual(record.recovery?.response.transactions[2].status, .waitingForTimelock(spendableAtHeight: 1000)) + + chain.tip = 909 + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["r1"]) + + chain.tip = 999 + _ = try await driver(withSdk: false).tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["r1", "r2"]) + } + + func testWithoutTheSdkSkipsATimelockedStepWhoseParentHeightIsUnknown() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: nil)), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies, csv: 5), + ])) + chain.tip = 5000 + + _ = try await driver(withSdk: false).tick(record) + + XCTAssertTrue(chain.broadcasts.isEmpty) + } + + /// A timelock not yet final is retried and never counts towards the rejection alert. + func testNotMatureIsNeverARejection() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + chain.outcome = .notMature("non-BIP68-final") + + for _ in 0 ..< 4 { + record = try await driver(withSdk: false).tick(record) + } + + XCTAssertEqual(chain.broadcasts.count, 4) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0) + XCTAssertNil(record.broadcastLog.first?.isInputSpent) + XCTAssertNil(record.surfacedRejection) + } + + /// The operators went first: with its parents confirmed, missing inputs mean another transaction spent the + /// step's input. The keyless pass stops sending it; the SDK path still follows its check. + func testWithoutTheSdkStopsAStepWhoseInputSomeoneElseSpent() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"]), + ])) + chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") + + record = try await driver(withSdk: false).tick(record) + record = try await driver(withSdk: false).tick(record) + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(record.broadcastLog.first?.isInputSpent, true) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0) + + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid)] + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2", "n2"]) + } + + /// qa-03 repro: an older state's refund took the leaf, so every rebuild reproduces the same refund, whose input + /// is spent. After two such rebuilds the leaf is lost and the exit goes on with the rest. + func testALeafAnOlderStateClaimedIsLostAfterTwoRebuildsThatReproduceItsRefund() async throws { + var record = SparkExitRecord( + network: "regtest", + createdAt: clock, + phase: .running, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + leafIds: ["leaf-1", "leaf-2"], + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)], + expectedSats: 150_000 + ) + let stuck = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + SparkExitFixtures.refund("r2", leafId: "leaf-2", dependsOn: ["n1"], status: .waitingForTimelock(spendableAtHeight: 5000)), + ]) + record.adopt(stuck, at: clock) + let redo = CheckRecoverFundsResponse(recovery: stuck, verdict: .redo(reason: .onChainStateDiverged)) + sdk.checks = [CheckRecoverFundsResponse(recovery: stuck, verdict: .valid), redo, redo] + sdk.builds = [stuck, stuck] + chain.outcome = .inputsMissing(#"sendrawtransaction RPC error: {"code":-25}"#) + + record = try await driver().tick(record) + XCTAssertEqual(record.broadcastLog.first { $0.txid == "r1" }?.isInputSpent, true) + + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.spentStepStrikes, ["r1": 1]) + XCTAssertNil(record.lostLeaves) + + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2, "the build that found the lost leaf is not adopted") + XCTAssertEqual(record.lostLeaves, [.init(leafId: "leaf-1", valueSats: 100_000)]) + XCTAssertEqual(record.lostSats, 100_000) + XCTAssertTrue(record.isRebuilding) + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).first?.id, "lost-1") + + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), SparkExitFixtures.refund( + "r2", + leafId: "leaf-2", + dependsOn: ["n1"], + status: .waitingForTimelock(spendableAtHeight: 5000) + )])] + record = try await driver().tick(record) + guard case let .specific(leafIds) = sdk.prepareRequests.last?.selection else { return XCTFail("Expected a specific selection") } + XCTAssertEqual(leafIds, ["leaf-2"]) + XCTAssertEqual(record.generation, 3) + XCTAssertFalse(record.isRebuilding) + } + + func testAnExitWhoseEveryLeafAnOlderStateClaimedFails() async throws { + var record = SparkExitFixtures.record(phase: .running) + let stuck = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + ]) + record.adopt(stuck, at: .distantPast) + record.spentStepStrikes = ["r1": 1] + record.broadcastLog = [.init( + generation: 1, + txid: "r1", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "inputs missing", + isAccepted: false, + isInputSpent: true + )] + sdk.checks = [CheckRecoverFundsResponse(recovery: stuck, verdict: .redo(reason: .onChainStateDiverged))] + sdk.builds = [stuck] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .failed(reason: "Claimed by an older state")) + XCTAssertEqual(record.lostSats, 100_000) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.id), ["lost-1"]) + } + + /// A rebuild that replaces the spent step, the operators having gone first, is no strike. + func testARebuildThatReplacesTheSpentStepIsNoStrike() { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: [])])) + record.broadcastLog = [.init( + generation: 1, + txid: "r1", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "inputs missing", + isAccepted: false, + isInputSpent: true + )] + + XCTAssertEqual( + record.strikes(rebuiltInto: SparkExitFixtures.recovery([SparkExitFixtures.refund("r1b", leafId: "leaf-1", dependsOn: [])])), + [:] + ) + XCTAssertEqual( + record.strikes(rebuiltInto: SparkExitFixtures.recovery([SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: [])])), + ["r1": 1] + ) + } + + /// Top-ups join the exit under its own funding index, and the build signs with that index's key. + func testFundingAndSigningUseTheRecordsFundingIndex() async throws { + var record = SparkExitFixtures.record() + record.funding = [.init(index: 3, address: "bcrt1qfunding3", pubkey: "03cd")] + chain.utxosByAddress["bcrt1qfunding3"] = [SparkExitFixtures.utxo("f0", value: 12000), SparkExitFixtures.utxo("f1", value: 9000)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + var indexes: [UInt32] = [] + let driver = SparkExitDriver(chain: chain, recovering: sdk, makeSigner: { index in + indexes.append(index) + return StubCpfpSigner() + }, save: { _ in }) + + record = try await driver.tick(record) + + XCTAssertEqual(record.funding.map(\.index), [3, 3]) + XCTAssertEqual(indexes, [3]) + } + + /// A fee-ladder leaf whose refund cannot pay its fee fails a `Specific` rebuild as a whole: that leaf stays + /// behind and the rest are prepared again. + func testARebuildLeavesBehindTheLeafThatCannotPayItsRefund() async throws { + var record = twoLeafRecord() + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(first, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee at this fee rate")] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.specific(leafIds: ["leaf-1", "leaf-2"]), .specific(leafIds: ["leaf-2"])]) + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"]) + XCTAssertEqual(record.leftBehindSats, 100_000) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.exitLeafIds, ["leaf-2"]) + } + + /// An error that names no leaf falls back to `RecoverableOnly`, and what it leaves out stays behind. + func testARebuildFallsBackToRecoverableOnlyWhenTheErrorNamesNoLeaf() async throws { + var record = twoLeafRecord() + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(first, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.SparkError("A leaf cannot be exited")] + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.last?.selection, .recoverableOnly) + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"]) + XCTAssertEqual(record.generation, 2) + } + + func testAnExitWithNoLeafLeftToExitFails() async throws { + var record = SparkExitFixtures.record(phase: .running) + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(first, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee at this fee rate")] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .failed(reason: "No leaf can be exited")) + XCTAssertEqual(record.leftBehindSats, 100_000) + XCTAssertTrue(sdk.recoverRequests.isEmpty) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.id), ["failed"]) + } + + /// The last leaf lost on a tick that could not read the funding address: the next tick ends the exit through the + /// no-leaf path, still as lost, and only the lost notification fires. + func testASingleLostLeafEndedThroughTheNoLeafPathNotifiesOnlyTheLoss() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]), at: .distantPast) + record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 100_000)] + record.isRebuilding = true + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .failed(reason: SparkExitRecord.lostReason)) + XCTAssertTrue(sdk.prepareRequests.isEmpty) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.id), ["lost-1"]) + record.phase = .failed(reason: "No leaf can be exited") + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.id), ["lost-1"]) + } + + /// qa-03: a failed or lost-leaf exit with nothing left to sweep ended at once and Emergency exit opened a new + /// intro, so the user never saw why. + func testAFailedOrLostLeafResultStaysReachableUntilSeen() { + var failed = SparkExitFixtures.record(phase: .failed(reason: "No leaf can be exited")) + XCTAssertFalse(failed.needsDriving) + XCTAssertTrue(failed.showsResult) + failed.isResultSeen = true + XCTAssertFalse(failed.showsResult) + + var partlyLost = SparkExitFixtures.record(phase: .done) + XCTAssertFalse(partlyLost.showsResult) + partlyLost.lostLeaves = [.init(leafId: "leaf-1", valueSats: 4000)] + XCTAssertTrue(partlyLost.showsResult) + partlyLost.isResultSeen = true + XCTAssertFalse(partlyLost.showsResult) + + var running = SparkExitFixtures.record(phase: .running) + running.lostLeaves = [.init(leafId: "leaf-1", valueSats: 4000)] + XCTAssertFalse(running.hasUnseenResult) + } + + private func twoLeafRecord() -> SparkExitRecord { + SparkExitRecord( + network: "regtest", + createdAt: clock, + phase: .running, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + leafIds: ["leaf-1", "leaf-2"], + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)], + expectedSats: 150_000 + ) + } + + func testDoesNothingOnceFinished() async throws { + let record = try await driver().tick(SparkExitFixtures.record(phase: .done)) + + XCTAssertEqual(record.phase, .done) + XCTAssertTrue(saved.isEmpty) + } +} + +// MARK: - Fees and funding + +/// The entry point the background tasks call, from a cold launch with no UI. +final class SparkExitBackgroundRunTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + private var store: SparkExitStore! + private var connects: [TimeInterval] = [] + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + store = SparkExitStore(url: FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json")) + connects = [] + } + + override func tearDown() { + try? store.delete() + super.tearDown() + } + + private func backgroundRun(deadline: TimeInterval = 25, connects sdkConnects: Bool = true) -> SparkExitBackgroundRun { + let store = store! + return SparkExitBackgroundRun( + load: { try store.load() }, + makeDriver: { chain, recovering in + SparkExitDriver(chain: chain, recovering: recovering, makeSigner: { _ in StubCpfpSigner() }, save: { try store.save($0) }) + }, + chain: chain, + connected: nil, + connect: { [unowned self] timeout in + connects.append(timeout) + return sdkConnects ? sdk : nil + }, + deadline: Date(timeIntervalSinceNow: deadline) + ) + } + + private func savedRunningExit(_ transactions: [RecoveryTransaction]) throws -> RecoverFundsResponse { + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery(transactions) + record.adopt(recovery) + try store.save(record) + return recovery + } + + func testColdStartSendsStoredStepsThenChecksWithTheSdkOnly() async throws { + let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c")]) + sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .valid)] + + let outcome = await backgroundRun().run() + + XCTAssertTrue(outcome.isConnected) + XCTAssertEqual(connects.count, 1) + XCTAssertEqual(sdk.checkedRecoveries.count, 1) + // The keyless pass sent it; the SDK pass found it accepted and did not send it again. + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + let saved = try XCTUnwrap(store.load()) + XCTAssertEqual(saved.broadcastLog.map(\.txid), ["n1"]) + XCTAssertEqual(saved.broadcastLog.first?.isAccepted, true) + XCTAssertNotNil(outcome.seconds[.keyless]) + XCTAssertNotNil(outcome.seconds[.check]) + XCTAssertNotNil(outcome.seconds[.broadcast]) + } + + func testColdStartSignsTheBuildOnceFundingConfirmed() async throws { + try store.save(SparkExitFixtures.record()) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c1")])] + + let outcome = await backgroundRun().run() + + XCTAssertEqual(outcome.record?.phase, .running) + XCTAssertEqual(sdk.recoverRequests.count, 1) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + XCTAssertEqual(try store.load()?.generation, 1) + XCTAssertNotNil(outcome.seconds[.sign]) + } + + func testExpiryMidStepLeavesAWholeRecordAndTheNextRunSendsNothingTwice() async throws { + let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c1"), SparkExitFixtures.tx("n2", cpfp: "03c2")]) + // The OS expires the run while the first package is in flight. + chain.onBroadcast = { withUnsafeCurrentTask { $0?.cancel() } } + let first = backgroundRun() + _ = await Task { await first.run() }.value + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + XCTAssertTrue(connects.isEmpty) + let saved = try XCTUnwrap(store.load()) + XCTAssertEqual(saved.broadcastLog.map(\.txid), ["n1"]) + + chain.onBroadcast = nil + sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .valid)] + _ = await backgroundRun().run() + + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"]) + XCTAssertEqual(try store.load()?.broadcastLog.map(\.txid), ["n1", "n2"]) + } + + func testStaysKeylessWhenSparkDoesNotConnectOrTimeIsShort() async throws { + _ = try savedRunningExit([SparkExitFixtures.tx("n1")]) + + let late = await backgroundRun(deadline: SparkExitBackgroundRun.reserveSeconds - 1).run() + XCTAssertTrue(connects.isEmpty) + XCTAssertFalse(late.isConnected) + + let offline = await backgroundRun(connects: false).run() + XCTAssertEqual(connects.count, 1) + XCTAssertFalse(offline.isConnected) + XCTAssertTrue(sdk.checkedRecoveries.isEmpty) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1"]) + } + + func testDoesNothingWithoutARunningExit() async { + let outcome = await backgroundRun().run() + + XCTAssertNil(outcome.record) + XCTAssertTrue(connects.isEmpty) + XCTAssertTrue(chain.broadcasts.isEmpty) + } +} + +final class SparkExitMathTests: XCTestCase { + func testQuoteSplitsWhatArrivesFromWhatItCosts() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + + XCTAssertEqual(quote.exitSats, 100_000) + XCTAssertEqual(quote.networkFeeSats, 9000) + XCTAssertEqual(quote.receiveSats, 91000) + XCTAssertEqual(quote.fundingSats, 20000) + XCTAssertEqual(quote.incomingSats, 111_000) + XCTAssertEqual(quote.leftBehindSats, 300) + XCTAssertTrue(quote.isWorthExiting) + } + + func testQuoteIsNotWorthExitingWhenFeesExceedTheValue() { + var prepared = SparkExitFixtures.prepared() + prepared.totalFeeSats = 100_000 + XCTAssertFalse(SparkExitQuote(prepared: prepared).isWorthExiting) + XCTAssertEqual(SparkExitQuote(prepared: prepared).receiveSats, 0) + XCTAssertFalse(SparkExitQuote(prepared: SparkExitFixtures.prepared(leaves: [])).isWorthExiting) + } + + func testCooperativeOnlyQuoteNeedsNoFunding() { + XCTAssertEqual(SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: nil)).fundingSats, 0) + } + + func testClaimableHeightIsTheLatestTimelock() { + let recovery = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("a", status: .waitingForTimelock(spendableAtHeight: 2000)), + SparkExitFixtures.tx("b", status: .waitingForTimelock(spendableAtHeight: 2400)), + SparkExitFixtures.tx("c", status: .confirmed(blockHeight: 5)), + ]) + + XCTAssertEqual(SparkExitMath.claimableAtHeight(recovery), 2400) + XCTAssertEqual(SparkExitMath.confirmedCount(recovery), 1) + XCTAssertNil(SparkExitMath.claimableAtHeight(SparkExitFixtures.recovery([SparkExitFixtures.tx("d")]))) + } + + func testRemindsAheadOfEachTimelockUntilItsStepIsPublished() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let grace = TimeInterval(SparkExitMath.reminderGraceBlocks) * 30 + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("a", status: .waitingForTimelock(spendableAtHeight: 900)), + SparkExitFixtures.tx("b", status: .waitingForTimelock(spendableAtHeight: 2400)), + SparkExitFixtures.tx("c", status: .waitingForTimelock(spendableAtHeight: 1500)), + SparkExitFixtures.tx("d", status: .confirmed(blockHeight: 800)), + ])) + + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: now, network: .regtest), [ + SparkExitReminder(id: "1-h900", fireAt: now.addingTimeInterval(grace)), + SparkExitReminder(id: "1-h1500", fireAt: now.addingTimeInterval(500 * 30 + grace)), + SparkExitReminder(id: "1-h2400", fireAt: now.addingTimeInterval(1400 * 30 + grace)), + ]) + XCTAssertEqual(try SparkExitMath.claimableAtHeight(XCTUnwrap(record.recovery?.response)), 2400) + + record.broadcastLog = [.init( + generation: 1, + txid: "a", + attempts: 1, + rejections: 0, + lastAttemptAt: now, + lastOutcome: "accepted", + isAccepted: true + )] + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: now, network: .regtest).map(\.id), ["1-h1500", "1-h2400"]) + } + + func testRemindsWhenStepsWaitingForParentsStopMoving() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("f", kind: .fanOut), + SparkExitFixtures.tx("n1", dependsOn: ["f"], status: .waitingForDependencies), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies), + ])) + record.broadcastLog = [.init( + generation: 1, + txid: "f", + attempts: 1, + rejections: 0, + lastAttemptAt: now, + lastOutcome: "accepted", + isAccepted: true + )] + + let reminders = SparkExitMath.reminders(record, tip: 1000, now: now, network: .bitcoin) + + XCTAssertEqual(reminders, [SparkExitReminder(id: "1-idle-0-1", fireAt: now.addingTimeInterval(4 * 60 * 60))]) + } + + func testRemindsAtOnceForFeeFundingAndRepeatedRejections() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let funding = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 5000)) + XCTAssertEqual( + SparkExitMath.reminders(funding, tip: 1000, now: now, network: .bitcoin), + [SparkExitReminder(id: "funding-5000", fireAt: now, isFunding: true)] + ) + + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + record.broadcastLog = [.init( + generation: 1, + txid: "n1", + attempts: 3, + rejections: 3, + lastAttemptAt: now, + lastOutcome: "rejected", + isAccepted: false + )] + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: now, network: .bitcoin), [ + SparkExitReminder(id: "rejected-1-n1", fireAt: now), + SparkExitReminder(id: "1-ready-n1", fireAt: now.addingTimeInterval(TimeInterval(SparkExitMath.reminderGraceBlocks) * 600)), + SparkExitReminder(id: "1-idle-0-0", fireAt: now.addingTimeInterval(4 * 60 * 60)), + ]) + XCTAssertTrue(SparkExitMath.reminders(SparkExitFixtures.record(phase: .done), tip: 1000, now: now, network: .bitcoin).isEmpty) + } + + func testTimelocksMatureOnlyOnceNoRefundWaits() { + let node = SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 5)) + let waitingForParent = SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies) + let waitingForHeight = SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForTimelock(spendableAtHeight: 2000)) + let ready = SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .ready) + + XCTAssertFalse(SparkExitMath.areTimelocksMatured([])) + XCTAssertFalse(SparkExitMath.areTimelocksMatured([node])) + XCTAssertFalse(SparkExitMath.areTimelocksMatured([node, waitingForParent])) + XCTAssertFalse(SparkExitMath.areTimelocksMatured([node, waitingForHeight, ready])) + XCTAssertTrue(SparkExitMath.areTimelocksMatured([node, ready])) + } + + func testSettlesExitTransfersNoRunningExitOwns() { + let transfers = [ + Bitkit.Transfer(id: "transfer-1", type: .sparkExit, amountSats: 1000, createdAt: 0), + Bitkit.Transfer(id: "orphan", type: .sparkExit, amountSats: 1000, createdAt: 0), + Bitkit.Transfer(id: "channel", type: .toSavings, amountSats: 1000, createdAt: 0), + ] + var unowned = SparkExitFixtures.record(phase: .running) + unowned.transferId = nil + + XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: SparkExitFixtures.record(phase: .running)), ["orphan"]) + XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: unowned), ["transfer-1", "orphan"]) + XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: SparkExitFixtures.record(phase: .done)), ["transfer-1", "orphan"]) + XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: nil), ["transfer-1", "orphan"]) + } + + func testFundingTopUpTakesEveryConfirmedPaymentOnceTogetherTheyCover() { + let utxos = [ + SparkExitFixtures.utxo("a", value: 999), + SparkExitFixtures.utxo("pending", value: 50000, confirmed: false), + SparkExitFixtures.utxo("b", value: 1000), + ] + + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999)?.map(\.txid), ["a", "b"]) + XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 2000)) + XCTAssertNil(SparkExitMath.fundingTopUp([], requiredSats: 0)) + } + + func testShowsTheSparkBalanceBeyondTheExitsLeaves() { + var record = SparkExitFixtures.record(phase: .running) + record.leafTotalSats = 17097 + + XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 49865, record: record), 32768) + XCTAssertNil(SparkExitMath.outsideExitSats(sparkBalanceSats: 17097, record: record)) + record.phase = .done + XCTAssertNil(SparkExitMath.outsideExitSats(sparkBalanceSats: 49865, record: record)) + } + + /// A failed exit claims only what Spark still holds beyond its leaves, never the leaves it lost. + func testFailedExitNamesWhatStaysOnSpark() { + var record = SparkExitFixtures.record(phase: .failed(reason: "Claimed by an older state")) + record.leafTotalSats = 17097 + record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 17097)] + + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 49865, record: record), 32768) + XCTAssertNil(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 17097, record: record)) + XCTAssertNil(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 0, record: record)) + XCTAssertNil(SparkExitMath.outsideExitSats(sparkBalanceSats: 49865, record: record)) + } + + /// qa-03: once Bitkit paid the funding from savings, or a payment covering it is seen, the exit waits for it + /// instead of asking again; only what stays short after confirmation is asked for. + func testFundingStateWaitsForAPaymentInsteadOfAskingAgain() { + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 9000)) + + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .ask(sats: 9000)) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("a", value: 4000)]), .ask(sats: 5000, receivedSats: 4000)) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("x", value: 9000, confirmed: false)]), .seen(txid: "x")) + + record.fundingPaymentTxid = "pay" + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: nil), .paid(txid: "pay")) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .paid(txid: "pay")) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("pay", value: 9000, confirmed: false)]), .paid(txid: "pay")) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("pay", value: 6000)]), .ask(sats: 3000, receivedSats: 6000)) + + // Adopted, and spent by a rebuild's CPFP: a later shortfall is asked for, not shown as paid. + record.funding = [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "pay", vout: 0, valueSats: 9000)] + record.phase = .needsFunding(requiredSats: 12000) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .ask(sats: 3000)) + + record.phase = .running + XCTAssertNil(SparkExitMath.fundingState(record, utxos: [])) + } + + /// qa-03: 2,000 of 4,348 at the address showed no hint until the whole sum arrived. + func testAPartialPaymentNamesWhatIsStillMissing() { + XCTAssertNil(SparkExitMath.partialFundingSats(receivedSats: 0, requiredSats: 4348)) + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 2000, requiredSats: 4348), 2348) + XCTAssertNil(SparkExitMath.partialFundingSats(receivedSats: 4348, requiredSats: 4348)) + // qa-03: bitcoind refuses a 68-sat output, so no ask goes below the P2WPKH dust limit. + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 4280, requiredSats: 4348), 294) + let short = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 4348)) + XCTAssertEqual( + SparkExitMath.fundingState(short, utxos: [SparkExitFixtures.utxo("a", value: 4280)]), + .ask(sats: 294, receivedSats: 4280) + ) + + // A top-up counts only new funding: the adopted input and CPFP change at the address are not received. + var record = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 9000)) + record.funding = [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 0, valueSats: 5000)] + let utxos = [SparkExitFixtures.utxo("f0", value: 5000), SparkExitFixtures.utxo("new", value: 1500, confirmed: false)] + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: utxos), .ask(sats: 2500, receivedSats: 1500)) + } + + func testEachExitTakesAFundingIndexNoEarlierExitUsed() { + var record = SparkExitFixtures.record(phase: .done) + + XCTAssertEqual(SparkExitMath.nextFundingIndex(counter: 0, record: nil), 0) + XCTAssertEqual(SparkExitMath.nextFundingIndex(counter: 0, record: record), 1, "a record from before per-exit indexes used 0") + XCTAssertEqual(SparkExitMath.nextFundingIndex(counter: 3, record: record), 3) + record.funding[0] = .init(index: 4, address: "a", pubkey: "02ab") + XCTAssertEqual(SparkExitMath.nextFundingIndex(counter: 1, record: record), 5) + } + + /// qa-03: value the quote cannot exit, such as a counter-swap still unclaimed, is said on the quote screen. + func testQuoteNamesTheBalanceItCannotExit() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared()) + + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 133_068, quote: quote), 300 + 32768) + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 100_300, quote: quote), 300, "the skipped leaves") + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 90000, quote: quote), 300) + } + + func testReadsTheLeafAnUnexitableErrorNames() { + let message = "Leaf 0193a7c2-11aa-7bbb-8ccc-1234567890ab cannot be exited: its refund cannot pay its own fee at this fee rate" + XCTAssertTrue(SparkExitMath.isUnexitableLeafError("SparkError: " + message)) + XCTAssertEqual(SparkExitMath.unexitableLeafId("SparkError: " + message), "0193a7c2-11aa-7bbb-8ccc-1234567890ab") + XCTAssertNil(SparkExitMath.unexitableLeafId("The leaf cannot be exited: no refund")) + XCTAssertFalse(SparkExitMath.isUnexitableLeafError("operators unavailable")) + } + + /// With the operators down an older state's refund can take a leaf 100 blocks after ours matures, and the + /// watchtower may publish after 50: reminders land well inside the smaller window. + func testRemindersLandWellInsideTheDeadline() { + let deadline = min(SparkExitMath.operatorWindowBlocks, SparkExitMath.olderStateLeadBlocks) + XCTAssertLessThanOrEqual(SparkExitMath.reminderGraceBlocks * 2, deadline) + XCTAssertLessThanOrEqual(UInt32(SparkExitMath.reminderSeconds(network: .bitcoin) / 600) * 2, deadline) + } + + func testSavingsMustCoverTheFundingAndItsFee() { + XCTAssertTrue(SparkExitMath.canFundFromSavings(spendableSats: 10500, fundingSats: 10000, paymentFeeSats: 500)) + XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10499, fundingSats: 10000, paymentFeeSats: 500)) + XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10000, fundingSats: 0, paymentFeeSats: 0)) + } + + func testEtaUsesTheNetworksBlockTime() { + XCTAssertEqual(SparkExitMath.secondsUntil(height: 110, tip: 100, network: .bitcoin), 6000) + XCTAssertEqual(SparkExitMath.secondsUntil(height: 110, tip: 100, network: .regtest), 300) + XCTAssertEqual(SparkExitMath.secondsUntil(height: 90, tip: 100, network: .bitcoin), 0) + } + + func testDecodesACompressedWif() throws { + let key = try SparkExitFundingKey.secretKey(fromWif: "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn") + + XCTAssertEqual(key, Data(repeating: 0, count: 31) + Data([1])) + } + + func testRejectsAWifWithABadChecksumOrUncompressedKey() { + XCTAssertThrowsError(try SparkExitFundingKey.secretKey(fromWif: "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWo")) + // Uncompressed key 1 (no 0x01 flag). + XCTAssertThrowsError(try SparkExitFundingKey.secretKey(fromWif: "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf")) + } + + func testFundingKeyDerivesOnItsOwnAccount() throws { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + let regtest = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .regtest) + let again = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .regtest) + let mainnet = try SparkExitFundingKey.derive(index: 0, mnemonic: mnemonic, passphrase: nil, network: .bitcoin) + + XCTAssertEqual(SparkExitFundingKey.path(index: 0, network: .regtest), "m/84'/1'/7797'/0/0") + XCTAssertEqual(SparkExitFundingKey.path(index: 3, network: .bitcoin), "m/84'/0'/7797'/0/3") + XCTAssertTrue(regtest.address.hasPrefix("bcrt1q")) + XCTAssertTrue(mainnet.address.hasPrefix("bc1q")) + XCTAssertEqual(regtest.pubkey.count, 66) + XCTAssertEqual(regtest.secretKey.count, 32) + XCTAssertEqual(regtest.secretKey, again.secretKey) + XCTAssertNoThrow(try singleKeyCpfpSigner(secretKeyBytes: regtest.secretKey)) + } + + func testTxidIgnoresWitnessData() { + let segwit = "0200000000010111111111111111111111111111111111111111111111111111111111111111110000000000ffffffff01e80300000000000016001422222222222222222222222222222222222222220202abcd0301020300000000" + + XCTAssertEqual(SparkExitTxid.txid(hex: SparkExitFixtures.parentHex), SparkExitFixtures.parentTxid) + XCTAssertEqual(SparkExitTxid.txid(hex: segwit), SparkExitFixtures.parentTxid) + XCTAssertNil(SparkExitTxid.txid(hex: "02zz")) + } +} + +// MARK: - Broadcast client + +final class SparkExitBroadcasterTests: XCTestCase { + final class FakeHTTP: SparkExitHTTP, @unchecked Sendable { + var responses: [(path: String, status: Int, body: String)] = [] + private(set) var requests: [URLRequest] = [] + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + let url = try XCTUnwrap(request.url) + guard let index = responses.firstIndex(where: { url.absoluteString.hasSuffix($0.path) }) else { throw URLError(.cannotConnectToHost) } + let response = responses.remove(at: index) + return (Data(response.body.utf8), HTTPURLResponse(url: url, statusCode: response.status, httpVersion: nil, headerFields: nil)!) + } + } + + private let hostA = URL(string: "https://a.example/api")! + private let hostB = URL(string: "https://b.example/api")! + + func testSendsAPackageAsAJsonArray() async throws { + let http = FakeHTTP() + http.responses = [("a.example/api/v1/txs/package", 200, #"{"package_msg":"success","tx-results":{}}"#)] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("n1", cpfp: "03child")) + + XCTAssertEqual(outcome, .accepted) + let request = try XCTUnwrap(http.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(try JSONDecoder().decode([String].self, from: XCTUnwrap(request.httpBody)), ["02n1", "03child"]) + } + + func testFallsBackToTheUnversionedPackagePathThenTheNextHost() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/v1/txs/package", 404, "Not Found"), + ("a.example/api/txs/package", 404, "Not Found"), + ("b.example/api/v1/txs/package", 200, #"{"package_msg":"success"}"#), + ] + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("n1", cpfp: "03c")) + + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(http.requests.map { $0.url?.host }, ["a.example", "a.example", "b.example"]) + } + + func testSendsAStepWithoutCpfpAsOneTransaction() async throws { + let http = FakeHTTP() + http.responses = [("a.example/api/tx", 200, "s1")] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(try String(decoding: XCTUnwrap(http.requests.first?.httpBody), as: UTF8.self), "02s1") + } + + func testTriesTheNextHostAfterARejection() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 400, #"sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"#), + ("b.example/api/tx", 200, "s1"), + ] + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .accepted) + } + + func testTriesTheNextHostWhenOneIsUnavailable() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 429, "Too Many Requests"), + ("b.example/api/tx", 200, "s1"), + ] + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .accepted) + } + + func testANodeRefusalWinsOverAnUnavailableHost() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 400, #"sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"#), + ("b.example/api/tx", 503, ""), + ] + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + guard case .rejected = outcome else { return XCTFail("Expected the refusal, got \(outcome)") } + } + + func testClassifiesNodeAnswers() { + XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 400, body: Data(#"sendrawtransaction RPC error: {"code":-27}"#.utf8)), .accepted) + XCTAssertEqual( + SparkExitBroadcastClassifier.single( + status: 400, + body: Data(#"sendrawtransaction RPC error: {"code":-26,"message":"non-BIP68-final"}"#.utf8) + ), + .notMature(#"sendrawtransaction RPC error: {"code":-26,"message":"non-BIP68-final"}"#) + ) + XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 503, body: Data()), .retryLater("HTTP 503")) + if case .rejected = SparkExitBroadcastClassifier.single(status: 400, body: Data(#"{"code":-26,"message":"min relay fee not met"}"#.utf8)) {} + else { + XCTFail("Expected a rejection") + } + let missing = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"a","error":"bad-txns-inputs-missingorspent"},"w2":{"txid":"b","error":"bad-txns-inputs-missingorspent"}},"replaced-transactions":[]}"# + if case .inputsMissing = SparkExitBroadcastClassifier.package(status: 200, body: Data(missing.utf8)) {} else { + XCTFail("Missing inputs are their own outcome") + } + if case .inputsMissing = SparkExitBroadcastClassifier.single(status: 400, body: Data(#"sendrawtransaction RPC error: {"code":-25}"#.utf8)) {} + else { + XCTFail("RPC -25 is missing inputs") + } + let parentNotFinal = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"a","error":"non-BIP68-final"},"w2":{"txid":"b","error":"bad-txns-inputs-missingorspent"}}}"# + if case .notMature = SparkExitBroadcastClassifier.package(status: 200, body: Data(parentNotFinal.utf8)) {} else { + XCTFail("A child missing its immature parent is not mature, not spent") + } + let childMissing = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"step"},"w2":{"txid":"child","error":"bad-txns-inputs-missingorspent"}}}"# + if case .retryLater = SparkExitBroadcastClassifier.package(status: 200, body: Data(childMissing.utf8), txid: "step") {} else { + XCTFail("Only the step's own missing input means it was spent") + } + let stepMissing = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"step","error":"bad-txns-inputs-missingorspent"}}}"# + if case .inputsMissing = SparkExitBroadcastClassifier.package(status: 200, body: Data(stepMissing.utf8), txid: "step") {} else { + XCTFail("The step's own input is missing") + } + let childRejected = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"step","error":"non-BIP68-final"},"w2":{"txid":"child","error":"min relay fee not met"}}}"# + if case .notMature = SparkExitBroadcastClassifier.package(status: 200, body: Data(childRejected.utf8), txid: "step") {} else { + XCTFail("An immature parent outranks the child's refusal") + } + let stepRejected = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"child","error":"non-BIP68-final"},"w2":{"txid":"step","error":"min relay fee not met"}}}"# + if case .rejected = SparkExitBroadcastClassifier.package(status: 200, body: Data(stepRejected.utf8), txid: "step") {} else { + XCTFail("The step's own refusal decides, whatever its child reports") + } + let stepSpent = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"child","error":"non-BIP68-final"},"w2":{"txid":"step","error":"bad-txns-inputs-missingorspent"}}}"# + if case .inputsMissing = SparkExitBroadcastClassifier.package(status: 200, body: Data(stepSpent.utf8), txid: "step") {} else { + XCTFail("The step's own missing input decides, whatever its child reports") + } + let stepKnown = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"step","error":"txn-already-in-mempool"},"w2":{"txid":"child","error":"min relay fee not met"}}}"# + if case .rejected = SparkExitBroadcastClassifier.package(status: 200, body: Data(stepKnown.utf8), txid: "step") {} else { + XCTFail("A step the node already has leaves the outcome to its child") + } + let known = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"a","error":"txn-already-in-mempool"},"w2":{"txid":"b"}}}"# + XCTAssertEqual(SparkExitBroadcastClassifier.package(status: 200, body: Data(known.utf8)), .accepted) + let nonFinal = #"{"package_msg":"transaction failed","tx-results":{"w1":{"txid":"a","error":"non-BIP68-final"}}}"# + if case .notMature = SparkExitBroadcastClassifier.package(status: 200, body: Data(nonFinal.utf8)) {} else { + XCTFail("A timelock is not mature") + } + XCTAssertEqual( + SparkExitBroadcastClassifier.package(status: 400, body: Data(#"{"error":"submitpackage RPC error: {\"code\":-25}"}"#.utf8)), + .inputsMissing(#"{"error":"submitpackage RPC error: {\"code\":-25}"}"#) + ) + } + + func testReadsTipAndFundingUtxos() async throws { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/blocks/tip/height", 200, "3681536\n"), + ( + "a.example/api/address/bcrt1qf/utxo", + 200, + #"[{"txid":"t","vout":1,"value":5000,"status":{"confirmed":true,"block_height":7,"block_hash":"h"}}]"# + ), + ] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let tip = try await chain.tipHeight() + let utxos = try await chain.utxos(address: "bcrt1qf") + + XCTAssertEqual(tip, 3_681_536) + XCTAssertEqual(utxos, [SparkExitFixtures.utxo("t", vout: 1, value: 5000)].map { + EsploraUtxo(txid: $0.txid, vout: $0.vout, value: $0.value, status: EsploraTxStatus(confirmed: true, blockHeight: 7)) + }) + } +} From 0eed9c2a0939b663852be6d42f8cc0ade25146ad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 02:40:03 +0100 Subject: [PATCH 002/378] feat: add spark via breez sdk Spending keeps Lightning without channels through a Spark wallet: transfer to spending funds it from Savings or another wallet, Lightning payments and Paykit run on it, and transfer to savings picks channels, Spark or both. Breez data sync and the Lightning address server are off, and the Spark exit data is backed up. --- .agents/commands/release.md | 2 + .gitignore | 2 +- Bitkit.xcconfig | 4 + Bitkit.xcodeproj/project.pbxproj | 42 + .../xcshareddata/swiftpm/Package.resolved | 20 +- Bitkit/AppScene.swift | 31 + .../coin-stack-x.imageset/Contents.json | 21 + .../coin-stack-x.imageset/coin-stack-x.png | Bin 0 -> 71068 bytes Bitkit/Components/FeeDisplayRow.swift | 6 +- Bitkit/Components/FeeItem.swift | 62 +- Bitkit/Components/LightningChannel.swift | 106 ++- Bitkit/Components/ProgressSteps.swift | 65 ++ Bitkit/Components/Widgets/Suggestions.swift | 6 +- Bitkit/Constants/Env.swift | 19 + .../FixedWidthInteger+Saturating.swift | 5 + Bitkit/Info.plist | 2 + Bitkit/MainNavView.swift | 13 + Bitkit/Managers/SparkManager.swift | 738 ++++++++++++++++++ Bitkit/Models/BackupCategory.swift | 5 + Bitkit/Models/BackupPayloads.swift | 19 +- Bitkit/Models/SettingsBackupConfig.swift | 2 + .../Localization/ar.lproj/Localizable.strings | 65 ++ .../Localization/ca.lproj/Localizable.strings | 65 ++ .../Localization/cs.lproj/Localizable.strings | 65 ++ .../Localization/de.lproj/Localizable.strings | 65 ++ .../Localization/el.lproj/Localizable.strings | 65 ++ .../Localization/en.lproj/Localizable.strings | 65 ++ .../es-419.lproj/Localizable.strings | 65 ++ .../Localization/es.lproj/Localizable.strings | 65 ++ .../Localization/fr.lproj/Localizable.strings | 65 ++ .../Localization/it.lproj/Localizable.strings | 65 ++ .../Localization/nl.lproj/Localizable.strings | 65 ++ .../Localization/pl.lproj/Localizable.strings | 65 ++ .../pt-BR.lproj/Localizable.strings | 65 ++ .../Localization/pt.lproj/Localizable.strings | 65 ++ .../Localization/ru.lproj/Localizable.strings | 65 ++ Bitkit/Services/BackupService.swift | 40 + Bitkit/Services/CoreService.swift | 26 + Bitkit/Services/LightningService.swift | 131 +++- Bitkit/Services/PaykitAllowanceManager.swift | 3 +- .../Services/PaykitPaymentProofService.swift | 24 +- .../PaykitPaymentRequestService.swift | 2 +- .../PrivatePaykitService+Invoices.swift | 52 +- Bitkit/Utilities/AppStatusHelper.swift | 7 +- .../QuickPayPaymentCoordinator.swift | 6 +- Bitkit/Utilities/QuickPaySpendStore.swift | 11 + Bitkit/ViewModels/AppViewModel.swift | 27 +- Bitkit/ViewModels/NavigationViewModel.swift | 11 + Bitkit/ViewModels/SettingsViewModel.swift | 4 + Bitkit/ViewModels/SparkTransferFlow.swift | 322 ++++++++ Bitkit/ViewModels/TransferViewModel.swift | 216 ++++- Bitkit/ViewModels/WalletViewModel.swift | 68 +- .../CreatePaymentRequestView.swift | 2 +- .../Advanced/AdvancedSettingsView.swift | 3 +- .../Advanced/LightningConnectionsView.swift | 23 +- .../Advanced/SparkConnectionViews.swift | 250 ++++++ .../Settings/Security/DataBackupsScreen.swift | 9 +- .../CreateSubscriptionView.swift | 2 +- Bitkit/Views/Transfer/FundingOptions.swift | 61 +- .../Transfer/Hardware/SpendingAmountHw.swift | 177 +++++ .../Transfer/Hardware/SpendingHwSign.swift | 247 ++++++ .../Transfer/Hardware/SpendingHwSigned.swift | 54 ++ .../Views/Transfer/SavingsAdvancedView.swift | 72 +- .../Views/Transfer/SavingsConfirmView.swift | 67 +- .../Views/Transfer/SavingsProgressView.swift | 69 +- Bitkit/Views/Transfer/SettingUpView.swift | 178 +++++ Bitkit/Views/Transfer/SpendingAmount.swift | 209 +++++ Bitkit/Views/Transfer/SpendingConfirm.swift | 286 +++++++ Bitkit/Views/Transfer/SpendingIntroView.swift | 40 + Bitkit/Views/Transfer/TransferIntroView.swift | 33 + .../Transfer/TransferLearnMoreView.swift | 64 ++ .../Views/Wallets/HardwareWalletScreen.swift | 8 +- .../Views/Wallets/Receive/ReceiveEdit.swift | 6 +- .../Views/Wallets/SavingsWalletScreen.swift | 8 +- .../Views/Wallets/Send/LnurlPayAmount.swift | 9 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 17 +- .../Views/Wallets/SpendingWalletScreen.swift | 10 +- BitkitTests/AppCacheDataTests.swift | 12 +- BitkitTests/ChannelFreeRailTests.swift | 177 +++++ .../PaykitPaymentProofServiceTests.swift | 2 + .../PaykitPaymentRequestServiceTests.swift | 34 + BitkitTests/SavingsSelectionTests.swift | 69 ++ BitkitTests/SavingsSwapTests.swift | 6 +- BitkitTests/SparkClaimFeeTests.swift | 10 + BitkitTests/SparkClaimOfferTests.swift | 55 ++ BitkitTests/SparkConfigTests.swift | 15 + BitkitTests/SparkExitBackupTests.swift | 51 ++ BitkitTests/SparkLightningSendTests.swift | 72 ++ BitkitTests/SparkTransferActivityTests.swift | 48 ++ BitkitTests/SparkTransferFlowTests.swift | 147 ++++ BitkitTests/SparkWithdrawalTests.swift | 116 +++ Docs/RELEASE.md | 2 + README.md | 4 + Secrets.xcconfig.template | 3 + changelog.d/next/1128.added.md | 1 + journeys/README.md | 3 +- journeys/amount-limits/README.md | 18 +- .../transfer-spending-over-max.xml | 30 + journeys/hardware-wallet/README.md | 21 +- .../passphrase-transfer-to-spending.xml | 36 + .../transfer-to-spending-over-max.xml | 39 + .../hardware-wallet/transfer-to-spending.xml | 28 + journeys/transfer/savings-select-funds.xml | 36 + .../transfer/spark-transfer-to-spending.xml | 27 + .../spending-confirm-amount-change.xml | 35 + .../transfer/transfer-unavailable-sheet.xml | 4 +- 106 files changed, 5898 insertions(+), 202 deletions(-) create mode 100644 Bitkit.xcconfig create mode 100644 Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/coin-stack-x.png create mode 100644 Bitkit/Components/ProgressSteps.swift create mode 100644 Bitkit/Managers/SparkManager.swift create mode 100644 Bitkit/ViewModels/SparkTransferFlow.swift create mode 100644 Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift create mode 100644 Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift create mode 100644 Bitkit/Views/Transfer/SettingUpView.swift create mode 100644 Bitkit/Views/Transfer/SpendingAmount.swift create mode 100644 Bitkit/Views/Transfer/SpendingConfirm.swift create mode 100644 Bitkit/Views/Transfer/SpendingIntroView.swift create mode 100644 Bitkit/Views/Transfer/TransferIntroView.swift create mode 100644 Bitkit/Views/Transfer/TransferLearnMoreView.swift create mode 100644 BitkitTests/ChannelFreeRailTests.swift create mode 100644 BitkitTests/SavingsSelectionTests.swift create mode 100644 BitkitTests/SparkClaimFeeTests.swift create mode 100644 BitkitTests/SparkClaimOfferTests.swift create mode 100644 BitkitTests/SparkConfigTests.swift create mode 100644 BitkitTests/SparkExitBackupTests.swift create mode 100644 BitkitTests/SparkLightningSendTests.swift create mode 100644 BitkitTests/SparkTransferActivityTests.swift create mode 100644 BitkitTests/SparkTransferFlowTests.swift create mode 100644 BitkitTests/SparkWithdrawalTests.swift create mode 100644 Secrets.xcconfig.template create mode 100644 changelog.d/next/1128.added.md create mode 100644 journeys/amount-limits/transfer-spending-over-max.xml create mode 100644 journeys/hardware-wallet/passphrase-transfer-to-spending.xml create mode 100644 journeys/hardware-wallet/transfer-to-spending-over-max.xml create mode 100644 journeys/hardware-wallet/transfer-to-spending.xml create mode 100644 journeys/transfer/savings-select-funds.xml create mode 100644 journeys/transfer/spark-transfer-to-spending.xml create mode 100644 journeys/transfer/spending-confirm-amount-change.xml diff --git a/.agents/commands/release.md b/.agents/commands/release.md index f8a8cedab..d5b8cf3f7 100644 --- a/.agents/commands/release.md +++ b/.agents/commands/release.md @@ -300,6 +300,8 @@ Print the path to the local draft. Store it for the summary. Slack posting is de ### 7. Build & Upload to TestFlight (optional) +Before archiving, `Secrets.xcconfig` must exist at the repo root with `BREEZ_API_KEY` set (copy `Secrets.xcconfig.template`). Release builds fail without it. Check with `grep -q '^BREEZ_API_KEY = .\+' Secrets.xcconfig`; if it fails, stop and ask the releaser for the key. Never print the key. + Use `AskUserQuestion`: **Question:** `"Archive and upload to TestFlight?"` diff --git a/.gitignore b/.gitignore index 493b4a6c4..cb63b2664 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,5 @@ buildServer.json # XcodeBuildMCP .xcodebuildmcp/config.yaml -# Local build secrets +# Local build secrets (see Secrets.xcconfig.template) Secrets.xcconfig diff --git a/Bitkit.xcconfig b/Bitkit.xcconfig new file mode 100644 index 000000000..fb68a49d5 --- /dev/null +++ b/Bitkit.xcconfig @@ -0,0 +1,4 @@ +// Base settings for the Bitkit app target. +// Local secrets stay out of git: copy Secrets.xcconfig.template to Secrets.xcconfig and fill it in. +// Without it the app builds with Spark (Lightning without channels) off on mainnet. +#include? "Secrets.xcconfig" diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f83b181fa..507f9cb4e 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; + 72B5EE0000000000000000A3 /* BreezSdkSpark in Frameworks */ = {isa = PBXBuildFile; productRef = 72B5EE0000000000000000A2 /* BreezSdkSpark */; }; 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 968FE13F2DFB016B0053CD7F /* LDKNode */; }; 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; }; 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA03B2DE8BBAB009932BF /* BitkitCore */; }; @@ -74,6 +75,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Bitkit.xcconfig; sourceTree = ""; }; 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; 4A319B4F2E8F24F2002B9AC9 /* BitkitWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 4A319B502E8F24F2002B9AC9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -262,6 +264,7 @@ buildActionMask = 2147483647; files = ( 182817C12F59A7F10055A441 /* Paykit in Frameworks */, + 72B5EE0000000000000000A3 /* BreezSdkSpark in Frameworks */, 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */, 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */, @@ -302,6 +305,7 @@ 96FE1F582C2DE6AA006D0C8B = { isa = PBXGroup; children = ( + B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */, 4A319B6E2E8F25F6002B9AC9 /* BitkitWidgetExtension.entitlements */, 96A44E912CEF5EA700FBACFF /* Bitkit */, 96A44F4A2CEF5F4B00FBACFF /* BitkitTests */, @@ -378,6 +382,7 @@ isa = PBXNativeTarget; buildConfigurationList = 96FE1F862C2DE6AC006D0C8B /* Build configuration list for PBXNativeTarget "Bitkit" */; buildPhases = ( + B17C0F1A2EAE000000000002 /* Check Breez API Key */, 96FE1F5D2C2DE6AA006D0C8B /* Sources */, 96FE1F5E2C2DE6AA006D0C8B /* Frameworks */, 96FE1F5F2C2DE6AA006D0C8B /* Resources */, @@ -402,6 +407,7 @@ 4AAB08C92E1FE77600BA63DF /* Lottie */, 18D65DFF2EB964B500252335 /* VssRustClientFfi */, 182817C02F59A7F10055A441 /* Paykit */, + 72B5EE0000000000000000A2 /* BreezSdkSpark */, ); productName = Bitkit; productReference = 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */; @@ -511,6 +517,7 @@ 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */, 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */, + 72B5EE0000000000000000A1 /* XCRemoteSwiftPackageReference "breez-sdk-spark-swift" */, ); productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */; projectDirPath = ""; @@ -565,6 +572,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + B17C0F1A2EAE000000000002 /* Check Breez API Key */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Check Breez API Key"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"$CONFIGURATION\" = \"Release\" ] && [ -z \"$(echo \"${BREEZ_API_KEY}\" | tr -d ' ')\" ]; then\n echo \"error: BREEZ_API_KEY is empty. Copy Secrets.xcconfig.template to Secrets.xcconfig and set the key before a Release build or archive (Docs/RELEASE.md).\"\n exit 1\nfi\n"; + showEnvVarsInLog = 0; + }; 751000000000000000000001 /* Generate Plural Localizations */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -944,6 +971,7 @@ }; 96FE1F872C2DE6AC006D0C8B /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -997,6 +1025,7 @@ }; 96FE1F882C2DE6AC006D0C8B /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B17C0F1A2EAE000000000001 /* Bitkit.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1219,6 +1248,14 @@ version = 4.6.1; }; }; + 72B5EE0000000000000000A1 /* XCRemoteSwiftPackageReference "breez-sdk-spark-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/breez/breez-sdk-spark-swift"; + requirement = { + kind = exactVersion; + version = 0.27.2; + }; + }; 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/ldk-node"; @@ -1266,6 +1303,11 @@ package = 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */; productName = Lottie; }; + 72B5EE0000000000000000A2 /* BreezSdkSpark */ = { + isa = XCSwiftPackageProductDependency; + package = 72B5EE0000000000000000A1 /* XCRemoteSwiftPackageReference "breez-sdk-spark-swift" */; + productName = BreezSdkSpark; + }; 9613018B2C5022D700878183 /* LDKNode */ = { isa = XCSwiftPackageProductDependency; package = 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a921d6b3..8401f1e44 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "86914da7e92df3dff5ecfc5ee3ce34ea4b3f911278d0c81ac20b137bacf28367", + "originHash" : "e06c417fd776e53210166a031513bb03039574ba3e0cc1f832151ad19d6a5057", "pins" : [ { "identity" : "bitkit-core", @@ -10,6 +10,15 @@ "version" : "0.8.0-rc11" } }, + { + "identity" : "breez-sdk-spark-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/breez/breez-sdk-spark-swift", + "state" : { + "revision" : "259f79b8572ed4a1df8b5f6fea64c6f2e534a65d", + "version" : "0.27.2" + } + }, { "identity" : "ldk-node", "kind" : "remoteSourceControl", @@ -37,6 +46,15 @@ "version" : "0.1.1-rc2" } }, + { + "identity" : "swift-bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mkrd/Swift-BigInt.git", + "state" : { + "revision" : "7e2117797cd1b608303cc2da1a3a2263cc4e7727", + "version" : "2.4.0" + } + }, { "identity" : "swift-secp256k1", "kind" : "remoteSourceControl", diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 88150906f..7ed764826 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -297,6 +297,16 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { return IncomingPaykitPaymentRequestPresentationFeedback(paymentDetailsPendingWasRequested: wasRequested) } + /// Ends a presentation the user asked for once its payment details fail to resolve, so the send sheet stops loading + /// and the user sees why; the request stays in Payment Requests to try again. Automatic presentations keep retrying. + static func finishRequestedPresentationAfterResolutionFailure( + for request: PaykitPaymentRequest, + with manager: PaykitPaymentRequestManager + ) -> IncomingPaykitPaymentRequestPresentationFeedback? { + guard manager.requestedPresentationId == request.id else { return nil } + return finishPendingPrivateLink(for: request, with: manager) + } + static func handleStateChange( from previous: IncomingPaykitPaymentRequestPresentationState, to current: IncomingPaykitPaymentRequestPresentationState, @@ -1799,6 +1809,20 @@ struct AppScene: View { return } guard case let .opened(paymentTarget, privatePaymentContext, endpoints) = result else { + let isWaitingForUpdatedList = if case .waitingForUpdatedPaymentList = result { + true + } else { + false + } + if !isWaitingForUpdatedList, + let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: paykitPaymentRequestManager + ) + { + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + return + } deferIncomingPaykitPaymentRequestPresentation( request, reason: result.incomingPaymentRequestFailureReason ?? .resolutionFailed @@ -1926,6 +1950,13 @@ struct AppScene: View { return } catch { guard isCurrentIncomingPaymentRequestPreparation(preparation) else { return } + if let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: paykitPaymentRequestManager + ) { + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + return + } deferIncomingPaykitPaymentRequestPresentation(request, reason: .resolutionFailed) return } diff --git a/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/Contents.json new file mode 100644 index 000000000..600f19a4b --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "coin-stack-x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/coin-stack-x.png b/Bitkit/Assets.xcassets/Illustrations/coin-stack-x.imageset/coin-stack-x.png new file mode 100644 index 0000000000000000000000000000000000000000..62388ddf9535b82cf05b930d18adff6b00118302 GIT binary patch literal 71068 zcmbSycQBk^)c@*<9wd562oWuawt5gmkPt-gy|WgpM-aV+=%RN)v|TlbzF57ju*$Ap zR&THG``_=c-^_dFJa_JM?sGqL&gagYnS1VYBR^;;lMyoz0{{RrRTV`o000mD-}3

oKBS|gefI3>%a`1j@Czm;CR*Aj zk7;P`zrh}3kJ^uzm>B8rg^rHr6G_z-+|cXwxNdw*~5 zUhgeeS68T&)&2bgT3Xt<`T72yzO2lQk-_2NfuWUuD`#hC)>f8-1A`kI8|>`th_SJ& z%d6k@_4oGH*4F#}JsM(SVt8mMF(KjJ)!y!&>#M6V_$aicWn+E4uC6XNHm3K_A8Bdn z^0G2%X{orln6lE+g8cmYnz~P)J}D|Hn3N@{4R|IErUFwie4DNavIgFql( zLPGTPbh>_3X=*Y>%)y57@^hlfW%KtM@JK|w)&ALqT>L_|c79zD7bmWqn%!Gj0) z6p;`UGcYh18ynl$SUWg4(9qBr8W`Mj_2kK8Q&UqbD=Sx57bhntBO@bcXXktB%*@Qp z&CT`o^(`zcEG;cS?rtNZNPGY$?8H#av;O-*fWZBI|nSFc_P z3k$isyMO%nk(Za3pP!$TlT%DgEFd7@#S5;NFJF3ic=-DIs;RwGSAXy2<>l?|?dRtw zBJx^ENl8aXM_E}}PEJlzQc^)dp{AxrLPBDEe0*?lP*zqZC@5%ha*~^yyP~2(RaG@9 zDJk{)_j>~S`ue)NyX))g!^6YjaCl^7WPE)5+}zyE^vs(#Z@M}=LqbAYT3Qwt7l((3 zQ7BYQOiX%udT3~9Z*Om7V`ENE&gSN3dwY9nX(l#i(B=&4zl zo)A36=j2wT5+uB67G);^)M6rmt^BOWZQG>S%9;_yOZ0pxad_SNA2ye$|?X8r$$ zeDHD5_+p5>v+w0l`-=?TifyB^M&>F{I9)+QwdmzA-R6#XW0jV#iLJgijr0YcYvoSS zu2FBL*OYpMIbB>I%ETv#s^VJ{Wqg86m`?i{azE@EOh+D3R`J`tQOB2W?o5%QHLOn4 z*RTM_UCdmcnxXJ{Z+O93qp$5(|MGOd+PSGhIWpGSBw6Z!LFgaVx!ub-m$_ZfB{7}R zdZVB^Be+s^1!WaiZpQxa+A^OnM7K)ZVNDv|f(PdxSgM_6Cr8j~2b-%UgP*_Ks(0We zNO2!JybCkUg>P~h1}b)_`y2VtpT`6cv6QYmqvmut+^ zpl(vKY>iZAM3_gLs_b87 zDvkTI^o4QquZrg=kMlk#PGyF~nWvWc)YwSU+S0aZS|(-toMkm#E+H%4O3+rXgy~ZD z4(a4@*h2}mIB@%ThU|xM`EPm$ z3lB@`eKMaL>~avN`@Zg;CFC8d+ojC;Syjt>4yKetS}xKK?K*tlJUhHBj0V?`)UJTW%iig>WEr5%D8>~a?Ev-aa}je=)`^b_*YUUKieZ5x2~14N5S~N zP?^OmJ~^xw<3#(Yb&sNK&AN4|2eGDMwxg-po*}}19qJ1{tIRm5-pUT$w^Y5xeNm|` z#BQaF=rdk9m|D|EN4WRy)uo``Rw%Aaw?aYh+M7sM@xTZ0f+L=jH(&lJYcJDWm)iB! zH?8P79QH`A6oz`XcpQlTc>Uq>BEa%Gha@vb!WJ70>J+^*T&^%wq0 zkA)Z9FVw^QZQp_6KYO)*QM=|IPoE#XiLzvrOpX&7)%qZ%)LURi9PUS$a;$t23+~`7 zl|mszm^+M)&D#YK{)}^RYu~FwzhwJR`0vSC%X;*|zy4&_JmM=npCk^bY9>D%yfBtt zU#XE>-o-eXRC-h%>p%KhYzAz+^S6ebEaoXxQ@bV}i-t?czVpBk3>6L^;xaXvQ;G$F zueX+QaN;la!tx{wuOPJ=Y>VG_p#`kfN0p>t-a|g1B^UAu*O|GAzQ;_w?#urA=NhP= z`(|+!kDwh zqZi4{);o`e!HN)+=Ee5nLG~_I_g($hk1u z^=Pu{MpS9#c6b@FWQ|h7K8~}u`f^6E_3u7yg`Ipu*|Sc{mAG(fk8C^s5>{A2c~gQA zW>hC7ExKt8)|55#vj6OhI!n~Ge`di>m~|A#bXSgCbH7&@p(1tae4b*odt}#E8UbF$qw!T zPb>SUGt~`d`6r5noq-GNeiGH%VO(3Io9_FSgHHTAQyVVHsZBkzqL97xRhxLRjJIAF zjy9JQ68Kkq_^_u~mfNvsEPqep8-PBg*W^r=t9 znp1dETg>g+q1mGX&=K!l$w|PmkTArTLLNQ6p&Jj zHiKaa2LN%`@^#pgeIoZ%7ng}=DKQxGyR}HhUf@T|`6Yp)3w!?1wLO=_YT_b^RhR?6 zo_D5di=X3=)t*JZuLztJ zjCcmZKx!!)*JLW~C&4TjPdSW^6?3!$zgqmX_8PdbA^tsK zPLXgvgWYpmq;=Pj}74h%Bd*gxssmvjMY4YUt z&D^JI%Z6KYS2hn5eECnd-g?C3HzAGBuUSiGAoaWz&!`;g@;(kIO)^plu zgS7NkNquOCabtNHCZsE#f)X_IfRkKj1 z5zG?cTh_KPGd-sYc8eF1-};~%S+-X$~a{8`3S?ugm z(upyiwRQqs!lH9jLyNeEWN1O!w1haQQ?# zlU}x`%UXQfrVb{0r$FjReC58m=EnIJCPfNK3L%$a%hgSO?Ew#pa} z$}E{qM9IO9O`0p!nbMW!5+4gMulP+csq z3;(D*sy#vw9He>pI4<2Lm~Ee{$0xrQiqn5Jy)j5iBVk-->2d!o& z_Gr)$BsIw8$^UJ9Z25W`XPJBH0EeR66b)>}X+eNX+?l4Vlg#zYJ*Ol#SIJX1mGT`= z(RH~WMvX^F)bqu7hw^hK+~USt`;Xd+JNCLvCkpTYQ@?M_rRW=!L@Iz9xu|kac+Zq_ z(V*wEi?l;KI^P`Hi}3QyROeA?F~_7Mx&}L=4b+Z9G-kcc>8Vp{7Ic) zoXwJ|6>MEqFKa&i;$RSiWO{EiVNOY)(DAMZQm-m)s~7yKCpM#C z)h%xo_rV)z?F{#^LjN`X=Q)Y;!{CLG^6jZtTi$+=d?i}81wi>JjFW^6QI03o9&#D! z(wCZuHbETu)8TjLm2FQIqh33MAF5c3Ue`EJ0Ytt_Exw%BQ3) ztZliOw*BW9tPj}3uMioAlPhLbF`$!mxuxN6 z;0(d6>e2G&d7^D#cX&x?r2y%ENlqP6DO7ALpzVNRJ_C&_Q|Bkpv04P&vgdC5}go)2M$d6Ysr&QAQw zFCn$lna0$Yhz3tw0iNt&)N6raRn!G|4$iG9R_;AtgTL$1Fl~#>he}-7=OdXJu z;R%kr`{n2)fPcy}n^r!`FB6ohd=j&*HpRNMPHiV%a>_*H)0;OFOB5;&em(*=ZX{f) z3;gvx;B8BAY{nkp=6VM(s$3vWBiIhVJjT`xQ6t0jtf_xyD-C z|Ew_m*`;0R8&!>>GXe>q&V|6Q%XWQTDjow*%Jb7mHzhC-4Nme3zh59Xo}x}CP90pSglk#^2obU0x3@xRfNZ-mBKK6#2FZy!0PB3jca(71~*N*BIYLX4OqKtAO zE}xWiUZhGn`cAJ`V&>B3ETgq&;QhC+mgf~10I-4fi1INx}^ zIn%O0E*U~0WUiMi_^1(Y=g}m=_`_9J)@AZ7Xa;#@#dh&=F_W!qwe)xts%0}K8duDHANc;Td6k<&gQ;C;FDID9n+T+No{NC9^CWr z?<^hD1AJVore_2{RU`}-edvqTts&(wDH1oeyYO1ud2*_ARLv!9LG)d9fyuud8^sPX zw=!h;jA50x2E^ah*3qv)pHZO2zS?24Ta2m<&&$qCrB`sig-7ArD-Qkb^21ysxd7=T zWKXGN_s9suy?B0DGVTv#CBDp5*82St`i{_jiLJkEim^(!pn*+%f5(|m)N#F)B*(&> zpWs2%Vhb+uU7o9O!7r3b0(dO6<-RuBO9)7DU;#gP$%O1-O|kCyY~(1FUDnYQfFS8_ z(V;A4)E0YJYB-a9&}nSvGl%kTJA{WEW}j~4{F6fU3mN-%;EgQHoftb{0*wV~^EcUC zXogvI$$pA5@wexF`ye%GIS<5uj$~V}1GX*<=Dcugv&4xd;sTaHTW=qN7#KkhDi%Gn zPu>7ca(3b$Ld0nSW)#7daV5eoG#{D`FGfiZotgfNX|&u(LB-8|Lpl7!EU?yW-_E}fi)2z_c{u-c0aRNhDm#=&wA_FAP7Cq1t)^p9-Soc_n#vd{CF&@r6z z?OPeD)8=}2$_MM<3gP4A=;t$!UV?(u5WA9TqfDwrq?>Oag~1qaK2o=Ui-dG{tZ){y z*Vo~h@!kCrN|_ebJ@%_qEQjQ*1oa};ra+eTDx071_jOksIC5zF*JzrPAP=tx;T|M8 zWuqe^inkhT#B<==ty&^(&EhX&#A=HqiCL2Bqj0V?H9&%?p-T-vnJtd|rV?kn-MG4f zH~j$nsd#Wf53($@KD_FN+X;t~)!ZzBNJJ|5tDwKfyx@j;6C;Mku;d6!4NpOQW2UL=HoapF3EWgI zGqUU?6$5;ePT5esN&s8b$yZjtY<@nFL2`XOimgd_ zCSWDj7d2to@cMxSiX`79!o(WZ6YoI`l4P*#VzcyZT?wfeR0WI;g@= zT!6PgC&@ymG^VGe!MrE{!3Xd@@7*_Gn2wR&g#Yh5w!5QV2SW76-@jg7nesX*>@FO^p zg06^BbA0-FZyh0EYn4j2;bU4U#&NX;kTPlfvbx@Ht-f zedPN{G$@p?YFN9n$99lPET@fgD>#vlXFCG|G|v=Rhym_Cbm0k}3~Rv-LAO&gqdd7C z4~GfRZ&=%>hgdKzB(zEeE`#3e<(_tEjx^3J5{2BXbOWzWT20?Ei$}hQ0HV3Sl6TfZ zv3TEuoE6FqXsO>`0D{FFfjpyWIEB447n&dba*25blH_S4FOPioR<>Pp^~Od&@)SfD zRnC+ypD>*8Z{QXCh#@3Fw(iVEtC9o@7 z{fnv83{4feq+Nyf=PK+vLhAGG(=hpkOwx>SsR~p;(e&T z0LWR8(|nc&e0$=yR>F=4kgI*&_W~IvL;`y}URv{DEVT0O`T)e8c6eHU5`BS95zBV@ zEQ|i5vsc(dFg0eX5$|F(V5q&>%5#!zb^Q@wzy4L_Y0?lXJxKrWGR+6T6LwXe7Swb- zHNyHLeiauGxF*n1FJg~Fk(cN)1Kbvlmx)xV=25*Ib>00rJa$nKaixCZ!U5VYeB4kt z;o-fJn_5K)4+VeYxMELoV>-?an@Rab7MW%#)T?bz`27`7PAQ!;%}zW|U@bI(PYCqj zP3JT`83sK~GoeE|N;CJWXs9IJwi&=vs&ix(mt8q0V-;zXZK7w_g%n2L(i;04h6nww zz&aGt#{WCkWP7SWB_d~4`@BS2Iw@X{!>Ewewm|sr%9Q#q2Y}oQPR$ac%Pf2xz{}JU z&t#_Q9*Y1E7Iq1Ola~0F>oCtW#S<6sZ)OlPOk1vAv9S4ti+Q8gyE7qBAg=1X82)Hi ziP)=m=V_>Kr51brxo<^YI9h$VU@MaJ1+bADENZ@a7?3~#_>tRD<5QLu1+})$FpWB< z&_}R}dbW^k?nQ1bDS8kYc{}sS5xHXa=8fuu1+&P3UclApZ$C8!SjOhpB}|f@#3^Uw zKBevnQw7K=K>pDGsj2=^^o(2S8o&lofCYuM4JW`0$ep;Y=VZZsZxzq^8BZuwE;o1m z_coN`L7ION6m{@LFfa5Y)9=o?m$6lw6Kf;0$)5CIi2j`zOZwnkD6cfH>7SnXB;0DA zwhHk8&@oVlb_YDU^BzA9(5s76oz6!tZ1a;LhR5XR`>5&Z+pqgLlZn{x4UvoA4OuWY zIlE?Rl~Kn7J)x=44&Fds0143A=nZeCo&VRJ)kKq@qTF|W39ZkbKg9vG#~4&3DC$!H z?9j@hovZ3(fIri4Hx$Qha`*cuFSz(yK9C=LTBJi{AC7EICTlLKkT+;vj$WGW4aWLC zOFT9cD-M+NTCH;)$p1a_r+KQ;c}4WeAKzE8#C0f&Nx2Zahcq<+ zV+Rt0{r7l)Y#k3JmN|gjqCJjWlEBxsQ-o)Adl2`(?ao9Kx_QTcuQO|I4gxB(V4i1z zGZR;Y!-OZH+?9)u^5iU;Q|k5j3+HOIk8}9~7`vW#xOHxdoL2_>zPpuY@y~ir7A5%Q z=ou^8%vVE=D35K(CKR{K+74QwhCB%qR(<>hSd^uzLQK67yZHt5+AD?aW=4h2d-Lcf zQ21wH@JVKy@V0YBRQ@Y6?t)NBNHNF|p302cHN9OL3nD;&g8xz$}gNka%mKqfO+et)l^utoxfCBh|}JEXPE(Nwn6=Y&R z@<%y)WVU{X4~hL7*uV+aDG*DIg)&K2M?feG#gKuXPDSBZ&Bt3PLwlvm?+-Cc<;vH9 zJVy>E0xR_C9VE`z71NvGPwMaRh8d<8mK>UX-ZmYR?bLSi_6e#xPqs0@8<5Ar^QcMR zu;inmnIW~D1+~d`wTmkWI9aB-6_{x1JGzePn?c|yLa5rkD_EjLtL?|mhfM59%+QGO zB_dgsN|B}~cP8DeBL4ILL|2icG#ZFqHsml={_%e;3N^Yv2FmUs>05UnP3j`V^c zD89CAz5ATH2L^H&nvC`g3)wDIbrtYFEA(^BS_7olqIpG2QK~m|uSD?xs-;r7RNg%T zjpu3YyI|Y1B*WZo;{Dh)^l;lo4!lq~=T_c1_ZB0e^dH?nS)lBsfHEdv#N}29zw%hy zTN8F1?}0Vl)2*WqVCI5{8;8$gU$aDa&17wFn0)oN=rxfk%Aqu!9{+tRdJM_js64!| zsdIA)hjixL+5{%nc}pf;ftu*fFsd9gI?!z-Q^RtD&}ob~&)K=iJgc!U%3L z*7O_?(DF<`bo7C8W7<~FK4oINNdYqv4EhA`Acr29y?TUN;#GHZ+ zYCHb^1#KgEI=pU*c8i~&@M@^9_-~U=F8Q=|Oxftd zHSQ+r%+UApJZ}Rw9i{az+bj`8isany{JxLiWEq*z!;kF7%RX~wy=7VtuORqE(PkIv zSz1ir|0E48J1<@TiJZMsRh~^%)0v5}Iw{VCIp_97U^D6r3Hg<#(!oDBIL!y$?J{B$ zOtbB7{%gG!KJjE-B!DTUnahT4#9j?2xcy(iX$pm@KPeu(WUCTzm>ecEeGamn4U zkN%Xl-JIK)rJv|QG7Sz9<2t1oBM}S8XgS&KQuRJB7q&d4kg+mq9!U&P+yz+mnt0nE z3jxO3`56HUx_~jQxxJ7h6{{KEK(+?2qp=kQ*VaJn3ScJ`U z%=J%|AY0#mEDTwy^VsU~C47j21)nl)&#r#Z*%CtoRN$9grStNx4uN0->f!Cm1{lD` zYb3X5Ync z+zGIAd294Q2_%_}-?-q4*u!h&N%_DNi94=~v#mAo(Pt7ir^kRNcg?oC7t@5^^$s(m zp*HH$dxOShb+3Q2d=GAkQ zI6dIC9-Cl%n~}MU2gJh01_JT%@p152J@`2jPle%%#5UY!K}2pP9=yi15VNu?7gBE- zL}ArDju!yTcYsG9emGC#% z`zPPeoBi^*qT5-Te!JHqxEf-4Af)w{Ngzo$P zXEV)Q$=BT>OmZ_hw?i{&IG_`bG~`=|Eth_uZ90URNmEMJ+7w@Vp1k>|gu{zlg4Cs`yA8a>aYOf3s8c}Fg z>69YX&UB4;b!C-hNo@bNB|V5#mWRA1V?4q5u=42LP}oM(a_VGi)Jfg!3Nb6lZ_TB- zAqSu*A+OkR+51+%D?eMf+n28?(e?CZw1J@_LNYbTai;0)ebH6h2f&daOUX?N6{zp$ zTf5X3w&^)CsWXMFgcvDyJq*Q^p~ANym;pG`FH^KmDI_Zr>M$`X<#r~2ip?qXxsSy7 z^f7v)xp?I3eA{UU%E*FelJ0WD&vJKuQ7UJ;e6qLb=~{YomCo*m*ldcdbfZN=^!!90 z8KQ)9`O^?a2N8jfk;oEDewc>*Ha$z{MdtUK1a@&Af;5>b;;R3ZUD5aU0f_rNH8Ru# zd&jt`Yu|p>?F>@e1^z_H6uoI3pKY^ni@iF1x?fdO7INIx!2aBfCAs}GRj*{~gfCsH zcNJn0b_AN-3;eOGaFhd7E>U6$V$I=?J}ax6M<(rj-nEk!TD8rl1<*zh;$Pm4jSCd~ z>zy7ygxhH?0_;%Y5(B=;J;op4QE8JKnrnLBJhiPbJherewyTd?>~^xxO7^q%M?*kG zqHccwdHF?|GZQ10*ZTJAs@VAz*TO5CS{XW=W&jXRcUaVN%@`Vq1FJJ>J$XnmV(3 z=+!8NBUBjwGttk~Y8p91>7n`lkF}S#hQ8nZexZeQ?MZBHI>uf&L3;?J=E;2oA^`F% zl*o;K9f~-9o>mm}uGcRt>TGQg!(0i=;ii2fh8B8HEaXgfX%e%n*k})>xP5-iGC4Nd zkV*8{%SYpnKofBC)k zVKt|Djhf273bRi&4jRH6GwGh^$JajGcD;ebrw(eQ>YXcab9iH8tIU`}o!a-M34*0pO#Lgiabz0edgRs*#QP_ZiUPItVZy7z715f$jUF{t1 zC08&N8fjAO!uzLJRQDrkKaY%2jqcRGv($|98ZMbK&M>sx~Q~36bE6`uhtMEF;Mm_w%##fb-?iW!^JmcMWHVBE)46 zO{)IKps-@?rXTNYcYi7EH-@-T& zu2Cq7P>bf=blO6UH>G?Vw&zr5hBq2w% z^=GQ91&;dFQ#>;}R=oddFwhFat@};N#Y~5rMC^knO;TU!`9TrXyRGM%8JNG=|^Gob)MMG9-&uhB2s~WIGnim5WV(^ zb`woe<|?V=i28n1Dzfl-;~B4}p6?>Y?_!=WtJO3y52>0T)RNm4WbRUk-lPNdL&s=o zew!!Xo_zpky?HqIsP(}LgOXd`;w#U_<3I2fE&D_==0ZF#b;H5JS6x3@@4BpgRyq2Sfp(+LX{(dD z8i`}SX#HL4>)+PIz5#uYmKM^LC&VrbHF-ddLz1&1` zi?^AO?SfqgDi;U;M_GM^6A?R8Vd%%YX(rmO+L9 zg5+ie`~H9yq~sf%F0bRh?i}c5-u_-4`26>vp~dWT^is-S`lHix3pP`t)A^wSU8052 zTn5WQ9H^D?0u&FcQ~=g1>_#NEPB0h>4tF6Y>}Aam^(%kTbl#Z6-$fOxH=@|4};Q#TDcDFcyL_s50eEv1o%0EW?_SZcXAIx z3HEVtz%`uE)ATs1j`9wMr?M!f$_yI={eUk@DAwH5;+Fi_=6a?jo((@nx3zi8G{GDE z1lJ>YowpCLaquMp%QSvX7uOi5$tRKsa73RP$6Ki*PyIhU(}!dU$(<6s(k$`aRE$T> z%|2WocXQ%+r4>?TKpdv?Zx6|JVAyEc%ETh&20uh`Q(JY#rSJZ&;X_dM(nLF}!({f9 zUlL5R$~dcAo7N+}u|Qh0s!c{CArtqIaPvrV4+kW8SbZ$s2!f+ZeM~bWib_mh&QMYcv!GB>Q2yKGX4xxl!ZK!%bm*8Bvva~ZCfTn7oveNEyu~0zawK+ zXy2p--X?|)=nFGHo%Iz64Lk`Ty~H&Hk|`!zOro~h?hA0mU(%Wa6G#`Cz;J`#igex6 z*?*2jlt)M{`Lbak-r6#$>dsVn9xZVhdWIGaiRD2c68mmE4l|QS4|?u{Z|a}}onK7N zNNGv1>O%yjA51osC>L_M8!7zss4;CKI^FWM+C-5`h|{+7CrdOP@F&S=zceuStA|L7 zX2heUs!7$j#wMOhLZeXcNTD=|x0spbR+#Vd?N{t6ttC8<2VT>*u|7D>nJ6QMFny1A zl3>m05m_|zolA<@Ax1)V?t<=`sR1SmR({UfwA z(P5uJkN+l$^&@uYjrA{!mLzkwcAt+WD8uCqNch^=jHcxZ_eo<1-vlaLGOgU@}-62$;}~t z-yY9nYZ3d+e?|!=e`fLxc`jj;86YQfgYD;Pqn($ zdD*1vj`x-O`W|54QpNCGW~bCs!lD-gw$z<0v>h>vMFJxw1Bl1yUQnDog}0qecrhyP z?1Y8Z#2nV?&Lj!(xK+%IVgx|jO&0N}u^zR6ZO2Gn#tn_A$GGp3 zKU&=n0mY%-jgDKVBy+Z9_vU%7XpjLz0&mD_vWz(c?$$Mz1CD@(mvXfa5DMzh4XL^B z{103~q=m0Mojz^LO3wo~q8nuSw_r#5(gY-9TwJT_P7jbXMBT3*>tC9siZo}4$;Zn) z>NAEmGfnMXB_-R;>%8<(Ue(B3K1UF`w;xaU$p%~ZDfG!)(P z9`$Np=F!Za!)o?vV__MXYYekLqetT8I00DtAjN#vC0q{PSKU}z_!LyBvljQBy{_RHf$xl|@@s)TAC+Y%HM8QKvvcp9X0A+I zr<7AQ3Q5#PWr-LQTr^U^MVB=Bz9y1LMRPwb!CTWWNDDc!b#arPMnOY;YOOpy%*2G_ z!b7asH_+2Zu=*la%KrG5Lu5mgn@laNCGvNlLvE)gIczaZt!ipBc|>iXpR3VY>%)%f zr&YR^n<@zTs}AM%aW%w4(CT8(o=Jo=G?(0xB&_@~`sC@YbQn@c6{-g8+tbdpf4C4L z&$U$Y#x+*n7S8R_v2z=UNUfp6GUwi2n!pi`cxnRhW_k#pI}mTd*Q#6XkfZG5yQc=h z$8Xmsu7+FEWAV^NvD#kV+Hn;Xq&m~n!k8%m4~l=gqGXWv*#MW*YHc2J(#C+(l0g** zW*|jx?N3(U-?#y-0TH-2H`ye~Sw%yTEbcyn&|kyNbZ!w*#hW5tpqCO6yIOMkN!7VH zNc@h+&zS_)(E~<7>VNmPbtK{oNM5q3 zd`X~B8%yYZ8>tagHCF-aMwDFXNbvv3i`aHN_fB=t&66yxNKY@*gSX3kPTA8F+yofA z2Tr;(U`@Yy65dR>+I}rM?w6FKXpBsGX@~Y`%(k^csFn(L1i#EHqVUH7ACMwOYHD2o zki`XS!Z?15vnId#sjNb3v<@-0T#td82IlxsZIf`}z}c3NXV(8^A=-f%`m&cfU=i;u z_Uz2{ZcRD3-Pt|*7xsF+*;Wthp|i5#0^#3nEp`nsTr7|psCk3C92PYBCYNB$Jrfje zHaC91xRR|!DGo*6+7i`Tp)9M%$Y=W;4?&s7`~qOpADJ!NlXw)Qr{Ayz=nk72n!X7k zD>O0oaCdn_?rsa!f~-Urv4fR)q)S_)9sC4kXVe=49m;*RO7A?M_mm`*{!JIbs35$S zvWbv9++Ygj?v;1nwf%p*cMB!M#h4_?1lSWu0J+^j9g!PZH0Z}nEj(is9cS(LHwgK1 zh0v1V*1KChN!qfsIQh?`i0pLwXa3gltu=5S+b8@dHoSWw8VTLgcO=i-BI&8*#K=T@ z_R>|JhiDWkPrABhFtwEo^~WvQmH;nk8vn5o$wo!*K~@;FK*7j%lyY@%ZGr2R&&%8* zJW%(=y9VqtcgeR_3Ay!_F8(ciE;~%i#Aj=_n@o9q9B7lI>c3`JfRz6*vI5_Q48 zFcqeeE>ygoIBaHaHiTE33`>5yn?bWHi|Ke%V7+#Ms(Ql0UVyf!M-lhzMvj`>`*RqgcgZeH&nL`P1e(PSs<)29VV z6W8a%WauaRzpzHHUHeXgGOr5(#_xyCMETPSCx+jEA@yd38uMZA zXTcifkuH&f8}A{3vmw9PSn`gg(WyUB)?#ai(K}4W;=s2yKzs9OmtSEIL164ybYa{R zGN2z6KfA(xImURf-o0z!eLJA{gmQCg938Twihr~_%I1WYz z0x7x(^R6@?M&WWZnJVt%H$RA2=oz=uO*78qN;qz=ddp&+Fni^o?v}cy$GQm$vwQD! zSF~9F8lbck%@L6+s5{EQw4kcy0AA{OM_rLPAg1+2n~c_Io-z$OwSZ*SOQc%=Waf*u z&P}n9Hb@p9B}9zHNXxz(>p1st^wiDX2zK`lb^!;7I@SDm%^Xp?_S47>zR>#2sYzbX zHQejrjqGa2_)S=Gd=6NCGm-us;_44E7Dxc$Bvg(lsi2Lna3}E`Shzpp0H0+)0Ij1V zSjwaKCXFRG>aI$ZW%Ui-FlNhba@@>lp2k&3BiE#{pc>l(MUu8=4&97kr;KBIh1v{E!#q|F(3)LA=xp;0+1fc}U?L z6fi?I{kwCVTh(+CC=xfzoad*eqsM|lm#lP4$UV{)YP~~c=x!`NT~acV3GOnqZ-wGs zR%i9OivyvKfF+WpaZ_#Uvp5I}t>7HX##~H}gC#I1HDF0uy5TgAPEOwIWAgiBxsZHJ zj5U6O?%;R1F)tJ_crG7NpoU1h1B%9wrWjP}YM+_oxx-&-K(Xf>$igHx+I^Ox(=eal zerZ|sHV%-_tiX%!WSmBBU!#01`(p$LT0x3|c4v1_Om!@tLs>RaE(ioVRsKCL_^Lqe zZY{KJyC1<$py^}|$XgH04;2^CDc=&HL$ zpx^uS_g{O1amvv@)}Vjz)22SQba7ydsJmM@EZgWEv5V{HARS$`=$5qNoJj}kns&bW zo7l?)-eDU*3}445%RV<5n?nlFb$@NBF3g~Z=x?<0bI2aK z*8=DbaC8;93(%FL*M;j+88z!(`)bb7`DIw=$~%KjIeM_OyB|e4y6fmCJ^8)?{VUJW z7q@=!!%uVD$Qeg~dssSij_#TUeT@ftr*ZU@ z0-gU#!%YC4Nhi=vuI_AAa9E(OXqp z&7gyG*-5v9_hmUD2gHl5KBus~e*4-ObIzdiOY)9{-MhlOL4H4?PoRTy)Z)~7bOHJX z20fDr(_d*#wgsR=#?%4&Bs+Rw>Auq{Rg5#E?%L6xAke*4swQ;j*!|}{$t+ts4Bg!d z_St1lIzTVhR(mn?Rzs|nL5CFWu7EgS8tUQq`^o7=ijJZ?tzHv3I;~%e66jS7`i928 zg08+i&?ov%)%!gMp!-UCSN94pM?YFS`s2F@^v{H&e^2eTtsJ}WoobJgr3*)QLw9KT zk&d6!*{v;kf>XGRLDc=Uj_JdlK$?7nAqv_2AorGs^6zAD5x-km4^%GmiVRTH{5nOYnMW-QQC)dYH^ok3^L6FJ5W z=?r?827Og9cef5Ytn{e9!;UusAHu+4+7PO0kz4^c=zJ;U5tQ5V;)Zzr_*}7D=j_WRU z=>*GVojE7aXOoxEtUE>SL5}V(l^vD~-4C@vKk04;T~5Gypzqx?m&~dx9h|%UwPhQ< z7ip`ZtCsC-P@DsG`gVXW~H0zFTZJ6FkXc7$oNZ)xs-%mdOO@5m!O<(_g!(=yeDyM zn&@%>)_}fUgWi#?H7UAaJptMo^oiLwFS@xrAl-jD%s9HY+cR7GX}5k&pmQxwIQj<~ z^gUDGjEu9T1N7A*bT^j1%+|7*Kc7>FLwdfYxgn-xAScRmu=8CJ;^FW%{{a6V3!taL zx#Q^XYS4!=nT}K?fu3#23%X9%dl>Zb)34`RT)?@{R3;B<@jyTMY69Jix(i4D`IkK( z&CPaoN3^9A<9{`D2kK>URMYBwGG{cX^juROBfO-kBW2gG(n4I%pLmhX1#U{oMDC=& zrsx*v-MyJ?N0@7IV>o(UkdAi-efL7|1?LAvcOS|-z~NA{AYCQtmw)%;{H|R9edou* z(FydaZ)V#1xE{wVTKG%IMcVi0I;_s$TeHh>z3mOa+_H3!aAoJFK8Fid5%^6|59|@$ zJ3QmP6}Q6BV|s-uNA7klPPNh1di3t0Y)6NR+}AVI47w|J^i2yR9T%%MdQf!tnP~8V z9bK2*0s4sy`c-!>&8y|;7 zoI@?JbR!YMx8QPL#jr!b*}xC>^*26DRnOxbhHgjhdUMEW1$tkGBlk=La}LrSQzy`8 z5}9-L1Z-gGK9d@aW6)J69Z5P><0wgoqjQSxIQmC(-^>iG4|DL&02kqWxoiTR2XcCO z!|he0P2k*vUGOdh)WN!XBu&0l{m#7s^42qpa|YcQx>bwQpl=-NnB*>cXp9l3;N1~G zADMaS!c&9u(fuhJnZY)&GD*MYYwn|aqwe49QTGpi`~9cAy&_Vf2xc@FV}}GVxg)^(yzR8o~!N#^hJRFv&zvacAuH( z8jEo74$$`j_2+zQ>P5?Nl%5Z+Pg4&|8oFidB*)#|z+QU2!5qw%1l<)%?l5$(xn^o{ zx{bbZXh=ow8*JU4hPy zPOTy19D*RIX%80=L$ci z2z`_6 z=q~7-q674f2pM`uOmJ>GZ2 zlndS^48qc-srzp9-TlK^=OIVmUE82IFN!(<2k*$YH3z?(eNEd%9CvJZ++u59MU;*@!0_M12IlhkTQeH3bQOGk75V)hY zmdnslbGK@7-Mxd{L{F`K4WtX!4e@V`?C!oq9msT80Jx3 zj|{)IrVnX47IDf+*yWI@7g(-_Jtl~AfWEJ&8^*Pj^9Af4eB-u+2<*bblso*K3>}gC zy1%68RCMp<@z>Djj-i_ZJYT%lH8Gq~wYXq1&X?lQ9hRP}yHoJaGcy#smjHeDz^1Oo zSF=nyOk5CAkelcIV(P-m-ILENsSKCapo8?%A$;jH9>AOC9Qeogz6mpNZ0FvTJ2G@c z?s_hcitdB;6uD=b8Rw)zJSkv@UK?I$ie7NO8jK4DW_&0D9St@Don~f^%5~UIzG-RK z4(#e)5cHAZp5(er4F=@IZpg*(dF};&n7ZKHiQ8+Z*N~qJDbnHXVE*4x?@rW8{&D3B zvoe}h5Yf zj>ll}y2u_dh{TAb+gWhBZ?lAOD&(O#Exe^zej4|hN zIJ#%)U|rq5wrQgCrzdYd^k7ALIq&xz6yAL&)nL<}E-n4EJC=4(><-Y);xz~KwpjB^ zomt1zOOnLl`~P%{Sa-lZw_J^;UYp?OLJf*_yp+;~PzUWc>#%n3!N20YK^Xc=c?=y3 zT6p-i{v9%ObggUE};I5_DjwC{_>3v0Jcp1$y5(ffBf zN8IkMW-%Fht`x_sSt_R+iSxKtl7TnDJ0s6e6PqX2eK7MVfsWX{tSl(I`%k=~RJqC0 zF&?KaeV&taI*`epm)T=Lhoi?^8`i$`a%V>o(*0F_U!6B@=lFvbpEs>#Lub_U)9#r% zu1hl?EAeA7S-o#4&3W44+%@#^A)1K`4aHt-G@!%PS=!On(>Df&6Hh(;FgrSv9&A40 zPci6rJ&v+;W9hp-Fy8 z#FTnDzzdT{y$)|B<$656P{7NNJ08N%n+)eLbi=tS#dSw1a8JFGNHETZbWg;&{t@b} zz52rk_dfv8gO#d2)t~I0*-xWY7{=Wf+;`&|HEwZ9+?Tk-M2%hN*@T9zrp)>(8EXCyNY#&L%6O8pmn-*cXR=R1Ba1;hpHL;LkB zxDdx*pLaXw9EUkr2|C8(3`-ZF@3Vrw8;+g=y}72U(-$m)r>{o;txTPhb1UU6o^Fq{ z7=A7>m%a|AB_539UVN=x6@VVM3UE9TNT7d?1RX7O48?KDol9{MkAo8Qz{d#O4Vf{Q zsr#p&4G$gs`jxX!I^`5@u$_&?xy3tW=?e#U9^J>F>l3iM_Y&yC($PEG+^tRJVqCS=!ZpMXNGCDw>4nUlUzc@w zxAh*NBgV%Il!bVN?Rvf4T5%r~mAD9R{OYM!_4ah-IQbbs)33t^_l>-A_8F&fHI6O) zSW=GjjVP|i>6U&5cA%h%ce3>FGJyU?3iLJ!dOHR0GrdK|;0$s89%;ozxzc|*e$PDK zUep(0KW~zl8;QE{f~%Ih^8) zZduEQlmH!-cVX$qd>pmdzFFA$6M+s#*VnX|LU)Q13G{AXINGl~Jw5CH)h#J97&@<3 zBgzrE!`2PlabdFI{K+6=O$)0wo*egCz^wI z)Z@NcICykXueu|22k31QbaAd1AILk}Uz)F?cWCAD`#)Nh%f2kNI9|n^v)Ik)DPMKu zCF9~LDOYW{j6#(j0{W0feirjL*V{ok;&yOus<;cztD~Y0hqIU=RgPoK#gA&g`1F&b z6GumrFALC55;=PApv8?WqwtQ!EsFH>Z+v1qSvqp`bfJ4k%FzKjk=~~~U1?>w{cEVp zO)V|k$yCSc%WFV7Je@el#i~{feO%!48Kqis;@0H#c;MZ#rr(^Fb9B(Lm<61VG}P9S zW6LkT0CbQpIHx_S>W03oI<#&3T=IqI&pwkKotCx8Bt3VE!^V{?yrc4ty`PzMQI9)# zlw$Yvpf?CdZ?1u(%m1V1nRJz=L#t@D{o_?R>$%9%&kLf=xyjNM?tpzc32%qFt4sQb z9#;c&ocxU*Ik3VbL{nNx( z^^~F$=%X- z%(+^e%A6M!4oW|l0_XJ9INw5h&((cVS2?!r+kNxNH&16rM<1OGJ(r_zVCB)syEx1N z8$~M8&%f`v?K_U{nm+Vv8b|-Qwz7j9{m-CB6I~?+=w_`LI~r2SGEMsrPsEeV2;}{`P!}goCe(OE}!so{dmR>Av5Rh zijGf)T8`|T*glzvQHoBEPB}VRy4>?Qx5LcF73y)+Ojn*ROYaxo^Zf!_`mcx6fKJ6* ztD}>9B)mjA@^nqBSk?B=Owb@dx6a9wcKL{!8l@(SB;^1dz{BY?!LHwB&fl}6kEh!1 z6_k_AoChT5(Q$8ak=?tsmi?R=S0^)eDAGG**zR_>)eg7s-?#AXHXo(v3_3e{sv37( z&eAuojw6ZSU3ofof2PX&w`}RZXrN<*nT9%(qpN-``G?oy>8l=~Dg9?qPw(fY{oNYp zO+dXS9qzbD>AwqVW25_8D#bYM=}(#CtvRa6c& ze=>G(;@f>OO3|t4j)58B=(!wyBa4@|5a}43F-R8$xf`FDIJj@@(BUJ<(M_P&66kIe zlWfXDaKsaem? z0XpjKs}>pQy)FI zB7LKa9bKK!gmr9!^mEU@=edRN7N>tZ@}mXxh7{;kaC8IoSls)eB7Lo>fAIGQ@M%`E zh}~qEbNLr_^~Ez_F66l%uV?@ilf0oo81}P!sAWw zbBN!niX7QFhi9$s+S=|ti#sO1ooJ>M{q(a_p}TPO+!+oVTnOF;>PXXhAseUZmpnkh zJ6ZaVM(94Nh3)`-Yc~vJgh7vm9QrKmn#*#QzWU!9wsZg7?kCPA=@!A-fjm&pn7!l4 z)Z?`&Bkcy(&A8Qi@TL(wOVqA&j=q{P;hyMoQWGUMd`9r&@wesV@ zXVc#;Z2x9QIPuy`MQ^pB~w_ZDRYki}Q&W?;=Mh z(9uU1py#%M-O$Q2D(@msSM7A#gyLQb-gg~3yo5dDuw|S8UARC!KsOwnEIkn{$zRUX zR}sMfO{j}wUoD)MIL2G*Q`@>+^6k z^SuQWwvSgd)Je{z=oO?Z(S##cRm{lhmT(7nxJqPD}o??>2_Qr4>&nh$Ky0jzJ1?E-t&6O=J zd!~}B(9VjrN2<&=ub+N<2R`52|>)LlDGjJhLq&pS3}=o?;yd8)}~YzvEm+%J2tma7rx zS%nfExL*DX|FD{^A1_j4|JfYbD)xjhW}XQ?w5(OzqZqnkmG*t3AXMpuWeuVdsc zId>7~7SOW;FO%i0sk?TMCyDQtAF;y)LT;|DU@@w2D8tQ+cRB4gPq>4lb`Z@1>@aq| zVaz!@-%1}3-BsO*^2xd1b{*ZhgF)XlAA9RAqZY^WGP%fo(2P!gogJZt?ZgavB9RO^0Q!2N4#C#*|5~{nSH*ebAaf4XR|`Ar zo$jD)R&s34a;LJ&Ar{DMR0;TC>>0FI7X5G1xqqo-p z`nWR-=xcU$)ZjnJUxy>mv7ZbrKX0iwgT89qC4zvvJR9r4TN}Mmjkyfc%)H86gj0*v zqCI5}yVGCazYg;pX^`yWo%J`zOaQ-bVTmNIY7@D`leAH z+UZ6=ohowd>9oG(f_op{vG~gpS^8d_)S?!(z|l3(3jw;$`CmsJHFy-^|N5j1IIl`M zy6{WMI%vo1N@an4WsP`1uiRdTT%Uu3nd{AS--zE+YCed+u*evpRF1=$HIaGSH+FcAEgeTP8LQV2y3Z)kwb3|;VO}2fRYdUUt*>r} zISjaKaXW1%qx@VWU9)y*wdCUp;a86$!MV!L33IN&HO89dxf(P{sTvTNrC>=fkf8*Lku~RbGSJN?J7GT40)>w^U;s$8W6POTvou27m~yrQ9ErX7o8c5gaQ@AU(YQa z7NCDepl@?0UU(HXcXZKno4Ri@)Hn`~e0JM`o1*KFwky;W^IVVZR( z(7lYgl|V*$JxOOI92=t$#w2PD| z#c5si+@|iEN<_LUy^BQMSe|~)6%Ty%_Msp5?4G39oj|8*oDALd$vENYefd_>f%rPQ zIvQ@-eLdg052VgHK+nKAE-?7DOEu(l;I-HEl*7yw<`{X6Mm$c!e6*EH?Nn(8=IPW* zZVo%=sGa_tVk3!Q$k+P$-u<%%=rkEO7EkgLRbwoUIL{T`H=~553z7b%jNQd)s`^$^ z2;GApq_4&i!|U$q66CbLo^Q>~0lMHk0M3<|aPeUAHA^fnFRM-2Ego;l*vrk7tA3hR zYUlA7hpoCd&?Z_SvZ9WY9q>)igmZ0Ve&tFza%5!iTdNm@sT)*E&B zI5$WYkWW$K!Nf`am*VwdIq~e)xLN?17~tza5(1wMd{JO+;R~iwym= z)U-@)cbQEpF$~KxZ7#)2*;1r0{yz8hXTzV<|6g2oPY-%eY1YxW>{=!1h~bL_>-pAo zYI=qa&NGP-1N0fpJ+svV(bZ6HQN2!iyE5~w!psL;6tjC{<#qIV3(7M}>`BAPxs2Kq z@$pbR;r9j;;g*WpVFCKw(vQC$njT}&r+Y)MpN9-xO~vJQfZdGZ(Fo<~CnJ34Y1j*C z?0wtVk=fludP613?pVHN4&8bA8q3;W`}=G?-|+P!h8>t^F!a)?Ks#}sRrT=qEVvsT zIH^?Pj_7{1swe4Q&PrkK8}0G1nfLhIbvzy8hHGaMV^04}v3_MH%9w}Z<5QlVM7X`R z^7G-zAHLo{izI#V=)oO(23~kxtwiSxos?U-wkdTSPHM7{PSdbV`mV!7`p~C!T#e%- zU4afqPx|d?K?mo5UjWbIxSUuoqKw^&I(`n$L3(!JQKtj(q%1ZMN5;U-D#Lm}4$4tx z2j+p1P+wWG&Dj^Nsbn*UmuEW64I9QX<}zwej)x+lc#p^Hj}3O#v^9M4#bo|8rT;Uq z^9(y+w_*-IC(!vRzgB{J>J@Q)cERG!7Xflc!c~o3Gjm1x_<)PLX|7Obn^>O)GuL*9 z*6tCsSG7?Y*;Vr_?IBL)-NBxaJ9T}e@%8Ok+;nuo3Vdik{OakWR!p%83A1BQHplLA$45Em2FTOxCW(Vq+0B7DyLpe_-&N@84aG)$?d8n` z`sYJmO#Z;JJ16M``ggngZsHcY5xD2Jf8FfLBf-*%badI^={G+0)#4kodxt-+bT@M~ zcdK=s>IWcQ7kARvKzd>SK%dKj6)d`eYeEH1&&N54x7+OuJN;5zZ}+tm=o;lLAhrhH zk-KN>%V|_I>3FNh>spbWF%N|2BYkDU%Lkh)#YCbT*|)$MPvD&b--J2OyAtO99>RQ} zps=fFJT?;UZYgiA=xF$K2(kNtC6S~rc0T*^nPLYi48O`T_q^QxubWr|y6Fg)Q_)YC zq(AW0*pGWADNT1bacHP-F_WZqssKIBy!%>6FDdBn@%MTQi}Ed`OTew;>;RozomrQ# z7nSflTxYwG33Qk`Kjn3`0;yW|4*WACZ*g)+v+{V*Q|NFwJ&|y8B_eh>xn=l60DloAzJzxXyWQQ^?J(yyM{M z=87D4qM&Kmrx)MYGx<}(c^I!x##+T4t-GU2}UCRDVL3mU7LoH_o=Ie zb17n-jGbV&75hhgTRXvdMltKF3+U^uAwA4~ncf`!HZZQNoJH;On4hfN;V77jbyieX zf^tSYi(uw5HwWgy-d>N>=?VJB6QeWXKxa#Rle>*$ccbopn5uDe19zl2KaoJsEnEA4 zD34^24pZmJne%up+ta&#-1FHdwQV1jBS|-x-95xPl&&7PCR;Bm>+gw#LcQMVQifbX zZQTnF0_5}%DiN^ToUVRqE(JP?_AI1B62(;q^g8JZbr?T{d_lKzhCI^WSY6CkK3KDb z19niJVwrJH%Q(zjOuMFn_FypB>vcNH`hxyYa&%F=$b_4hv{Tla^?$0Ji^ zc1z)_6SB<1Ms*Xdzwa zyr|a~@ImzJCH!3l#YB54sfew;z%vj@jQG0A+zB+no0WG&@VHZux2(bCHK~joPR>R? zI^NsmqHw+1>yNg(DPHfW5RhZT8C@`Talv93#O#du2xH#UJ3#6$v)jtNQ~X~pF;Ad} z33O%YpG}I`os#sWXI~YQui!jaasR(4Pww`95u+})fd=VfNbcf$9zJ+z?uVf|cTJVB zbXARu_Y>&KXaDMeoL-nKZWOF1X)Y(|sV+k24AT1L0~DXLW%E;j+{q#FRHKt>@GSQB z;%Zk{@A&9ofN^Ku73~=vo*~7B9;*}oJpf!%Ze-;1%y>1;uorYiMylMEh}QvmMk)Tw zVdfmO6Xr?6JUGzbKh^KD7gc-unDg;uY?MK#*xh95q8!Ja^u0GdA`e8DfqO1P|Gy|t zOwQz8B-W>^=p7C9^Y49P$D!FDhAQ1PEh*AbjT>FZ8EAAC zeVB%GuCEKV3)}%am#D(g@u0WRp=h@`3mdzFp~MJ+c@n{%o_M%Bu5fwrl~rirAyj7x z#KX}4fOgW^zj&@ zjb&yic88^J5thDJ#O{X=zjHPFIf1Sc^glWJl~-PQ>#et5e);9sTyxE(q^mXYcjyO~ zUrv9`l~*2<)5cMr(6Iph1oCt=)Xxy3GmkDz&+Y!CgM)VkI$3&esRTWR`nrJ*ymxEB zr-b02SPbOgLB1^CNsI9W$^Pw*%sg+Ji`hGI>Qw+=r^oyQ(hN!-V zc)skiYj3#Ywuf%I>ALH#y6ToY@4TLLJLzUk{2ltiop;`H%T?E1chf_+-EqUUmtB47 zHJ9I-qy9fH2K}PE<1Z4VlT4m|IyKap^x;Y>y$jM)v3pO^haYNb`g)Ac=*Iqmy^L6o z2-ar?$;6U9T?Mw%4}?mJoPC3xQVMb_@0~I$E)&7L;=O?UUG$rM1bin7cCdJ>khv?| zm3Nx~H;U?FwP-TZ+wUR79X5xvtjizsRkdy*A7{oHaj4uRL9|A=WE_dPFmtZ6&nH6> zGV{JJQd!Z5Mb$1(m*6}?pvM!j`Dk=T1n=z}ymOZRg;=Y)^y9l%pTdUz$Rm$mLufPE zH(httEd(|3eE?xx#rxc2HxbHx9b_2=6A z$*LsB#rIuH`@g9BsJy+~6ubNFC3^6_4zsY<5Wd7w)`iS7Izq4$Kk*)FJ(u-y|7#Tn|2e!InK^aM+047V-Y#!J(TAnZvPMr=|5Q+bZdm$^s>hL~ z4}ZzA`+?aPU%l*F3HOui)vhw?SQG;8lpOrzD_$Mq8!YiwzNZ^qOnt%qH{X8eRRsKH zmtLO3{XZ$Pbd7WozN5`{@!b#a`*Hu54eoj-eP%|+?hzY-4p0A^K<9sgVBOmv?1>QT zoO_1DKKdm%^4<#05xr*+JX(0M2cmDlP%( zI_kRCfppS`wgOKd7^L)*AY=-Gx}j8RoQ8FMC7USDheu-L{$N*Qfzv^gvyh#bC+Lfv z)dgk5kGHE21o)Aaam#eG{ zl&9q!jtznp>AGO+>Yg7go%fq>zvZSIF1sd2`+rKbmhGZr&7_}fkj^DJD!%Xi+)dFt zJ6M!j>Eq5)I!9$$Pe-2qmuVNQ+o@_kz(G8W-Df!NRz-Ry)**9Uodk~$Q=cm27kk|0R4A#zH+OjrQy%bX~Yh8>~q({`WDG}yS*HuPxks2Et z2{UisR1mGK=!}nx+fA(>@qwl|-%#*v0o>5CJIvObAy0WZ67wKY-biv$%${Fjt0p@~ z+|fTJEZyws(NvF(EWM+aV)w821}SD&oLddufp9##to$RJSL7RcytDBB>#w>a2m60T z$I-cL7jc_SqOKD4)5Iw*l&CN59`2~`4EO};i7u*P%i#S(czRma*FoDk+JL>29)e&#N9AS$f^a!;`x^1?PpDo$DrU%P0qZ zrNbN!4im?%Ln&qLg*V@M-3^!KSpS2)^L~n}isE?07B^87Ta0Z+ja`3G$2vOJ(a~|7 z(b3VdkG;%TN7&-Bz$R{3OT-!_Ah-w%0ZmjO0ZTMe)R@>ciM_`DFL=(q-}lbD-hI9V zS$DzrWtV+GOjzWz-_!2BWuOSJW}_K7s>)$>hCAvUz3a^=cy?yZOHbWLqz_S1Xn8JH z2h3CG-psKngb(6Y#axw%4*v*{XqTZq%?k)n(vStMeN~HZ{Enb%oZP}2w z>*|||*cIjVqQs_@ge!6!OJ=JXbdWBb zkLQq_ovkBb&rlz{yF>!-0Ua06Eff zrW|>>N*`#B|DMXKd+MSohjRyXDjKdN)P-~@$Z_!g*b@)E_|hPk4H95W^QEdrcw^;`|UX;RH+qV&}TVqwk=S6$9b!~$mNS4Ry}wB zqwR|HCiRQrt-V7<0$qg%rm+Kd&e>J%Nh)-vG~aH+-KXu2hk~*fw2gJ28bjEc&VpP8 z?9@q9ls7i05?Yo*cEY?mno^ttbRk`$cf-@$TiMbddT~)AmDJ9S*JsJNHs*X}@l)2AE6y2oSh^y;+aR5OAWS+Ja(Wh)=Vn?L&Ykz{Xszqun=sg96K~TF*parQ+>Xb>F?=Qw zqF#DckW}&AI-8Bg zIlANNFm;fgXVTl?>3V7UqPh3Br{q<5lTX&MU^h1A6xiKl+zQ>JURww1yH5#KQbqW@ zCVTXqH+|o3#~pV*_Vl;jq>lQd?Q|?icd}Z-cf-?*R+6)A2F}iND{SqWb~KWKJJD`e z<%Rh`gO?J$znmp6h4Xp+CC_4YAg&o-It0kc$(?wei{q&%HO%q+m?T}jhNBbe;^{&< z`spBju#YT#(PK|EN;*EKridNLi?d4@A0;7Uhn<_WyY)Lp{eUCGvRsM9qcfIa6IdMPExt+FsYgE2W?x;pcq6V88g?ekAAXc-#JWnzLl zbMB8iytqiwJA`zWY3DI;y?PThXm)p#cL2}K+pm9M|J3+Y5VmS z02{R$(pi2*VoMj)#n#OsIgHlPUBY+jwH>nWlu#uTM&DKR?jFwO#P3{_rJ5eUKfz43+KgXtcwH@-e4lln=)wJogr}Io&6oaGx17(!~=RWnGeSNKT$YEplIyoBeuBJ??Mn@N{0s-_qY`QO^1&%BjHb{L6TG z!7B@z_o^hoFDX4MI~&uqhYxcO(gk%}z+Fr|y?0oXE16Jx=TyA{M=JyMsj|%FI5zbc z<}F&f?3HxP?6U+<_F9*JdB|M2@&58l^4 zG|(6o(rc}fToGLzxnuzE9EC=mHRftbXHGU_0r5Z{%maHxzKHN8gy~JBKP_s9fH(22 zPN3C*GvQQmW5j9K2ry2F>oX$%7LSNM0(B{Ai>I5_cNv~hr1O)cLt9$%g_aNdn>m7T zgyGtqEz)(Cu(HG5?P7F*x8ip|hpB^f7jJw*3UVRorB?(~a!NRH)!cf9hTqxLA6v9^ zMRU^Q>8yV!$&EYf;GFOOj61n|l_*VviJZ14zw$q8cU@zM&d7wr#xweO+}0{G9HE zr5odtaIG?d0euKQUXjxkt>)>P-UQFF2-rbxcy?@wHFSQtTl;mW3Mh4|( zQcwNmH7`8YKa|UQNDtUGvS_O0yC{fv7kDCGA&=aa(@tPd8X%35Rwyl3@09c>Mp?wQ zCR?%Hrs<}W6`FbD3dh1TLO502Y{Ug~Cw8_;2R!g}%{sz&)6~^dX?taGG`cBgYgF?8 zHHEsYU$Ib+;|GSH82!MQ^yLgXZK>PR8;?o8;a8J0H<>&1@Nkt&$;I=q5hh&*<)o)h zrhe?{Z~wXe!MW|DjTF9Pv2#)qfh!`_=U$8^>t*U8g*IER^;NlEg75RYXLr(c=mWTnIe5-l(|snVt#Dt zhBC|Dcag^0m~PLSP+hVJe$i2I0d$~_0N$Ien>9J0?wY!IZN6<_)Z{$y&2P(N z=E9w}^WI$h44HaUN{6O(0Nww<)uKxS?^C7=WL_7;q`w5LL|OGF8?Iri7Q{5zct@Ui zu&Usqzzbe#*t%rCF{{z$?X&^(j$9Y~Tz&-jq4+zxpk81Bx|n(fsXB_9Tn*qfok?Fv zThz^47Ng7vpv#~fE{x4$bySi#bidi^_pBm3*|ju+t4HAJvta5HzoXMeMY+X~_Ybwj zqYmmd=(7pw{{`!nc8L!5K30jvfk0EtrL{(PQ{|edW2EBiP=TVH*VT=Y*anSXzIQY( zKkbc5nB)m-;YmyU&IT>xGpKEJc7t>ky!StPA00Y1Sj#I92y!i}*bdBDIzrg{jLozv zDneT?oz&@Uyq}!0BL!Bva8XXh@6SEbKiHiSQzv^W`j}jBkKK*p@m78UQv$lC3ld+i zGe{>DXsV~-v)vuN1K`~KNd&3zzB2f3ysn;10nfBFE@%}IkxrJrXu%^-JaBJw6%QhN z_LlVJJj;q7x}PcoG3eoW?IjaPkI3{4igHe>t|#TbS%2^RC0z|ExANrgbeg{ZPL<}+ zEI*bF^HnzJV(P-WwQCy|ikX9Q0KJnqudh^+rynTeo09L#h~Q&#$c3cpdH}OPOuxvO zw&5NeqHQkjn|JTCEBL>yrLf(0bfBI=g+YT}zxNq@=A$~tnTtY^ko8h1g5981rp~0Z zw$=gsR=U-u>+i2WzHlfP$Dmxm`3?xKi!Z@kJ}w*9jq_pMinrsV=(W+!L|w~R=mOHU z$M!JiMu($vFmSO7Pj@In8b#dH2zAFh7@M_trG$+uKgsJn{8q10F zx%2K_R9#i6qIbQgv|WkwV6Og>rX#ncUnv*%Fn%wYkf_srb2-n2SXbqDPStrw=o3$R zXZY#)OS@E4J&1G-+Q z%I|#0gCbp8ZO%~a_S1LfMQy9@?_ZWrY|Kb($w-$g+$*JYhr*qpZuvceeu)XpPvWnX z4qpK1mJ0ehVrN0!^z;mvBXjL(%camte*D`#x*;t+!+;(l=L}hXXVvQlvufD5oanl=$c-ROPVLg;D?W_4`{t=*}8N zIiKRY5K(jm^pKqdRg!o?9;JH|U_1!s_`3Y}kbf^9MK3>N2kYp#S);MC;sh-(#X05Y z`791$;f1f`N4lUcMLHrmnaLtk=jjLK}H#<(y)1X z2jSA})`^T-A3zsV_hG$?-wF!(I#37elBq-H9x!T`jgGFKEM|DHkHvVRZoCkoH&Cbd z>I3O{S~Azy-V#@1^A6}KZ)?rqoFP}BcOE`<8$z9=QQwRd2JfX2K&Se91(Ocgjs0iT zwiKrxbM_r0&vL6R2~+oyUOpwcjdJ@+i+?!3sB#R;<>gbtHlP=j#MlA4<>~@DfkT{k z=0-a*DFI#YW>J*j-5ipQW#aX2Np5-Hl6rofV$ivf2F|r4Y=@bX_&dMyl&!V`>yE33 zqcbIys=sR)uC6wqVbo_#-|LvOzZiaS;j&yhis^TZ$@z`Wln?GuFvgVuO*{nflH~ge z;k(VcfDWo4gLLU?6D@5kI<%i_(24X~kUn04J0#x{NawYH@dhTHqxZfB^#^#X&Mbp& zX6S->TC1ME^|P)bpJ3E={LayPSb8t1B1qI(=2la(tsJd&A=Dpww67x(6VkomdpT6) z#+QHiz5O|Hb%P!YBA`?lb*af2o-U_45a->!qs@esK|1ejF*elgQ(DZ(9l^W2>`<5< zZ*112H`g%dbun6Q&g%iB-fnYVPok6W+pe894X7*98Fe0jpAxEMim+y3O`B&~8|Ugs z*0*QW-&u2S|BB8`3~RVZzGpT~JkmFTe-!7&+Hn)CFffOaSWiQnVSWa(j= zUUIRgGwvwLNn4#=9jH@Zolw7K&AqL(s*O$Ee}^fU`h+z0zyM#Uk*lCM-mc48ZFL46 zyQphFZ)naBdFln~G95q{&RNE~TD3H$ zCwJY9?Y9Hz?CEUjA?RfwYOBw}_K`{&bt%kkt5JVv^}H4ups|ngq?@71q+_*_S_5n5373DwuS(^;vWmQ}^y6$rbo(*3MhnTMyL5)C*|RfF7`F zBuE9CZB^bbo5^{7Q+{-17Rk9qx<;Lt1L|Y0$C;qJfrTu!0qIP0JceaC)%6DGNYKqq z?wUJ$cty;$yAbI})VJBj3Esm2xiXM4bpkzNRN$Q>SXnefGkv#XF1%*VL+t8Ye*arb zeKJ(wH(nsk8J6)CF!z!!gbC*yw)3z_Z*P-ZYB%C`ykpXX>+kZVz=IW~3}<34O?^72 zS?xyLw;wfgm+8}IY!6Fk z(5cWH*5k@W5v5tgI$#Iu=&aL1R?;rVU3A%Z&oAl)>XxZbtRb3zEjK*(m2Z*`yb!jN zowvcxZ8?5UZ<4C}Pvpq8sK)&?vIj^yeIQ*h2k6!MX(ZwTx)0|RxOaWNVZ%>%UbVwa z_H?oIEjJG>y_^&Q>k;N$?<~iov6z@cbA6YCE;{Gt7yG*!=;R(j{hyY3PR4xQUGrj+ z9?ZIlIXLI+{1uvoBfQL@Jt|kYiK%mAOtJt-uZ`osT|U8Y{NSp_6)-pUm@;>vPQ>`R z-CdJTn1+sx;nQ+5Bn{KGJ^RUqU*27M>CQXMoWA3ZGq&B9EuGW!Fm^8|QTPtd=^aU% zI(q9)5KsNK8GD_1&QGfzS>Bw|sEeon&+2lOfuKV+=)yVMInBbgHL0eWm7WvXAlzZy zK^;PVP4_^llA9xkZyCpxQCx4QJf)snj1Aom-1Tm?2;9lgzy55)Z=Wr?b*G)#(m8ma zM%{D-?;+`BC8B*ck*-nK+t2`Yjl0U%r|o(g*X4#fveibbO{rWLyA(&kpjZ8U`(^mK z*m-llw>fKt?a0f;*MYlIOz@5I+s4TzU!+Nwp1#vS%hagI9h^(#ZXC1Tq}RdBd;hBq zzin81%dTwc(~Wu@{ThX!mzg3AyJDSa=f!P6eREXe$=auGHS@TO?)r1_d`i}7UMo0& zpFEv)%hOTt+9=W`Zb#Uzig3LhaTvM9xtKZ&*KlZ0IKF zFmy@K^=@;oeEjWK8-Dri>)TJ=X@{ALbPC=XbRD~g1MlT0H(BS+<(PFX#X5^)c#7aJ zykh-*1C7bLzZKyB7d3ckq-&0SF*i7O%I&(VmQh8xTqBZmOHp_@-}ym62e?{Ec}(I@ z%V&J$=kr3kF1IDCDr>SvhVEzR&%W{VX9W8DcV4;^S-J||d4Y?L-NPik92Ma=Hg%l1 z3}ctUxr15Ljymn4bA}&S(vhgHwE}ocldt^l_P6xH-QfIOnpv>(hUQ#XXBM2>{WQnb zHNzyK7yJv*=0|cFZ1Ohm^jX<5k{WEucvk$}G;~FJcULDH`j4Od{MlE({Px+yw-V?S zyc6h7>>f_Mm!~+!nvLQdXlDWTIdir+SZV92GfqD9oSR=-$hI!a+6r?%xqQ9Sds395 z*ynlMoMY8@hHLFzJ#kFP6l$j7(s6W2(&6>`nlDY+kO#}4JqFYh1arK)HtQ3(WAe3Y z{n|Vk`ZKQ*=#-@YGIGXk7aF>IT++us0lk<9p+;;OQuA~?Tbm8Oju`mtj z|D81K$o;ue7_Uz!GYH(p(Bodvmk)pQ3~e2|{Ok8M=)b*v-EN58Vd+y9=rm+KB~&>o zLN};_S>H^h?F9QAE!5$s?szh1>vKn&l6AomJcB-|YICq{^}PN4+{n&V)GnAC5--H3)1(vVigYT+ zh0}56t%y^H*P6v|tU0qZ?3=4;nyro^TVMA=doIrXwm{Yi@yXI(7or*Ups*c9IKsR; zm#^2Ib6Xl`Aa8^2S8f|O@P&0EJ)KT?ww5t_=*;;CBR~DVfHL&<&);xB|LU3#vjaetn($zT%;kP^Wv`em7eg6lY)LRGY0aNgk#Mzw| zn;tOrX{*7`b*(+#(3#6MGCp3xtCM&zKhHM^A-nl;5u`mms~T$nU8Y~1J!Kk~4gdc8 zddkqdzIpZi_dfadv#+Qc_sj6D3iO!_y6Uj;bX-`Dn<$E?`)o(EAhocSeSPY5uFQQj zpSGf@s|{T6GAS0gc_v|%X`7xdb`H$B2FHuM^LfJDs=i6DTqXF5YO3_8`_!)n~b(QIK|**d>R zIi6rg2!HcS{k)V_Y~3$_pV%v29LN#Cvu%TPn{%RE*}1|zx3b=vjWJ-C=9$?S7uLsz z=3sHr5P^qTbH|>qkB)r#{km4#27c({cVB(u{ZGDCvHP!YP?9cncQR$)@2eWnH@Vbbh+((!rdhF;^wH#+4mS)HgRHc0724 zbhE-HH^_e|X1tv~1JqYaKoiKcHf0JppcoF zaO=2upa}lQc{6@oEX-{aBx1z7ERiZi23|bVmyFT8(+4 zzPYC}ok>|$b`;v>LWbJ?Rs7@}aeJol;VgWnV%Or~;o*@reRPa_@AsbrbX4Phef5^z zb~R>Z^o~+tHEv>yF?aTz<2RCKeLH4--HS_eX~gh?y5;HeXx4E^H=4f8^u0HRx}+ zpl`VCQcr_z8anBlZ?Rcu>61nZc8xlUb5nOY`Ql5iUbUdBG3AcP`jJjDT(fK^=nC!3 zsOMbihP_p_nKySg#bM@PTb7y&->`Cl8=tiS9H*!~W?aYc=sYA-XrG&)gY@RdR*&9ujJ+jk#+u0fY-+;3k{lI~jiG*9dvR^ujxDhPBkcJ1tR!`{(bZZqT5i_g7! z_<_EjgihEI!v}QO`J(I&eyOzm9Kxupi+M6z-_+fhW?T#WL2pA8342g~z06y0Hs&B*n|UVP&K3v9yX7S@Y?cUpU+Cq~KXLrYG zcBbmwwXZ$4W+YF>ZlWH{JCgVRSO(uWH|GPo0GEvxOC==cG7{s`E}^ppRH zP7%Kayj?r{w#wMAzh{~<_65NX&i`vsyurB9PUAC&OT|Jy=@~dld>g=fpac5O3p`t~ zx}$C);#p&k9p33C8h-erXP$oS>8GA~wiqSQOXa7&CeX>!0lJ%{U#F^ZvcWrn9&RYr z&0^d&?5M_b6u-5GeObK_Pd63k{x2I*%@wn&Dtk7XDHbwhebPho_!i^FOCs(f?&&3$@VtqY8H>+`*-nq$3(s%O4-NSV) z-7eJU)CLwv*>}{i-`2=QQVa3*|CXN%=uMR8a%|-+tCVhXjf>8Xx;f?xVH#O{1zOIUXAZt0XA z0_d{*seE7~sZ<&l`73jsGFP{7Q*g{8n3Q*dLszWfK^v`OlJ$zqws~V=K>OvRU8aa@qLJ-Y&x8yyHbTxdPhu z$c*Er=aNQX%AFqQI=Axa$8K5v!171#exQ<1YS0T~-!teReq~kTe!K8&6}!ut^lkMy z4qJqv_l$^gO54FYX{&9IKl=uv{h1Yw0<1j<_2?KN$&9XfFIfEwLa)Mvlpnj3Gv;}sgJ@twZ@U+{Ek>ydKL$^JtdiW-cWM z+jzixop5ivmM$MS(m@MK-(lynT2>2zd~W>lN0;Ar>s|NUzWkLOeeMJOEd{ze1nVwf z+w{XtDoMu)%Kmd4!cKbc$(^Lr{dS^#M@{>kV?)Ix&(L`*Vg3W*yC3oahMd=Il9L+~ zt1`w8N0&Dte7U3HI&Qc&UqOyF42NR7fbJC)lS8*Z{@}9v9=dPYy|+#0c`G?Dbl>>g zTVMb59Y8md^qahq8MbtQ9$I=YYm4o40$S{;YLn;A=H$WL+mOT4<%N=5a?QC;ZhPq2XII>M+q1(F z#}E1|4Eo0==t$D9I(v@TeamoUCKThY4F0@>_CKavnOOD|Es zgR53O`_NruMRkmefWGDr23@LgvVd*VrDyrpwaA+E(9uIF?RnmzM;;N4sg22srTX~t zXTE&<%cmcCcCuQaQ92*$9j2WDhotPL5og&O!AN}_ukqCJ61yFh8)Vak&A(hbhMh}C zki~y~Y4FbF_pKTqUpctqzL|Ww3G}t=UzTc|IWqJ94V0u~WM(JqG81k-5lZW_0}1n3 zUW;gF-V4RbrWC7!EyJ+f?YY|WZi$k4}X z32aUe(8Ut z#YDzC z`kDTb8SUs{=-%tnm?vEk51xekIZ~V)i?oN?GPC@(tBH6f;)xb7R4F!ffQjLVQ@{( zfw`rnX(z4(7GZ6+fPuu`)<}ySerBN0vI}zf-r}-zr}dhf*fMT_+M1McSjKA!00eW9 zdhL+PEAPK`d}?NLa%Sz+npdWy)ZPT?X4$>?$i}5H+}d$D!I8W;x_m0A>l@y{-Eo1B zHCc1C(hT6Iw}Ctn`9}{rN8#T#=vUarpu`sXE)NrZR2`WOAaE; zlQ!o4F6IQfkRIgbeFMPU7T&kFVFk(YCc5Q@FQ^Bxc|qT>_O@{Ew7NGl0&(+Oc+J9- z1{{_z$+;!2dFH3@TDEfP_33(jdU9snR6R4`o;u@NI;wF|s>Tie!AUxSZZBZlbSBTn zVc$3gJ={&Ihjr9J`yVkU=IID0=`7)#F`t|Be)Pd?StgGMp`0ZP$i?2w3kl;*2gGOX z^K1JvkZS?$Sp9SNT8ixk<@)y5m?taGE?YGxl61Ek zCkxoFRI_pVnDiaJg{ooMy$5yFJ_pZ@`G7;xNpDV_sxadXMi<(0f;VNUnI#5_K_a@pX-WPNC8s8OGMeP$>+uoQx&BS}vJ z^l6@$ktE&N^ZDm7g}X`hp7uN7@MH2e<|)ElN9_PT;lq3m)ccn5S~5n@ zluU98!p$kUXX-+@nTN|qc<8hMvMm9`lnH=U#FZR5gqsy(K?&u}r1y91^Qb0p_1jL2ASZgUPt z7qWdgo9qkAag$u|Ey=jZ1i#&5G0wbkyvEq*r5TnE6O6gAFcS;m_H2j-Ck zpT(GpAZiz-`z1BE+voi$J?0YeV=XTPaeV`{K;D{Fm-pcHfqb_CdprlwZR02xG8SV@ z!#F~_T*+h{Ft`3{jCpS6p}~piq3ZDPaJAB?*Q$8)$3qkK0ajBi@RyVyVnIz{Clk*hJJdpFWvKYm#NGfhDU~BO&%Q9Wr{Nu4x zQ8{?FO(1prZ!2G!aj#Fw&F6?16!M3jgIv zxy@>);lc>naWl><_TIx~o7`LAn%}&^sIXavS167{mkfpvWDM?G`A+b?_omDWN$g|NiXTuzK@t^$f3P zH@E*UqkraTebp+$d~~EX!k|~Gyh%E>-vNu$CUul0%P+ z;Y40M!Or^=s_gCxG5frrBSVivz}Gs39BSIxEnrOwf6`Go0kiA^CS-ZF;~BacmpfJm z9ZybRL(L&6%vY|fS7&SGa&2UmGW2p5m<#HCfuQHcH~2s&(hbmgU`DS~#a>e32yCxt zzkT*UB@4iPm@7Nas!Sb956r*RypK6gqY`KJ$<XQb|0ev)M|3Ti8_7Q0eC5iujp30pi~M}FoCoH9(jgP{WIi9uu&1cLn{abTRsxy%ynZf~II(TvSuEZdUbRdd z-}nb5T#HMKH%o>tt_{q=t~n2b&m=0qd)5@3MEY>^#>$m5L&LQaNf~-MKhS5(=%g#q z(O-*2bB!+~Nq<3NcLn+h?uO4AbQI&l4WGMSh}t2)o%cJ$KLe_>busgK6Lk2wmRuOI zJm7CCxk(iN&kD5JzAu;y%=)I}LG02FiIX>%;bdP4d;>xWBvT(BdwpoMTq+ier4n&o z1m>2E1{?Zoh~0-@HL7u+x=A{Ne&Qa+Hn8FDkUgRUbgdmRJ9ZyTr>NMTO1j67QerNY z&(Aq0=m^oxLYv)V6aGQAk)(G{c8|>F9?#&{WZ#}>l?Yy5OVsaPc@;QG>VR)SGX(lcP3zH-qKA&&)mn1wA$d@4+Bt>o zSv@)vNenD}6SzJeiA!rghg(adTxxLAFT=a?%5120>A5vx9gKY7q?FYNE$I$|Y!rnLEYT$EF&?9ZhXSbvGp6R z1#F*uLXN)aV%=SneJ5y{YIvM#ck95TRqI@&C*imXH^9541M}87cVv?oe^p(K-OsYv zwu~Sp@m6aIiV>@KSisGcMB=~>H+eD-T@a#$aq~iQb3A(+I1__1;lqjHFvTkpcO6!ZE#EhFQVCx*O|IJ3SF&8@(V1s!i~lf_BGB!{yCj z+6^Tcz-Tt2R|d(KxCD}jRVOBP_W1slP9pY-e$hTL-7e71#y; z);e=IdO&aGE?cscqOVd&nKT5_l^w%cL58lq0o!{RxIO-qFxfaBVT z-#gMVckD|<)8f^^=Pm$!3DsTx?TkDac-dlt$;)MQt1OdGItdmIRv~{c)Tdsr)QY)i zo6O=9(Am-f`VXJ|Of`2|ql(R*!^LY|tGy3DA{)fH zedc-Vel8^AL6GijgWN{fu`dO4iO)f}`w~KiZt-heS;$Lup1C6{FO7_pX<&V%5Y5Kn z=zQ>bB$2F5PEA+Kg=kD2d6mybVCIkj-%5XtL7#n3s&NAPx4+7Y7CDe9ti^Sy_S@&k zn2C8r@9e4$T4|AUd+=4}WBg`r1OE35aZU@!OB48Z9#&tyHU{!=vS5CKt;@DTR(vs-7EKJIQ9A&nES}LhwQYg*nqcZ!yjr zf-OF=0o6Dk=%4+}O>|zlrfTleTq8%{EL4|i?}JZC&gJJR&AHmtpk+IEId>L)C{hPp z-99Tx+*=ZNylF9Q2Jnu>{yd-)ZiIP>G(vV>9VSyBsg-KwTCtc(I`MLSh6-?G<~Z0r z9sF~n_Ri0L4Z2R8&|iCWgCyy1yuqMTQtf&R3n!HMeY}zEbCc#7b&i} zZxznJcA=J>VOQIgpM!I#13*>^XIq)X$=I#`_ zYeOf_chx=g9hh@nbKg7!z01^;o@1rC=GC-?Jj&5bYw7n6 z8kj-oF1txpUv_}rJ2+3dp!c`JIsQ{4&hcLu z5dY`o*l*aguF@>W$I?l&qIK}!x>P|T;posApraO7F6XjNvRG+U%Q-aw%LmY+%HAUA zaCDn{t&V~-oWhyS*TBdt+lv=B1$mE>WipzMI>w5b? z0=l_~)Xdk7#oqweo?mxxVKFZ6EvC$_Y#N+t&?y?J6xf+AX)+g*XrNmZC`?<`{5q z^C(-eeVenaB!hpkW_$<4TLpCs>xf*nWai4IT|*T|&*gG?8fPz6hB#!8B-Bq0$`>cA z=58I~AfOkYQ2n*HX!+V3pT6|TB`55K1YH7m7|bndRjD45U84pqyUe8frr zZOZMUJ5F#QrBax6mmIzL%s1=R{*WJj^#TR%=enFDa90b_8S^kj?;y^vG&k zF23jv&aD*P-3OLIe@cN)k^39(eDK-D`|53Fl%cx`x;rehSqOS}XvyL39upBOaA(j} z@ZPGO4-B}mVkczQ&>cIZH{4vK4vBfwm5>k7%eJ7dc6mfJl!Bppi%K{fj}%6$v&ATt z-}u)xhZEXTiL=gAO$+E0P!v`(=xpepzVz7@Co9e|Dg!@f&cms=E|TIr3C?j>oU1mE z*1|crYH*#A6Bx!DWjFb%MYS;wL-#hynb6XLG4r6Y3YASqiMrq+2jq81Rek8*GxSc)^cYngSg zZP>_${>Jm4TzS$SJo&0PMF1(_Fszde+zj9WJ;a33H6bowZgn;kB5xjk(L>}}1@?Q>eB=#Lcr$sgB$ z|J>K#ef7@!G!f_J=TeBkPXowe@KWe2HqQcZcMgQOMe1AQ%o?QKfV9rMyg-8lRR z*fy8o zPuL4{GCR>Oap2q=l?fB{KiNCmr>M#>jOSKXt7(QBLq${5Of^j(YNp5;t!c&@Yo-}{ z`PfV!`UCnDEUdw@Yte3*(R&e8LJ}z%nPo_7Y7nAkk(DH*0@-{2LihbV*E#ojc=oUh z?H0Rxu6Q}HW;5Z>>wbIgr$}_!H7Nw{7U=a_@bhdZWO9JHVa*vE>GEKd(q(xBfKGd% zX^v1=|9zEl19A~_&RvUmLvt6r+@L(q2|5Wyf8m@#|Lm)=Gkv4Odx-OL;T)5%%z5DF zMWst`AkIl9=rHt|9j@@!+H(@D+mwS_Q<8>j$>ch)$79Abo*dS(Jk_R&8Bt8%?r<@_fn0+z#ApoeX_% z|HP>w;k=B~bHwd9vZf6^xGA|PM1{Ci8qnztpo8-n*?F!;-7I65j9eI&XO?l|)UkSV z7zUqmQ+wm$o9xCF!ek_^CRQJ9X;l$B`e=DlwcoL|bcUX2-!gWp?`Pti`f6%Q#^dLD z@|8MkLF8UEy6mP@lLp;f2kG3$T^p~Nv7P7VY^g)FeXoLk1EufX0YuEL>xuI6NdiMXQD101*$=pq3fmR_AD=ea4u0am*fXYOwl zgExlO!8@LE3AY`vM>yYR3ENXRi!da$i2`&9-jSpOo>;b=|J#9AaO*z@2ac71y!$_R*2JTudHF?Y2$AI=*a>XLNjX}p5^iw>64qoaKg25P{k%Yk_#u#VCA|XNt8bxO>iSE|5ol_hqDXrvgSGQ-bVvx zh7RstQ&kQ-XU<84x!N2>Qk(~v7qS%OTuhx8o_ELfiKq*@xArce$FkylHg*nR!LNK_ z{{o>}R=QcQc}zX$KCTeaR-PVm*^k#4iPoj^S$xZUVRDU?pnDIqG$r?bJ}^FXaNp_` zOVK<>cFve%hdU-;1JVmxp)hA(l!9GChEAZ1r#G3F&T(oi>#GxIKi(>zJhV%T9FiC1 z4N|Y=(!FiSd4Ys<(G1D5+gNQX$^m;ekmtV)XL61u&#C0cKaQLkJ#pX4%4NVDoS#pW z>xDAGKwROA>Te+h`PrnaZ){ee3+X24ysNY^qn+2_W`>z3C2vHQuobiq7QYR5bC{o8A8RM&TFw_W zGYi-APdUnPHs||a?w=SMq_Ew!^ZAaSBW@4;yciT#aXV9f?m6^xz3c8|BEg_fK^>s0 zk4?4JGY;ELP<>3?o>=E+GoVj*VW}9qQI5;q6r6T)Y=TMyIfO6%Usan*lb(Ayuj|;* zf9lxb)f~1H=6azskI3j^J1%PAdAPvO3t1s9x1Y_N6X;Z6z2K?`lL;oBS!bmqy(v;N z9p|_=ac1`{q*=Xe=H&P_EIfG-mqQeRdYF)0ry1jwvoOQ z_pPd2q9G{)NojG3-rynl;kmuj= za9azJE~rOsQ)f$Wte(ctXMJ2nP?w}!{M|i}=5v*49w5g>k_5|K&dPnGc5CA-Nyc82 zz1v6nR28la$Ii_X9CNP9aDkr}xZ(yM6x`JF?BMDflA%2??~V76Y7 z2k-DYWM}30m`m0b;~cF6auu<=oD=0H)^m>W^hLO8;XLM@l5w*0O(T6g+OC$uxvg^} zJ15SAVtbKDA7tT*yAIqJmo2YYJ=)&AwGE~Y)Rm=Mq&Gd@SZ(>aW!$yC7o?eaxsa}2 z^6!Y_%Um)pyKUnn!JJ4bK+5gzx!AcH{fTyca{SamWapmKsORchI|g2Ze7cE?} zY}M&^+PgtIGIcw87u4feufUwAzb?hP&Dg##Nk}*2cm0HK<}x2=!N*<7#n6?Rn_w3X z|9fh)2L3=9&lbv3WdV}U6tIa5?4l`-}2Mwj(5Aa zc6AZyj;W)~CZ%_8{7$HIf=--^ZM)_zpMbpJjt8Kv-UV)ReMD|{Kw+1PFm7NjH8}F} z!m#r6pqrOC8kaOg8d9xaY#HO8+OmaZ9?oIsaPwesd$EZFcSP>oTDxfB@{5<=x_00C zj!s3ogzu848=ENTANn#9=ap!P+g(hp%S|1(w%r^50+_}(ZAJcVRUPTL@w?a8dfZ$= zKDWWnDrOhX8FLawUYnBdz1~kFGF9lQ$%Kq>4m&?9D8m(toF2Lc9g+LRORrn|>uB%0 zo!$GlrXf8F(s4MQNY?>;tvTNgp6vy#R=O^&Z~&Rp;u{dxQrAgYs)wb6a$&pxXwQVX z=3I*H(fx0KJ8|&#RXp*^=T~q_jVZ@+38&Z|bkB=QA&K0Tqn9!0m1}<5_hAQ-zSST- zX?Z$Pu0^Qx17-<5FbDyKDG4ARI29UZ9*IFBm8HSt4T}V3AES>Hy29 zAC7R;zLd_XErOeSi*QbA9!$Izmw270MDDJmSFHTy^l(qRNqW-E(;GDDu2fr{VHesB z>?QdIyk>9RWpjG8BiRy;iaHP%#%&cOk&J1^%gGE!?tN6j6P z3Cz3*6~dT1YH^E|qnB6x_QThGyNLAtM)aN_Y0zar4wtvkN8F+hXBP?9{06p#Jv@oI z|I0*NOdM^qX{1lA6d2@4DlEqBVmnbTdtRHOov$7`Lj$jsOJv~Hdl(h9_8@34Lg_9# z{dG>wHC>Aa zSy(r8CkgDXyRx*s%D6g!7rya1OCk7YixS}~=PXsoM(&%R*Z7=A#UNaw2b6A$SMsmf-D(@xh|Q zqdXdtSB+og`Rl{xdj^CnBVSjzMxF_Cv!xbyj;Ymkesbu*;ni;EoGB+er=L1w4m%Hu za78Un(LHT+fWEZ+y0yRkbo%SV2gf(R+ubdoPnnw$;J|u%+->(|83j1Exn>N^swTY& zH%rECvS++dt8Vu7D?X45=Efi#VZOiT^S=hyR8`VFOQ^x21ZR%BGUEa0#i~%q7=T@@ zigC+0NheGHZr}F@4?Nj{dYq6hL+=KCmTJAgD?{dYQ;f33bDGMd!r!a^Zf5vim2ZRpJWVv7ODr~6si|^tZrY7YI81j zZVkw6J2PmM+8xeS4UTK=!4jEL5I{%hzGzXI1NyCh{Pxoi-+jG%aCmg$nH@wrK=+-G zvq*;no0}ble%GQAhFeF~XDI@_a7W#y*L1nL9kp|*{j&{Y4(4)t#T&K<%c4p}Zs?92 z9ie;0b@x*2e){OX!-FSB4;+81)6-xxLHDi4fnR}J3T=7XAt5JI3hQ9X%n`Raj~STj zj@pjbCWi@g?x-!Mt5H#Ems4tdX-6>cS|TFQ(O=V{dzp6h@`}}e{Gu%V`x7Te`^L8I z+}|pGE}^?mk`AGyny#3`7#(Wv>SYpmE zu=YMGKMxL_mxMy@YDj0+{i20P(kt(!*!|bTBEA2}rp@gw;M^kILL7y- z90*l()492ZvWmP@%9KQ-z}g^oo_g z|H835S^D>b!^fEPf#W}JdaAc8k&-nnc@+S6h3%|*1M}S5tDXmC#yqCRT_rgu%wHHe zc+V|*u>_^&*zW2^?Pmw4S4vUg5N2Sh>W)J#PBpGdCFvBq@8;m0NgpH9x4i#y&yLn8 zgU%S*LWgVCV2gxqxiO_g0N0fJ?S1sG!BKAh+^NCUG#-Nr9D;V*;%r>X5cJbZO_ZR? zbxaGK8CW9SP127t=oGw<_9;(4v}OB-Ctlpy-kfqSG?}V;AJxi(;B0g4*{cq^iSLpE z9GGW_+co9p8MQ>mYk%Lrx{5K!nkcxrMD6pW2p5oELefiM&&H`1fUc(9D=OFMB%Lh% zdu8c;rzR%*k01Jc`#VoQvvKpTj@A?|A!H!wjYLn4uHu&J<()r40GnM~NudqR!RyWZ zmH&>KE6)|kSo=tQ^Y^wLAj~V4$gr!F;8@PtmB7$TQS(B|(J>n5P109+V)wz}pGSud zoS7J5(zk7T_KByT`lRO@WXT`Hq|cUt~>?Tx@*gU z`&Zstq1xtfbHwa`98vqcU_ho6rE_$Zq|aALx`2MG2AyMfD!eO8|8oqaKlj>88{c@l zXXoCIwrC_Ci&?bt8=iawr1@jOwkh?$A_M2%1u}GWrDuSK;8svSZIOA88g)*AImY0E z0h!ViK*vQb0yLeYpJ6U=kS^DI_R{-!p55Gr#RHVPW{;5wk_w3x&yQ90gp}x^U zy?O>JjE&0wt^B8B>?7yM%~M^Q`%hA#ol}tg92IcJML8-k&C1v;XJ*y{llCGv; zPixR&>EmSSMEVv*`qNLnvH7hJKHl|d?~cy41i_vb^?>D>Ds#{tb1~fAilvUi(G&6mrbc9t@8^wY2>Dk!v< zwiM}l^@NqA|3;we1{=rj$G98^OaJ_%58r(Dm6x=q@BH|iy#LeS3HIWTW~tAF?&O3lg4k(kRtRE0T{4$1@4OIzB}8FZXqb3o@QSV_`V zD_xi4C`&)|`IhbPZ+QKgm)5_?q<=x-yCS_qUHZ^kR~rjTY%>eivt9=W$_evm_xeeW z*_SvMoRgSw6x!YFJQOC-gT;@fuCT10pg%0^==w;ynu2wc^utKf5B#NKcdo~grBm>} z@x@Ozzty8i?3a8so_6=n#+92%I>+vPZtPB3`ljbzf8vGpPf?oQqw;i+-mVwNQo`O?<6<6U>a(b} z`(hqxYVA4n2-n%UXRgQDac%{adn<6^pwJ$O6*fTkj?AoBYb5DBGoxd7D#uabeZ!Nl zJoEgiXh9cOn=_lnW^$H};GIdwoo$WDu!Qf1^}h*$&d_V3z&T>}c-y?k@45wMuDl$S zI|g0%FW;}6Ij27;U@I{)vMgPWq|?X@I_dVKs?uPinHjFTQ|!K)V)xhPaXHSi^gaUJ zO4G3oJ{dHgK18j(fSU{FLV3s=PEgYvW`3wEF=w%t6X@mT%((J$1Gaa2BTzGd{dVdF#p75ID``8Yt3FgdsFwnM0O~6e)oFT{3 z7??RrMeODDD1#VsL%ET&m99WXgH1qpCh6~4!*O)71+n|uyiYUPsM5t z=tGp@+;Y_5;O4<(*AmXmk3ABYOTf-jc{zzFCod-%D>ur&AQGEk)f6nY%n<1~s_ID6 zG4F2G-4{{a9kKg*j@{8>vt7>UHJFchtAyCOU`}QB_SgTs>sIQS(`1b7l3)?y7UwJu z+RMv~j~nIJejr^s>9F*_Pr=G?9M|2|%nV}p#Vg&hJML`r&ormi#KjBFz@Z2y>tld< zIJI*715aOl=25t71yeC{{*)kR%**)2l%GI<7&|x0o1|kn&XuH}d7UKb&IX&Bcjwss zWvRPoov}MWPt=*vT}YP#+>otv!aTy5*AK3u`r8%f(`G1y?67izT%4SJ95Q}xR6u1V zwlnEOx|~VJQPp3aQ?MA0L*0G$q6MhC4^Z7*#^acDiQO&z4=g?3H;6u(#O%Y^Im|r1 zbne!tZ@KWK<8j8EredsxF`%4F>@1a;pJ1A4MkBa98LMC^cIPDhsN*mN%boOH038iB zU3cG!*!^XmnQ3*`-5GSm?*9`<_qdL!$luM40vD1aH&-G1;?JMD{5Iq&i7LtbI5efjYd{>w>8*~k||hsk}k3PTl017-ek?p*voOKyG!i;%!D6>wNJX) z24W;FAC7a_xi^tre$UfaUvWNrIr;cmXPw22PeQz|MqC+kc5*OYW}0b6BLH3M?r5;N zkE*(2_cc0pmzf!{bo(4^()a&J((x$<${`ov!{+97p+NI|saXqwn{}`yS9iy;bbv0?aZGxA!mpbF;q)54oc**= zG}Ex<+FLI?Y4U+K%1=G@tOHIZ%7Hl*--vWrd8vuljYcsAE8E@m6s&b&%WvFCFC4o| z-CZ44MJpY3cYq$7^*^w5&zLmR8L|gEnm&Bsma9*i%>8nUaeAG4zyb82q@4M-7%yea zOC-Tip1nZboENYXiW$8a3Zs_NLCSH}%ejtfk@ z^%E$;4Gna`b&s0zWcN-)>`Zy?T4O~w|4@|Ei!oQ68`h1A(n*&F+hl7vPNrbdVAHYt zSGKzQD+OcsHY;|YH6fGqLWCna&pq*TTgSYPC||K#*VvVptLxFAGDe&M2jq;made~N zt(lpGgDn`2a~@UQ!Ju24<0N+HJu}owUuccNilt-obB4!YLlf?5MRN3^dS)V&-s};3 zB;3)o{n}e@yL|G&RAT3NT_D%zQy}>G3C7BeiU&FlwxI4l8L_)_3YP9*L+t(qmcjCl z$X6Cg?C!Ai_BH~YEj>QrE@LZTGjwO-K4qJ?@ZUXIt_2iQq>zT9uPI)NM zx$GXM{BTubC(3c1%6?2aL$1PfQ+qQK6}=iKQ?Q50ygOodBd>%egaNYOY5!+AAu}EtGPCu2w|;W;?;=h~2Rxa=yBSjk5H@mhPV@!*ThF za4yOTY^at*2Txn(JkA085eKQ#8;qP@By-QH(OwmCzMpNqJ8oN4vHOkg1{)u2LF}Fz zgJnx+(6f0uY>B#wP>vh=26K+rYq8dm6$(WnbRsRWa;FN|51pi}T%4TvE;BZ6w0Bho zM^~gDDtl(s<~Uh1V{fqGB8Sz>)DbqEO7GSkHs_r!9gQj8J~087#}%Asg_X+{=Dv{6 z=k+(wW-F&YnozD}tlVgy2n{x6>9}oC%5fL`;%=}>?7kXD*buwRbetv~b@za0B3zh0 zo%4?jGm>Z;2wJC!gmE9E{K z{Z1RsjrO6iBNDB2+3&8hbUDH1th?g~+kC3K=PAb_cvrD|$O-zm%hZK(H|Ih*d%3@P z(bf$g|MbJE?v}R9%{Sj%Gp+LUsne@Zt2kv!)rsfaWIE(V`%~qf`=BG}daax5U{-zQmqrEOTx*S%u-{f%7b-DHK&e;8P9lKL2U6td=(pR{% zbRyjmyK8pF2kRa@hPm>TDUYVQpQm{J+wBXp?HN7#n@SSote7Z~IGd5LJpB?7Z-6)2 zi^3SJfPR>qVWWE7jbBUMT@Iz^)=$vMmX``xdYkU0ry`#57rcAat6=LKB{(wka4I|J ziw!?*-ZHN+55~i-}hrUtMvE0p4gYsSML`So^GEck*-`OTQSFE=f9Kcdolr zIc^S@YG@|t0Db(7ycZm6SM4+^vis^=7Hnn6UrQ$f!JyYmeKoBh zDI5+*LOOZEyVB# z!%jd zHe~6tQ>q17dL~)xzy*f>Unf0ZtsRB-kiTW_=YKx+=hpenv4GD9#xePdNq3CQVcP^s zc0ri@P`tChQ#^0%-KhAS%c7ZsFT08nzxPr0BB1Gw-ZC zymLH%`gDW4QL)8yTc=)j)s%`EQ!8t3ZtL&wU)t8z-rU^MneOcw%x?ZnkuDb~qm>Rz zf6bYtyVpbp)LJEvXPg+j+sOfW#GjtKTKYbAR8K#>=4NjHQg5guL!>Vi(%1d|t0FzO-~A7(97mQ;1Mjvh zeL!0}NN?A{dqTbUJ>$mIop8d*lj((Pwtv2QVS6IzWyWzHm486+a>z4;gm+Hibv*AE z5$}RKx%<=^6;q748~tO($;Qu3aod-gzaTxcls%nL*8%*-9iM5^GlJ~eBcYZ)Z zdx??JNR9l&DHRl}pPP)u>RC3@Q=~?Iv6AY^v3&abU7zjJq&qHWQb$|(vUGiy!%BTz zl|Yx^y+0G1B?SDJecfJtBO}jbUs|<%*@CW2JR~>|E03FV_jjnLfo-rmX&2Z@oWSz~ zv`?E_eM*(FcB2tMe%d9|&OJL3%XtMr7tzmTsDtS5XlqG7|IV-9?O@We4pw^Ud|Fi> zVk6L3I4oVK>5UOEkRNqzar?9`2lRkltvAurI%nDHS6Y{b>uRmfs4-y2@hAIdPZz-U*QhMeTkNVdGlX;w!0-7#OIT%*XG>t?7>Hv zKt76H8|ojsN`C~q!`kazqMCfAf!!#d&Z#=>l1f1C76^}qMN!Oiaq*XP_e|@aUB7lHk2rlOCgtR-niygtGM5H)6;_f&TS#pIfwcU0X0z2gHYz zGeQ}2UXs5D2iW24aCGN~Gr>Ni!i4cgb}K(+#_2VMA!o+JVKuL<<@I7(8>n*xPpI$S z^x1bi$kcz@!+E;g+M+A(3)eQ;Z*%C)1@Da!kY0!$Ainom2WX*af4Zx+b@5<-I8^H? zqQ-Qcn>Apg3-UQ*ug{~zxiwR(rx;`Zp9rf4@pNb971O6t;jKQuw2=o1@sK4!T})l0 zp5FEWr|R#%$EHs0bjNvBO4IX`-XC?epN55FufPt-XZaG%oxQ!CZ7E-5<}6P!1&qf$ zq(hy0%+i7N{FAfEb1P?@Vv6w-T%`crQXa~w^hQ>m|6Kwr_iD%^EJ2+}2kKy*QEy)J z>383Kq*4Ew!gp1X<2?PfdC%!Zk?#F*ZHd}i+c?SaeC!R-8K+rwk!UIwh|lyCUGja5 zopkis!Fp8kb+@rkn|{iPH|2YK@a+&Jb3ep*SE)t$ejpvRi-JG|Utye^_7 zrcSKuR2{4I`B@I%W!+3}uT(08Q(YpIsonbb%5va|>`{F)BKlc8O4tYd(WsA!|A!iB zw-8@bZJh=gC6NGq{1Wm+JpCZj;p&*5bA|AJJc#wm>M19SmE-f@+A?i+$&Xj*kL&%M z!bKn@Css_Y!9Yx&a8JLRkeP#XMm!j(PiAP$T`z2=;dlC=Q2!=Sx3cwQX7N{F?)Yfa zr(cq-|M3-7oG4M|n`w2p z0qm$bA1UYrxg1-^Z!v$Ek`gh0)EBAKo{l(6P#4Vo>VSi(Q zI(v3)elAwx;h8B5DsW!iB0Z%K7iAjh_dX<~W0uAq!8=aD#MsuW%1ag3>a8IjU}E@j zDvkO6N(b-@te00+Piy4yHX+?^0l-{wPU1+tKGT`)9vJA#_A%!<%sUk3tYkgAx}@uV z&3fayB^%y-d(+$Rd`$heAHE>gsV+yXzl1rtZtI>VCfyqW>KGIsk+qKSs@zQFW?<98x}@Ex>?2-ej4%LR<^5U!^c-3FIQo#ptd%p&krm`WCn|3!&qjX)JnwSVUiPeKUL>a?Oodl_P0M^*nd_*{F+tI&!IKYd!bP07l0-?sHPrHe%i8 z=>$4RC(wI)`uYZ28UsFYesn$k?*GGTnmJW7ql)0>_XFM;N<_N3^=*8qw8V0AU_Ob( ze{oqwWhRM5!$4hJ9dBoCTcmKHZD0WpyUcB1&NYzD)-5Y3=hostkhdN>a7TSE**>uR zt36+S`sq9GeX?Qm_djm^eA~w5Z@x&ixrJPWALxRwvo(2Dg*y-KP{Bd?;pf!#@Tg3f zj8^bkz=o(M0pS@}o z7t3F0XkFR8zON_U2|L%6{NeqN-+h-(xaq#z_KnM_ zJ~x|aU#x~`27Bm(q&X7{5&$XPHZ#mNa&lDgjW9LsoR4QxL7d3pF zY_y=Wa(6U>beOt>^xnaBT>~qd8X8*DiC_@(4emjBV2*=`naYaGCfQ`MUTR!@+^Cer zlR`}MQ7Hc+P1DT9^~%}4-j??M#)Lmk z97SZ;ngE|4`M;kQmxEpiEs7|>E^P_!brrkk=ds@fAy+%;l`{k|@^m^+l`Y^Nu z@Flz#?q&ayj$K{;T~yTZnTR01*sP;m=rDD3&|vCz(mOTj4bRSd@%h<3e){JEy35bO zIgw7LehBFxda(#~RhKih_i-ZToYQm7`60x)(t!`(yEIomljz-QrNgMS0*$Y1*}`?R zH|NY=u#h>=4zitd(9W2Ha?LjsjRs{Ho)wNKo9REIZIT&MKWiyV5l?DsPIs-HvwY|F ztzUir^XAPPHf*L{(R{vjt6CyULpH15T=v!*FY+l@+ECNf)I{e>S{IXATh}(MZD?p{ zYFgZtPGTHa`g?mpu9V$J;uxM`FCyrWCLPsxAw8B%a`eum_pR?<*);d1*WXyZdSOC` zWT-W|I0xpuGNh*ZGB$yO74Pin6a}h0-HgQk#ooF7#8pLMTweC{UQrv`|K{rBdhsQy2!Y+=gqhP~>8ZZN++ldZ&1=e}il7 z{he87v*&OV?NQGs7LXZRk>7l4ueJ7C{{ksLXU<{g*R!@>|LSWKYVQ|J-STwDr%0iO zrX^}B`Mv{~(;j=2pVlVTg)ytVsdxBn7<0pvu;r_&teeYHDUYI>MH8nlGu(0&a($Y8Ie6zH$Fiq@}rlxqd?Cb@a zX7PR%B@P<9=4LX@ZFQk5r!WaMH{EnlQjEsAO`B*VrjF>{d0ErpyE>;gCn+H}GMPBC zr>v@{{osieD4GMK4ofMqX{{@1fgLATSwKDD1*Vv8$x}aVv zptE$FTj}9TR0!k1z5L*wBZ)~4qRGxR=4|IMGsUB*j-L!PD9xLLXq4d(?5*d2m`<3q=7)T6K8jd4yMD1kfQiOBD)2@M|k zk$6MBvlzIZNcI#uZdOqoi3R+bap>>^=c*GP#;%$VIG>=ez-$iaVBI90P#4mvoSr1o z$VLMLeuycf~xV{QeItrw7!)&}R8jtGi?y3UI^yaCk z8*%)ZeZsjbIfodYqnl#w>zg`UTFsh})^|7QDGJ_a$kJ=~z1P$}d#TaMY#P0sInkF?-; z4nL2o=sv=X18)6}K48vK@-3w5IV!ewYm`xA7yI6i>GrwF37w4 zTIA>K=n}YZ-*MY*cka6Ph52N&C+QG$pLRn;|#L8_`{xOkrBJVrHb zF>#lQ73h4|Gom7EZOEIzI9#ce-+YX@z%Cu}ggRZygRO6dq06ud^usFB(IZEdi;=Fz z17#I`7w22^BBc$@oz#>VxBXmW4$hmCXCA-(&TY3KdRO^4CVflLHsfcxPUy(Pk)Pjs z$AizzXRsIrq+7@IC~;QbKcqk}pE}!Dd5}1#8m4*w2334RfN|s^HoGY^_(5{>9 zz%qWx9i!8mmywr>iCgWuz9l^@woaM&612l_3hDqIM}V&XF8qbJZ^J7fxTLp#i#Dswe2=d`i)uAw*KT93a!8w7>dAMzN?z;WaZ>PCU zU6DRvk}iZ1=z}9E0zGg4eBZJCH7$d4%( zgl$BtA{V+s3d7fN*_S)wQE?+~Hx9bYig$OvQ_4+6?;w4wzq_{V*x4xpJ>EP#8Yktj zb2sP7^yMcXy5rVux9!}?md-Uc)e9TUq42fP`>FzWDxbs9?|%B{AJa*Tbh(#;BXua5 z?j+C)sxLN`)g&h8xU9yUa~CRNQ2`yZBEk9Ftm49ma$0ebm6GVW8TaHHlndh8__#RmqU6= zfqr1Tobv4>-1SPS`i8ow>Z1|RMc{llIOls+Ax6~{^5+7%hl#JQ^&T&Vm(!7OT{6S}f5p#*bJdfGeYlzaPkwt` zfsWu^_rmh%nP4`oZ$*K-PQ{6#Z@cvl2K|Q*N0Zo>AoX;c^t!gvg8ayl@%@cm{WF|z z@2HPD^)zs9qK>#-{2Y$HUagEbegWnG2({wR;lEOFgR6se{mtTZhI=Xes+>zBtYFRp z=v--wQS{zMq?4s5%9l{6a z;%p3AJKj*op+a`LDm5i>Fd;%Fx|`yTQ3O^qJ4T{Pp4I@21%OP6B<0iroSF zmaDG{ZR320O$se&y^Y`cKyY2V-{Vz+-vB;Pms>z2{Vil6 zE+VOYHJFa)d9#HikPGNVdKg7pDm`}K(&?|dlC3&$UshMsdAQar=;@z6|MqtVonv=C zrK4$vL-)YaeJiJm&Y+_yjvf6j2Avk2o=IrXVd=|BM^`kFet2=dwi&JMu50SJjVdG4 z6z7b2aYbh*XSDV#T_7e4v&X9fB$VxG60FiN* z-_5(=ZqB}yTj}83*lP>WDf!+oHa=aEo)FHLCF0cN52WHo|2Xx_w;FUgr0cwU0J@*W zM|4!tDRSSsgO2C|I&C>U`PutP<>$nCj(nWV16Ef{xs zKGI?18=>5GPBC?WXOQUd4}nb2U>-c1591FE%)_~y9Y}}pCV>V%vxql&$EiqFGu*4z zWO|%?`yEp*i*o84lFhhLoRu8V^^hNc-hblM=U;yN>u(O|+oXMyvm!8tl*NCbLw`FE$z|DZsBT7gat@9L0#y#_rP{p?@q z8B*6d?J_u`YtX;_<#V$1uRi_$U`j{r_6!PpwKf?8gN{y0<>>Hpl+g8%2@MahmIF3; z&e9Ikw(8*PYU-%#s_P0`P#m{JZoUz964y3AP|tm}3rNjJ;@>ErN(tSueb?_{@$W+4OnhxqU0@YeDSpU(wA%B;9WW=;G+V ze*5L;=TCj`#YeQZ{Bl#;Jj2uquy}r{Omfz$J1w9x5_MS%7v(ajq%HHy)@%61hW| z@jNkd_Uo@se*D3yPd=w3IzZ<_I%VV7(XR*SM0yar`%}){HD@H`Z8qp+>EC^Q;=@bx zdsAf5fYPIFMTExIP#Blk6y!2DT(sGkTiLNJ8*o{}5Ct}oK@l*QPlb5l%`jQ(EFjVL zavwJAUJPGeedJRJpId;7-6K^ekv!rlY(rNXs>(dry4&)kj}^tRr^^ z^gB>r1L!&%7wl&8sR(p-bV|B=fKIXd$ijW9xKlgv@G%kXWR1O?k4kcxVD_zz~B7PP5{_O4_W-nSH#+- zw#j$aoP33`WX(HFQw`3YdoF$U>FJM7enEzQ-f(nWV9Tc6uMN)VKGhbd16ECW-s%B5 z#qK|nrT_8G!nxkT2DGuWR4JC^eeE))nlkiMcmBYh*a)o|Y`YRI$t+7hXvp=xj?zGu>S8G5;MncG zw2Vnd;*H3*=e)UHeLa^+;Gv*XP=!o;wgOx4PfSqE;2aXGwfOYLS^jdjXTlXxQmK(Z15(z=AW3Y@zon{ioKwcOBs_WLuReWwiF2A!|9P<>6$lj6`lIHCJh zWaw~ogzg@obL{@%PnQ;Gvtj%G;dn$B%$#nk0;$akgF+@!Z}GMXEL?OgcUlE?Czowy z&;=bgCIX1CZb4!gqY-RdZfkj^hm7cY<%ilYDmJ}YT&<_Bu4M^;kg+?d7=&6uyG`hdKm#vtg;s%9 z%oP%5SEqIL-`X~E@8`KGc1Pp3T!mNToO`w1Lf$8XM z=-e77q5C!029dj8b@R<=fpr?=>@zxp{?(@{cE9`$TRM?`bk9&-5s{8|3~8}6%spfT ziScyp(em=6@6~oEx<-q;gj6$EZfCq8@NZi&dke|(Hz%Fw?%O}RKeqEmIv0G(Uo06LM* zb@U+V?o+82Sb#2}yMTV)0G(p@1>S+)e!Quoe1FYgYZ%wdae)Gtr5JZKTe=qU)}>l1 zdV4OOo8mRcdKQ+AoLsyYIKsQ2n)i5JB z(zyjT2;F@u2Hll)-~vh<_nW$zrQZ5b`qq=Ru|Frm6N z1#hR^&Pc`d;^oD~#Y>AzbkZL`-rje-_t>!}TBuHgq9-h_E+(kwQqwM(q${S- z%3r(!3OR^NFQQ@j_VFbqF3zpUv1nWK=tx&0k?5Wr>>rtJq7vuaRoY+CesNYC zI)TnPcekVeV1SM~`c{kF@l6R&kC+tl0F+f&8CL1TB<;25oW$s|*B`B_aVR9zen z@O}a9N-1PC4`&VBvjczt7s9y-mJj1?&BG&;-SnOApBow*9v&SXr7?e@e0r(IiK{5J zr>Fe_8~XGTf&SqM6ZB8u=mNT`qbtyZ<;lL56T06ZXLJqvQ@`anqifL5D$uLm+h4t> zW}u}jU0MRr1$7Y?0rx09{+94kM}!9io9Yj9+4W@aXn zY3`(gJHsAT-Law&>HkD{$jvvsmk7tZU})TGQHV!M>svE3BZHGfI04SF6P=Zk&7Jk3 zypp$BSng2QRoT-wPKK^Q|H%LyZM(7Sl zUs9lt_w{l*?p-=z6P`m81-nhqJ)E6pBo(t}TyZaJ`;c5R#;zLraU>TXol{x?rqs#IKy1^2y?`$3&8|kMI zodH*?cUY~RZShz>w!mOiY)Nc%Uq#b#HuU*((+e8(GY07AU7(}9<_vtkDOlm)YZ==c z#L;O}%kN&GpL0MjQ=qqWclBq&@N}DUIkZbcp6^ga>pN1k_lqLQM51xvNNw%j+M0nx zOZQ~|$PhVBk_HjddA*ZF3!duKMlhMIq=efvzBtSORo{lJnU`xx57>a`MZ)oh)+Bv* zM@GoOneUW)@LxP!RQR@)JdGv}RUPZmpidF#mlnTY4mw{)7tqZ*`VH4!6Wm_&t&A2} zIQrT_Ke+Ee?LZ43`I3bKx;N^w8YsIkB*H_>CTOc?GMyf!uzhT7Xl{fKkfWpNByC^j z@w5D+Nt|;W52n3x&yZy7eH-Q*Xd7Yq8vnptw_L_I$LJvfyg5BHHZ(LeqrQXGbSz`Q zc+6Kgj~F+1D(jkP{>Y*7il(09;}98deAWAXCZW#7Fg?y?hQI+ zLm*@zk%oHn`@tve*-i$9te z@h|^~mXCmU54#OP>&!XC4Ex3g7kagf)=#7|>EW@lnPL43Nek+;V?66fYKL`xiSa)u zJ1ADwM>_T$uB@QsD1&}(ZJ_JH6FX!{dT>Vft+JhQ9J_ml?xz9zWf$m23G{b38<*&s z9308y3+SGtdrKye%NP`WX_(o2GR23b#?8P*j=Z6$}mb%$Xmvl*q%n?v4us3 zT-x{arSpC5WXn8vVObT^(wPNCb2Hi)jzf$ zB`tRO`40C)HZOmY>BMmo>P+hwcmsZ`3bQfjSD@=DnLo8r#RP71H4EIE@a~|!=4mR9 z>C-o6vZK*RrfKj+^nBW~{-&2)uds#5cMiShfIhE7_eC3YJ9L-L$-(V4AB)EG(ivUq z=sRyQK|c>iH$(T?@jmXD0qCmnVlJKUpu1L67sPGZ^*Md8$KEh>(i8bS1Vx4O^f$c} zXbWUc0L{v7dF6#auA_+Q+brZR>zM9jCQTx`TUBTdxE0c|h=`s}%QAMmHnP!se$$fB zT|f^v?x!RAv8Ll*p*zo=ut66`SCMQx&iuv0v(RNpF($bbOwF6 z=xy-3iueJmJNGQNWj>ipMo z$u5`pUItwy-E|p#VQCG?I5kSjxu(jX2eG?fy%Vn+^}c=VFtYy zpyx=&eQcf45xNK0RQ<0lMz1?U_ghyeuPtcM+bJ1Wd006*Kp%^uJ8ly&Ux#el!e<8m zPUn{%lEH7-LdJrpMv5Y~2-MY71cXHe*b1*<28<%7`5#4w4(uf6Rh9tTF>Um=$!2ty zuxITp;Ju3QeSz%iT1o4^vcqt6BN^xI=yxD=-w`C^{ta@*JtG;1Gx{C3uUKB=>uXKy z=mPr4Se=CKe}(kFU+^_o?27unEE2b+VH263+8oNh1jRH%%~Ut&WoEi_Mc>xLqG@0) z>yVjcY*nH+(%U3yR@lIQwiUd)lJ1#(?v8hEI{pza%`BCQXHM_iaUO>0XYn6u&x*dID zx763xF9h9wf;~&fZsHkBW@KuPQzJLqRgq(pb{3OnF=ptWjA`Bk>n7&_eTYEUS$7L` zx{t1>JsY6gp}QJ95oFzcD%@V9&|P+~-MY&vuLd}OTXK8bUW)# zj=nbwdaDL~U3~vLIR7sxXXsvBoIhA?W!=TmIdnhi4&4E|I6ALQ4wk9uVgk zZ@Y7s7wA8Uqxbey5a`t!^ezTHRlHtD2kQal|D4QYy7VCCbyaiL{e9EXzjr%27C8I> zM<>qV=v$r8J;=KISbRk1);OHeF$R`Ee}0wFy+`-D3+T-yNXB^^H|$$~1ibl|fc3v! zpggyu6X@Me=x#c?44we!aCCq!XY?ENHr0T0ziLZ%melp^=olJjfc}LC=ql?jp?k8> zc64wJ*LG#y{FP(>AISn;H_Q|z-YGj=iO}5w{ZnytIiu@j+%0;N)J?$|-RIgu8yv2^ zhOVi~8QlimZh-~pss$EV_e`P1Ik<+4ld_lx27ZNPa4wD>Yn0I4>WpJcm(YEgqwjRG z?$-vPyPrjduCj3gx*fXPb#wt;LU)ggs^Z@KtIx{i|369aZh#&hs4jbtK}TmCCd?oi z$FaK&I=8xW=q_86gIt_{r9yWBeTR4Gj^U6-ue&=Lm(H_5hk*y7x=(~WNseS(=m;F$ z)dDM^Q!>s5-JJFuY)$sLP+pS_4y%GL$vA}W0DZK;!_kA{nU7>}E})mzIt?>cdCfkf zo1kys&I4wYqX*?R--j59#z&N#QD7rm9k(f!dV638@kNG3zZYpQc6U1? z7fnTg0fNX`MG#O)#j}FQSw-=H$U#NSbD$!GpqAOc(EEPhv-Y6OS(gjesBZ&=%%VWK!2K}Pm!U! z9)Wea0v$^&CeX8V=gWjFYi*%>`$jwHdSk_HayYELJFSo%-8wJ> z(0fM=-KS0k==zOCcZ)~ktS>kO3P)f1yR)O?+_Sa4)?R|29G#-OSi|S&se5GT4$!;Q zz>E)${*pcd>j(01G0xIR=V5mPbU3(1 zfpq{~F0ZLJx*m*kxx8B~cuzIR0{U4F-S4Oz-Qwt$3p4G_#=U1k(n^N_)jLmd6CJ#<&I?kxgz)ka!5X$c6PC9`;Yw2U3Lif#v zqciA2dVS5MinVi&&^=>cP7zPW*+F;6(W8RZ3yTv)imtcOT`%?g8e2Md(nXRU@%Z5{ zk8U~?lDj!3fiT!c4rSLbE)T%7wwoD=B0sfq{Kq@}<6P?p^Ry4}&&7UpDbK1!e;J9gyinUl%n z4qni8@!d6wl^-kS<~Djt)=zMB6X-qW9Q}gH(H}nkFicF#h}AVbu&8%b-7gN^F*74U z2kF6)Z*%O92OR8>F4hd=y&nY>d1qT=+t9pvau03B$5O{8BrOO`H^ z^e6Xw1n9TR>uL|@5$O48aCGjkWeLz3bx5QB{|c@1kpp)Ieg6i44oA0Nm^sa$|NQGO zzkP@)ckx6EgN_H28FLM~%Y`vm0^LS>*dikR6lLjb>8Qqa@hgk9yVj)_Gw7!4kgJ(= z)lAnlx&Kl4r8+X^+(Rev5!hS_dL2MFy}dRlBX<$FKU^A)ha0fYf|9$A-2FrtgGD{g zLVDEukA5c7FR-P{Bwc|{jr2`weQjHc0Nu&bfx15S{@=-oa&XRm&YY+2*^tkm?iBQ>%Z7_slim^tP znV}@gPKE83Z%Q^Rcx#v9hc^+UH($X`xwJskr zc%nTOZdB|I5&!^IBk}KpTe0}2nzgU#5tHA~V@&&zUoTiq-@t=Qu_WBDn*tLZ7&?7&=K}S&bz_j!h4;LP`|~MTZT4&yZXvL zA-(U`s%Cnje|o!zxB^V)f5Z%$J%O#h{$;F-;U+lU&ZvrL;mu4xKh`?s0s)A45nOFk zcz~bl+!oT9&us;I1Ic~}A22xpVeJCcoR))cD}B_<%v;9jDjTFxiqt8z!Y5rPR2F_( z33!xu>G^G~>+9-JEk80RR8h)4t!5wEku(MM)Hk*OL-d|HKbXnO3glo2mm=(xfp{4G z>LV5wjDM#%1c4kd$T zMWjy5nDhmDHA^MYAQq_-7&W&K(+|kKMNk_A$H$1Ddyn*1jF>F0JpABA@k(&RDQDdP zi{LoVe~`>5)nj9o18bIfE2lX<12-|l7k4b^VQrur&J-i`wCEdY3H8m~I`$RGD*TWw9;eZ*AOYNSX1vZFFdP^o z|LhuxbVKyDDq>Zbc71Ir%*zWF08Y9GLCG>z^M4Sxn)3fda->=H@adxD$vEk;M4{y~ z3>vg^ab7wb9+dHmRe6s)0L;R7QqL6=%v&|V1B=7}kOD4w{2gO&6Wr?&GY{o?6R2Zd z`2>OTqg@&_oNaPhN`N&7oku(yj#aT%l1UcF`n6!aW1OBg#WTV)b_HvV%Lzm5i!I zoxVV+2A{>mck$0-grc1osq-k&=N#5R1lZ0;B>A?ht+>udpY~8 zyI-uw-Ot*MXIa(HGp;XaidPQy4b4T0L(m&DgCo<)rq@-?F6g|8;ow|ZjVO>=lYX8v z#QW|~D`mx*^XSsJV)|=87tyf?h>9QVfu(b6g&Q?RAw_=Hnzw@f-UCa+Mh%VKZe|qc z+?Viko|<~r4ox{MA9f=3XT|Yp_!gF?i;$M8_I64H!@Vge32J{fmq{t#2&<7L#$OF9 zWWis~zc{l8w@Ar(bZm2pw&cBLG@my@XM3|z#p_V_bD;=?L&3KGwV%&3L(i$%@$Hzx z3M?ROdl?=-+axonBcsOLT?N-;^F6JtKU(dezub7}^iI)qri%cwqFdw2PVK&O)H~qO zO;D}$z!l|`XSr?uYVOU-Hf1`35DhsqEzS9xe(gs5{?jZSx4A2C;3mIwuy!KBLm#!{ zYihme!gnw~fnva^AeWGQX25121H4_gVfgKPq=1|F{*wu`Nz4};aRuk=Ygz3RA}_5Y{n4U0`QeBt;(Tek<&f9# zzL^~BqW%rKO=wz!RM-#qN-qIpO<)2^ zTP>o*rsm*l4LP8X(%g#y?78S`N5{&SFb^TlgR9-I%y$rXm(Wai??gJ1ItB%D`I#eA=HFKQD5`NXPir5JVz(9?Fz38^!Lj6`0 zhhQ+^-pWPa%f;;Lak(A(@t^KGzNOkbt%w<~{n9&>SA#2fY*No`&#wXF3$&P*jqY%- zVs&=U4O{sJ3FX^j-%j#OWo}j`RtB?fRc_DWdH{=zkR*hcf&czWiRLd(Z zex8RpOm%XUxNECVYp&UL7%=%veT(cvp>aY>H$Z_-ht(6hrX5{l^R@Uw^Zf#;NGYr+ zzg8X2ygX5JyCBgqX9At%6wX)NhIE{Pf=XZ zu+4XG8XNMoA?RDsdnHi8%`^gi#*?iGqR@sw3CGjsT+nRUw}!iDsNgBv1omtQp;LbWcRicA-ZR>}k{NrDS z*xM)~(O>qeJqrP2?<-K~XlEoI$FFFe)=*emwjMlh`!rf=dnUE$Zo5Tu>z$=&1uo7u zZaZExAU8p`yhJ1x6h^let@u{^W@Y*_kLGc>9QKus_$zmE{zPdJB1EU6lPHl729WXa zBlEO^_om?m6(#aEJm>55jNH|SnwFmR)_+NhsldWo4-#ULTLv4Mp01MuK`+}-4MGdr zJ%+)zWeBpB_rzj5+n?q)*nXT7e7ij~O-sJo1T4UKurNBf+f=-u)!4T3aeksN=iaLq zqa}ge9E`r{^FT%T`n0$lep)NM3Wsvk?Mf^wWBvu`_# zftC-YQEk+|{EBY)ELr={JQDwSPDW*SRKakwwd&J;6R6pNeDQ?-JAB(*9BHO<8) z7nl!@4V%^n!&*YwUN?C1ZWh;UZ=UmN{Pcfk#M>0Fk)CggZ_Nwy zVZ#oKX`81S4!twWDl(rv9qM{u-4ydXp+0^?mF}xO$8wAp7GdA(P>iizvPGDl#*hn6 zZ1T-ZUJ$jSI6X3f@|^{3a;({wRCM#VP7;tkEUj-Dl(pOnW!+wUeYvn2<{ztd%bkPQ zIu(qd{v)hAr}~17SpA8c5}KN}+{1%g19jC*Q=4kawMjhd(??9JD#??}_V#E2r{ho zl<%I};pE~1%vZgS9q_7x4$m3zNkZsaxcp9f!* zZzU_r@NdougWt&0%L$LUE-K3W`4doi9+aetIOc3UeG+={plwqE5V^UL-{t;YH6;oC zvAa0%eFvOYRoQ0Cq;Va*NTZNF3&@M=kqkLJ*O7?_#$E{7>BCK3P7|-=`Azpa7qt>$ z@|j84ZN@cQ=|S>jT!M3ZIU{_zWwvH$=OA(R&w-&dSz~1V58$xnoH=(n#F> zWEKSl%T`z@0BlakHFp2bozw%-wJqLJ_U#PzZCQ|n1IKSR>0CX@_{igMZ^m7ar2=5a z^U9sb?q}Q1X1*c7t<#h(;WO-uIHjXqBAfTZBV>a3LxJpN%_^oh4DF_Jco6raLUykL z)=54N#et1k77)0Eyfq-d1xOgxTXqjzoQyG zpXua)00dX%`}KnbQ|F4!R|?hHjt*o}hNMkBbU@-y@GG_ldzZA!EYuZ4KB=(=+}x&a z6g+l&C&l=@#DfSLjjvAb$HxRuuLY#|vA|W%k}jT<6CPSKY!xpwAk{B4 z@G0G7z0-*lc>6g+;m$G>)d_?@84^&eb!vvU9&0oJ{4e1NAOmlefdq|F< z@eHiocFGyJk4ux|ilBm`f9~+OfSGiV?cB6Z(J%EK_KOq47|q5$n{hdC8v54rtzV?C ziJl~2_xsWzUL(mM&5oU(oWq}YWCut`iIM`!k0BrMTVXCn=%Dxt8oA|1f|F@Gmm5TR zKHO9wwEvKYmd~-iCy(mLa5_iia- zG!?8fK7C;1$aW0;s69>SKwOl|iM-0j;0_JyepB63TKaU@`&tJa3+VD!!n~%I68j)H z{^sz8aH2+mba7T{$Tg=WJm{%9fi&5kU?v}A{52h2Vums;h!TIZre zVB2_}6%X&qVdmI-4b~5XU+GeJGpGFu{Eyaq`V5It!!~T=vi9++1s%MsgJK%=w~?m6 zg!alipl*$P&A5kGxRuuI18PR`laK5~<}(!6(L?e|tO^Yy8Ep3HiG|EgECEKzeY zR7Q?pT0tY(m0b;;uMOuXddi}gL)tyqS^tee_q~}17yd!#SvjkT>Ax@*PEGYsN#Km2 zY4ESge|P@>k^epOU;U)LnYe#8d@yiEn3DRZ^}irbrKS7A$F#x9=AWvLhMs!iL)) Void @EnvironmentObject var currency: CurrencyViewModel - private var rangeText: String { - rangeOverride ?? speed.range + /// - Parameter rangeOverride: When set (e.g. for custom speed with fee estimates), shown instead of `speed.range`. + init( + speed: TransactionSpeed, + amount: UInt64, + isSelected: Bool, + isDisabled: Bool, + rangeOverride: String? = nil, + onPress: @escaping () -> Void + ) { + self.init( + title: speed.title, + subtitle: rangeOverride ?? speed.range, + iconName: speed.iconName, + iconColor: speed.iconColor, + amount: amount, + isSelected: isSelected, + isDisabled: isDisabled, + onPress: onPress + ) + } + + init( + title: String, + subtitle: String, + iconName: String, + iconColor: Color, + amount: UInt64, + amountPrefix: String? = nil, + isSelected: Bool, + isDisabled: Bool, + onPress: @escaping () -> Void + ) { + self.title = title + self.subtitle = subtitle + self.iconName = iconName + self.iconColor = iconColor + self.amount = amount + self.amountPrefix = amountPrefix + self.isSelected = isSelected + self.isDisabled = isDisabled + self.onPress = onPress } var body: some View { @@ -23,14 +65,14 @@ struct FeeItem: View { Button(action: onPress) { HStack(spacing: 16) { - Image(speed.iconName) - .foregroundColor(isDisabled ? .gray : speed.iconColor) + Image(iconName) + .foregroundColor(isDisabled ? .gray : iconColor) .frame(width: 32, height: 32) HStack { VStack(alignment: .leading, spacing: 0) { - BodyMSBText(speed.title, textColor: isDisabled ? .gray3 : .textPrimary) - BodySSBText(rangeText, textColor: isDisabled ? .gray3 : .textSecondary) + BodyMSBText(title, textColor: isDisabled ? .gray3 : .textPrimary) + BodySSBText(subtitle, textColor: isDisabled ? .gray3 : .textSecondary) } Spacer() @@ -42,6 +84,7 @@ struct FeeItem: View { unitType: .primary, size: .bodyMSB, symbol: true, + prefix: amountPrefix, color: isDisabled ? .gray3 : .textPrimary ) @@ -50,6 +93,7 @@ struct FeeItem: View { unitType: .secondary, size: .bodySSB, symbol: true, + prefix: amountPrefix, color: isDisabled ? .gray3 : .textSecondary ) } diff --git a/Bitkit/Components/LightningChannel.swift b/Bitkit/Components/LightningChannel.swift index 5d35ecfe3..66ff6c77d 100644 --- a/Bitkit/Components/LightningChannel.swift +++ b/Bitkit/Components/LightningChannel.swift @@ -12,19 +12,23 @@ struct LightningChannel: View { let remoteBalance: UInt64 let status: ChannelStatus let showLabels: Bool + /// False for a balance whose receiving isn't capped by a channel, such as Spark: only the spending side is drawn. + let showReceiving: Bool init( capacity: UInt64, localBalance: UInt64, remoteBalance: UInt64, status: ChannelStatus = .open, - showLabels: Bool = false + showLabels: Bool = false, + showReceiving: Bool = true ) { self.capacity = capacity self.localBalance = localBalance self.remoteBalance = remoteBalance self.status = status self.showLabels = showLabels + self.showReceiving = showReceiving } private var spendingColor: Color { @@ -90,7 +94,9 @@ struct LightningChannel: View { Spacer() - CaptionMText(t("lightning__receiving_label")) + if showReceiving { + CaptionMText(t("lightning__receiving_label")) + } } } @@ -106,49 +112,75 @@ struct LightningChannel: View { Spacer() - HStack(spacing: 4) { - Image("arrow-down") - .resizable() - .frame(width: 14, height: 14) - .foregroundColor(receivingAvailableColor) + if showReceiving { + HStack(spacing: 4) { + Image("arrow-down") + .resizable() + .frame(width: 14, height: 14) + .foregroundColor(receivingAvailableColor) - BodySSBText(formatNumber(remoteBalance), textColor: receivingAvailableColor) + BodySSBText(formatNumber(remoteBalance), textColor: receivingAvailableColor) + } } } - HStack(spacing: 4) { - // Spending bar (left) - ZStack(alignment: .trailing) { - // Background - Rectangle() - .fill(spendingColor) - .cornerRadius(8, corners: [.topLeft, .bottomLeft]) - - // Available - Rectangle() - .fill(spendingAvailableColor) - .frame(width: spendingPercentage * 100.0) - .cornerRadius(8, corners: [.topLeft, .bottomLeft]) - } - - // Receiving bar (right) - ZStack(alignment: .leading) { - // Background - Rectangle() - .fill(receivingColor) - .cornerRadius(8, corners: [.topRight, .bottomRight]) - - // Available - Rectangle() - .fill(receivingAvailableColor) - .frame(width: receivingPercentage * 100.0) - .cornerRadius(8, corners: [.topRight, .bottomRight]) - } + if showReceiving { + channelBars + } else { + spendingBar } - .frame(height: 16) } .opacity(status == .pending ? 0.5 : 1.0) } + + private var spendingBar: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(localBalance == 0 ? Color.gray6 : spendingColor) + .cornerRadius(8) + + Rectangle() + .fill(spendingAvailableColor) + .frame(width: spendingPercentage * geometry.size.width) + .cornerRadius(8) + } + } + .frame(height: 16) + } + + private var channelBars: some View { + HStack(spacing: 4) { + // Spending bar (left) + ZStack(alignment: .trailing) { + // Background + Rectangle() + .fill(spendingColor) + .cornerRadius(8, corners: [.topLeft, .bottomLeft]) + + // Available + Rectangle() + .fill(spendingAvailableColor) + .frame(width: spendingPercentage * 100.0) + .cornerRadius(8, corners: [.topLeft, .bottomLeft]) + } + + // Receiving bar (right) + ZStack(alignment: .leading) { + // Background + Rectangle() + .fill(receivingColor) + .cornerRadius(8, corners: [.topRight, .bottomRight]) + + // Available + Rectangle() + .fill(receivingAvailableColor) + .frame(width: receivingPercentage * 100.0) + .cornerRadius(8, corners: [.topRight, .bottomRight]) + } + } + .frame(height: 16) + } } /// Extension to apply rounded corners to specific corners diff --git a/Bitkit/Components/ProgressSteps.swift b/Bitkit/Components/ProgressSteps.swift new file mode 100644 index 000000000..2772d2e67 --- /dev/null +++ b/Bitkit/Components/ProgressSteps.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// A view that displays a list of steps with circles and numbers. +struct ProgressSteps: View { + let steps: [String] + let currentStep: Int + + private let size: CGFloat = 32 + + var body: some View { + VStack(spacing: 0) { + // Steps with circles and separators + GeometryReader { geometry in + ZStack(alignment: .center) { + // Dashed line background + Path { path in + let y = geometry.size.height / 2 + let padding = 36.0 * 2.5 // Account for circle radius (16) + horizontal padding (20) + path.move(to: CGPoint(x: padding, y: y)) + path.addLine(to: CGPoint(x: geometry.size.width - padding, y: y)) + } + .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + .foregroundColor(Color.white32) + + // Circles with numbers + HStack(spacing: 0) { + ForEach(Array(steps.enumerated()), id: \.0) { index, _ in + // Circle with number or checkmark + ZStack { + Circle() + .fill(index < currentStep ? Color.purpleAccent : Color.black) + .frame(width: size, height: size) + + if index < currentStep { + // Checkmark for completed steps + Image("check-mark") + .foregroundColor(.black) + } else { + // Number for current and upcoming steps + Text("\(index + 1)") + .foregroundColor(index == currentStep ? Color.purpleAccent : .white32) + .font(.custom(Fonts.regular, size: 17)) + } + + // Border for uncompleted steps + if index >= currentStep { + Circle() + .stroke(index == currentStep ? Color.purpleAccent : Color.white32, lineWidth: 1) + .frame(width: size, height: size) + } + } + .padding(.horizontal, 16) + } + } + } + } + .frame(height: size) + + VStack { + BodySSBText(steps[currentStep], textColor: .white32) + } + .frame(height: 56) + } + } +} diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift index 3936ba524..e9d81ce9b 100644 --- a/Bitkit/Components/Widgets/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -374,7 +374,11 @@ struct Suggestions: View { case .hardware: sheets.showSheet(.hardwareConnect) case .transferToSpending: - sheets.showSheet(.transferUnavailable) + if SparkManager.shared.isReady { + route = app.hasSeenTransferIntro ? .fundingOptions : .transferIntro + } else { + sheets.showSheet(.transferUnavailable) + } } if let route { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 90aed6ed0..4cdd9ea87 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -272,6 +272,25 @@ enum Env { .appendingPathComponent("wallet\(walletIndex)/ldk") } + static func sparkStorage(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark") + } + + /// A restored Spark exit data backup waiting to be imported once Spark connects; outside Spark's storage, which a + /// wipe removes. + static func sparkExitRestore(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark-exit-restore.json") + } + + /// Breez SDK API key, set with the `BREEZ_API_KEY` build setting. Spark on mainnet needs it; its regtest does not. + static var breezApiKey: String? { + infoPlistValue("BREEZ_API_KEY") + } + static func bitkitCoreStorage(walletIndex: Int) -> URL { appStorageUrl .appendingPathComponent(networkName) diff --git a/Bitkit/Extensions/FixedWidthInteger+Saturating.swift b/Bitkit/Extensions/FixedWidthInteger+Saturating.swift index 3a954b13a..e56e9968e 100644 --- a/Bitkit/Extensions/FixedWidthInteger+Saturating.swift +++ b/Bitkit/Extensions/FixedWidthInteger+Saturating.swift @@ -5,4 +5,9 @@ extension FixedWidthInteger { let (sum, overflow) = addingReportingOverflow(other) return overflow ? Self.max : sum } + + func saturatingSub(_ other: Self) -> Self { + let (difference, overflow) = subtractingReportingOverflow(other) + return overflow ? Self.min : difference + } } diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 3ef85706e..e6bac9837 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -32,6 +32,8 @@ + BREEZ_API_KEY + $(BREEZ_API_KEY) E2E_BACKEND $(E2E_BACKEND) E2E_HOMEGATE_URL diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 64da4682a..0b7cf1540 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -25,6 +25,7 @@ struct MainNavView: View { @Environment(\.scenePhase) var scenePhase @State private var usdtReceipts = UsdtReceiptQueue() + @State private var sparkTransfer = SparkTransferFlow() @State private var showClipboardAlert = false @State private var clipboardUri: String? @State private var didResumePendingPubkyProfileSetup = false @@ -80,6 +81,7 @@ struct MainNavView: View { NavigationStack(path: $navigation.path) { navigationContent } + .environment(sparkTransfer) .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in guard resumeState.shouldResume(didResume: &didResumePendingPubkyProfileSetup) else { return } navigation.navigate(.createProfile) @@ -509,7 +511,17 @@ struct MainNavView: View { case .scanner: ScannerScreen() // Transfer + case .transferIntro: TransferIntroView() case .fundingOptions: FundingOptions() + case .spendingIntro: SpendingIntroView() + case let .spendingIntroHw(walletId): SpendingIntroView(walletId: walletId) + case .spendingAmount: SpendingAmount() + case let .spendingAmountHw(walletId): SpendingAmountHw(walletId: walletId) + case let .spendingHwSign(walletId): SpendingHwSign(walletId: walletId) + case .spendingHwSigned: SpendingHwSigned() + case .spendingConfirm: SpendingConfirm() + case .transferLearnMore: TransferLearnMoreView() + case .settingUp: SettingUpView() case let .fundManual(nodeUri): FundManualSetupView(initialNodeUri: nodeUri) case let .fundManualAmount(lnPeer): FundManualAmountView(lnPeer: lnPeer) case let .fundManualConfirm(lnPeer, amountSats): FundManualConfirmView(lnPeer: lnPeer, amountSats: amountSats) @@ -692,6 +704,7 @@ struct MainNavView: View { case .addressTypePreference: AddressTypePreferenceView() case .connections: LightningConnectionsView() case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId) + case .sparkConnectionDetail: SparkConnectionDetailView() case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift new file mode 100644 index 000000000..332c17997 --- /dev/null +++ b/Bitkit/Managers/SparkManager.swift @@ -0,0 +1,738 @@ +import BigNumber +import BitkitCore +import BreezSdkSpark +import Combine +import Foundation +import LDKNode +import Observation + +/// Lightning without channels, through the Breez SDK on Spark. Savings to Spending funds this wallet, +/// receives fall back to it when no channel has the inbound capacity, and sends fall back to it when no +/// channel has the outbound capacity. Its keys derive from the Bitkit seed on Spark's own path (`m/8797555'`). +/// Its payments reach the app as LDK events through `LightningService.emitChannelFreeRailEvent`, so the +/// receive and send flows built on node events work for both rails. +@Observable @MainActor +final class SparkManager: ChannelFreeLightningRail { + static let shared = SparkManager() + private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject() + /// Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. + nonisolated static var exitStateChangedPublisher: AnyPublisher { + exitStateChanged.eraseToAnyPublisher() + } + + private nonisolated static let depositClaimFeeLeewaySatPerVbyte: UInt64 = 2 + private nonisolated static let depositClaimFeePpm: UInt32 = 2000 + + private(set) var isReady = false + private(set) var balanceSats: UInt64 = 0 + /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. + private(set) var pendingDepositSats: UInt64 = 0 + private(set) var recoverableFundsSats: UInt64 = 0 + /// On-chain deposits into Spark, pending ones first, for the Lightning Connections screens. + private(set) var deposits: [SparkDeposit] = [] + private(set) var withdrawals: [SparkWithdrawal] = [] + /// Called when Spark claims on-chain deposits, which completes a Savings to Spending transfer. + var onDepositsClaimed: (() -> Void)? + /// Called after the Spark balance or readiness was read again, so balances and the receive invoice can follow. + var onStateChanged: (() -> Void)? + + /// Deposits this app broadcast that Spark hasn't listed yet, by txid, so they show as pending at once. + @ObservationIgnored private var broadcastDeposits: [String: UInt64] = [:] + /// Sends recorded before Spark knew their fee; its completion event reports 0 until a later sync. + @ObservationIgnored private var sendsRecordedWithoutFee: Set = [] + @ObservationIgnored private var sdk: BreezSdk? + /// The last withdrawal quote, which the withdrawal reuses while it holds. + @ObservationIgnored private var preparedWithdrawal: (quote: SparkWithdrawalQuote, response: PrepareSendPaymentResponse)? + /// Withdrawals this app sent, known before Spark lists them. + @ObservationIgnored private var sentWithdrawalTxIds: Set = [] + @ObservationIgnored private var listenerId: String? + @ObservationIgnored private var connectTask: Task? + + private init() {} + + var isSupported: Bool { + Self.sparkNetwork != nil + } + + var isConnected: Bool { + sdk != nil + } + + /// Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a + /// Spark transfer, which lets two dev builds pay each other's invoices. + nonisolated static var prefersSpark: Bool { + Env.network == .regtest + } + + /// Returns as soon as the payment starts, like LDK, so its completion reaches callers as an event they are already + /// watching for; waiting for completion here fired that event before they watched. + nonisolated static var bolt11SendOptions: SendPaymentOptions { + .bolt11Invoice(preferSpark: prefersSpark, completionTimeoutSecs: nil) + } + + private static var sparkNetwork: BreezSdkSpark.Network? { + switch Env.network { + case .bitcoin: Env.breezApiKey == nil ? nil : .mainnet + case .regtest: .regtest + default: nil + } + } + + func start(walletIndex: Int) async { + guard let network = Self.sparkNetwork else { return } + do { + if sdk == nil { + if connectTask == nil { + connectTask = Task { try await connect(walletIndex: walletIndex, network: network) } + } + try await connectTask?.value + connectTask = nil + } + // Registered before the first refresh marks Spark ready, so Lightning calls never see it ready while they + // still go to LDK, even when that refresh or the activity sync fails. + LightningService.shared.channelFreeRail = self + try await refresh() + try await syncActivities() + } catch { + connectTask = nil + Logger.error("Failed to start Spark wallet: \(error)", context: "SparkManager") + } + } + + private func connect(walletIndex: Int, network: BreezSdkSpark.Network) async throws { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + let config = Self.sdkConfig(network: network, apiKey: Env.breezApiKey) + let storageDir = Env.sparkStorage(walletIndex: walletIndex) + try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) + let connected = try await BreezSdkSpark.connect(request: ConnectRequest( + config: config, + seed: .mnemonic(mnemonic: mnemonic, passphrase: passphrase), + storageDir: storageDir.path + )) + listenerId = await connected.addEventListener(listener: SparkEventListener { [weak self] event in + await self?.onSdkEvent(event) + }) + sdk = connected + Logger.info("Connected Spark wallet on '\(network)'", context: "SparkManager") + await importRestoredExitState(walletIndex: walletIndex) + Self.exitStateChanged.send() + } + + /// The SDK config Bitkit connects with. + nonisolated static func sdkConfig(network: BreezSdkSpark.Network, apiKey: String?) -> BreezSdkSpark.Config { + var config = defaultConfig(network: network) + config.apiKey = apiKey + // No Breez data sync server and no Lightning address server: Bitkit uses neither. + config.realTimeSyncServerUrl = nil + config.lnurlDomain = nil + // Lowering it below the spread keeps the deposit from being claimed early, and does not hold back its claim at + // maturity. An early claim is made only when the user chose Instant (`claimEarly`). + config.maxDepositClaimFee = MaxFee( + onchain: .networkRecommended(leewaySatPerVbyte: depositClaimFeeLeewaySatPerVbyte), + proportionalPpm: nil + ) + return config + } + + func wipe(walletIndex: Int) async { + if LightningService.shared.channelFreeRail === self { + LightningService.shared.channelFreeRail = nil + } + if let sdk { + if let listenerId { + _ = await sdk.removeEventListener(id: listenerId) + } + try? await sdk.disconnect() + } + sdk = nil + listenerId = nil + isReady = false + balanceSats = 0 + pendingDepositSats = 0 + recoverableFundsSats = 0 + deposits = [] + try? FileManager.default.removeItem(at: Env.sparkStorage(walletIndex: walletIndex)) + onStateChanged?() + } + + // MARK: ChannelFreeLightningRail + + nonisolated func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { + let method = ReceivePaymentMethod.bolt11Invoice( + description: description, + amountSats: amountSats, + expirySecs: expirySecs, + paymentHash: nil, + receiverIdentityPublicKey: nil + ) + return try await requireSdk().receivePayment(request: ReceivePaymentRequest(paymentMethod: method)).paymentRequest + } + + /// Errors before `sendPayment` are `ChannelFreeRailSendRefused`, since nothing left the wallet; a failure inside it + /// stays uncertain, as the payment may have been dispatched. + nonisolated func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash { + let prepared: PrepareSendPaymentResponse + let sdk: BreezSdk + do { + sdk = try await requireSdk() + prepared = try await prepareSend(paymentRequest: bolt11, amountSats: sats) + } catch { + throw ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment", underlying: error) + } + guard case let .bolt11Invoice(invoiceDetails, _, _) = prepared.paymentMethod else { + throw ChannelFreeRailSendRefused(reason: "Unexpected Spark payment method: \(prepared.paymentMethod)") + } + let response = try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: Self.bolt11SendOptions + )) + Logger.info("Sent Spark payment '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") + return invoiceDetails.paymentHash + } + + nonisolated func lightningPayments() async -> [ChannelFreeRailPayment] { + guard let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments else { return [] } + return payments.compactMap { payment in + guard case let .lightning(_, _, _, htlcDetails, _, _, _, _) = payment.details else { return nil } + return ChannelFreeRailPayment( + paymentHash: htlcDetails.paymentHash, + direction: payment.paymentType == .send ? .outbound : .inbound, + status: Self.ldkStatus(payment.status), + preimage: htlcDetails.preimage + ) + } + } + + nonisolated func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 { + let prepared = try await prepareSend(paymentRequest: bolt11, amountSats: amountSats) + guard case let .bolt11Invoice(_, sparkTransferFeeSats, lightningFeeSats) = prepared.paymentMethod else { return 0 } + if Self.prefersSpark, let sparkTransferFeeSats { + return sparkTransferFeeSats + } + return lightningFeeSats + } + + // MARK: Transfers + + /// The static on-chain address that funds the Spark wallet, claimed automatically once confirmed. + func depositAddress() async throws -> String { + let request = ReceivePaymentRequest(paymentMethod: .bitcoinAddress(newAddress: nil)) + return try await requireSdk().receivePayment(request: request).paymentRequest + } + + /// Size of Spark's deposit claim transaction; the SDK prices a standard claim as the fastest recommended fee rate + /// times this size before the provider quotes it (breez/spark-sdk `CLAIM_TX_SIZE_VBYTES`). + private nonisolated static let claimTxSizeVbytes: UInt64 = 99 + + /// What claiming a deposit of `amountSats` costs for each speed. The provider quotes the real fee only once the + /// deposit exists, so Instant is the ceiling the user accepts and Later the mature claim at the half-hour rate. + func depositClaimFees(amountSats: UInt64) async throws -> SparkClaimFees { + let fees = try await requireSdk().recommendedFees() + return SparkClaimFees( + instantMaxSats: Self.maxDepositClaimFeeSats(fastestFeeRate: fees.fastestFee, amountSats: amountSats), + laterSats: Self.matureDepositClaimFeeSats(halfHourFeeRate: fees.halfHourFee) + ) + } + + nonisolated static func matureDepositClaimFeeSats(halfHourFeeRate: UInt64) -> UInt64 { + halfHourFeeRate * claimTxSizeVbytes + } + + nonisolated static func maxDepositClaimFeeSats(fastestFeeRate: UInt64, amountSats: UInt64) -> UInt64 { + let onchain = (fastestFeeRate + depositClaimFeeLeewaySatPerVbyte) * claimTxSizeVbytes + let proportional = amountSats.multipliedReportingOverflow(by: UInt64(depositClaimFeePpm)).partialValue / 1_000_000 + return onchain + proportional + } + + /// How the deposit in `txid` can be claimed, or nil while Spark has not seen it yet. The early quote asks the + /// provider on every call, so this is called once a deposit is listed, not on a timer. + func depositClaimOffer(txid: String) async throws -> SparkClaimOffer? { + let sdk = try await requireSdk() + let unclaimed = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()).deposits + guard let deposit = unclaimed.first(where: { $0.txid.caseInsensitiveCompare(txid) == .orderedSame }) else { return nil } + let quote = try await sdk.fetchClaimDepositQuote(request: FetchClaimDepositQuoteRequest(txid: deposit.txid, vout: deposit.vout)) + return SparkClaimOffer(txid: deposit.txid, vout: deposit.vout, quote: quote) + } + + /// Accepts the early claim of `offer` at its quoted fee. The fee ceiling stays on the deposit, so an early claim + /// not offered at this depth is still made once the provider offers it. + func claimEarly(_ offer: SparkClaimOffer) async throws -> SparkEarlyClaim { + guard case let .choice(earlyFeeSats, _, _, _) = offer.kind else { return .claimed } + let request = ClaimDepositRequest( + txid: offer.txid, + vout: offer.vout, + maxFee: MaxFee(onchain: .fixed(amount: earlyFeeSats), proportionalPpm: nil) + ) + let claim: SparkEarlyClaim + do { + let outcome = try await requireSdk().claimDeposit(request: request).outcome + claim = SparkEarlyClaim(outcome) + } catch SdkError.DepositClaimInProgress { + claim = .claimed + } + Logger.info("Early claim of Spark deposit \(offer.txid): \(claim)", context: "SparkManager") + await refreshState() + return claim + } + + /// Moves the whole Spark balance to the on-chain `address`, fees included, for Spending to Savings. + func withdrawAll(to address: String) async throws { + let prepared = try await prepareWithdrawAll(to: address) + let response = try await requireSdk().sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium) + )) + preparedWithdrawal = nil + if case let .withdraw(txId) = response.payment.details { + sentWithdrawalTxIds.insert(txId) + } + try await refresh() + } + + /// The fee `withdrawAll(to:)` pays at its speed, which comes out of the balance it moves. + func withdrawAllFeeSats(to address: String) async throws -> UInt64 { + let prepared = try await prepareWithdrawAll(to: address) + guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { return 0 } + return feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat + } + + /// Whether `txid` is a withdrawal of this wallet's Spark balance to Savings, so its receipt is a transfer. + func isWithdrawal(txid: String) -> Bool { + Self.isWithdrawal(txid: txid, sent: sentWithdrawalTxIds, withdrawals: withdrawals) + } + + nonisolated static func isWithdrawal(txid: String, sent: Set, withdrawals: [SparkWithdrawal]) -> Bool { + sent.contains(txid) || withdrawals.contains { $0.txid == txid } + } + + /// Reuses the provider's withdrawal quote while it holds for the same address and balance, so the withdrawal + /// pays the fee the confirm screen showed; a second full-balance quote can take minutes. + private func prepareWithdrawAll(to address: String) async throws -> PrepareSendPaymentResponse { + let balanceSats = balanceSats + let now = UInt64(Date().timeIntervalSince1970) + if let preparedWithdrawal, preparedWithdrawal.quote.holds(address: address, balanceSats: balanceSats, now: now) { + return preparedWithdrawal.response + } + let response = try await prepareSend(paymentRequest: address, amountSats: balanceSats, feePolicy: .feesIncluded) + if case let .bitcoinAddress(_, feeQuote) = response.paymentMethod { + let quote = SparkWithdrawalQuote(address: address, balanceSats: balanceSats, expiresAt: feeQuote.expiresAt) + preparedWithdrawal = (quote, response) + } + return response + } + + /// What a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. + func exportExitState() async throws -> String { + try await requireSdk().exportUnilateralExitState().exitState + } + + /// The exit data backup, or nil while Spark is not connected and there is nothing to back up. + func exitBackup() async throws -> SparkExitBackupV1? { + guard isConnected, let network = Self.sparkNetwork else { return nil } + return try await SparkExitBackupV1( + version: 1, + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), + network: "\(network)", + exitState: exportExitState() + ) + } + + /// Puts a restored exit data backup back: at once while Spark is connected, otherwise right after it connects, + /// before it is used, so a restored wallet can exit on-chain even while the Spark operators are unreachable. + func restoreExitBackup(_ backup: SparkExitBackupV1, walletIndex: Int) async throws { + guard let network = Self.sparkNetwork, backup.network == "\(network)" else { + Logger.warn("Skipped Spark exit data from network '\(backup.network)'", context: "SparkManager") + return + } + let file = Env.sparkExitRestore(walletIndex: walletIndex) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(backup.exitState.utf8).write(to: file, options: .atomic) + if isConnected { + await importRestoredExitState(walletIndex: walletIndex) + } + } + + private func importRestoredExitState(walletIndex: Int) async { + let file = Env.sparkExitRestore(walletIndex: walletIndex) + guard let sdk, let data = FileManager.default.contents(atPath: file.path) else { return } + do { + let imported = try await sdk.importUnilateralExitState( + request: ImportUnilateralExitStateRequest(exitState: String(decoding: data, as: UTF8.self)) + ) + try FileManager.default.removeItem(at: file) + Logger.info( + "Imported Spark exit data: '\(imported.importedLeaves)' leaves, '\(imported.skippedForeignLeaves)' foreign, " + + "'\(imported.skippedConflictingLeaves)' conflicting, '\(imported.skippedChains)' already held", + context: "SparkManager" + ) + } catch { + Logger.error("Failed to import Spark exit data: \(error)", context: "SparkManager") + } + } + + // MARK: Private + + private nonisolated func prepareSend( + paymentRequest: String, + amountSats: UInt64?, + feePolicy: FeePolicy? = nil + ) async throws -> PrepareSendPaymentResponse { + try await requireSdk().prepareSendPayment(request: PrepareSendPaymentRequest( + paymentRequest: .input(input: paymentRequest), + amount: amountSats.map { BInt(Int($0)) }, + tokenIdentifier: nil, + conversionOptions: nil, + feePolicy: feePolicy + )) + } + + private nonisolated func requireSdk() async throws -> BreezSdk { + guard let sdk = await sdk else { + throw AppError(message: "Spark wallet is not connected", debugMessage: nil) + } + return sdk + } + + /// Reads the Spark balance and deposits again, for pull to refresh. + /// Lists a deposit the app just broadcast as pending right away, then syncs so Spark sees it sooner than its + /// minute-long background sync; Spark's own deposit state replaces it once listed. + func onDepositBroadcast(txid: String, amountSats: UInt64) { + broadcastDeposits[txid] = amountSats + applyBroadcastDeposits() + onStateChanged?() + Task { + do { + _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) + } catch { + Logger.warn("Failed to sync Spark after deposit \(txid): \(error)", context: "SparkManager") + } + await refreshState() + } + } + + private func applyBroadcastDeposits() { + let known = Set(deposits.map(\.txid)) + let local = broadcastDeposits + .filter { !known.contains($0.key) } + .map { SparkDeposit(txid: $0.key, amountSats: $0.value, isClaimed: false, confirmations: 0) } + guard !local.isEmpty else { return } + deposits = local + deposits + pendingDepositSats = deposits.filter { !$0.isClaimed }.reduce(0) { $0 + $1.amountSats } + } + + func refreshState() async { + guard sdk != nil else { return } + do { + try await refresh() + } catch { + Logger.warn("Failed to refresh Spark state: \(error)", context: "SparkManager") + } + } + + private func refresh() async throws { + let sdk = try await requireSdk() + let info = try await sdk.getInfo(request: GetInfoRequest(ensureSynced: false)) + let unclaimed = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()).deposits + let payments = try await sdk.listPayments(request: ListPaymentsRequest()).payments + let claimed = payments.filter { $0.method == .deposit && $0.status == .completed } + let pending = unclaimed.reduce(0) { $0 + $1.amountSats } + let changed = !isReady || balanceSats != info.balanceSats || pendingDepositSats != pending + isReady = true + balanceSats = info.balanceSats + pendingDepositSats = pending + recoverableFundsSats = info.recoverableFundsSats + deposits = await Self.deposits(unclaimed: unclaimed, claimed: claimed) + withdrawals = Self.withdrawals(payments) + for deposit in deposits { + broadcastDeposits[deposit.txid] = nil + } + applyBroadcastDeposits() + if changed { + onStateChanged?() + } + for payment in payments where sendsRecordedWithoutFee.contains(payment.id) && Self.sats(payment.fees) > 0 { + await recordActivity(payment) + } + for txId in Self.transferTxIds(unclaimed: unclaimed, payments: payments) { + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txId) + } + } + + nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { + payments.compactMap { payment in + guard payment.status != .failed, case let .withdraw(txId) = payment.details else { return nil } + return SparkWithdrawal(txid: txId, amountSats: sats(payment.amount), feeSats: sats(payment.fees)) + } + } + + /// On-chain transactions that moved funds between Savings and Spark, so their activities show as transfers. + nonisolated static func transferTxIds(unclaimed: [DepositInfo], payments: [Payment]) -> [String] { + unclaimed.map(\.txid) + payments.compactMap { payment in + guard payment.status != .failed else { return nil } + switch payment.details { + case let .deposit(txId, _), let .withdraw(txId): return txId + default: return nil + } + } + } + + private static func deposits(unclaimed: [DepositInfo], claimed: [Payment]) async -> [SparkDeposit] { + let confirmations = await onchainConfirmations() + let pending = unclaimed.map { + SparkDeposit(txid: $0.txid, amountSats: $0.amountSats, isClaimed: false, confirmations: confirmations[$0.txid] ?? nil) + } + let done: [SparkDeposit] = claimed.compactMap { payment in + guard case let .deposit(txId, _) = payment.details else { return nil } + return SparkDeposit( + txid: txId, + amountSats: sats(payment.amount), + isClaimed: true, + feeSats: sats(payment.fees), + claimedAt: payment.timestamp + ) + } + return pending + done + } + + /// Confirmations of the on-chain transactions this wallet sent or received, 0 while unconfirmed. + private static func onchainConfirmations() async -> [String: UInt32?] { + let tip = LightningService.shared.status?.currentBestBlock.height + let payments = await LightningService.shared.listPayments() ?? [] + var result: [String: UInt32?] = [:] + for payment in payments { + guard case let .onchain(txid, status) = payment.kind else { continue } + switch status { + case .unconfirmed: + result[txid] = 0 + case let .confirmed(_, height, _): + result[txid] = tip.map { $0 >= height ? $0 - height + 1 : 0 } + } + } + return result + } + + private func syncActivities() async throws { + let payments = try await requireSdk().listPayments(request: ListPaymentsRequest()).payments + for payment in payments { + await recordActivity(payment) + } + } + + private func onSdkEvent(_ event: SdkEvent) async { + do { + switch event { + case .synced, .newDeposits: + try await refresh() + case .claimedDeposits: + try await refresh() + onDepositsClaimed?() + case let .unclaimedDeposits(deposits): + Logger.warn("Found '\(deposits.count)' unclaimed Spark deposits", context: "SparkManager") + case let .paymentSucceeded(payment), let .paymentFailed(payment): + try await refresh() + await recordActivity(payment) + if let event = Self.nodeEvent(for: payment) { + LightningService.shared.emitChannelFreeRailEvent(event) + } + case let .paymentPending(payment): + await recordActivity(payment) + case .unilateralExitStateChanged: + Self.exitStateChanged.send() + default: + break + } + } catch { + Logger.error("Failed to handle Spark event '\(event)': \(error)", context: "SparkManager") + } + } + + private func recordActivity(_ payment: Payment) async { + guard case let .lightning(description, invoice, _, htlcDetails, _, _, _, _) = payment.details else { return } + let details = PaymentDetails( + id: htlcDetails.paymentHash, + kind: .bolt11( + hash: htlcDetails.paymentHash, + preimage: htlcDetails.preimage, + secret: nil, + description: description, + bolt11: invoice + ), + amountMsat: Self.sats(payment.amount) * 1000, + feePaidMsat: Self.sats(payment.fees) * 1000, + direction: payment.paymentType == .send ? .outbound : .inbound, + status: Self.ldkStatus(payment.status), + latestUpdateTimestamp: payment.timestamp + ) + if payment.paymentType == .send, Self.sats(payment.fees) == 0 { + sendsRecordedWithoutFee.insert(payment.id) + } else { + sendsRecordedWithoutFee.remove(payment.id) + } + do { + try await CoreService.shared.activity.recordChannelFreeRailPayment(details) + } catch { + Logger.error("Failed to record Spark payment '\(payment.id)': \(error)", context: "SparkManager") + } + } + + private static func nodeEvent(for payment: Payment) -> Event? { + guard case let .lightning(_, _, _, htlcDetails, _, _, _, _) = payment.details else { return nil } + let hash = htlcDetails.paymentHash + switch (payment.paymentType, payment.status) { + case (.receive, .completed): + return .paymentReceived(paymentId: hash, paymentHash: hash, amountMsat: sats(payment.amount) * 1000, customRecords: []) + case (.send, .completed): + return .paymentSuccessful( + paymentId: hash, + paymentHash: hash, + paymentPreimage: htlcDetails.preimage, + feePaidMsat: sats(payment.fees) * 1000 + ) + case (.send, .failed): + return .paymentFailed(paymentId: hash, paymentHash: hash, reason: .unexpectedError) + default: + return nil + } + } + + private nonisolated static func ldkStatus(_ status: BreezSdkSpark.PaymentStatus) -> LDKNode.PaymentStatus { + switch status { + case .completed: .succeeded + case .pending: .pending + case .failed: .failed + } + } + + private nonisolated static func sats(_ value: U128) -> UInt64 { + UInt64(value.description) ?? 0 + } +} + +/// An on-chain deposit into Spark: pending until Spark claims it after 3 confirmations. +struct SparkDeposit: Identifiable, Equatable { + let txid: String + let amountSats: UInt64 + let isClaimed: Bool + var feeSats: UInt64 = 0 + var claimedAt: UInt64? + /// On-chain confirmations, known for deposits this wallet sent. + var confirmations: UInt32? + + var id: String { + "\(txid)-\(isClaimed)" + } +} + +/// How a deposit into Spark can be claimed. The provider decides per deposit whether to credit it early, at 0 +/// confirmations or 1-2, for a fee above the standard claim's at 3 confirmations. +struct SparkClaimOffer: Equatable { + enum Kind: Equatable { + /// No early claim is offered, so Spark claims it at `confirmations` for `feeSats`. + case standard(feeSats: UInt64, confirmations: UInt32) + /// The early claim fits this wallet's fee cap, so Spark claims it at `confirmations` by itself. + case automatic(feeSats: UInt64, confirmations: UInt32) + /// The early claim costs more than the cap, so it waits for the user to accept `earlyFeeSats`. + case choice(earlyFeeSats: UInt64, earlyConfirmations: UInt32, standardFeeSats: UInt64, standardConfirmations: UInt32) + } + + let txid: String + let vout: UInt32 + var kind: Kind + + init(txid: String, vout: UInt32, kind: Kind) { + self.txid = txid + self.vout = vout + self.kind = kind + } + + init(txid: String, vout: UInt32, quote: FetchClaimDepositQuoteResponse) { + let mature = quote.mature + let kind: Kind = if let early = quote.instant { + if let cap = early.maxFeeSats, early.feeSats <= cap { + .automatic(feeSats: early.feeSats, confirmations: early.confirmationsRequired) + } else { + .choice( + earlyFeeSats: early.feeSats, + earlyConfirmations: early.confirmationsRequired, + standardFeeSats: mature.feeSats, + standardConfirmations: mature.confirmationsRequired + ) + } + } else { + .standard(feeSats: mature.feeSats, confirmations: mature.confirmationsRequired) + } + self.init(txid: txid, vout: vout, kind: kind) + } +} + +/// What claiming a deposit costs: at most `instantMaxSats` to add it before confirmations, about `laterSats` to add it +/// after 3 confirmations. +struct SparkClaimFees: Equatable { + let instantMaxSats: UInt64 + let laterSats: UInt64 +} + +/// What accepting an early claim did. +enum SparkEarlyClaim: Equatable { + /// The claim is submitted and the deposit reaches the balance shortly. + case claimed + /// Not offered at this depth yet; Spark claims it early once a confirmation brings the offer. + case waiting + /// The provider now asks `requiredFeeSats`, more than was accepted. + case repriced(requiredFeeSats: UInt64) + /// The provider declined it, so Spark claims the deposit at 3 confirmations. + case declined + + init(_ outcome: ClaimDepositOutcome) { + switch outcome { + case .settled, .submitted: + self = .claimed + case let .deferred(reason): + switch reason { + case .noEarlyClaimAvailable: self = .waiting + case let .maxFeeExceeded(requiredFeeSats, _): self = .repriced(requiredFeeSats: requiredFeeSats) + case .providerDeclined: self = .declined + } + } + } +} + +/// A Transfer to Savings out of Spark, with what Spark kept for it. +/// A withdrawal quote the provider issued; it holds for the same address and balance until shortly before it expires, +/// so it can't expire mid-send. An estimate, which no provider issued, expires at 0 and never holds. +struct SparkWithdrawalQuote: Equatable { + static let expiryMarginSecs: UInt64 = 30 + + let address: String + let balanceSats: UInt64 + let expiresAt: UInt64 + + func holds(address: String, balanceSats: UInt64, now: UInt64) -> Bool { + self.address == address && self.balanceSats == balanceSats && expiresAt > now + Self.expiryMarginSecs + } +} + +struct SparkWithdrawal: Identifiable, Equatable { + let txid: String + let amountSats: UInt64 + let feeSats: UInt64 + + var id: String { + txid + } +} + +private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked Sendable { + private let handler: @Sendable (SdkEvent) async -> Void + + init(handler: @escaping @Sendable (SdkEvent) async -> Void) { + self.handler = handler + } + + func onEvent(event: SdkEvent) async { + await handler(event) + } +} diff --git a/Bitkit/Models/BackupCategory.swift b/Bitkit/Models/BackupCategory.swift index 1ea1acb40..8b3b90017 100644 --- a/Bitkit/Models/BackupCategory.swift +++ b/Bitkit/Models/BackupCategory.swift @@ -9,6 +9,7 @@ enum BackupCategory: String, CaseIterable { case settings = "SETTINGS" case widgets = "WIDGETS" case metadata = "METADATA" + case sparkExit = "SPARK_EXIT" } // MARK: - UI Extensions @@ -32,6 +33,8 @@ extension BackupCategory { return "stack" case .metadata: return "tag" + case .sparkExit: + return "bolt-hollow" } } @@ -53,6 +56,8 @@ extension BackupCategory { return t("settings__backup__category_widgets") case .metadata: return t("settings__backup__category_tags") + case .sparkExit: + return t("settings__backup__category_spark_exit") } } } diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 5992a353d..a083b111c 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -69,6 +69,8 @@ struct AppCacheData: Codable { let hasSeenNotificationsIntro: Bool let hasSeenQuickpayIntro: Bool let hasSeenShopIntro: Bool + let hasSeenTransferIntro: Bool + let hasSeenTransferToSpendingIntro: Bool let hasSeenTransferToSavingsIntro: Bool let hasSeenWidgetsIntro: Bool let hasDismissedWidgetsOnboardingHint: Bool @@ -89,6 +91,8 @@ struct AppCacheData: Codable { hasSeenNotificationsIntro: Bool, hasSeenQuickpayIntro: Bool, hasSeenShopIntro: Bool, + hasSeenTransferIntro: Bool = false, + hasSeenTransferToSpendingIntro: Bool = false, hasSeenTransferToSavingsIntro: Bool, hasSeenWidgetsIntro: Bool, hasDismissedWidgetsOnboardingHint: Bool, @@ -106,6 +110,8 @@ struct AppCacheData: Codable { self.hasSeenNotificationsIntro = hasSeenNotificationsIntro self.hasSeenQuickpayIntro = hasSeenQuickpayIntro self.hasSeenShopIntro = hasSeenShopIntro + self.hasSeenTransferIntro = hasSeenTransferIntro + self.hasSeenTransferToSpendingIntro = hasSeenTransferToSpendingIntro self.hasSeenTransferToSavingsIntro = hasSeenTransferToSavingsIntro self.hasSeenWidgetsIntro = hasSeenWidgetsIntro self.hasDismissedWidgetsOnboardingHint = hasDismissedWidgetsOnboardingHint @@ -127,6 +133,8 @@ struct AppCacheData: Codable { hasSeenNotificationsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenNotificationsIntro) ?? false hasSeenQuickpayIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenQuickpayIntro) ?? false hasSeenShopIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenShopIntro) ?? false + hasSeenTransferIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferIntro) ?? false + hasSeenTransferToSpendingIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSpendingIntro) ?? false hasSeenTransferToSavingsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSavingsIntro) ?? false hasSeenWidgetsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenWidgetsIntro) ?? false hasDismissedWidgetsOnboardingHint = try c.decodeIfPresent(Bool.self, forKey: .hasDismissedWidgetsOnboardingHint) ?? false @@ -143,7 +151,7 @@ struct AppCacheData: Codable { private enum CodingKeys: String, CodingKey { case hasSeenContactsIntro, hasSeenProfileIntro, hasSeenNotificationsIntro, hasSeenQuickpayIntro - case hasSeenShopIntro, hasSeenTransferToSavingsIntro + case hasSeenShopIntro, hasSeenTransferIntro, hasSeenTransferToSpendingIntro, hasSeenTransferToSavingsIntro case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp case dismissedSuggestions, lastUsedTags @@ -220,3 +228,12 @@ struct WidgetsBackupV1 { return try JSONSerialization.data(withJSONObject: dict) } } + +/// The Spark wallet's unilateral exit data, as `exportUnilateralExitState` returns it, kept so a wallet restored on +/// another device can still exit on-chain while the Spark operators are unreachable. +struct SparkExitBackupV1: Codable { + let version: Int + let createdAt: UInt64 + let network: String + let exitState: String +} diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 03a769836..b494f9e6e 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -21,6 +21,8 @@ enum SettingsBackupConfig { "hasSeenNotificationsIntro", "hasSeenQuickpayIntro", "hasSeenShopIntro", + "hasSeenTransferIntro", + "hasSeenTransferToSpendingIntro", "hasSeenTransferToSavingsIntro", "hasSeenWidgetsIntro", "hasDismissedWidgetsOnboardingHint", diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 5e0d9209d..2ba6f3041 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -170,11 +170,19 @@ "fee__custom__description" = "يعتمد على الرسوم"; "fee__custom__shortDescription" = "يعتمد على الرسوم"; "fee__custom__range" = "يعتمد على الرسوم"; +"lightning__transfer_intro__title" = "رصيد\nالإنفاق"; +"lightning__transfer_intro__text" = "موّل رصيد إنفاقك للاستمتاع بمعاملات فورية ورخيصة مع الأصدقاء والعائلة والتجار."; +"lightning__transfer_intro__button" = "ابدأ"; +"lightning__funding__text" = "أسهل خيار هو استخدام مدخرات Bitkit الخاصة بك أو إرسال بيتكوين من محفظة مختلفة."; "lightning__funding__title" = "موّل رصيد إنفاقك"; "lightning__funding__button1" = "تحويل من المدخرات"; "lightning__funding__button2" = "استخدام محفظة أخرى"; "lightning__funding__button3" = "إعداد يدوي"; "lightning__funding_advanced__button_short" = "إعداد متقدم"; +"lightning__no_funds__title" = "لا توجد أموال متاحة"; +"lightning__no_funds__description" = "قبل أن تتمكن من تحويل الأموال من رصيد مدخراتك، تحتاج إلى إرسال Bitcoin إلى محفظة Bitkit الخاصة بك."; +"lightning__no_funds__fund" = "تمويل المحفظة"; +"lightning__spending_amount__title" = "تحويل\nإلى الإنفاق"; "lightning__transfer__nav_title" = "تحويل الأموال"; "lightning__transfer__confirm" = "يرجى\nالتأكيد"; "lightning__transfer__swipe" = "اسحب للتحويل"; @@ -182,15 +190,29 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "ربما تم بث معاملة التمويل. يرجى التحقق من نشاطك قبل المحاولة مرة أخرى."; "lightning__transfer_hw__reconnect_error_description" = "يرجى إعادة توصيل جهازك، يبدو أنه غير متصل بهاتفك."; "lightning__transfer_hw__reconnect_error_title" = "إعادة توصيل الجهاز"; +"lightning__transfer_hw__sign_title" = "وقّع باستخدام\nجهازك"; +"lightning__transfer_hw__signed_title" = "تم توقيع\nالمعاملة"; "lightning__transfer_to_savings_button" = "تحويل إلى المدخرات"; "lightning__transfer_to_spending_button" = "تحويل إلى الإنفاق"; +"lightning__spending_intro__title" = "تحويل\nإلى الإنفاق"; +"lightning__spending_intro__text" = "حوّل الأموال إلى رصيد إنفاقك للاستمتاع بمعاملات فورية ورخيصة مع الأصدقاء والعائلة والتجار."; +"lightning__spending_intro__button" = "ابدأ"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "الحد الأقصى لرصيد الإنفاق"; "lightning__spending_amount__error_max__description" = "المبلغ الذي يمكنك تحويله إلى رصيد الإنفاق محدود حاليًا بـ ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "رسوم الشبكة"; "lightning__spending_confirm__lsp_fee" = "رسوم الخدمة"; "lightning__spending_confirm__amount" = "إلى الإنفاق"; "lightning__spending_confirm__total" = "الإجمالي"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "تغيّرت الرسوم"; +"lightning__spending_confirm__fees_changed_network" = "الرسوم أعلى بمقدار {amount} لأن معدلات الرسوم أو رصيد مدخراتك تغيّر أثناء وجودك في هذه الشاشة. اسحب مرة أخرى للتأكيد."; "lightning__savings_intro__title" = "تحويل\nإلى المدخرات"; "lightning__savings_intro__text" = "حوّل رصيد إنفاقك إلى مدخراتك لتخزين Bitcoin على المدى الطويل وتأمين مستقبلك."; "lightning__savings_intro__button" = "ابدأ"; @@ -202,6 +224,7 @@ "lightning__savings_confirm__amount_too_low" = "المبلغ منخفض جدًا للتحويل إلى المدخرات."; "lightning__savings_confirm__close_instead" = "إغلاق القناة بدلاً من ذلك"; "lightning__savings_confirm__network_fee" = "رسوم الشبكة"; +"lightning__savings_confirm__spark_fee" = "رسوم Spark"; "lightning__savings_confirm__receive" = "ستستلم"; "lightning__savings_confirm__service_fee" = "رسوم الخدمة"; "lightning__savings_advanced__title" = "اختر الأموال\nللتحويل"; @@ -214,6 +237,12 @@ "lightning__savings_interrupted__nav_title" = "تحويل\nمتقطع"; "lightning__savings_interrupted__title" = "أبقِ Bitkit\nمفتوحًا"; "lightning__savings_interrupted__text" = "لم تُحوَّل الأموال بعد. سيحاول Bitkit بدء التحويل خلال الـ 30 دقيقة القادمة. يرجى إبقاء التطبيق مفتوحًا."; +"lightning__savings_spark_failed__title" = "فشل\nالتحويل"; +"lightning__savings_spark_failed__text" = "تعذّر نقل رصيد Spark إلى المدخرات، ويبقى في رصيد الإنفاق."; +"lightning__savings_spark_failed__text_partial" = "يجري نقل أموال قنواتك إلى المدخرات، لكن تعذّر نقل رصيد Spark ويبقى في رصيد الإنفاق."; +"lightning__savings_spark_failed__toast" = "فشل تحويل Spark"; +"lightning__transfer_success__nav_title" = "نجح التحويل"; +"lightning__transfer_success__title_spending" = "انتقلت الأموال\nإلى الإنفاق"; "lightning__transfer_success__title_savings" = "انتقلت الأموال\nإلى المدخرات"; "lightning__transfer_success__text_savings" = "تم تحويل أموالك. يمكنك استخدام هذه الأموال فورًا."; "lightning__external__nav_title" = "اتصال Lightning"; @@ -232,6 +261,11 @@ "lightning__savings" = "المدخرات"; "lightning__spending_label" = "رصيد الإنفاق"; "lightning__receiving_label" = "سعة الاستلام"; +"lightning__setting_up_step1" = "جارٍ معالجة الدفع"; +"lightning__setting_up_step2" = "نجح الدفع"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "متابعة استخدام Bitkit"; "lightning__node_info" = "عقدة Lightning"; "lightning__connections" = "اتصالات Lightning"; "lightning__conn_pending" = "الاتصالات المعلقة"; @@ -258,6 +292,36 @@ "lightning__transaction" = "المعاملة"; "lightning__order_fee" = "رسوم الطلب"; "lightning__balance" = "الرصيد"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "رسوم المطالبة"; +"lightning__spark_confirmations" = "{count}/3 تأكيدات"; +"lightning__spark_deposit" = "إيداع"; +"lightning__spark_deposit__choice" = "يمكن لـ Spark إضافته {when} مقابل رسوم أعلى، أو بعد {standard} تأكيدات، أي بعد نحو {minutes} دقيقة."; +"lightning__spark_deposit__claim_early" = "استخدمه {when}"; +"lightning__spark_deposit__claimed" = "يضيفه Spark إلى رصيد الإنفاق."; +"lightning__spark_deposit__early_fee" = "رسوم الاستخدام {when}"; +"lightning__spark_deposit__later" = "بعد {count} تأكيدات"; +"lightning__spark_deposit__later_one" = "بعد تأكيد واحد"; +"lightning__spark_deposit__looking" = "بانتظار أن يرى Spark تحويلك."; +"lightning__spark_deposit__now" = "الآن"; +"lightning__spark_deposit__standard" = "يضيفه Spark إلى رصيد الإنفاق بعد {standard} تأكيدات، أي بعد نحو {minutes} دقيقة."; +"lightning__spark_deposit__standard_fee" = "رسوم الانتظار"; +"lightning__spark_deposit__wait_button" = "انتظر"; +"lightning__spark_deposit__waiting" = "يضيفه Spark إلى رصيد الإنفاق {when}."; +"lightning__spark_deposit__waiting_next" = "يضيفه Spark إلى رصيد الإنفاق بعد التأكيد التالي."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "إيداعات تمت المطالبة بها"; +"lightning__spark_deposits_pending" = "إيداعات قيد التنفيذ"; +"lightning__spark_provider" = "المزوّد"; +"lightning__spark_provider_value" = "Spark عبر Breez"; +"lightning__spark_receiving_unlimited" = "بلا حد"; +"lightning__spark_trust" = "يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "رصيد الاحتياطي (غير قابل للإنفاق)"; "lightning__total_size" = "الحجم الإجمالي للقناة"; "lightning__fees" = "الرسوم"; @@ -691,6 +755,7 @@ "settings__backup__category_transaction_log" = "سجل المعاملات"; "settings__backup__category_wallet" = "التعزيزات والتحويلات"; "settings__backup__category_settings" = "الإعدادات"; +"settings__backup__category_spark_exit" = "بيانات الخروج من Spark"; "settings__backup__category_widgets" = "الأدوات"; "settings__backup__category_tags" = "العلامات"; "wallet__payment_recipient_rejected" = "رفض المستلم دفعة Lightning هذه. تحقق من الفاتورة وحاول مرة أخرى."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index c00df3068..78a612fe0 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__slow__longRange" = "± 1-2 hores"; "fee__custom__longTitle" = "Personalitzat"; "fee__custom__longRange" = "Depèn de la tarifa"; +"lightning__transfer_intro__title" = "Saldo de\ndespesa"; +"lightning__transfer_intro__text" = "Finança el teu saldo de despesa per gaudir de transaccions instantànies i barates amb amics, família i comerciants."; +"lightning__transfer_intro__button" = "Començar"; "lightning__funding__nav_title" = "Saldo de despesa"; +"lightning__funding__text" = "La forma més fàcil és utilitzar els teus estalvis de Bitkit o enviar bitcoin des d\'una altra cartera."; "lightning__funding__title" = "Finança el teu saldo de despesa"; "lightning__funding__button1" = "Transferir des d\'estalvis"; "lightning__funding__button2" = "Utilitza un altre moneder"; "lightning__funding__button3" = "Configuració manual"; "lightning__funding_advanced__button_short" = "Configuració avançada"; +"lightning__no_funds__title" = "No hi ha fons disponibles"; +"lightning__no_funds__description" = "Abans de poder transferir fons del teu saldo d'estalvis, has d'enviar bitcoin a la teva cartera Bitkit."; +"lightning__no_funds__fund" = "Finançar cartera"; +"lightning__spending_amount__title" = "Transferir\na despesa"; "lightning__transfer__nav_title" = "Transferir fons"; "lightning__transfer__confirm" = "Si us plau\nconfirma"; "lightning__transfer__swipe" = "Llisca per transferir"; @@ -208,16 +216,30 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "És possible que la teva transacció de finançament s'hagi difós. Comprova la teva activitat abans de tornar-ho a provar."; "lightning__transfer_hw__reconnect_error_description" = "Torna a connectar el teu dispositiu de maquinari; sembla que s'ha desconnectat del telèfon."; "lightning__transfer_hw__reconnect_error_title" = "Torna a connectar el dispositiu"; +"lightning__transfer_hw__sign_title" = "Signa amb\nel teu dispositiu"; +"lightning__transfer_hw__signed_title" = "Transacció\nsignada"; "lightning__transfer_to_savings_button" = "Transferir a estalvis"; "lightning__transfer_to_spending_button" = "Transferir a despesa"; +"lightning__spending_intro__title" = "Transferir\na despesa"; +"lightning__spending_intro__text" = "Transfereix fons al teu saldo de despesa per gaudir de transaccions instantànies i barates amb amics, família i comerciants."; +"lightning__spending_intro__button" = "Començar"; "lightning__transfer_failed__continue" = "Torna a intentar"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Màxim del saldo de despesa"; "lightning__spending_amount__error_max__description" = "L'import que pots transferir al teu saldo de despesa està limitat a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Comissions de xarxa"; "lightning__spending_confirm__lsp_fee" = "Comissions de servei"; "lightning__spending_confirm__amount" = "A despesa"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Les tarifes han canviat"; +"lightning__spending_confirm__fees_changed_network" = "Les tarifes són {amount} més altes perquè les taxes de tarifa o el teu saldo d'estalvis han canviat mentre eres en aquesta pantalla. Torna a desplaçar per confirmar."; "lightning__savings_intro__title" = "Transferir\na estalvis"; "lightning__savings_intro__text" = "Transfereix el teu saldo de despesa als teus estalvis per emmagatzemar els teus Bitcoin a llarg termini i assegurar el teu futur."; "lightning__savings_intro__button" = "Començar"; @@ -229,6 +251,7 @@ "lightning__savings_confirm__amount_too_low" = "L'import és massa baix per transferir-lo a estalvis."; "lightning__savings_confirm__close_instead" = "Tancar el canal"; "lightning__savings_confirm__network_fee" = "Tarifa de xarxa"; +"lightning__savings_confirm__spark_fee" = "Tarifa de Spark"; "lightning__savings_confirm__receive" = "Rebràs"; "lightning__savings_confirm__service_fee" = "Tarifa de servei"; "lightning__savings_advanced__title" = "Selecciona fons\nper transferir"; @@ -241,6 +264,12 @@ "lightning__savings_interrupted__nav_title" = "Transferència\ninterrompuda"; "lightning__savings_interrupted__title" = "Mantén Bitkit\nobert"; "lightning__savings_interrupted__text" = "Els fons encara no s\'han transferit. Bitkit intentarà iniciar la transferència durant els pròxims 30 minuts. Si us plau, mantén l\'aplicació oberta."; +"lightning__savings_spark_failed__title" = "Transferència\nfallida"; +"lightning__savings_spark_failed__text" = "El teu saldo Spark no s'ha pogut moure a l'estalvi i es queda al teu saldo de despesa."; +"lightning__savings_spark_failed__text_partial" = "Els fons dels teus canals s'estan movent a l'estalvi, però el teu saldo Spark no s'ha pogut moure i es queda al teu saldo de despesa."; +"lightning__savings_spark_failed__toast" = "Transferència Spark fallida"; +"lightning__transfer_success__nav_title" = "Transferència realitzada"; +"lightning__transfer_success__title_spending" = "Fons transferits\na despesa"; "lightning__transfer_success__title_savings" = "Fons transferits\na estalvis"; "lightning__transfer_success__text_savings" = "Els teus fons s\'han transferit. Pots utilitzar aquests fons immediatament."; "lightning__external__nav_title" = "Connexió Lightning"; @@ -261,6 +290,11 @@ "lightning__savings" = "Estalvis"; "lightning__spending_label" = "Saldo de despesa"; "lightning__receiving_label" = "Capacitat de recepció"; +"lightning__setting_up_step1" = "Processant pagament"; +"lightning__setting_up_step2" = "Pagament realitzat amb èxit"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continua utilitzant Bitkit"; "lightning__node_info" = "Node Lightning"; "lightning__node_id" = "ID de Node LDK"; "lightning__node_disconnected" = "desconnectat"; @@ -295,6 +329,36 @@ "lightning__transaction" = "Transacció"; "lightning__order_fee" = "Comissió de comanda"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Comissió de reclamació"; +"lightning__spark_confirmations" = "{count}/3 confirmacions"; +"lightning__spark_deposit" = "Dipòsit"; +"lightning__spark_deposit__choice" = "Spark pot afegir-ho {when} per una comissió més alta, o després de {standard} confirmacions, d'aquí a uns {minutes} minuts."; +"lightning__spark_deposit__claim_early" = "Fes-ho servir {when}"; +"lightning__spark_deposit__claimed" = "Spark ho està afegint al teu saldo de despeses."; +"lightning__spark_deposit__early_fee" = "Comissió per fer-ho servir {when}"; +"lightning__spark_deposit__later" = "després de {count} confirmacions"; +"lightning__spark_deposit__later_one" = "després d'1 confirmació"; +"lightning__spark_deposit__looking" = "Esperant que Spark vegi la teva transferència."; +"lightning__spark_deposit__now" = "ara"; +"lightning__spark_deposit__standard" = "Spark ho afegeix al teu saldo de despeses després de {standard} confirmacions, d'aquí a uns {minutes} minuts."; +"lightning__spark_deposit__standard_fee" = "Comissió per esperar"; +"lightning__spark_deposit__wait_button" = "Espera"; +"lightning__spark_deposit__waiting" = "Spark ho afegeix al teu saldo de despeses {when}."; +"lightning__spark_deposit__waiting_next" = "Spark ho afegeix al teu saldo de despeses després de la confirmació següent."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Dipòsits reclamats"; +"lightning__spark_deposits_pending" = "Dipòsits en curs"; +"lightning__spark_provider" = "Proveïdor"; +"lightning__spark_provider_value" = "Spark mitjançant Breez"; +"lightning__spark_receiving_unlimited" = "Sense límit"; +"lightning__spark_trust" = "Aquest saldo es guarda a Spark, gestionat per Lightspark, Breez i Flashnet. Està segur mentre almenys un d’ells actuï amb honestedat. Poden pausar l’accés, i sortir-ne sense ells requereix una sortida on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva (no gastable)"; "lightning__total_size" = "Mida total del canal"; "lightning__fees" = "Comissions"; @@ -787,6 +851,7 @@ "settings__backup__category_transaction_log" = "Registre de transaccions"; "settings__backup__category_wallet" = "Impulsos i transferències"; "settings__backup__category_settings" = "Configuració"; +"settings__backup__category_spark_exit" = "Dades de sortida de Spark"; "settings__backup__category_widgets" = "Ginys"; "settings__backup__category_tags" = "Etiquetes"; "settings__backup__category_profile" = "Perfil"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 1c04e6f03..755de70ef 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Odvíjí se od poplatku"; "fee__custom__shortRange" = "Odvíjí se od poplatku"; "fee__custom__longRange" = "Odvíjí se od poplatku"; +"lightning__transfer_intro__title" = "Disponibilní\nZůstatek"; +"lightning__transfer_intro__text" = "Navyšte svůj disponibilní zůstatek a užívejte si okamžité a levné transakce s přáteli, rodinou a obchodníky."; +"lightning__transfer_intro__button" = "Začít"; "lightning__funding__nav_title" = "Dostupný zůstatek"; +"lightning__funding__text" = "Nejjednodušší možností je použít úspory z Bitkitu nebo poslat bitcoin z jiné peněženky."; "lightning__funding__title" = "Navyšte svůj disponibilní zůstatek"; "lightning__funding__button1" = "Převod z úspor"; "lightning__funding__button2" = "Použijte jinou peněženku"; "lightning__funding__button3" = "Manuální nastavení"; "lightning__funding_advanced__button_short" = "Pokročilé nastavení"; +"lightning__no_funds__title" = "Žádné dostupné prostředky"; +"lightning__no_funds__description" = "Než budete moci převést prostředky ze svého zůstatku na spořicím účtu, musíte poslat bitcoiny do peněženky Bitkit."; +"lightning__no_funds__fund" = "Nabít peněženku"; +"lightning__spending_amount__title" = "Převést\ndo výdajů"; "lightning__transfer__nav_title" = "Převést prostředky"; "lightning__transfer__confirm" = "Prosím,\npotvrďte"; "lightning__transfer__custom_fee" = "Vlastní poplatek"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Vaše financující transakce mohla být odeslána do sítě. Než to zkusíte znovu, zkontrolujte prosím svou aktivitu."; "lightning__transfer_hw__reconnect_error_description" = "Připojte prosím znovu své hardwarové zařízení, zdá se, že je od telefonu odpojené."; "lightning__transfer_hw__reconnect_error_title" = "Znovu připojte hardwarové zařízení"; +"lightning__transfer_hw__sign_title" = "Podepište\nsvým zařízením"; +"lightning__transfer_hw__signed_title" = "Transakce\npodepsána"; "lightning__transfer_to_savings_button" = "Převést do úspor"; "lightning__transfer_to_spending_button" = "Převést do útraty"; +"lightning__spending_intro__title" = "Převést\ndo výdajů"; +"lightning__spending_intro__text" = "Převeďte peníze na svůj disponibilní zůstatek a využívejte okamžité a levné transakce s přáteli, rodinou a obchodníky."; +"lightning__spending_intro__button" = "Začít"; "lightning__transfer_failed__nav_title" = "Selhání převodu"; "lightning__transfer_failed__title" = "Převod\nneúspěšný"; "lightning__transfer_failed__text" = "Omlouváme se, ale Bitkit se nepodařilo nastavit váš disponibilní zůstatek nebo dokončit převod. Zkuste to prosím znovu."; @@ -221,11 +234,20 @@ "lightning__spending_amount__error_min__description" = "K vytvoření disponibilního zůstatku je zapotřebí minimálně ₿ {amount}."; "lightning__spending_amount__error_max__title" = "Maximální disponibilní zůstatek"; "lightning__spending_amount__error_max__description" = "Částka, kterou můžete převést na svůj disponibilní zůstatek, je v současné době omezena na ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Váš převod do disponibilního zůstatku je omezen z důvodu pravidel likvidity. Podrobnosti naleznete v Centru nápovědy."; "lightning__spending_confirm__network_fee" = "Síťové poplatky"; "lightning__spending_confirm__lsp_fee" = "Poplatky za služby"; "lightning__spending_confirm__amount" = "Do výdajů"; "lightning__spending_confirm__total" = "Celkem"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Poplatky se změnily"; +"lightning__spending_confirm__fees_changed_network" = "Poplatky jsou o {amount} vyšší, protože se během zobrazení této obrazovky změnily sazby poplatků nebo zůstatek vašich úspor. Pro potvrzení přejeďte znovu."; "lightning__savings_intro__title" = "Převést\ndo úspor"; "lightning__savings_intro__text" = "Převeďte si disponibilní zůstatek na spoření, abyste si Bitcoiny uložili na delší dobu a zajistili si budoucnost."; "lightning__savings_intro__button" = "Začít"; @@ -237,6 +259,7 @@ "lightning__savings_confirm__amount_too_low" = "Částka je příliš nízká pro převod do úspor."; "lightning__savings_confirm__close_instead" = "Místo toho uzavřít kanál"; "lightning__savings_confirm__network_fee" = "Síťový poplatek"; +"lightning__savings_confirm__spark_fee" = "Poplatek Spark"; "lightning__savings_confirm__receive" = "Obdržíte"; "lightning__savings_confirm__service_fee" = "Poplatek za službu"; "lightning__savings_advanced__title" = "Vyberte prostředky\nk převodu"; @@ -249,6 +272,12 @@ "lightning__savings_interrupted__nav_title" = "Převod\npřerušen"; "lightning__savings_interrupted__title" = "Ponechte Bitkit\notevřený"; "lightning__savings_interrupted__text" = "Prostředky dosud nebyly převedeny. Bitkit se pokusí převod iniciovat během následujících 30 minut. Nechte prosím aplikaci otevřenou."; +"lightning__savings_spark_failed__title" = "Převod\nneúspěšný"; +"lightning__savings_spark_failed__text" = "Zůstatek Spark se nepodařilo přesunout do úspor a zůstává ve vašem zůstatku na útratu."; +"lightning__savings_spark_failed__text_partial" = "Prostředky z kanálů se přesouvají do úspor, ale zůstatek Spark se přesunout nepodařilo a zůstává ve vašem zůstatku na útratu."; +"lightning__savings_spark_failed__toast" = "Převod Spark selhal"; +"lightning__transfer_success__nav_title" = "Přenos úspěšný"; +"lightning__transfer_success__title_spending" = "Prostředky převedeny\ndo výdajů"; "lightning__transfer_success__title_savings" = "Prostředky převedeny\ndo úspor"; "lightning__transfer_success__text_savings" = "Vaše prostředky byly převedeny. Tyto prostředky můžete okamžitě použít."; "lightning__external__nav_title" = "Připojení lightning"; @@ -271,6 +300,11 @@ "lightning__spending_label" = "Disponibilní zůstatek"; "lightning__receiving_label" = "Kapacita příjmu"; "lightning__setting_up_header" = "V převodu"; +"lightning__setting_up_step1" = "Zpracování platby"; +"lightning__setting_up_step2" = "Platba úspěšně proběhla"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Pokračujte v používání Bitkit"; "lightning__node_info" = "Lightning uzel"; "lightning__node_id" = "ID uzlu LDK"; "lightning__node_disconnected" = "Odpojeno"; @@ -311,6 +345,36 @@ "lightning__transaction" = "Transakce"; "lightning__order_fee" = "Poplatek za objednávku"; "lightning__balance" = "Zůstatek"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Poplatek za připsání"; +"lightning__spark_confirmations" = "{count}/3 potvrzení"; +"lightning__spark_deposit" = "Vklad"; +"lightning__spark_deposit__choice" = "Spark ji může připsat {when} za vyšší poplatek, nebo po {standard} potvrzeních, asi za {minutes} minut."; +"lightning__spark_deposit__claim_early" = "Použít {when}"; +"lightning__spark_deposit__claimed" = "Spark ji připisuje na váš zůstatek k utrácení."; +"lightning__spark_deposit__early_fee" = "Poplatek za použití {when}"; +"lightning__spark_deposit__later" = "po {count} potvrzeních"; +"lightning__spark_deposit__later_one" = "po 1 potvrzení"; +"lightning__spark_deposit__looking" = "Čekáme, až Spark uvidí váš převod."; +"lightning__spark_deposit__now" = "hned"; +"lightning__spark_deposit__standard" = "Spark ji připíše na váš zůstatek k utrácení po {standard} potvrzeních, asi za {minutes} minut."; +"lightning__spark_deposit__standard_fee" = "Poplatek za čekání"; +"lightning__spark_deposit__wait_button" = "Počkat"; +"lightning__spark_deposit__waiting" = "Spark ji připíše na váš zůstatek k utrácení {when}."; +"lightning__spark_deposit__waiting_next" = "Spark ji připíše na váš zůstatek k utrácení po dalším potvrzení."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Připsané vklady"; +"lightning__spark_deposits_pending" = "Probíhající vklady"; +"lightning__spark_provider" = "Poskytovatel"; +"lightning__spark_provider_value" = "Spark přes Breez"; +"lightning__spark_receiving_unlimited" = "Bez limitu"; +"lightning__spark_trust" = "Tento zůstatek je uložen na Spark, který provozují Lightspark, Breez a Flashnet. Je v bezpečí, dokud alespoň jeden z nich jedná poctivě. Mohou pozastavit přístup a odchod bez nich vyžaduje on-chain výstup."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Rezervní zůstatek"; "lightning__total_size" = "Celková velikost kanálu"; "lightning__fees" = "Poplatky"; @@ -850,6 +914,7 @@ "settings__backup__category_transaction_log" = "Transakční protokol"; "settings__backup__category_wallet" = "Posílení a převody"; "settings__backup__category_settings" = "Nastavení"; +"settings__backup__category_spark_exit" = "Data pro výstup ze Spark"; "settings__backup__category_widgets" = "Widgety"; "settings__backup__category_tags" = "Tagy"; "settings__backup__category_profile" = "Profil"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index db08ad69f..41d543636 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Abhängig von der Gebühr"; "fee__custom__shortRange" = "Abhängig von der Gebühr"; "fee__custom__longRange" = "Abhängig von der Gebühr"; +"lightning__transfer_intro__title" = "Spending\nBalance"; +"lightning__transfer_intro__text" = "Fülle dein Guthaben auf, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu genießen."; +"lightning__transfer_intro__button" = "Los geht\'s"; "lightning__funding__nav_title" = "Spending Balance"; +"lightning__funding__text" = "Am einfachsten ist es, deine Bitkit-Ersparnisse zu verwenden oder Bitcoin von einer anderen Wallet zu senden."; "lightning__funding__title" = "Fund your spending balance"; "lightning__funding__button1" = "Von Sparkonto übertragen"; "lightning__funding__button2" = "Anderes Wallet verwenden"; "lightning__funding__button3" = "Manuelle Einrichtung"; "lightning__funding_advanced__button_short" = "Fortgeschrittenes Setup"; +"lightning__no_funds__title" = "Keine verfügbaren Mittel"; +"lightning__no_funds__description" = "Bevor du Geld von deinem Sparkonto übertragen kannst, musst du Bitcoin zu deiner Bitkit Wallet senden."; +"lightning__no_funds__fund" = "Wallet aufladen"; +"lightning__spending_amount__title" = "zu den Ausgaben"; "lightning__transfer__nav_title" = "Übertrage Guthaben"; "lightning__transfer__confirm" = "Bitte\nbestätige"; "lightning__transfer__custom_fee" = "Benutzerdefiniert fee"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Deine Finanzierungstransaktion wurde möglicherweise bereits übertragen. Bitte überprüfe deine Aktivität, bevor du es erneut versuchst."; "lightning__transfer_hw__reconnect_error_description" = "Bitte verbinde dein Hardware-Gerät erneut, es scheint von deinem Telefon getrennt zu sein."; "lightning__transfer_hw__reconnect_error_title" = "Hardware-Gerät neu verbinden"; +"lightning__transfer_hw__sign_title" = "Mit deinem\nGerät signieren"; +"lightning__transfer_hw__signed_title" = "Transaktion\nsigniert"; "lightning__transfer_to_savings_button" = "Zu Ersparnissen transferieren"; "lightning__transfer_to_spending_button" = "Zu Ausgaben transferieren"; +"lightning__spending_intro__title" = "zu den Ausgaben"; +"lightning__spending_intro__text" = "Überweisen Sie Geld auf Ihr Guthaben, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu tätigen."; +"lightning__spending_intro__button" = "Los geht\'s"; "lightning__transfer_failed__nav_title" = "Übertragung fehlgeschlagen"; "lightning__transfer_failed__title" = "Übertragung\nfehlgeschlagen"; "lightning__transfer_failed__text" = "Entschuldigung, Bitkit konnte dein Guthaben nicht einrichten oder deine Übertragung nicht abschließen. Bitte versuche es erneut."; @@ -220,10 +233,19 @@ "lightning__spending_amount__error_min__title" = "Minimales Sparguthaben"; "lightning__spending_amount__error_max__title" = "Maximaler Ausgabensaldo"; "lightning__spending_amount__error_max__description" = "Der Betrag, den Sie auf Ihr Ausgabenkonto übertragen können, ist derzeit auf ₿ {amount} begrenzt."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Netzgebühren"; "lightning__spending_confirm__lsp_fee" = "Servicegebühren"; "lightning__spending_confirm__amount" = "Zu den Ausgaben"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Gebühren geändert"; +"lightning__spending_confirm__fees_changed_network" = "Die Gebühren sind um {amount} höher, weil sich die Gebührensätze oder dein Sparguthaben geändert haben, während du auf diesem Bildschirm warst. Wische erneut, um zu bestätigen."; "lightning__savings_intro__title" = "Übertragung\nzu den Einsparungen"; "lightning__savings_intro__text" = "Übertragen Sie Ihr Ausgabensaldo auf Ihr Sparguthaben, um Ihre Bitcoin langfristig zu speichern und Ihre Zukunft zu sichern."; "lightning__savings_intro__button" = "Los geht\'s"; @@ -235,6 +257,7 @@ "lightning__savings_confirm__amount_too_low" = "Der Betrag ist zu niedrig für eine Übertragung auf das Sparkonto."; "lightning__savings_confirm__close_instead" = "Stattdessen Kanal schließen"; "lightning__savings_confirm__network_fee" = "Netzwerkgebühr"; +"lightning__savings_confirm__spark_fee" = "Spark-Gebühr"; "lightning__savings_confirm__receive" = "Du erhältst"; "lightning__savings_confirm__service_fee" = "Servicegebühr"; "lightning__savings_advanced__title" = "Fonds auswählen\nzu übertragen"; @@ -247,6 +270,12 @@ "lightning__savings_interrupted__nav_title" = "Transfer\nunterbrochen"; "lightning__savings_interrupted__title" = "Bitkit behalten\nopen"; "lightning__savings_interrupted__text" = "Die Gelder wurden noch nicht übertragen. Bitkit wird versuchen, die Übertragung in den nächsten 30 Minuten zu initiieren. Bitte halte deine App offen."; +"lightning__savings_spark_failed__title" = "Übertragung\nfehlgeschlagen"; +"lightning__savings_spark_failed__text" = "Dein Spark-Guthaben konnte nicht ins Sparkonto verschoben werden und bleibt in deinem Ausgabenguthaben."; +"lightning__savings_spark_failed__text_partial" = "Dein Kanalguthaben wird ins Sparkonto verschoben, aber dein Spark-Guthaben konnte nicht verschoben werden und bleibt in deinem Ausgabenguthaben."; +"lightning__savings_spark_failed__toast" = "Spark-Transfer fehlgeschlagen"; +"lightning__transfer_success__nav_title" = "Übertragung erfolgreich"; +"lightning__transfer_success__title_spending" = "Bewegte Mittel\nzu den Ausgaben"; "lightning__transfer_success__title_savings" = "Bewegte Mittel\nzu den Einsparungen"; "lightning__transfer_success__text_savings" = "Ihr Geld wurde überwiesen. Sie können diese Mittel sofort verwenden."; "lightning__external__nav_title" = "Lightning-Verbindung"; @@ -269,6 +298,11 @@ "lightning__spending_label" = "Ausgabenguthaben"; "lightning__receiving_label" = "Empfangskapazität"; "lightning__setting_up_header" = "In Übertragung"; +"lightning__setting_up_step1" = "Zahlung wird verarbeitet"; +"lightning__setting_up_step2" = "Zahlung erfolgreich"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Bitkit weiter verwenden"; "lightning__node_info" = "Lightning Node"; "lightning__node_id" = "LDK Node ID"; "lightning__node_disconnected" = "getrennt"; @@ -309,6 +343,36 @@ "lightning__transaction" = "Transaktion"; "lightning__order_fee" = "Auftragsgebühr"; "lightning__balance" = "Guthaben"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Einlösegebühr"; +"lightning__spark_confirmations" = "{count}/3 Bestätigungen"; +"lightning__spark_deposit" = "Einzahlung"; +"lightning__spark_deposit__choice" = "Spark kann es {when} gegen eine höhere Gebühr gutschreiben, oder nach {standard} Bestätigungen, in etwa {minutes} Minuten."; +"lightning__spark_deposit__claim_early" = "{when} nutzen"; +"lightning__spark_deposit__claimed" = "Spark schreibt es deinem Ausgabenguthaben gut."; +"lightning__spark_deposit__early_fee" = "Gebühr für {when}"; +"lightning__spark_deposit__later" = "nach {count} Bestätigungen"; +"lightning__spark_deposit__later_one" = "nach 1 Bestätigung"; +"lightning__spark_deposit__looking" = "Warte, bis Spark deine Überweisung sieht."; +"lightning__spark_deposit__now" = "sofort"; +"lightning__spark_deposit__standard" = "Spark schreibt es deinem Ausgabenguthaben nach {standard} Bestätigungen gut, in etwa {minutes} Minuten."; +"lightning__spark_deposit__standard_fee" = "Gebühr fürs Warten"; +"lightning__spark_deposit__wait_button" = "Warten"; +"lightning__spark_deposit__waiting" = "Spark schreibt es deinem Ausgabenguthaben {when} gut."; +"lightning__spark_deposit__waiting_next" = "Spark schreibt es deinem Ausgabenguthaben nach der nächsten Bestätigung gut."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Eingelöste Einzahlungen"; +"lightning__spark_deposits_pending" = "Laufende Einzahlungen"; +"lightning__spark_provider" = "Anbieter"; +"lightning__spark_provider_value" = "Spark über Breez"; +"lightning__spark_receiving_unlimited" = "Kein Limit"; +"lightning__spark_trust" = "Dieses Guthaben liegt auf Spark, betrieben von Lightspark, Breez und Flashnet. Es ist sicher, solange mindestens einer von ihnen ehrlich handelt. Sie können den Zugang sperren, und ein Ausstieg ohne sie erfordert einen On-Chain-Exit."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Reserveguthaben"; "lightning__total_size" = "Gesamtgröße des Channels"; "lightning__fees" = "Gebühren"; @@ -848,6 +912,7 @@ "settings__backup__category_transaction_log" = "Transaktionshistorie"; "settings__backup__category_wallet" = "Boosts & Übertragungen"; "settings__backup__category_settings" = "Einstellungen"; +"settings__backup__category_spark_exit" = "Spark-Ausstiegsdaten"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profil"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 385a69a9a..3bcdf21e1 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -191,12 +191,20 @@ "fee__custom__range" = "Εξαρτάται από το τέλος"; "fee__custom__shortRange" = "Εξαρτάται από το τέλος"; "fee__custom__longRange" = "Εξαρτάται από την προμήθεια"; +"lightning__transfer_intro__title" = "Υπόλοιπο\nΔαπανών"; +"lightning__transfer_intro__text" = "Χρηματοδοτήστε το υπόλοιπο δαπανών σας για να απολαύσετε άμεσες και φθηνές συναλλαγές με φίλους, οικογένεια και εμπόρους."; +"lightning__transfer_intro__button" = "Ξεκινήστε"; "lightning__funding__nav_title" = "Υπόλοιπο Δαπανών"; +"lightning__funding__text" = "Ο ευκολότερος τρόπος είναι να χρησιμοποιήσεις τις αποταμιεύσεις Bitkit ή να στείλεις bitcoin από διαφορετικό πορτοφόλι."; "lightning__funding__title" = "Χρηματοδοτήστε το υπόλοιπο δαπανών σας"; "lightning__funding__button1" = "Μεταφορά από Αποταμιεύσεις"; "lightning__funding__button2" = "Χρήση Άλλου Πορτοφολιού"; "lightning__funding__button3" = "Χειροκίνητη Ρύθμιση"; "lightning__funding_advanced__button_short" = "Ρύθμιση για Προχωρημένους"; +"lightning__no_funds__title" = "Δεν υπάρχουν διαθέσιμα κεφάλαια"; +"lightning__no_funds__description" = "Πριν μπορέσεις να μεταφέρεις κεφάλαια από το υπόλοιπο αποταμιεύσεων, πρέπει να στείλεις bitcoin στο πορτοφόλι Bitkit."; +"lightning__no_funds__fund" = "Χρηματοδότηση πορτοφολιού"; +"lightning__spending_amount__title" = "Μεταφορά\nστις δαπάνες"; "lightning__transfer__nav_title" = "Μεταφορά Κεφαλαίων"; "lightning__transfer__confirm" = "Παρακαλώ\nεπιβεβαιώστε"; "lightning__transfer__swipe" = "Σύρετε για Μεταφορά"; @@ -204,16 +212,30 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Η συναλλαγή χρηματοδότησης μπορεί να έχει μεταδοθεί. Ελέγξτε τη δραστηριότητά σας πριν δοκιμάσετε ξανά."; "lightning__transfer_hw__reconnect_error_description" = "Σύνδεσε ξανά τη συσκευή υλικού σου, φαίνεται ότι αποσυνδέθηκε από το τηλέφωνό σου."; "lightning__transfer_hw__reconnect_error_title" = "Επανασύνδεση συσκευής υλικού"; +"lightning__transfer_hw__sign_title" = "Υπόγραψε με\nτη συσκευή σου"; +"lightning__transfer_hw__signed_title" = "Η συναλλαγή\nυπογράφηκε"; "lightning__transfer_to_savings_button" = "Μεταφορά στις Αποταμιεύσεις"; "lightning__transfer_to_spending_button" = "Μεταφορά στις Δαπάνες"; +"lightning__spending_intro__title" = "Μεταφορά\nστις δαπάνες"; +"lightning__spending_intro__text" = "Μεταφέρετε κεφάλαια στο υπόλοιπο δαπανών σας για να απολαύσετε άμεσες και φθηνές συναλλαγές με φίλους, οικογένεια και εμπόρους."; +"lightning__spending_intro__button" = "Ξεκινήστε"; "lightning__transfer_failed__continue" = "Προσπαθείστε Ξανά"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Μέγιστο υπόλοιπο δαπανών"; "lightning__spending_amount__error_max__description" = "Το ποσό που μπορείς να μεταφέρεις στο υπόλοιπο δαπανών περιορίζεται προς το παρόν σε ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Τέλη δικτύου"; "lightning__spending_confirm__lsp_fee" = "Τέλη υπηρεσίας"; "lightning__spending_confirm__amount" = "Στις δαπάνες"; "lightning__spending_confirm__total" = "Σύνολο"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Τα τέλη άλλαξαν"; +"lightning__spending_confirm__fees_changed_network" = "Τα τέλη είναι {amount} υψηλότερα, επειδή τα ποσοστά τελών ή το υπόλοιπο αποταμιεύσεων άλλαξαν ενώ ήσουν σε αυτή την οθόνη. Σύρε ξανά για επιβεβαίωση."; "lightning__savings_intro__title" = "Μεταφορά\nστις αποταμιεύσεις"; "lightning__savings_intro__text" = "Μεταφέρετε το υπόλοιπο δαπανών σας στις αποταμιεύσεις για να αποθηκεύσετε τα Bitcoin σας μακροπρόθεσμα και να εξασφαλίσετε το μέλλον σας."; "lightning__savings_intro__button" = "Ξεκινήστε"; @@ -225,6 +247,7 @@ "lightning__savings_confirm__amount_too_low" = "Το ποσό είναι πολύ χαμηλό για μεταφορά στις αποταμιεύσεις."; "lightning__savings_confirm__close_instead" = "Κλείσιμο καναλιού αντί αυτού"; "lightning__savings_confirm__network_fee" = "Τέλος δικτύου"; +"lightning__savings_confirm__spark_fee" = "Τέλος Spark"; "lightning__savings_confirm__receive" = "Θα λάβεις"; "lightning__savings_confirm__service_fee" = "Τέλος υπηρεσίας"; "lightning__savings_advanced__title" = "Επιλέξτε κεφάλαια\nγια μεταφορά"; @@ -237,6 +260,12 @@ "lightning__savings_interrupted__nav_title" = "Μεταφορά\nδιακόπηκε"; "lightning__savings_interrupted__title" = "Κρατήστε το Bitkit\nανοιχτό"; "lightning__savings_interrupted__text" = "Τα κεφάλαια δεν έχουν μεταφερθεί ακόμα. Το Bitkit θα προσπαθήσει να ξεκινήσει τη μεταφορά τα επόμενα 30 λεπτά. Παρακαλώ κρατήστε την εφαρμογή ανοιχτή."; +"lightning__savings_spark_failed__title" = "Μεταφορά\nαπέτυχε"; +"lightning__savings_spark_failed__text" = "Το υπόλοιπο Spark δεν μεταφέρθηκε στις αποταμιεύσεις και παραμένει στο υπόλοιπο δαπανών."; +"lightning__savings_spark_failed__text_partial" = "Τα κεφάλαια των καναλιών σου μεταφέρονται στις αποταμιεύσεις, αλλά το υπόλοιπο Spark δεν μεταφέρθηκε και παραμένει στο υπόλοιπο δαπανών."; +"lightning__savings_spark_failed__toast" = "Η μεταφορά Spark απέτυχε"; +"lightning__transfer_success__nav_title" = "Επιτυχής Μεταφορά"; +"lightning__transfer_success__title_spending" = "Τα κεφάλαια μεταφέρθηκαν\nστις δαπάνες"; "lightning__transfer_success__title_savings" = "Τα κεφάλαια μεταφέρθηκαν\nστις αποταμιεύσεις"; "lightning__transfer_success__text_savings" = "Τα κεφάλαιά σας έχουν μεταφερθεί. Μπορείτε να χρησιμοποιήσετε αυτά τα κεφάλαια αμέσως."; "lightning__external__nav_title" = "Σύνδεση Lightning"; @@ -255,6 +284,11 @@ "lightning__savings" = "Αποταμιεύσεις"; "lightning__spending_label" = "Υπόλοιπο δαπανών"; "lightning__receiving_label" = "Χωρητικότητα λήψης"; +"lightning__setting_up_step1" = "Επεξεργασία Πληρωμής"; +"lightning__setting_up_step2" = "Επιτυχής Πληρωμή"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Συνεχίστε να Χρησιμοποιείτε το Bitkit"; "lightning__node_info" = "Κόμβος Lightning"; "lightning__node_disconnected" = "αποσυνδεδεμένο"; "lightning__connections" = "Συνδέσεις Lightning"; @@ -282,6 +316,36 @@ "lightning__transaction" = "Συναλλαγή"; "lightning__order_fee" = "Τέλος παραγγελίας"; "lightning__balance" = "Υπόλοιπο"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Χρέωση εξαργύρωσης"; +"lightning__spark_confirmations" = "{count}/3 επιβεβαιώσεις"; +"lightning__spark_deposit" = "Κατάθεση"; +"lightning__spark_deposit__choice" = "Το Spark μπορεί να το προσθέσει {when} με υψηλότερη χρέωση, ή μετά από {standard} επιβεβαιώσεις, σε περίπου {minutes} λεπτά."; +"lightning__spark_deposit__claim_early" = "Χρήση {when}"; +"lightning__spark_deposit__claimed" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας."; +"lightning__spark_deposit__early_fee" = "Χρέωση για χρήση {when}"; +"lightning__spark_deposit__later" = "μετά από {count} επιβεβαιώσεις"; +"lightning__spark_deposit__later_one" = "μετά από 1 επιβεβαίωση"; +"lightning__spark_deposit__looking" = "Αναμονή για να δει το Spark τη μεταφορά σας."; +"lightning__spark_deposit__now" = "τώρα"; +"lightning__spark_deposit__standard" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας μετά από {standard} επιβεβαιώσεις, σε περίπου {minutes} λεπτά."; +"lightning__spark_deposit__standard_fee" = "Χρέωση αναμονής"; +"lightning__spark_deposit__wait_button" = "Αναμονή"; +"lightning__spark_deposit__waiting" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας {when}."; +"lightning__spark_deposit__waiting_next" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας μετά την επόμενη επιβεβαίωση."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Εξαργυρωμένες καταθέσεις"; +"lightning__spark_deposits_pending" = "Καταθέσεις σε εξέλιξη"; +"lightning__spark_provider" = "Πάροχος"; +"lightning__spark_provider_value" = "Spark μέσω Breez"; +"lightning__spark_receiving_unlimited" = "Χωρίς όριο"; +"lightning__spark_trust" = "Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Υπόλοιπο αποθεματικού (μη δαπανήσιμο)"; "lightning__total_size" = "Συνολικό μέγεθος καναλιού"; "lightning__fees" = "Τέλη"; @@ -732,6 +796,7 @@ "settings__backup__category_transaction_log" = "Αρχείο Συναλλαγών"; "settings__backup__category_wallet" = "Ενισχύσεις & Μεταφορές"; "settings__backup__category_settings" = "Ρυθμίσεις"; +"settings__backup__category_spark_exit" = "Δεδομένα εξόδου Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Ετικέτες"; "settings__backup__category_profile" = "Προφίλ"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 389638b46..9f299e68b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -188,12 +188,20 @@ "fee__custom__description" = "Depends on the fee"; "fee__custom__shortDescription" = "Depends on the fee"; "fee__custom__range" = "Depends on the fee"; +"lightning__transfer_intro__title" = "Spending\nBalance"; +"lightning__transfer_intro__text" = "Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants."; +"lightning__transfer_intro__button" = "Get Started"; "lightning__funding__nav_title" = "Spending Balance"; +"lightning__funding__text" = "The easiest option is to use your Bitkit savings or send bitcoin from a different wallet."; "lightning__funding__title" = "Fund your spending balance"; "lightning__funding__button1" = "Transfer from Savings"; "lightning__funding__button2" = "Use Other Wallet"; "lightning__funding__button3" = "Manual Setup"; "lightning__funding_advanced__button_short" = "Advanced Setup"; +"lightning__no_funds__title" = "No Available Funds"; +"lightning__no_funds__description" = "Before you can transfer funds from your savings balance, you need to send bitcoin to your Bitkit wallet."; +"lightning__no_funds__fund" = "Fund wallet"; +"lightning__spending_amount__title" = "Transfer\nto spending"; "lightning__transfer__nav_title" = "Transfer Funds"; "lightning__transfer__confirm" = "Please\nconfirm"; "lightning__transfer__custom_fee" = "Custom fee"; @@ -202,8 +210,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Your funding transaction may have been broadcast. Please check your activity before trying again."; "lightning__transfer_hw__reconnect_error_description" = "Please reconnect your hardware device, it appears to be disconnected from your phone."; "lightning__transfer_hw__reconnect_error_title" = "Reconnect Hardware Device"; +"lightning__transfer_hw__sign_title" = "Sign with\nyour device"; +"lightning__transfer_hw__signed_title" = "Transaction\nsigned"; "lightning__transfer_to_savings_button" = "Transfer To Savings"; "lightning__transfer_to_spending_button" = "Transfer To Spending"; +"lightning__spending_intro__title" = "Transfer\nto spending"; +"lightning__spending_intro__text" = "Transfer funds to your spending balance to enjoy instant and cheap transactions with friends, family, and merchants."; +"lightning__spending_intro__button" = "Get Started"; "lightning__transfer_failed__nav_title" = "Transfer Failure"; "lightning__transfer_failed__title" = "Transfer\nfailed"; "lightning__transfer_failed__text" = "Sorry, Bitkit could not set up your spending balance or complete your transfer. Please try again."; @@ -214,11 +227,20 @@ "lightning__spending_amount__error_min__description" = "A minimum of ₿ {amount} is needed to set up your spending balance."; "lightning__spending_amount__error_max__title" = "Spending Balance Maximum"; "lightning__spending_amount__error_max__description" = "The amount you can transfer to your spending balance is currently limited to ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center."; "lightning__spending_confirm__network_fee" = "Network fees"; "lightning__spending_confirm__lsp_fee" = "Service fees"; "lightning__spending_confirm__amount" = "To spending"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Fees changed"; +"lightning__spending_confirm__fees_changed_network" = "Fees are {amount} higher because fee rates or your savings balance changed while you were on this screen. Swipe again to confirm."; "lightning__savings_intro__title" = "Transfer\nto savings"; "lightning__savings_intro__text" = "Transfer your spending balance to your savings to store your Bitcoin long-term and secure your future."; "lightning__savings_intro__button" = "Get Started"; @@ -230,6 +252,7 @@ "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__spark_fee" = "Spark fee"; "lightning__savings_confirm__receive" = "You'll receive"; "lightning__savings_confirm__service_fee" = "Service fee"; "lightning__savings_advanced__title" = "Select funds\nto transfer"; @@ -242,6 +265,12 @@ "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."; +"lightning__savings_spark_failed__title" = "Transfer\nfailed"; +"lightning__savings_spark_failed__text" = "Your Spark balance could not move to savings and stays in your spending balance."; +"lightning__savings_spark_failed__text_partial" = "Your channel funds are moving to savings, but your Spark balance could not move and stays in your spending balance."; +"lightning__savings_spark_failed__toast" = "Spark transfer failed"; +"lightning__transfer_success__nav_title" = "Transfer Successful"; +"lightning__transfer_success__title_spending" = "Funds moved\nto spending"; "lightning__transfer_success__title_savings" = "Funds moved\nto savings"; "lightning__transfer_success__text_savings" = "Your funds have been transferred. You are able to use these funds immediately."; "lightning__external__nav_title" = "Lightning Connection"; @@ -264,6 +293,11 @@ "lightning__spending_label" = "Spending balance"; "lightning__receiving_label" = "Receiving capacity"; "lightning__setting_up_header" = "In transfer"; +"lightning__setting_up_step1" = "Processing Payment"; +"lightning__setting_up_step2" = "Payment Successful"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continue Using Bitkit"; "lightning__node_info" = "Lightning Node"; "lightning__node_id" = "LDK Node ID"; "lightning__node_disconnected" = "disconnected"; @@ -304,6 +338,36 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Order fee"; "lightning__balance" = "Balance"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Claim fee"; +"lightning__spark_confirmations" = "{count}/3 confirmations"; +"lightning__spark_deposit" = "Deposit"; +"lightning__spark_deposit__choice" = "Spark can add it {when} for a higher fee, or after {standard} confirmations, about {minutes} minutes."; +"lightning__spark_deposit__claim_early" = "Use {when}"; +"lightning__spark_deposit__claimed" = "Spark is adding it to your spending balance."; +"lightning__spark_deposit__early_fee" = "Fee to use {when}"; +"lightning__spark_deposit__later" = "after {count} confirmations"; +"lightning__spark_deposit__later_one" = "after 1 confirmation"; +"lightning__spark_deposit__looking" = "Waiting for Spark to see your transfer."; +"lightning__spark_deposit__now" = "now"; +"lightning__spark_deposit__standard" = "Spark adds it to your spending balance after {standard} confirmations, about {minutes} minutes."; +"lightning__spark_deposit__standard_fee" = "Fee to wait"; +"lightning__spark_deposit__wait_button" = "Wait"; +"lightning__spark_deposit__waiting" = "Spark adds it to your spending balance {when}."; +"lightning__spark_deposit__waiting_next" = "Spark adds it to your spending balance after the next confirmation."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Claimed deposits"; +"lightning__spark_deposits_pending" = "Deposits in progress"; +"lightning__spark_provider" = "Provider"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "No limit"; +"lightning__spark_trust" = "This balance is held on Spark, run by Lightspark, Breez and Flashnet. It is safe while at least one of them acts honestly. They can pause access, and leaving without them takes an on-chain exit."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Reserve balance (not spendable)"; "lightning__total_size" = "Total channel size"; "lightning__fees" = "Fees"; @@ -833,6 +897,7 @@ "settings__backup__category_transaction_log" = "Transaction Log"; "settings__backup__category_wallet" = "Boosts & Transfers"; "settings__backup__category_settings" = "Settings"; +"settings__backup__category_spark_exit" = "Spark exit data"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profile"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 1580b2219..7de3e658a 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -196,12 +196,20 @@ "fee__custom__range" = "Depende de la tarifa"; "fee__custom__shortRange" = "Depende de la tarifa"; "fee__custom__longRange" = "Depende de la tasa"; +"lightning__transfer_intro__title" = "Saldo de\nGasto"; +"lightning__transfer_intro__text" = "Fondee el saldo de gastos para disfrutar de transacciones baratas e instantáneas con amigos y comercios."; +"lightning__transfer_intro__button" = "Empezar"; "lightning__funding__nav_title" = "Saldo para gastos"; +"lightning__funding__text" = "La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde una billetera diferente."; "lightning__funding__title" = "Fondee su saldo de gastos"; "lightning__funding__button1" = "Transferir desde Ahorros"; "lightning__funding__button2" = "Usar Otra Billetera"; "lightning__funding__button3" = "Configuración Manual"; "lightning__funding_advanced__button_short" = "Configuración avanzada"; +"lightning__no_funds__title" = "Sin fondos disponibles"; +"lightning__no_funds__description" = "Para transferir fondos a su saldo de ahorros, primero tiene que enviar bitcoin a su billetera Bitkit."; +"lightning__no_funds__fund" = "Fondear billetera"; +"lightning__spending_amount__title" = "Transferir\na gastos"; "lightning__transfer__nav_title" = "Transferir fondos"; "lightning__transfer__confirm" = "Por favor,\nconfirme"; "lightning__transfer__custom_fee" = "Tarifa manual"; @@ -210,8 +218,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Es posible que su transacción de financiamiento ya se haya transmitido. Revise su actividad antes de volver a intentarlo."; "lightning__transfer_hw__reconnect_error_description" = "Vuelva a conectar su dispositivo de hardware; parece que está desconectado de su teléfono."; "lightning__transfer_hw__reconnect_error_title" = "Reconectar dispositivo de hardware"; +"lightning__transfer_hw__sign_title" = "Firme con\nsu dispositivo"; +"lightning__transfer_hw__signed_title" = "Transacción\nfirmada"; "lightning__transfer_to_savings_button" = "Transferir a ahorros"; "lightning__transfer_to_spending_button" = "Transferir a gastos"; +"lightning__spending_intro__title" = "Transferir\na gastos"; +"lightning__spending_intro__text" = "Transfiera fondos a su saldo de gastos para disfrutar de transacciones baratas e instantáneas con amigos y comercios."; +"lightning__spending_intro__button" = "Empezar"; "lightning__transfer_failed__nav_title" = "Falla en transfer"; "lightning__transfer_failed__title" = "Transfer\nfallido"; "lightning__transfer_failed__text" = "Lo sentimos, Bitkit no pudo configurar tu saldo para gastar ni completar la transferencia. Intenta de nuevo."; @@ -222,11 +235,20 @@ "lightning__spending_amount__error_min__description" = "Se necesita un mínimo de ₿ {amount} para configurar tu saldo para gastar."; "lightning__spending_amount__error_max__title" = "Saldo máximo de gastos"; "lightning__spending_amount__error_max__description" = "El monto que puedes transferir a tu saldo para gastar está limitado a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Tu transferencia al saldo para gastos está limitada por la política de liquidez. Visita el Centro de ayuda para más detalles."; "lightning__spending_confirm__network_fee" = "Comisiones de red"; "lightning__spending_confirm__lsp_fee" = "Comisión de servicio"; "lightning__spending_confirm__amount" = "Hacia Gastos"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Las comisiones cambiaron"; +"lightning__spending_confirm__fees_changed_network" = "Las comisiones son {amount} más altas porque las tarifas o su saldo de ahorros cambiaron mientras estaba en esta pantalla. Deslice de nuevo para confirmar."; "lightning__savings_intro__title" = "Transferir\na ahorros"; "lightning__savings_intro__text" = "Transfiere tu saldo de gastos al de ahorros para guardar tus Bitcoin a largo plazo y asegurar tu futuro."; "lightning__savings_intro__button" = "Empezar"; @@ -238,6 +260,7 @@ "lightning__savings_confirm__amount_too_low" = "El monto es demasiado bajo para transferirlo a ahorros."; "lightning__savings_confirm__close_instead" = "Cerrar el canal en su lugar"; "lightning__savings_confirm__network_fee" = "Comisión de red"; +"lightning__savings_confirm__spark_fee" = "Comisión de Spark"; "lightning__savings_confirm__receive" = "Recibirá"; "lightning__savings_confirm__service_fee" = "Comisión de servicio"; "lightning__savings_advanced__title" = "Selecciona fondos\na transferir"; @@ -250,6 +273,12 @@ "lightning__savings_interrupted__nav_title" = "Transfer\nfallido"; "lightning__savings_interrupted__title" = "Manten Bitkit\nabierto"; "lightning__savings_interrupted__text" = "Los fondos todavía no se transfirieron. Bitkit intentará iniciar la transferencia durante los próximos 30 minutos. Por favor, mantenga la aplicación abierta."; +"lightning__savings_spark_failed__title" = "Transfer\nfallido"; +"lightning__savings_spark_failed__text" = "Tu saldo de Spark no se pudo mover a ahorros y se queda en tu saldo de gastos."; +"lightning__savings_spark_failed__text_partial" = "Los fondos de tus canales se están moviendo a ahorros, pero tu saldo de Spark no se pudo mover y se queda en tu saldo de gastos."; +"lightning__savings_spark_failed__toast" = "Transferencia de Spark fallida"; +"lightning__transfer_success__nav_title" = "Transferencia exitosa"; +"lightning__transfer_success__title_spending" = "Fondos movidos\na gastos"; "lightning__transfer_success__title_savings" = "Fondos movidos\na gastos"; "lightning__transfer_success__text_savings" = "Sus fondos han sido transferidos. Podrá utilizar estos fondos inmediatamente."; "lightning__external__nav_title" = "Conexión Lightning"; @@ -272,6 +301,11 @@ "lightning__spending_label" = "Saldo para gastos"; "lightning__receiving_label" = "Capacidad de recepción"; "lightning__setting_up_header" = "En proceso"; +"lightning__setting_up_step1" = "Procesando pago"; +"lightning__setting_up_step2" = "Pago Efectuado"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuar Usando Bitkit"; "lightning__node_info" = "Nodo Lightning"; "lightning__node_id" = "ID del nodo LDK"; "lightning__node_disconnected" = "desconectado"; @@ -312,6 +346,36 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Comisión de reclamo"; +"lightning__spark_confirmations" = "{count}/3 confirmaciones"; +"lightning__spark_deposit" = "Depósito"; +"lightning__spark_deposit__choice" = "Spark puede agregarlo {when} por una comisión más alta, o después de {standard} confirmaciones, en unos {minutes} minutos."; +"lightning__spark_deposit__claim_early" = "Usar {when}"; +"lightning__spark_deposit__claimed" = "Spark lo está agregando a tu saldo de gastos."; +"lightning__spark_deposit__early_fee" = "Comisión para usar {when}"; +"lightning__spark_deposit__later" = "después de {count} confirmaciones"; +"lightning__spark_deposit__later_one" = "después de 1 confirmación"; +"lightning__spark_deposit__looking" = "Esperando a que Spark vea tu transferencia."; +"lightning__spark_deposit__now" = "ahora"; +"lightning__spark_deposit__standard" = "Spark lo agrega a tu saldo de gastos después de {standard} confirmaciones, en unos {minutes} minutos."; +"lightning__spark_deposit__standard_fee" = "Comisión por esperar"; +"lightning__spark_deposit__wait_button" = "Esperar"; +"lightning__spark_deposit__waiting" = "Spark lo agrega a tu saldo de gastos {when}."; +"lightning__spark_deposit__waiting_next" = "Spark lo agrega a tu saldo de gastos después de la próxima confirmación."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depósitos reclamados"; +"lightning__spark_deposits_pending" = "Depósitos en curso"; +"lightning__spark_provider" = "Proveedor"; +"lightning__spark_provider_value" = "Spark mediante Breez"; +"lightning__spark_receiving_unlimited" = "Sin límite"; +"lightning__spark_trust" = "Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva (no gastable)"; "lightning__total_size" = "Tamaño total del canal"; "lightning__fees" = "Tarifa"; @@ -851,6 +915,7 @@ "settings__backup__category_transaction_log" = "Registro de transacciones"; "settings__backup__category_wallet" = "Impulsos y transferencias"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_spark_exit" = "Datos de salida de Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Etiquetas"; "settings__backup__category_profile" = "Perfil"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 6ac479043..510f68918 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -193,12 +193,20 @@ "fee__custom__range" = "Depende de la comisión"; "fee__custom__shortRange" = "Depende de la tarifa"; "fee__custom__longRange" = "Depende de la comisión"; +"lightning__transfer_intro__title" = "Saldo de\nGasto"; +"lightning__transfer_intro__text" = "Rellene el saldo de gasto para disfrutar de transacciones baratas e instantáneas con amigos, familia y comerciantes."; +"lightning__transfer_intro__button" = "Empezar"; "lightning__funding__nav_title" = "Saldo de gastos"; +"lightning__funding__text" = "La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde un monedero diferente."; "lightning__funding__title" = "Rellene su saldo de gastos"; "lightning__funding__button1" = "Transferir desde Ahorros"; "lightning__funding__button2" = "Usar Otro Monedero"; "lightning__funding__button3" = "Configuración Manual"; "lightning__funding_advanced__button_short" = "Configuración avanzada"; +"lightning__no_funds__title" = "No hay fondos disponibles"; +"lightning__no_funds__description" = "Antes de que pueda transferir fondos a su saldo de ahorros, tiene que enviar bitcoin a su monedero Bitkit."; +"lightning__no_funds__fund" = "Añadir fondos"; +"lightning__spending_amount__title" = "Transferir\na gasto"; "lightning__transfer__nav_title" = "Transferir fondos"; "lightning__transfer__confirm" = "Por favor,\nconfirme"; "lightning__transfer__swipe" = "Deslizar para transferir"; @@ -206,16 +214,30 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Es posible que su transacción de financiación se haya difundido. Revise su actividad antes de volver a intentarlo."; "lightning__transfer_hw__reconnect_error_description" = "Vuelva a conectar su dispositivo de hardware; parece que se ha desconectado de su teléfono."; "lightning__transfer_hw__reconnect_error_title" = "Reconecte el dispositivo de hardware"; +"lightning__transfer_hw__sign_title" = "Firme con\nsu dispositivo"; +"lightning__transfer_hw__signed_title" = "Transacción\nfirmada"; "lightning__transfer_to_savings_button" = "Transferir a Ahorros"; "lightning__transfer_to_spending_button" = "Transferir a Gasto"; +"lightning__spending_intro__title" = "Transferir\na gasto"; +"lightning__spending_intro__text" = "Transfiera fondos a su saldo de gasto para disfrutar de transacciones baratas e instantáneas con amigos, familia y comerciantes."; +"lightning__spending_intro__button" = "Empezar"; "lightning__transfer_failed__continue" = "Inténtelo de nuevo"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Máximo del Saldo de Gastos"; "lightning__spending_amount__error_max__description" = "La cantidad que puedes transferir a tu saldo de gastos está actualmente limitada a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Comisión de red"; "lightning__spending_confirm__lsp_fee" = "Comisión de servicio"; "lightning__spending_confirm__amount" = "A gasto"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Las comisiones han cambiado"; +"lightning__spending_confirm__fees_changed_network" = "Las comisiones son {amount} más altas porque las tasas de comisión o su saldo de ahorro cambiaron mientras estaba en esta pantalla. Deslice de nuevo para confirmar."; "lightning__savings_intro__title" = "Transferir\na ahorros"; "lightning__savings_intro__text" = "Transfiera su saldo de gasto a sus ahorros para almacenar su Bitcoin a largo plazo y asegurar su futuro."; "lightning__savings_intro__button" = "Empezar"; @@ -227,6 +249,7 @@ "lightning__savings_confirm__amount_too_low" = "La cantidad es demasiado baja para transferirla a ahorros."; "lightning__savings_confirm__close_instead" = "Cerrar el canal en su lugar"; "lightning__savings_confirm__network_fee" = "Comisión de red"; +"lightning__savings_confirm__spark_fee" = "Comisión de Spark"; "lightning__savings_confirm__receive" = "Recibirá"; "lightning__savings_confirm__service_fee" = "Comisión de servicio"; "lightning__savings_advanced__title" = "Seleccionar fondos\npara transferir"; @@ -239,6 +262,12 @@ "lightning__savings_interrupted__nav_title" = "Transferencia\ninterrumpida"; "lightning__savings_interrupted__title" = "Mantener Bitkit\nabierto"; "lightning__savings_interrupted__text" = "Los fondos todavía no se transfirieron. Bitkit intentará iniciar la transferencia durante los próximos 30 minutos. Por favor, mantenga la aplicación abierta."; +"lightning__savings_spark_failed__title" = "Transferencia\nfallida"; +"lightning__savings_spark_failed__text" = "Tu saldo de Spark no se pudo mover a ahorros y se queda en tu saldo de gastos."; +"lightning__savings_spark_failed__text_partial" = "Los fondos de tus canales se están moviendo a ahorros, pero tu saldo de Spark no se pudo mover y se queda en tu saldo de gastos."; +"lightning__savings_spark_failed__toast" = "Transferencia de Spark fallida"; +"lightning__transfer_success__nav_title" = "Transferencia exitosa"; +"lightning__transfer_success__title_spending" = "Fondos movidos\na gasto"; "lightning__transfer_success__title_savings" = "Fondos movidos\na ahorros"; "lightning__transfer_success__text_savings" = "Sus fondos han sido transferidos. Puede usar estos fondos inmediatamente."; "lightning__external__nav_title" = "Conexión Lightning"; @@ -259,6 +288,11 @@ "lightning__savings" = "Ahorro"; "lightning__spending_label" = "Saldo para gastos"; "lightning__receiving_label" = "Capacidad de recepción"; +"lightning__setting_up_step1" = "Procesando Pago"; +"lightning__setting_up_step2" = "Pago Exitoso"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuar Usando Bitkit"; "lightning__node_info" = "Nodo Lightning"; "lightning__node_id" = "ID del nodo LDK"; "lightning__node_disconnected" = "desconectado"; @@ -296,6 +330,36 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Comisión de reclamo"; +"lightning__spark_confirmations" = "{count}/3 confirmaciones"; +"lightning__spark_deposit" = "Depósito"; +"lightning__spark_deposit__choice" = "Spark puede añadirlo {when} por una comisión más alta, o tras {standard} confirmaciones, en unos {minutes} minutos."; +"lightning__spark_deposit__claim_early" = "Usar {when}"; +"lightning__spark_deposit__claimed" = "Spark lo está añadiendo a tu saldo de gastos."; +"lightning__spark_deposit__early_fee" = "Comisión para usar {when}"; +"lightning__spark_deposit__later" = "tras {count} confirmaciones"; +"lightning__spark_deposit__later_one" = "tras 1 confirmación"; +"lightning__spark_deposit__looking" = "Esperando a que Spark vea tu transferencia."; +"lightning__spark_deposit__now" = "ahora"; +"lightning__spark_deposit__standard" = "Spark lo añade a tu saldo de gastos tras {standard} confirmaciones, en unos {minutes} minutos."; +"lightning__spark_deposit__standard_fee" = "Comisión por esperar"; +"lightning__spark_deposit__wait_button" = "Esperar"; +"lightning__spark_deposit__waiting" = "Spark lo añade a tu saldo de gastos {when}."; +"lightning__spark_deposit__waiting_next" = "Spark lo añade a tu saldo de gastos tras la próxima confirmación."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depósitos reclamados"; +"lightning__spark_deposits_pending" = "Depósitos en curso"; +"lightning__spark_provider" = "Proveedor"; +"lightning__spark_provider_value" = "Spark mediante Breez"; +"lightning__spark_receiving_unlimited" = "Sin límite"; +"lightning__spark_trust" = "Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva"; "lightning__total_size" = "Tamaño total del canal"; "lightning__fees" = "Comisiones"; @@ -798,6 +862,7 @@ "settings__backup__category_transaction_log" = "Registro de Transacciones"; "settings__backup__category_wallet" = "Impulsos y Transferencias"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_spark_exit" = "Datos de salida de Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Etiquetas"; "settings__backup__category_profile" = "Perfil"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 7f1c02d5b..4074d833e 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -196,12 +196,20 @@ "fee__custom__range" = "Dépend des frais"; "fee__custom__shortRange" = "Dépend des frais"; "fee__custom__longRange" = "Dépend du montant des frais"; +"lightning__transfer_intro__title" = "Dépenses \nSolde"; +"lightning__transfer_intro__text" = "Alimentez votre solde de dépenses pour profiter de transactions instantanées et bon marché avec vos amis, votre famille et les commerçants."; +"lightning__transfer_intro__button" = "Commencer"; "lightning__funding__nav_title" = "Solde Dépenses"; +"lightning__funding__text" = "Le plus simple est d\'utiliser vos économies Bitkit ou d\'envoyer des bitcoins depuis un autre portefeuille."; "lightning__funding__title" = "Financez votre solde Dépenses"; "lightning__funding__button1" = "Transfert depuis l\'épargne"; "lightning__funding__button2" = "Utiliser un autre portefeuille"; "lightning__funding__button3" = "Configuration manuelle"; "lightning__funding_advanced__button_short" = "Configuration avancée"; +"lightning__no_funds__title" = "Pas de fonds disponibles"; +"lightning__no_funds__description" = "Avant de pouvoir transférer des fonds depuis votre solde d\'épargne, vous devez envoyer des bitcoins à votre portefeuille Bitkit."; +"lightning__no_funds__fund" = "Alimentez votre portefeuille"; +"lightning__spending_amount__title" = "Transfert \nDépenses"; "lightning__transfer__nav_title" = "Transfert de fonds"; "lightning__transfer__confirm" = "Veuillez\nconfirmer"; "lightning__transfer__custom_fee" = "Frais sur mesure"; @@ -210,8 +218,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Votre transaction de financement a peut-être été diffusée. Veuillez vérifier votre activité avant de réessayer."; "lightning__transfer_hw__reconnect_error_description" = "Veuillez reconnecter votre appareil matériel, il semble être déconnecté de votre téléphone."; "lightning__transfer_hw__reconnect_error_title" = "Reconnecter l'appareil matériel"; +"lightning__transfer_hw__sign_title" = "Signez avec\nvotre appareil"; +"lightning__transfer_hw__signed_title" = "Transaction\nsignée"; "lightning__transfer_to_savings_button" = "Transférer vers l\'épargne"; "lightning__transfer_to_spending_button" = "Transférer vers les dépenses"; +"lightning__spending_intro__title" = "Transfert\naux dépenses"; +"lightning__spending_intro__text" = "Transférez des fonds vers votre solde Dépenses pour profiter de transactions instantanées et moins chers avec vos amis, votre famille et les commerçants."; +"lightning__spending_intro__button" = "Commencer"; "lightning__transfer_failed__nav_title" = "Échec du transfert"; "lightning__transfer_failed__title" = "Transfert\néchoué"; "lightning__transfer_failed__text" = "Désolé, Bitkit n’a pas pu configurer votre solde Dépenses ou effectuer le transfert. Veuillez réessayer."; @@ -222,11 +235,20 @@ "lightning__spending_amount__error_min__description" = "Un minimum de ₿ {amount} est nécessaire pour établir votre solde de Dépenses."; "lightning__spending_amount__error_max__title" = "Seuil Maximum Épargne"; "lightning__spending_amount__error_max__description" = "Le montant que vous pouvez transférer vers votre solde de dépenses est actuellement limité à ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Votre transfert vers le solde des dépenses est limité en raison de la politique de liquidité. Pour plus de détails, consultez le Centre d\'aide."; "lightning__spending_confirm__network_fee" = "Frais de réseau"; "lightning__spending_confirm__lsp_fee" = "Frais de service"; "lightning__spending_confirm__amount" = "Vers Dépenses"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Frais modifiés"; +"lightning__spending_confirm__fees_changed_network" = "Les frais sont plus élevés de {amount}, car les taux de frais ou votre solde d'épargne ont changé pendant que vous étiez sur cet écran. Glissez à nouveau pour confirmer."; "lightning__savings_intro__title" = "Transfert \nvers l\'Épargne"; "lightning__savings_intro__text" = "Transférez le solde de votre compte Dépenses vers votre Épargne pour conserver vos bitcoins à long terme."; "lightning__savings_intro__button" = "Commencer"; @@ -238,6 +260,7 @@ "lightning__savings_confirm__amount_too_low" = "Le montant est trop faible pour être transféré vers l'épargne."; "lightning__savings_confirm__close_instead" = "Fermer plutôt le canal"; "lightning__savings_confirm__network_fee" = "Frais de réseau"; +"lightning__savings_confirm__spark_fee" = "Frais Spark"; "lightning__savings_confirm__receive" = "Vous recevrez"; "lightning__savings_confirm__service_fee" = "Frais de service"; "lightning__savings_advanced__title" = "Sélectionnez les fonds\nà transférer"; @@ -250,6 +273,12 @@ "lightning__savings_interrupted__nav_title" = "Transfert \ninterrompu"; "lightning__savings_interrupted__title" = "Gardez Bitkit \nouvert"; "lightning__savings_interrupted__text" = "Les fonds n\'ont pas encore été transférés. Bitkit va essayer d\'initier le transfert au cours des 30 prochaines minutes. Veuillez garder votre application ouverte."; +"lightning__savings_spark_failed__title" = "Transfert\néchoué"; +"lightning__savings_spark_failed__text" = "Ton solde Spark n'a pas pu être transféré vers l'épargne et reste dans ton solde de dépenses."; +"lightning__savings_spark_failed__text_partial" = "Les fonds de tes canaux sont transférés vers l'épargne, mais ton solde Spark n'a pas pu l'être et reste dans ton solde de dépenses."; +"lightning__savings_spark_failed__toast" = "Échec du transfert Spark"; +"lightning__transfer_success__nav_title" = "Transfert réussi"; +"lightning__transfer_success__title_spending" = "Fonds transférés \nvers Dépenses"; "lightning__transfer_success__title_savings" = "Fonds transférés \nà l\'épargne"; "lightning__transfer_success__text_savings" = "Vos fonds ont été transférés. Vous pouvez utiliser ces fonds immédiatement."; "lightning__external__nav_title" = "Connexion Lightning"; @@ -272,6 +301,11 @@ "lightning__spending_label" = "Solde Dépenses"; "lightning__receiving_label" = "Capacité de réception"; "lightning__setting_up_header" = "En transfert"; +"lightning__setting_up_step1" = "Traitement des paiements"; +"lightning__setting_up_step2" = "Paiement réussi"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuer à utiliser Bitkit"; "lightning__node_info" = "Nœud Lightning"; "lightning__node_id" = "ID du nœud LDK"; "lightning__node_disconnected" = "déconnecté"; @@ -312,6 +346,36 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Frais"; "lightning__balance" = "Solde"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Frais de réclamation"; +"lightning__spark_confirmations" = "{count}/3 confirmations"; +"lightning__spark_deposit" = "Dépôt"; +"lightning__spark_deposit__choice" = "Spark peut l'ajouter {when} pour des frais plus élevés, ou après {standard} confirmations, dans environ {minutes} minutes."; +"lightning__spark_deposit__claim_early" = "Utiliser {when}"; +"lightning__spark_deposit__claimed" = "Spark l'ajoute à votre solde de dépenses."; +"lightning__spark_deposit__early_fee" = "Frais pour l'utiliser {when}"; +"lightning__spark_deposit__later" = "après {count} confirmations"; +"lightning__spark_deposit__later_one" = "après 1 confirmation"; +"lightning__spark_deposit__looking" = "En attente que Spark voie votre transfert."; +"lightning__spark_deposit__now" = "maintenant"; +"lightning__spark_deposit__standard" = "Spark l'ajoute à votre solde de dépenses après {standard} confirmations, dans environ {minutes} minutes."; +"lightning__spark_deposit__standard_fee" = "Frais d'attente"; +"lightning__spark_deposit__wait_button" = "Attendre"; +"lightning__spark_deposit__waiting" = "Spark l'ajoute à votre solde de dépenses {when}."; +"lightning__spark_deposit__waiting_next" = "Spark l'ajoute à votre solde de dépenses après la prochaine confirmation."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Dépôts réclamés"; +"lightning__spark_deposits_pending" = "Dépôts en cours"; +"lightning__spark_provider" = "Fournisseur"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "Sans limite"; +"lightning__spark_trust" = "Ce solde est détenu sur Spark, géré par Lightspark, Breez et Flashnet. Il est en sécurité tant qu’au moins l’un d’eux agit honnêtement. Ils peuvent suspendre l’accès, et en sortir sans eux nécessite une sortie on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Solde de réserve (non dépensable)"; "lightning__total_size" = "Taille totale du canal"; "lightning__fees" = "Frais"; @@ -851,6 +915,7 @@ "settings__backup__category_transaction_log" = "Journal des transactions"; "settings__backup__category_wallet" = "Boosts et transferts"; "settings__backup__category_settings" = "Réglages"; +"settings__backup__category_spark_exit" = "Données de sortie Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Profil"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 4e1820a11..ff531fab8 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -194,12 +194,20 @@ "fee__custom__range" = "Dipende dalla commissione"; "fee__custom__shortRange" = "Dipende dalle fee"; "fee__custom__longRange" = "Dipende dalla commissione"; +"lightning__transfer_intro__title" = "Conto\ndi Spesa"; +"lightning__transfer_intro__text" = "Manda fondi al tuo conto di spesa per usufruire di transazioni istantanee ed economiche con amici, familiari e commercianti."; +"lightning__transfer_intro__button" = "Inizia"; "lightning__funding__nav_title" = "Conto di Spesa"; +"lightning__funding__text" = "Il modo più semplice è usare i tuoi risparmi Bitkit o inviare bitcoin da un portafoglio diverso."; "lightning__funding__title" = "Manda fondi al tuo conto di spesa"; "lightning__funding__button1" = "Trasferimento da risparmio"; "lightning__funding__button2" = "Usa un altro portafoglio"; "lightning__funding__button3" = "Configurazione manuale"; "lightning__funding_advanced__button_short" = "Configurazione Avanzata"; +"lightning__no_funds__title" = "Nessun fondo disponibile"; +"lightning__no_funds__description" = "Prima di poter trasferire fondi dal tuo conto di risparmio, devi inviare bitcoin al tuo portafoglio Bitkit."; +"lightning__no_funds__fund" = "Finanzia portafoglio"; +"lightning__spending_amount__title" = "Trasferisci\na spesa"; "lightning__transfer__nav_title" = "Trasferimento Fondi"; "lightning__transfer__confirm" = "Per favore\nconferma"; "lightning__transfer__swipe" = "Swipe Per Trasferire"; @@ -207,8 +215,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "La tua transazione di finanziamento potrebbe essere stata trasmessa. Controlla la tua attività prima di riprovare."; "lightning__transfer_hw__reconnect_error_description" = "Ricollega il tuo dispositivo hardware, sembra essere disconnesso dal telefono."; "lightning__transfer_hw__reconnect_error_title" = "Ricollega il Dispositivo Hardware"; +"lightning__transfer_hw__sign_title" = "Firma con\nil tuo dispositivo"; +"lightning__transfer_hw__signed_title" = "Transazione\nfirmata"; "lightning__transfer_to_savings_button" = "Trasferisci Ai Risparmi"; "lightning__transfer_to_spending_button" = "Trasferisci A Spesa"; +"lightning__spending_intro__title" = "Trasferisci\na spesa"; +"lightning__spending_intro__text" = "Trasferisci fondi al tuo conto di spesa per usufruire di transazioni istantanee ed economiche con amici, familiari e commercianti."; +"lightning__spending_intro__button" = "Inizia"; "lightning__transfer_failed__nav_title" = "Trasferimento fallito"; "lightning__transfer_failed__title" = "Trasferimento\nfallito"; "lightning__transfer_failed__text" = "Siamo spiacenti, Bitkit non è riuscito a impostare il conto di spesa o a completare il trasferimento. Per favore riprova."; @@ -217,10 +230,19 @@ "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Massimo Conto di Spesa"; "lightning__spending_amount__error_max__description" = "L'importo che puoi trasferire al tuo conto di spesa e' attualmente limitato a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Commissioni di rete"; "lightning__spending_confirm__lsp_fee" = "Commissioni di servizio"; "lightning__spending_confirm__amount" = "A spesa"; "lightning__spending_confirm__total" = "Totale"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Commissioni cambiate"; +"lightning__spending_confirm__fees_changed_network" = "Le commissioni sono aumentate di {amount} perché i tassi di commissione o il saldo dei tuoi risparmi sono cambiati mentre eri su questa schermata. Scorri di nuovo per confermare."; "lightning__savings_intro__title" = "Trasferisci\nai risparmi"; "lightning__savings_intro__text" = "Trasferisci il tuo saldo di spesa ai tuoi risparmi per conservare i tuoi Bitcoin a lungo termine e garantire il tuo futuro."; "lightning__savings_intro__button" = "Inizia"; @@ -232,6 +254,7 @@ "lightning__savings_confirm__amount_too_low" = "L'importo è troppo basso per essere trasferito ai risparmi."; "lightning__savings_confirm__close_instead" = "Chiudi invece il canale"; "lightning__savings_confirm__network_fee" = "Commissione di rete"; +"lightning__savings_confirm__spark_fee" = "Commissione Spark"; "lightning__savings_confirm__receive" = "Riceverai"; "lightning__savings_confirm__service_fee" = "Commissione di servizio"; "lightning__savings_advanced__title" = "Seleziona fondi\nda trasferire"; @@ -244,6 +267,12 @@ "lightning__savings_interrupted__nav_title" = "Trasferimento\ninterrotto"; "lightning__savings_interrupted__title" = "Mantieni Bitkit\naperto"; "lightning__savings_interrupted__text" = "I fondi non sono stati ancora trasferiti. Bitkit tenterà di avviare il trasferimento durante i prossimi 30 minuti. Per favore mantieni l'app aperta."; +"lightning__savings_spark_failed__title" = "Trasferimento\nfallito"; +"lightning__savings_spark_failed__text" = "Il tuo saldo Spark non è stato spostato nei risparmi e resta nel tuo saldo di spesa."; +"lightning__savings_spark_failed__text_partial" = "I fondi dei tuoi canali stanno passando ai risparmi, ma il tuo saldo Spark non è stato spostato e resta nel tuo saldo di spesa."; +"lightning__savings_spark_failed__toast" = "Trasferimento Spark non riuscito"; +"lightning__transfer_success__nav_title" = "Trasferimento Riuscito"; +"lightning__transfer_success__title_spending" = "Fondi trasferiti\na spesa"; "lightning__transfer_success__title_savings" = "Fondi trasferiti\nai risparmi"; "lightning__transfer_success__text_savings" = "I tuoi fondi sono stati trasferiti. Puoi utilizzare questi fondi immediatamente."; "lightning__external__nav_title" = "Connessione Lightning"; @@ -266,6 +295,11 @@ "lightning__spending_label" = "Conto di spesa"; "lightning__receiving_label" = "Capacità di ricezione"; "lightning__setting_up_header" = "In trasferimento"; +"lightning__setting_up_step1" = "Elaborazione del pagamento"; +"lightning__setting_up_step2" = "Pagamento Riuscito"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continua ad utilizzare Bitkit"; "lightning__node_info" = "Nodo Lightning"; "lightning__node_id" = "ID Nodo LDK"; "lightning__node_disconnected" = "disconnesso"; @@ -305,6 +339,36 @@ "lightning__transaction" = "Transazione"; "lightning__order_fee" = "Fee Ordine"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Commissione di riscatto"; +"lightning__spark_confirmations" = "{count}/3 conferme"; +"lightning__spark_deposit" = "Deposito"; +"lightning__spark_deposit__choice" = "Spark può aggiungerlo {when} con una commissione più alta, oppure dopo {standard} conferme, tra circa {minutes} minuti."; +"lightning__spark_deposit__claim_early" = "Usa {when}"; +"lightning__spark_deposit__claimed" = "Spark lo sta aggiungendo al tuo saldo di spesa."; +"lightning__spark_deposit__early_fee" = "Commissione per usarlo {when}"; +"lightning__spark_deposit__later" = "dopo {count} conferme"; +"lightning__spark_deposit__later_one" = "dopo 1 conferma"; +"lightning__spark_deposit__looking" = "In attesa che Spark veda il tuo trasferimento."; +"lightning__spark_deposit__now" = "ora"; +"lightning__spark_deposit__standard" = "Spark lo aggiunge al tuo saldo di spesa dopo {standard} conferme, tra circa {minutes} minuti."; +"lightning__spark_deposit__standard_fee" = "Commissione per l'attesa"; +"lightning__spark_deposit__wait_button" = "Attendi"; +"lightning__spark_deposit__waiting" = "Spark lo aggiunge al tuo saldo di spesa {when}."; +"lightning__spark_deposit__waiting_next" = "Spark lo aggiunge al tuo saldo di spesa dopo la prossima conferma."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depositi riscattati"; +"lightning__spark_deposits_pending" = "Depositi in corso"; +"lightning__spark_provider" = "Fornitore"; +"lightning__spark_provider_value" = "Spark tramite Breez"; +"lightning__spark_receiving_unlimited" = "Nessun limite"; +"lightning__spark_trust" = "Questo saldo è custodito su Spark, gestito da Lightspark, Breez e Flashnet. È al sicuro finché almeno uno di loro agisce onestamente. Possono sospendere l’accesso e uscire senza di loro richiede un’uscita on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo di riserva (non spendibile)"; "lightning__total_size" = "Dimensione totale del canale"; "lightning__fees" = "Commissioni"; @@ -831,6 +895,7 @@ "settings__backup__category_transaction_log" = "Log delle transazioni"; "settings__backup__category_wallet" = "Potenziamenti e Trasferimenti"; "settings__backup__category_settings" = "Impostazioni"; +"settings__backup__category_spark_exit" = "Dati di uscita Spark"; "settings__backup__category_widgets" = "Widget"; "settings__backup__category_tags" = "Tag"; "settings__backup__category_profile" = "Profilo"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 24c357982..087be4fee 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Afhankelijk van de vergoeding"; "fee__custom__shortRange" = "Afhankelijk van het tarief"; "fee__custom__longRange" = "Afhankelijk van de kosten"; +"lightning__transfer_intro__title" = "Bestedings\nSaldo"; +"lightning__transfer_intro__text" = "Activeer uw bestedingssaldo en profiteer van directe en goedkope transacties met vrienden, familie en winkeliers."; +"lightning__transfer_intro__button" = "Starten"; "lightning__funding__nav_title" = "Bestedingssaldo"; +"lightning__funding__text" = "De makkelijkste optie is om je Bitkit-spaargeld te gebruiken of bitcoin te versturen vanuit een andere wallet."; "lightning__funding__title" = "Vul uw bestedingssaldo"; "lightning__funding__button1" = "Overboeking van Spaargeld"; "lightning__funding__button2" = "Gebruik Andere Wallet"; "lightning__funding__button3" = "Handmatige configuratie"; "lightning__funding_advanced__button_short" = "Geavanceerde Configuratie"; +"lightning__no_funds__title" = "Geen Geld Beschikbaar"; +"lightning__no_funds__description" = "Voordat u geld van uw spaarsaldo kunt overboeken, moet u bitcoin naar uw Bitkit wallet sturen."; +"lightning__no_funds__fund" = "Wallet aufladen"; +"lightning__spending_amount__title" = "Overboeking\nnaar bestedingssaldo"; "lightning__transfer__nav_title" = "Geld Overboeken"; "lightning__transfer__confirm" = "Bevestig\nalstublieft"; "lightning__transfer__custom_fee" = "Benutzerdefiniert fee"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Uw financieringstransactie is mogelijk al verzonden. Controleer uw activiteit voordat u het opnieuw probeert."; "lightning__transfer_hw__reconnect_error_description" = "Verbind uw hardwareapparaat opnieuw, de verbinding met uw telefoon lijkt verbroken."; "lightning__transfer_hw__reconnect_error_title" = "Hardwareapparaat opnieuw verbinden"; +"lightning__transfer_hw__sign_title" = "Onderteken met\nuw apparaat"; +"lightning__transfer_hw__signed_title" = "Transactie\nondertekend"; "lightning__transfer_to_savings_button" = "Overboeken Naar Spaargeld"; "lightning__transfer_to_spending_button" = "Overboeken Naar Bestedingssaldo"; +"lightning__spending_intro__title" = "Overboeking\nnaar bestedingssaldo"; +"lightning__spending_intro__text" = "Boek geld over naar uw bestedingssaldo om te genieten van directe en goedkope transacties met vrienden, familie en winkeliers."; +"lightning__spending_intro__button" = "Starten"; "lightning__transfer_failed__nav_title" = "Overboeking Mislukt"; "lightning__transfer_failed__title" = "Overboeking\nMislukt"; "lightning__transfer_failed__text" = "Sorry, Bitkit kon uw bestedingssaldo niet opzetten of uw overboeking niet voltooien. Probeer het alstublieft opnieuw."; @@ -220,10 +233,19 @@ "lightning__spending_amount__error_min__title" = "Minimales Sparguthaben"; "lightning__spending_amount__error_max__title" = "Maximaler Ausgabensaldo"; "lightning__spending_amount__error_max__description" = "Der Betrag, den Sie auf Ihr Ausgabenkonto übertragen können, ist derzeit auf ₿ {amount} begrenzt."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Netwerkkosten"; "lightning__spending_confirm__lsp_fee" = "Servicekosten"; "lightning__spending_confirm__amount" = "Naar bestedingssaldo"; "lightning__spending_confirm__total" = "Totaal"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Vergoedingen gewijzigd"; +"lightning__spending_confirm__fees_changed_network" = "De vergoedingen zijn {amount} hoger, omdat de vergoedingstarieven of uw spaargeld zijn gewijzigd terwijl u op dit scherm was. Veeg opnieuw om te bevestigen."; "lightning__savings_intro__title" = "Overboeking\nnaar spaargeld"; "lightning__savings_intro__text" = "Boek uw bestedingssaldo over naar uw spaargeld om uw Bitcoin langdurig te bewaren en uw toekomst veilig te stellen."; "lightning__savings_intro__button" = "Starten"; @@ -235,6 +257,7 @@ "lightning__savings_confirm__amount_too_low" = "Bedrag is te laag om naar spaargeld over te boeken."; "lightning__savings_confirm__close_instead" = "In plaats daarvan kanaal sluiten"; "lightning__savings_confirm__network_fee" = "Netwerkvergoeding"; +"lightning__savings_confirm__spark_fee" = "Spark-vergoeding"; "lightning__savings_confirm__receive" = "U ontvangt"; "lightning__savings_confirm__service_fee" = "Servicevergoeding"; "lightning__savings_advanced__title" = "Selecteer geld\nom over te boeken"; @@ -247,6 +270,12 @@ "lightning__savings_interrupted__nav_title" = "Overboeking\nonderbroken"; "lightning__savings_interrupted__title" = "Houd Bitkit\nopen"; "lightning__savings_interrupted__text" = "Het geld is nog niet overgeboekt. Bitkit zal proberen de overboeking gedurende 30 minuten te initiëren. Houd uw app open."; +"lightning__savings_spark_failed__title" = "Overboeking\nMislukt"; +"lightning__savings_spark_failed__text" = "Je Spark-saldo kon niet naar sparen en blijft in je bestedingssaldo."; +"lightning__savings_spark_failed__text_partial" = "Je kanaalsaldo gaat naar sparen, maar je Spark-saldo kon niet mee en blijft in je bestedingssaldo."; +"lightning__savings_spark_failed__toast" = "Spark-overboeking mislukt"; +"lightning__transfer_success__nav_title" = "Overboeking Voltooid"; +"lightning__transfer_success__title_spending" = "Geld verplaatst\nnaar bestedingssaldo"; "lightning__transfer_success__title_savings" = "Geld verplaatst\nnaar spaargeld"; "lightning__transfer_success__text_savings" = "Uw geld is overgeboekt. U kunt dit geld direct gebruiken."; "lightning__external__nav_title" = "Lightning-verbinding"; @@ -269,6 +298,11 @@ "lightning__spending_label" = "Bestedingssaldo"; "lightning__receiving_label" = "Ontvangstcapaciteit"; "lightning__setting_up_header" = "Onderweg"; +"lightning__setting_up_step1" = "Betaling Verwerken"; +"lightning__setting_up_step2" = "Betaling Voltooid"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Ga Door Met Bitkit"; "lightning__node_info" = "Lightning Node"; "lightning__node_id" = "LDK Node ID"; "lightning__node_disconnected" = "verbinding verbroken"; @@ -309,6 +343,36 @@ "lightning__transaction" = "Transactie"; "lightning__order_fee" = "Order kosten"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Claimkosten"; +"lightning__spark_confirmations" = "{count}/3 bevestigingen"; +"lightning__spark_deposit" = "Storting"; +"lightning__spark_deposit__choice" = "Spark kan het {when} toevoegen voor een hogere vergoeding, of na {standard} bevestigingen, over ongeveer {minutes} minuten."; +"lightning__spark_deposit__claim_early" = "{when} gebruiken"; +"lightning__spark_deposit__claimed" = "Spark voegt het toe aan je bestedingssaldo."; +"lightning__spark_deposit__early_fee" = "Vergoeding om {when} te gebruiken"; +"lightning__spark_deposit__later" = "na {count} bevestigingen"; +"lightning__spark_deposit__later_one" = "na 1 bevestiging"; +"lightning__spark_deposit__looking" = "Wachten tot Spark je overboeking ziet."; +"lightning__spark_deposit__now" = "nu"; +"lightning__spark_deposit__standard" = "Spark voegt het na {standard} bevestigingen toe aan je bestedingssaldo, over ongeveer {minutes} minuten."; +"lightning__spark_deposit__standard_fee" = "Vergoeding om te wachten"; +"lightning__spark_deposit__wait_button" = "Wachten"; +"lightning__spark_deposit__waiting" = "Spark voegt het {when} toe aan je bestedingssaldo."; +"lightning__spark_deposit__waiting_next" = "Spark voegt het na de volgende bevestiging toe aan je bestedingssaldo."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Geclaimde stortingen"; +"lightning__spark_deposits_pending" = "Lopende stortingen"; +"lightning__spark_provider" = "Aanbieder"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "Geen limiet"; +"lightning__spark_trust" = "Dit saldo staat op Spark, beheerd door Lightspark, Breez en Flashnet. Het is veilig zolang ten minste één van hen eerlijk handelt. Zij kunnen de toegang pauzeren, en vertrekken zonder hen vereist een on-chain exit."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Reservesaldo (niet besteedbaar)"; "lightning__total_size" = "Grootte van kanaal"; "lightning__fees" = "Vergoedingen"; @@ -848,6 +912,7 @@ "settings__backup__category_transaction_log" = "Transactie Log"; "settings__backup__category_wallet" = "Boosts & Overboekingen"; "settings__backup__category_settings" = "Instellingen"; +"settings__backup__category_spark_exit" = "Spark-exitgegevens"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Labels"; "settings__backup__category_profile" = "Profiel"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 3fa7834d1..1cd7e31c2 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Zależy od opłaty"; "fee__custom__shortRange" = "Zależy od opłaty"; "fee__custom__longRange" = "Zależy od opłaty transakcyjnej"; +"lightning__transfer_intro__title" = "Saldo \nWydatków"; +"lightning__transfer_intro__text" = "Zasil swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami."; +"lightning__transfer_intro__button" = "Rozpocznij"; "lightning__funding__nav_title" = "Saldo wydatków"; +"lightning__funding__text" = "Najłatwiejszą opcją jest użycie oszczędności Bitkit lub wysłanie bitcoinów z innego portfela."; "lightning__funding__title" = "Zasil swoje saldo wydatków"; "lightning__funding__button1" = "Prześlij z Oszczędności"; "lightning__funding__button2" = "Użyj innego portfela"; "lightning__funding__button3" = "Ręczna konfiguracja"; "lightning__funding_advanced__button_short" = "Ustawienia zaawansowane"; +"lightning__no_funds__title" = "Brak dostępnych środków"; +"lightning__no_funds__description" = "Zanim będzie można przelać środki z salda oszczędności, należy wysłać bitcoiny do portfela Bitkit."; +"lightning__no_funds__fund" = "Zasil portfel"; +"lightning__spending_amount__title" = "Prześlij \nna saldo wydatków"; "lightning__transfer__nav_title" = "Prześlij środki"; "lightning__transfer__confirm" = "Proszę \npotwierdzić"; "lightning__transfer__custom_fee" = "Niestandardowa opłata transakcyjna"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Twoja transakcja zasilająca mogła zostać rozgłoszona. Sprawdź swoją aktywność, zanim spróbujesz ponownie."; "lightning__transfer_hw__reconnect_error_description" = "Połącz ponownie urządzenie sprzętowe, wygląda na to, że zostało odłączone od telefonu."; "lightning__transfer_hw__reconnect_error_title" = "Połącz ponownie urządzenie"; +"lightning__transfer_hw__sign_title" = "Podpisz\nurządzeniem"; +"lightning__transfer_hw__signed_title" = "Transakcja\npodpisana"; "lightning__transfer_to_savings_button" = "Prześlij na oszczędności"; "lightning__transfer_to_spending_button" = "Prześlij na wydatki"; +"lightning__spending_intro__title" = "Prześlij \nna saldo wydatków"; +"lightning__spending_intro__text" = "Przelewaj środki na swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami."; +"lightning__spending_intro__button" = "Rozpocznij"; "lightning__transfer_failed__nav_title" = "Błąd podczas przelewu środków"; "lightning__transfer_failed__title" = "Transfer środków \nniepowiódł się"; "lightning__transfer_failed__text" = "Przepraszamy, Bitkit nie mógł skonfigurować salda wydatków lub zakończyć transferu. Proszę spróbować ponownie."; @@ -221,11 +234,20 @@ "lightning__spending_amount__error_min__description" = "Do utworzenia salda wydatków wymagana jest minimalna kwota ₿ {amount}."; "lightning__spending_amount__error_max__title" = "Maksymalne saldo wydatków"; "lightning__spending_amount__error_max__description" = "Kwota, którą można przelać na saldo wydatków, jest obecnie ograniczona do ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Transfer do salda wydatków jest ograniczony ze względu na politykę płynności. Aby uzyskać szczegółowe informacje, proszę odwiedzić Centrum pomocy."; "lightning__spending_confirm__network_fee" = "Opłaty sieciowe"; "lightning__spending_confirm__lsp_fee" = "Opłaty serwisowe"; "lightning__spending_confirm__amount" = "Do salda wydatków"; "lightning__spending_confirm__total" = "Razem"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Opłaty się zmieniły"; +"lightning__spending_confirm__fees_changed_network" = "Opłaty są wyższe o {amount}, ponieważ stawki opłat lub saldo oszczędności zmieniły się podczas wyświetlania tego ekranu. Przesuń ponownie, aby potwierdzić."; "lightning__savings_intro__title" = "Prześlij \nna saldo oszczędności"; "lightning__savings_intro__text" = "Przenieś saldo wydatków do oszczędności, aby przechowywać Bitcoiny długoterminowo i zabezpieczyć swoją przyszłość."; "lightning__savings_intro__button" = "Rozpocznij"; @@ -237,6 +259,7 @@ "lightning__savings_confirm__amount_too_low" = "Kwota jest zbyt niska, aby przesłać ją na oszczędności."; "lightning__savings_confirm__close_instead" = "Zamiast tego zamknij kanał"; "lightning__savings_confirm__network_fee" = "Opłata sieciowa"; +"lightning__savings_confirm__spark_fee" = "Opłata Spark"; "lightning__savings_confirm__receive" = "Otrzymasz"; "lightning__savings_confirm__service_fee" = "Opłata serwisowa"; "lightning__savings_advanced__title" = "Wybierz fundusze \ndo przesłania"; @@ -249,6 +272,12 @@ "lightning__savings_interrupted__nav_title" = "Transfer środków\nprzerwany"; "lightning__savings_interrupted__title" = "Pozostaw Bitkit\notwarty"; "lightning__savings_interrupted__text" = "Środki nie zostały jeszcze przelane. Bitkit spróbuje zainicjować transfer w ciągu najbliższych 30 minut. Proszę pozostawić aplikację otwartą."; +"lightning__savings_spark_failed__title" = "Transfer środków \nniepowiódł się"; +"lightning__savings_spark_failed__text" = "Nie udało się przenieść salda Spark do oszczędności; pozostaje ono w saldzie wydatków."; +"lightning__savings_spark_failed__text_partial" = "Środki z kanałów trafiają do oszczędności, ale nie udało się przenieść salda Spark; pozostaje ono w saldzie wydatków."; +"lightning__savings_spark_failed__toast" = "Transfer Spark nieudany"; +"lightning__transfer_success__nav_title" = "Transfer środków powiódł się"; +"lightning__transfer_success__title_spending" = "Środki przeniesione\nna saldo wydatków"; "lightning__transfer_success__title_savings" = "Środki przeniesione\nna saldo oszczędności"; "lightning__transfer_success__text_savings" = "Środki zostały przelane i są gotowe do natychmiastowego użycia."; "lightning__external__nav_title" = "Połączenie z Lightning"; @@ -271,6 +300,11 @@ "lightning__spending_label" = "Saldo wydatków"; "lightning__receiving_label" = "Zdolność odbioru"; "lightning__setting_up_header" = "W transferze"; +"lightning__setting_up_step1" = "Przetwarzanie płatności"; +"lightning__setting_up_step2" = "Płatność powiodła się"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Kontunuuj korzystanie z Bitkit\'a"; "lightning__node_info" = "Węzeł Lightning"; "lightning__node_id" = "Identyfikator węzła LDK"; "lightning__node_disconnected" = "odłączony"; @@ -311,6 +345,36 @@ "lightning__transaction" = "Transakcja"; "lightning__order_fee" = "Opłata za zamówienie"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Opłata za odbiór"; +"lightning__spark_confirmations" = "{count}/3 potwierdzeń"; +"lightning__spark_deposit" = "Wpłata"; +"lightning__spark_deposit__choice" = "Spark może je dodać {when} za wyższą opłatą albo po {standard} potwierdzeniach, za około {minutes} minut."; +"lightning__spark_deposit__claim_early" = "Użyj {when}"; +"lightning__spark_deposit__claimed" = "Spark dodaje je do Twojego salda wydatków."; +"lightning__spark_deposit__early_fee" = "Opłata za użycie {when}"; +"lightning__spark_deposit__later" = "po {count} potwierdzeniach"; +"lightning__spark_deposit__later_one" = "po 1 potwierdzeniu"; +"lightning__spark_deposit__looking" = "Czekamy, aż Spark zobaczy Twój przelew."; +"lightning__spark_deposit__now" = "teraz"; +"lightning__spark_deposit__standard" = "Spark doda je do Twojego salda wydatków po {standard} potwierdzeniach, za około {minutes} minut."; +"lightning__spark_deposit__standard_fee" = "Opłata za czekanie"; +"lightning__spark_deposit__wait_button" = "Czekaj"; +"lightning__spark_deposit__waiting" = "Spark doda je do Twojego salda wydatków {when}."; +"lightning__spark_deposit__waiting_next" = "Spark doda je do Twojego salda wydatków po następnym potwierdzeniu."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Odebrane wpłaty"; +"lightning__spark_deposits_pending" = "Wpłaty w toku"; +"lightning__spark_provider" = "Dostawca"; +"lightning__spark_provider_value" = "Spark przez Breez"; +"lightning__spark_receiving_unlimited" = "Bez limitu"; +"lightning__spark_trust" = "To saldo jest przechowywane na Spark, prowadzonym przez Lightspark, Breez i Flashnet. Jest bezpieczne, dopóki co najmniej jeden z nich działa uczciwie. Mogą wstrzymać dostęp, a wyjście bez nich wymaga wyjścia on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo rezerwy (nie do wydania)"; "lightning__total_size" = "Całkowity rozmiar kanału"; "lightning__fees" = "Opłaty"; @@ -850,6 +914,7 @@ "settings__backup__category_transaction_log" = "Log transakcji"; "settings__backup__category_wallet" = "Przyśpieszenia i transfery"; "settings__backup__category_settings" = "Ustawienia"; +"settings__backup__category_spark_exit" = "Dane wyjścia ze Spark"; "settings__backup__category_widgets" = "Widgety"; "settings__backup__category_tags" = "Tagi"; "settings__backup__category_profile" = "Profil"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 86338818c..bd54df0b4 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Depende da taxa"; "fee__custom__shortRange" = "Depende da taxa"; "fee__custom__longRange" = "Depende da taxa"; +"lightning__transfer_intro__title" = "Saldo de\nGastos"; +"lightning__transfer_intro__text" = "Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes."; +"lightning__transfer_intro__button" = "Começar"; "lightning__funding__nav_title" = "Saldo de Gastos"; +"lightning__funding__text" = "A forma mais fácil é usar suas economias do Bitkit ou enviar bitcoin de uma carteira diferente."; "lightning__funding__title" = "Transfira para seu saldo de gastos"; "lightning__funding__button1" = "Transferir do Saldo de Poupança"; "lightning__funding__button2" = "Transferir de Outra Carteira"; "lightning__funding__button3" = "Configuração manual"; "lightning__funding_advanced__button_short" = "Configuração Avançada"; +"lightning__no_funds__title" = "Não há Fundos Disponíveis"; +"lightning__no_funds__description" = "Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit."; +"lightning__no_funds__fund" = "Adicione fundos"; +"lightning__spending_amount__title" = "Transferir\npara gastos"; "lightning__transfer__nav_title" = "Transf. fundos"; "lightning__transfer__confirm" = "Por favor\nconfirme"; "lightning__transfer__custom_fee" = "Taxa personalizada"; @@ -209,8 +217,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Sua transação de financiamento pode ter sido transmitida. Verifique sua atividade antes de tentar novamente."; "lightning__transfer_hw__reconnect_error_description" = "Reconecte seu dispositivo de hardware, ele parece estar desconectado do seu telefone."; "lightning__transfer_hw__reconnect_error_title" = "Reconectar Dispositivo de Hardware"; +"lightning__transfer_hw__sign_title" = "Assine com\nseu dispositivo"; +"lightning__transfer_hw__signed_title" = "Transação\nassinada"; "lightning__transfer_to_savings_button" = "Transferir para poupança"; "lightning__transfer_to_spending_button" = "Transferir para gastos"; +"lightning__spending_intro__title" = "Transferir\npara gastos"; +"lightning__spending_intro__text" = "Transfira fundos para seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes."; +"lightning__spending_intro__button" = "Começar"; "lightning__transfer_failed__nav_title" = "Falha na Transferência"; "lightning__transfer_failed__title" = "Transferência\nfalhou"; "lightning__transfer_failed__text" = "Desculpe, a Bitkit não conseguiu configurar seu saldo de gastos ou concluir sua transferência. Por favor, tente novamente."; @@ -221,11 +234,20 @@ "lightning__spending_amount__error_min__description" = "É necessário um mínimo de ₿ {amount} para estabelecer seu saldo de gastos."; "lightning__spending_amount__error_max__title" = "Saldo de gastos Máximo"; "lightning__spending_amount__error_max__description" = "O valor que você pode transferir para seu saldo de gastos está atualmente limitado a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_amount__error_max__description_zero" = "Sua transferência para o saldo de gastos é limitada devido à política de liquidez. Para obter detalhes, visite a Central de Ajuda."; "lightning__spending_confirm__network_fee" = "Taxa da rede"; "lightning__spending_confirm__lsp_fee" = "Taxa de serviço"; "lightning__spending_confirm__amount" = "Para gastos"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Taxas alteradas"; +"lightning__spending_confirm__fees_changed_network" = "As taxas estão {amount} mais altas porque as taxas de rede ou seu saldo de poupança mudaram enquanto você estava nesta tela. Deslize novamente para confirmar."; "lightning__savings_intro__title" = "Transferir\npara poupança"; "lightning__savings_intro__text" = "Transfira seu saldo de gastos para sua poupança para armazenar seu Bitcoin a longo prazo e garantir seu futuro."; "lightning__savings_intro__button" = "Começar"; @@ -237,6 +259,7 @@ "lightning__savings_confirm__amount_too_low" = "O valor é baixo demais para transferir para a poupança."; "lightning__savings_confirm__close_instead" = "Fechar o canal em vez disso"; "lightning__savings_confirm__network_fee" = "Taxa de rede"; +"lightning__savings_confirm__spark_fee" = "Taxa do Spark"; "lightning__savings_confirm__receive" = "Você receberá"; "lightning__savings_confirm__service_fee" = "Taxa de serviço"; "lightning__savings_advanced__title" = "Selecione fundos\npara transferir"; @@ -249,6 +272,12 @@ "lightning__savings_interrupted__nav_title" = "Transferência\ninterrompida"; "lightning__savings_interrupted__title" = "Mantenha a Bitkit\naberta"; "lightning__savings_interrupted__text" = "Os fundos ainda não foram transferidos. A Bitkit tentará iniciar a transferência durante os próximos 30 minutos. Por favor, mantenha o aplicativo aberto."; +"lightning__savings_spark_failed__title" = "Transferência\nfalhou"; +"lightning__savings_spark_failed__text" = "Seu saldo Spark não pôde ser movido para a poupança e continua no seu saldo de gastos."; +"lightning__savings_spark_failed__text_partial" = "Os fundos dos seus canais estão indo para a poupança, mas seu saldo Spark não pôde ser movido e continua no seu saldo de gastos."; +"lightning__savings_spark_failed__toast" = "Transferência Spark falhou"; +"lightning__transfer_success__nav_title" = "Transferência bem-sucedida"; +"lightning__transfer_success__title_spending" = "Fundos transferidos\npara gastos"; "lightning__transfer_success__title_savings" = "Fundos transferidos\npara poupança"; "lightning__transfer_success__text_savings" = "Seus fundos foram transferidos. Você já pode usar esses fundos imediatamente."; "lightning__external__nav_title" = "Conexões Lightning"; @@ -271,6 +300,11 @@ "lightning__spending_label" = "Saldo de gastos"; "lightning__receiving_label" = "Capacidade de recebimento"; "lightning__setting_up_header" = "Em transferência"; +"lightning__setting_up_step1" = "Processando Pagamento"; +"lightning__setting_up_step2" = "Pagamento Realizado"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuar Usando o Bitkit"; "lightning__node_info" = "Nó Lightning"; "lightning__node_id" = "ID do LDK Node "; "lightning__node_disconnected" = "desconectado"; @@ -311,6 +345,36 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Taxa de resgate"; +"lightning__spark_confirmations" = "{count}/3 confirmações"; +"lightning__spark_deposit" = "Depósito"; +"lightning__spark_deposit__choice" = "A Spark pode adicionar {when} por uma taxa maior, ou após {standard} confirmações, em cerca de {minutes} minutos."; +"lightning__spark_deposit__claim_early" = "Usar {when}"; +"lightning__spark_deposit__claimed" = "A Spark está adicionando ao seu saldo de gastos."; +"lightning__spark_deposit__early_fee" = "Taxa para usar {when}"; +"lightning__spark_deposit__later" = "após {count} confirmações"; +"lightning__spark_deposit__later_one" = "após 1 confirmação"; +"lightning__spark_deposit__looking" = "Aguardando a Spark ver sua transferência."; +"lightning__spark_deposit__now" = "agora"; +"lightning__spark_deposit__standard" = "A Spark adiciona ao seu saldo de gastos após {standard} confirmações, em cerca de {minutes} minutos."; +"lightning__spark_deposit__standard_fee" = "Taxa para esperar"; +"lightning__spark_deposit__wait_button" = "Esperar"; +"lightning__spark_deposit__waiting" = "A Spark adiciona ao seu saldo de gastos {when}."; +"lightning__spark_deposit__waiting_next" = "A Spark adiciona ao seu saldo de gastos após a próxima confirmação."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depósitos resgatados"; +"lightning__spark_deposits_pending" = "Depósitos em andamento"; +"lightning__spark_provider" = "Provedor"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "Sem limite"; +"lightning__spark_trust" = "Este saldo fica na Spark, operada pela Lightspark, Breez e Flashnet. Ele está seguro enquanto pelo menos um deles agir com honestidade. Eles podem pausar o acesso, e sair sem eles exige uma saída on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva (não utilizável)"; "lightning__total_size" = "Tamanho total do canal"; "lightning__fees" = "Taxas"; @@ -850,6 +914,7 @@ "settings__backup__category_transaction_log" = "Histórico de transações"; "settings__backup__category_wallet" = "Boosts e transferências"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_spark_exit" = "Dados de saída do Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__backup__category_profile" = "Perfil"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 5d8e31fd4..5ab90bafd 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1,3 +1,5 @@ +"lightning__funding__text" = "A forma mais fácil é usar as suas poupanças Bitkit ou enviar bitcoin de uma carteira diferente."; +"lightning__spending_amount__title" = "Transferir\npara gastos"; "security__cp_text" = "Você pode alterar seu código PIN para uma nova\ncombinação de 4 dígitos. Primeiro, digite seu código PIN atual."; "wallet__onchain_abandon_transfer_note" = "As transferências para o saldo de gastos já não estão disponíveis. A Bitkit verifica primeiro a rede e depois devolve as moedas desta transferência à sua carteira, para que a transferência original nunca seja confirmada. Isto tem uma taxa de rede."; "wallet__payment_recipient_rejected" = "O destinatário rejeitou este pagamento Lightning. Verifique a fatura e tente novamente."; @@ -221,11 +223,17 @@ "fee__custom__description" = "Depende da taxa"; "fee__custom__shortDescription" = "Depende da taxa"; "fee__custom__range" = "Depende da taxa"; +"lightning__transfer_intro__title" = "Saldo de\nGastos"; +"lightning__transfer_intro__text" = "Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes."; +"lightning__transfer_intro__button" = "Começar"; "lightning__funding__title" = "Transfira para seu saldo de gastos"; "lightning__funding__button1" = "Transferir do Saldo de Poupança"; "lightning__funding__button2" = "Transferir de Outra Carteira"; "lightning__funding__button3" = "Setup Manual"; "lightning__funding_advanced__button_short" = "Configuração Avançada"; +"lightning__no_funds__title" = "Não há Fundos Disponíveis"; +"lightning__no_funds__description" = "Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit."; +"lightning__no_funds__fund" = "Financiar carteira"; "lightning__transfer__nav_title" = "Transf. fundos"; "lightning__transfer__confirm" = "Por favor\nconfirme"; "lightning__transfer__swipe" = "Deslizar Para Transferir"; @@ -233,15 +241,29 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "A sua transação de financiamento pode ter sido difundida. Verifique a sua atividade antes de tentar novamente."; "lightning__transfer_hw__reconnect_error_description" = "Volte a ligar o seu dispositivo de hardware; parece estar desligado do telemóvel."; "lightning__transfer_hw__reconnect_error_title" = "Voltar a ligar o dispositivo"; +"lightning__transfer_hw__sign_title" = "Assine com\no seu dispositivo"; +"lightning__transfer_hw__signed_title" = "Transação\nassinada"; "lightning__transfer_to_savings_button" = "Transferir Para Poupança"; "lightning__transfer_to_spending_button" = "Transferir Para Gastos"; +"lightning__spending_intro__title" = "Transferir\npara gastos"; +"lightning__spending_intro__text" = "Transfira fundos para seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes."; +"lightning__spending_intro__button" = "Começar"; "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Saldo de gastos Máximo"; "lightning__spending_amount__error_max__description" = "O valor que você pode transferir para seu saldo de gastos está atualmente limitado a ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Taxa da rede"; "lightning__spending_confirm__lsp_fee" = "Taxa de serviço"; "lightning__spending_confirm__amount" = "Para gastos"; "lightning__spending_confirm__total" = "Total"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "As taxas mudaram"; +"lightning__spending_confirm__fees_changed_network" = "As taxas estão {amount} mais altas porque as taxas de rede ou o seu saldo de poupança mudaram enquanto estava neste ecrã. Deslize novamente para confirmar."; "lightning__savings_intro__title" = "Transferir\npara poupança"; "lightning__savings_intro__text" = "Transfira seu saldo de gastos para sua poupança para armazenar seu Bitcoin a longo prazo e garantir seu futuro."; "lightning__savings_intro__button" = "Começar"; @@ -253,6 +275,7 @@ "lightning__savings_confirm__amount_too_low" = "O montante é demasiado baixo para transferir para a poupança."; "lightning__savings_confirm__close_instead" = "Fechar o canal"; "lightning__savings_confirm__network_fee" = "Taxa de rede"; +"lightning__savings_confirm__spark_fee" = "Taxa do Spark"; "lightning__savings_confirm__receive" = "Vai receber"; "lightning__savings_confirm__service_fee" = "Taxa de serviço"; "lightning__savings_advanced__title" = "Selecione fundos\npara transferir"; @@ -265,6 +288,12 @@ "lightning__savings_interrupted__nav_title" = "Transferência\ninterrompida"; "lightning__savings_interrupted__title" = "Mantenha a Bitkit\naberta"; "lightning__savings_interrupted__text" = "Os fundos ainda não foram transferidos. A Bitkit tentará iniciar a transferência durante os próximos 30 minutos. Por favor, mantenha o aplicativo aberto."; +"lightning__savings_spark_failed__title" = "Transferência\nfalhou"; +"lightning__savings_spark_failed__text" = "O teu saldo Spark não pôde ser movido para a poupança e fica no teu saldo de gastos."; +"lightning__savings_spark_failed__text_partial" = "Os fundos dos teus canais estão a ir para a poupança, mas o teu saldo Spark não pôde ser movido e fica no teu saldo de gastos."; +"lightning__savings_spark_failed__toast" = "Transferência Spark falhou"; +"lightning__transfer_success__nav_title" = "Transferência bem-sucedida"; +"lightning__transfer_success__title_spending" = "Fundos transferidos\npara gastos"; "lightning__transfer_success__title_savings" = "Fundos transferidos\npara poupança"; "lightning__transfer_success__text_savings" = "Seus fundos foram transferidos. Você já pode usar esses fundos imediatamente."; "lightning__external__nav_title" = "Conexões Lightning"; @@ -283,6 +312,11 @@ "lightning__savings" = "Poupança"; "lightning__spending_label" = "Saldo de gastos"; "lightning__receiving_label" = "Capacidade de recebimento"; +"lightning__setting_up_step1" = "Processando Pagamento"; +"lightning__setting_up_step2" = "Pagamento Realizado"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Continuar Usando o Bitkit"; "lightning__node_info" = "Nó Lightning"; "lightning__connections" = "Conexões Lightning"; "lightning__conn_pending" = "Conexões pendentes"; @@ -309,6 +343,36 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Taxa de resgate"; +"lightning__spark_confirmations" = "{count}/3 confirmações"; +"lightning__spark_deposit" = "Depósito"; +"lightning__spark_deposit__choice" = "A Spark pode adicioná-lo {when} por uma taxa mais alta, ou após {standard} confirmações, dentro de cerca de {minutes} minutos."; +"lightning__spark_deposit__claim_early" = "Usar {when}"; +"lightning__spark_deposit__claimed" = "A Spark está a adicioná-lo ao seu saldo de gastos."; +"lightning__spark_deposit__early_fee" = "Taxa para usar {when}"; +"lightning__spark_deposit__later" = "após {count} confirmações"; +"lightning__spark_deposit__later_one" = "após 1 confirmação"; +"lightning__spark_deposit__looking" = "A aguardar que a Spark veja a sua transferência."; +"lightning__spark_deposit__now" = "agora"; +"lightning__spark_deposit__standard" = "A Spark adiciona-o ao seu saldo de gastos após {standard} confirmações, dentro de cerca de {minutes} minutos."; +"lightning__spark_deposit__standard_fee" = "Taxa para esperar"; +"lightning__spark_deposit__wait_button" = "Esperar"; +"lightning__spark_deposit__waiting" = "A Spark adiciona-o ao seu saldo de gastos {when}."; +"lightning__spark_deposit__waiting_next" = "A Spark adiciona-o ao seu saldo de gastos após a próxima confirmação."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Depósitos resgatados"; +"lightning__spark_deposits_pending" = "Depósitos em curso"; +"lightning__spark_provider" = "Fornecedor"; +"lightning__spark_provider_value" = "Spark via Breez"; +"lightning__spark_receiving_unlimited" = "Sem limite"; +"lightning__spark_trust" = "Este saldo é mantido na Spark, operada pela Lightspark, Breez e Flashnet. Está seguro enquanto pelo menos um deles agir com honestidade. Podem suspender o acesso, e sair sem eles exige uma saída on-chain."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Saldo de reserva (não utilizável)"; "lightning__total_size" = "Tamanho total do canal"; "lightning__fees" = "Taxas"; @@ -738,6 +802,7 @@ "settings__backup__category_transaction_log" = "Histórico de transações"; "settings__backup__category_wallet" = "Boosts e transferências"; "settings__backup__category_settings" = "Ajustes"; +"settings__backup__category_spark_exit" = "Dados de saída do Spark"; "settings__backup__category_widgets" = "Widgets"; "settings__backup__category_tags" = "Tags"; "settings__support__title" = "Suporte"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 4d1bea428..d2df40a5b 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -195,12 +195,20 @@ "fee__custom__range" = "Зависит от комиссии"; "fee__custom__shortRange" = "Зависит от комиссии"; "fee__custom__longRange" = "Зависит от комиссии"; +"lightning__transfer_intro__title" = "Расходы\nБаланс"; +"lightning__transfer_intro__text" = "Пополните свой баланс расходов, чтобы наслаждаться мгновенными и недорогими транзакциями с друзьями, семьёй и продавцами."; +"lightning__transfer_intro__button" = "Начать"; "lightning__funding__nav_title" = "Баланс Расходов"; +"lightning__funding__text" = "Самый простой вариант — использовать ваши сбережения Bitkit или отправить биткоин с другого кошелька."; "lightning__funding__title" = "Пополните свой Баланс Расходов"; "lightning__funding__button1" = "Перевести из Сбережений"; "lightning__funding__button2" = "Использовать Другой Кошелёк"; "lightning__funding__button3" = "Ручная настройка"; "lightning__funding_advanced__button_short" = "Расширенная Настройка"; +"lightning__no_funds__title" = "Недостаточно Средств"; +"lightning__no_funds__description" = "Прежде чем вы сможете перевести средства со своего баланса сбережений, вам нужно отправить биткойны в ваш кошелёк Bitkit."; +"lightning__no_funds__fund" = "Пополнить Кошелёк"; +"lightning__spending_amount__title" = "Перевод\nна Расходы"; "lightning__transfer__nav_title" = "Перевод Средств"; "lightning__transfer__confirm" = "Пожалуйста,\nПодтвердите"; "lightning__transfer__swipe" = "Проведите для Перевода"; @@ -208,8 +216,13 @@ "lightning__transfer_hw__broadcast_uncertain_description" = "Ваша транзакция пополнения могла быть отправлена. Проверьте активность, прежде чем пробовать снова."; "lightning__transfer_hw__reconnect_error_description" = "Переподключите аппаратное устройство: похоже, оно отключилось от телефона."; "lightning__transfer_hw__reconnect_error_title" = "Переподключите Аппаратное Устройство"; +"lightning__transfer_hw__sign_title" = "Подпишите на\nустройстве"; +"lightning__transfer_hw__signed_title" = "Транзакция\nподписана"; "lightning__transfer_to_savings_button" = "Перевести в Сбережения"; "lightning__transfer_to_spending_button" = "Перевести на Расходы"; +"lightning__spending_intro__title" = "Перевод\nна Расходы"; +"lightning__spending_intro__text" = "Переведите средства на свой баланс расходов, чтобы наслаждаться мгновенными и недорогими транзакциями с друзьями, семьёй и продавцами."; +"lightning__spending_intro__button" = "Начать"; "lightning__transfer_failed__nav_title" = "Неудачный Перевод"; "lightning__transfer_failed__title" = "Перевод\nНе Удался"; "lightning__transfer_failed__text" = "Извините, Bitkit не смог настроить ваш баланс расходов или завершить ваш перевод. Пожалуйста, попробуйте снова."; @@ -218,10 +231,19 @@ "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_max__title" = "Максимум Баланса Расходов"; "lightning__spending_amount__error_max__description" = "Сумма, которую вы можете перевести на баланс расходов, в настоящее время ограничена ₿ {amount}."; +"lightning__spending_amount__error_fee" = "Couldn't get the network fee. Tap to try again."; "lightning__spending_confirm__network_fee" = "Сетевые Комиссии"; "lightning__spending_confirm__lsp_fee" = "Сервисные Комиссии"; "lightning__spending_confirm__amount" = "На Баланс Расходов"; "lightning__spending_confirm__total" = "Итого"; +"lightning__spending_confirm__spending_fee" = "Spending fee"; +"lightning__spending_confirm__up_to" = "up to"; +"lightning__spending_confirm__instant_text" = "Ready in a few minutes"; +"lightning__spending_confirm__later" = "Later"; +"lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; +"lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; +"lightning__spending_confirm__fees_changed_title" = "Комиссии изменились"; +"lightning__spending_confirm__fees_changed_network" = "Комиссии выросли на {amount}, потому что ставки комиссий или баланс сбережений изменились, пока вы были на этом экране. Проведите ещё раз для подтверждения."; "lightning__savings_intro__title" = "Перевод\nв Сбережения"; "lightning__savings_intro__text" = "Переведите свой баланс расходов в сбережения, чтобы хранить свой биткойн в долгосрочной перспективе и обеспечить своё будущее."; "lightning__savings_intro__button" = "Начать"; @@ -233,6 +255,7 @@ "lightning__savings_confirm__amount_too_low" = "Сумма слишком мала для перевода в сбережения."; "lightning__savings_confirm__close_instead" = "Вместо этого закрыть канал"; "lightning__savings_confirm__network_fee" = "Сетевая комиссия"; +"lightning__savings_confirm__spark_fee" = "Комиссия Spark"; "lightning__savings_confirm__receive" = "Вы получите"; "lightning__savings_confirm__service_fee" = "Сервисная комиссия"; "lightning__savings_advanced__title" = "Выберите Средства\nдля Перевода"; @@ -245,6 +268,12 @@ "lightning__savings_interrupted__nav_title" = "Перевод\nПрерван"; "lightning__savings_interrupted__title" = "Держите Bitkit\nОткрытым"; "lightning__savings_interrupted__text" = "Средства ещё не переведены. Bitkit попытается инициировать перевод в течение следующих 30 минут. Пожалуйста, держите приложение открытым."; +"lightning__savings_spark_failed__title" = "Перевод\nНе Удался"; +"lightning__savings_spark_failed__text" = "Баланс Spark не удалось перевести в сбережения, он остаётся на балансе расходов."; +"lightning__savings_spark_failed__text_partial" = "Средства каналов переводятся в сбережения, но баланс Spark перевести не удалось, он остаётся на балансе расходов."; +"lightning__savings_spark_failed__toast" = "Перевод Spark не выполнен"; +"lightning__transfer_success__nav_title" = "Перевод Выполнен Успешно"; +"lightning__transfer_success__title_spending" = "Средства Переведены\nна Расходы"; "lightning__transfer_success__title_savings" = "Средства Переведены\nв Сбережения"; "lightning__transfer_success__text_savings" = "Ваши средства переведены. Вы можете использовать эти средства немедленно."; "lightning__external__nav_title" = "Lightning Соединение"; @@ -267,6 +296,11 @@ "lightning__spending_label" = "Баланс Расходов"; "lightning__receiving_label" = "Входящая Ёмкость"; "lightning__setting_up_header" = "В Процессе Перевода"; +"lightning__setting_up_step1" = "Обработка Платежа"; +"lightning__setting_up_step2" = "Платеж Успешен"; +"lightning__setting_up_step_spark" = "Adding To Spending"; +"lightning__setting_up_speed" = "Speed: {speed}"; +"lightning__setting_up_button" = "Продолжить Использование Bitkit"; "lightning__node_info" = "Lightning Узел"; "lightning__node_id" = "ID Узла LDK"; "lightning__node_disconnected" = "Отключено"; @@ -306,6 +340,36 @@ "lightning__transaction" = "Транзакция"; "lightning__order_fee" = "Комиссия Ордера"; "lightning__balance" = "Баланс"; +"lightning__spark" = "Spark"; +"lightning__spark_claim_fee" = "Комиссия за зачисление"; +"lightning__spark_confirmations" = "{count}/3 подтверждений"; +"lightning__spark_deposit" = "Депозит"; +"lightning__spark_deposit__choice" = "Spark может зачислить их {when} за более высокую комиссию или после {standard} подтверждений, примерно через {minutes} минут."; +"lightning__spark_deposit__claim_early" = "Использовать {when}"; +"lightning__spark_deposit__claimed" = "Spark зачисляет их на ваш расходный баланс."; +"lightning__spark_deposit__early_fee" = "Комиссия, чтобы использовать {when}"; +"lightning__spark_deposit__later" = "после {count} подтверждений"; +"lightning__spark_deposit__later_one" = "после 1 подтверждения"; +"lightning__spark_deposit__looking" = "Ждём, пока Spark увидит ваш перевод."; +"lightning__spark_deposit__now" = "сейчас"; +"lightning__spark_deposit__standard" = "Spark зачислит их на ваш расходный баланс после {standard} подтверждений, примерно через {minutes} минут."; +"lightning__spark_deposit__standard_fee" = "Комиссия за ожидание"; +"lightning__spark_deposit__wait_button" = "Ждать"; +"lightning__spark_deposit__waiting" = "Spark зачислит их на ваш расходный баланс {when}."; +"lightning__spark_deposit__waiting_next" = "Spark зачислит их на ваш расходный баланс после следующего подтверждения."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__pay" = "Pay {fee}"; +"lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; +"lightning__spark_deposits_claimed" = "Зачисленные депозиты"; +"lightning__spark_deposits_pending" = "Депозиты в обработке"; +"lightning__spark_provider" = "Провайдер"; +"lightning__spark_provider_value" = "Spark через Breez"; +"lightning__spark_receiving_unlimited" = "Без ограничений"; +"lightning__spark_trust" = "Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода."; +"lightning__spark_learn__title" = "Transfer\nspeed"; +"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; +"lightning__spark_learn__label" = "Spending balance after transfer"; "lightning__reserve_balance" = "Резервный Баланс (не доступен для трат)"; "lightning__total_size" = "Общий Размер Канала"; "lightning__fees" = "Комиссии"; @@ -845,6 +909,7 @@ "settings__backup__category_transaction_log" = "Журнал Транзакций"; "settings__backup__category_wallet" = "Ускорения и Переводы"; "settings__backup__category_settings" = "Настройки"; +"settings__backup__category_spark_exit" = "Данные выхода из Spark"; "settings__backup__category_widgets" = "Виджеты"; "settings__backup__category_tags" = "Теги"; "settings__backup__category_profile" = "Профиль"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 5eeafe254..2d014bb4a 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -310,6 +310,11 @@ class BackupService { Logger.debug("Start observing backup statuses and data store changes", context: "BackupService") await installBackupObservers(generation: observation.generation, foregroundGeneration: observation.foregroundGeneration) + + // Spark's connect signal is missed when it connected before observing started. + if await SparkManager.shared.isConnected { + markBackupRequired(category: .sparkExit) + } } } @@ -409,6 +414,11 @@ class BackupService { } else { try await persistWalletBackup() } + } else if category == .sparkExit { + // Nothing to back up until Spark connects, which marks this category required again. + if let data = try await sparkExitBackupData() { + try await uploadBackup(category.rawValue, data) + } } else { if category == .metadata { try await prepareMetadataBackup() @@ -788,6 +798,11 @@ class BackupService { Logger.debug("Restored \(payload.orders.count) orders, \(payload.cjitEntries.count) CJITs", context: "BackupService") } + try await performRestore(category: .sparkExit) { dataBytes in + let backup = try JSONDecoder().decode(SparkExitBackupV1.self, from: dataBytes) + try await SparkManager.shared.restoreExitBackup(backup, walletIndex: LightningService.shared.currentWalletIndex) + } + // Always reset PIN settings after restore (PIN is never backed up for security) await SettingsViewModel.shared.resetPinSettings() @@ -864,6 +879,13 @@ class BackupService { } .store(in: cancellables) + SparkManager.exitStateChangedPublisher + .sink { [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .sparkExit) + } + .store(in: cancellables) + // Record requirements synchronously; the scheduled upload already has a debounce. WalletBackupChangeTracker.localChanges .merge(with: PaykitSdkService.walletBackupDataChangedPublisher) @@ -1370,7 +1392,25 @@ class BackupService { code: -1, userInfo: [NSLocalizedDescriptionKey: "LIGHTNING_CONNECTIONS backup is managed by ldk-node"] ) + + case .sparkExit: + guard let data = try await sparkExitBackupData() else { + throw NSError( + domain: "BackupService", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "SPARK_EXIT has nothing to back up while Spark is not connected"] + ) + } + return data + } + } + + private func sparkExitBackupData() async throws -> Data? { + if let backupData { + return try await backupData(.sparkExit) } + guard let backup = try await SparkManager.shared.exitBackup() else { return nil } + return try JSONEncoder().encode(backup) } /// Re-upload the envelopes whose Core-owned fields the restore had to migrate, so later restores diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 460eac965..366a612e1 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -955,6 +955,14 @@ class ActivityService { } } + /// Records a Lightning payment the channel-free rail sent or received the way an LDK payment is recorded, so it keeps + /// its Paykit contact and seen state across syncs. + func recordChannelFreeRailPayment(_ payment: PaymentDetails) async throws { + try await ServiceQueue.background(.core) { + try await self.processLightningPayment(payment) + } + } + private func processLightningPayment(_ payment: PaymentDetails) async throws { guard case let .bolt11(_, preimage, _, description, bolt11) = payment.kind else { return } @@ -1459,6 +1467,24 @@ class ActivityService { } } + /// Marks the on-chain activity for `txId` as a transfer between Savings and Spending that has no channel, + /// such as a Spark deposit or withdrawal. No-op until the activity exists or once it is marked. + func markOnchainActivityAsTransfer(txId: String) async { + do { + try await ServiceQueue.background(.core) { + guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txId), + !existing.isTransfer + else { return } + var updated = existing + updated.isTransfer = true + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.activitiesChangedSubject.send() + } + } catch { + Logger.error("Failed to mark activity as transfer for \(txId): \(error)", context: "ActivityService") + } + } + /// Resolve the on-chain activity for `txId`, preferring the row that most likely represents the /// transfer: one already flagged as a transfer, then a hardware-wallet send (the hardware /// funding path), then whatever else matches. diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index cfc03b871..97b7f4d40 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -5,6 +5,56 @@ import LDKNode // TODO: catch all errors and pass a readable error message to the UI +/// A Lightning rail that needs no channels (Breez SDK on Spark). The main app sets it; the notification +/// extension leaves it unset, so only LDK runs there. +protocol ChannelFreeLightningRail: AnyObject { + @MainActor var isReady: Bool { get } + @MainActor var balanceSats: UInt64 { get } + func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String + func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash + func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 + func lightningPayments() async -> [ChannelFreeRailPayment] +} + +@MainActor +extension ChannelFreeLightningRail { + /// The most the rail can send once its fee is paid, so a payment near the full balance isn't offered and then refused. + var sendableSats: UInt64 { + ChannelFreeRailFees.sendableSats(balanceSats: balanceSats) + } +} + +enum ChannelFreeRailFees { + /// Spark keeps 0.25% of a Lightning send as its service fee, on top of routing. + static let serviceFeePpm: UInt64 = 2500 + + static func sendableSats(balanceSats: UInt64) -> UInt64 { + // The fee on the sent amount, rounded up, must fit beside it: amount + ceil(amount * ppm / 1e6) <= balance. + let sendable = balanceSats * 1_000_000 / (1_000_000 + serviceFeePpm) + let fee = (sendable * serviceFeePpm + 999_999) / 1_000_000 + return sendable + fee <= balanceSats || sendable == 0 ? sendable : sendable - 1 + } +} + +/// The channel-free rail refused a payment before anything left the wallet: the rail was not connected, could not +/// prepare the payment, or its fee was over the caller's limit. A Paykit request or allowance can then end as failed. +struct ChannelFreeRailSendRefused: LocalizedError { + let reason: String + var underlying: Error? + + var errorDescription: String? { + (underlying as? LocalizedError)?.errorDescription ?? reason + } +} + +/// A Lightning payment the channel-free rail sent or received, for checks that would otherwise read only LDK's payments. +struct ChannelFreeRailPayment: Equatable { + let paymentHash: String + let direction: PaymentDirection + let status: PaymentStatus + let preimage: String? +} + /// The node lifecycle `WalletViewModel` drives, apart from the rest of `LightningService` so its ordering can be tested. protocol NodeLifecycleService: AnyObject { var hasNode: Bool { get } @@ -47,6 +97,9 @@ class LightningService: NodeLifecycleService { @MainActor private var cachedChannels: [ChannelDetails]? private var storedEventCallback: ((Event) -> Void)? + /// Rail events that arrived before the node's event callback was set, delivered once it is. + private var pendingChannelFreeRailEvents: [Event] = [] + weak var channelFreeRail: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? typealias OnchainActivityCompletion = @MainActor () async -> Void @@ -266,7 +319,7 @@ class LightningService: NodeLifecycleService { } if let onEvent { - storedEventCallback = onEvent + setEventCallback(onEvent) } listenForEvents(onEvent: storedEventCallback) @@ -324,6 +377,7 @@ class LightningService: NodeLifecycleService { if clearEventCallback { storedEventCallback = nil + pendingChannelFreeRailEvents = [] } await MainActor.run { @@ -745,6 +799,10 @@ class LightningService: NodeLifecycleService { } func receiveMsats(amountMsats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { + if let rail = channelFreeRail, await shouldReceiveWithRail(rail, amountSats: amountMsats.map { $0 / 1000 }) { + let amountSats = amountMsats.map { $0 / 1000 }.flatMap { $0 > 0 ? $0 : nil } + return try await rail.receive(amountSats: amountSats, description: description, expirySecs: expirySecs) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -771,12 +829,40 @@ class LightningService: NodeLifecycleService { return bolt11.description } + /// Receives on the channel-free rail when no ready channel has the inbound capacity for the amount. + @MainActor + private func shouldReceiveWithRail(_ rail: ChannelFreeLightningRail, amountSats: UInt64?) -> Bool { + guard rail.isReady else { return false } + let readyChannels = (channels ?? []).filter(\.isChannelReady) + guard !readyChannels.isEmpty else { return true } + let inboundSats = readyChannels.map(\.inboundCapacityMsat).reduce(0, +) / 1000 + guard let amountSats, amountSats > 0 else { return inboundSats == 0 } + return amountSats > inboundSats + } + + /// Pays on the channel-free rail when no channel has the outbound capacity and the rail's balance covers the amount. + @MainActor + private func shouldPayWithRail(_ rail: ChannelFreeLightningRail, bolt11: String, sats: UInt64?) -> Bool { + guard rail.isReady else { return false } + let invoiceSats = (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).amountMilliSatoshis()).flatMap { $0 }.map { $0 / 1000 } + guard let amountSats = sats ?? invoiceSats else { return false } + return !canSendWithChannels(amountSats: amountSats) && rail.sendableSats >= amountSats + } + /// Checks if we have the correct outbound capacity to send the amount /// - Parameter amountSats: Amount to send in satoshis /// - Returns: True if we can send the amount /// Note: Uses cached channels for fast, non-blocking checks @MainActor func canSend(amountSats: UInt64) -> Bool { + if let rail = channelFreeRail, rail.isReady, rail.sendableSats >= amountSats, !canSendWithChannels(amountSats: amountSats) { + return true + } + return canSendWithChannels(amountSats: amountSats) + } + + @MainActor + private func canSendWithChannels(amountSats: UInt64) -> Bool { guard let channels else { return false } @@ -856,6 +942,24 @@ class LightningService: NodeLifecycleService { params: RouteParametersConfig? = nil, beforeSubmission: @escaping () throws -> Void = {} ) async throws -> PaymentHash { + if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { + // Spark takes no route parameters, so a caller's routing fee cap is checked against its estimate instead. + if let maxFeeMsat = params?.maxTotalRoutingFeeMsat { + let feeSats: UInt64 + do { + feeSats = try await rail.estimateRoutingFees(bolt11: bolt11, amountSats: sats) + } catch { + throw error as? ChannelFreeRailSendRefused ?? ChannelFreeRailSendRefused(reason: "Spark fee estimate failed", underlying: error) + } + guard feeSats * 1000 <= maxFeeMsat else { + throw ChannelFreeRailSendRefused( + reason: "Spark routing fee of \(feeSats) sats exceeds the limit of \(maxFeeMsat / 1000) sats" + ) + } + } + try beforeSubmission() + return try await rail.send(bolt11: bolt11, sats: sats) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -1376,6 +1480,28 @@ extension LightningService { // MARK: Events extension LightningService { + /// Delivers a channel-free rail's payment as an LDK event, so flows that wait on node events cover both rails. + /// Sets the callback node and rail events are delivered to, and delivers the rail events that arrived before it. + func setEventCallback(_ onEvent: @escaping (Event) -> Void) { + storedEventCallback = onEvent + let pending = pendingChannelFreeRailEvents + pendingChannelFreeRailEvents = [] + pending.forEach(onEvent) + } + + /// Spark runs before the node starts, so its events wait until the node's event callback is set rather than being lost. + func emitChannelFreeRailEvent(_ event: Event) { + guard let storedEventCallback else { + pendingChannelFreeRailEvents.append(event) + return + } + storedEventCallback(event) + } + + func channelFreeRailPayments() async -> [ChannelFreeRailPayment] { + await channelFreeRail?.lightningPayments() ?? [] + } + func deliverEvent(_ event: Event, onEvent: ((Event) -> Void)?) async -> OnchainActivityCompletion? { let completion: OnchainActivityCompletion? = switch event { case .onchainTransactionReceived, .onchainTransactionConfirmed, .onchainTransactionReplaced, @@ -1773,6 +1899,9 @@ extension LightningService { } func estimateRoutingFees(bolt11: String, amountSats: UInt64? = nil) async throws -> UInt64 { + if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: amountSats) { + return try await rail.estimateRoutingFees(bolt11: bolt11, amountSats: amountSats) + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index f983011ee..d0853fa1f 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -103,7 +103,8 @@ final class PaykitAllowanceManager { guard LightningService.shared.status?.isRunning == true, let channels = LightningService.shared.channels else { return false } - return channels.isEmpty || channels.contains(where: \.isUsable) + // Spark pays without channels, so a channel still reconnecting does not hold it back. + return channels.isEmpty || channels.contains(where: \.isUsable) || LightningService.shared.channelFreeRail?.isReady == true } var entries: [PaykitAllowanceEntry] { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 94037be5f..cb289a597 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -195,11 +195,15 @@ protocol PaykitLightningPaymentProofLookingUp: Sendable { struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { - guard let payments = await LightningService.shared.listPayments() else { return .unavailable } + guard let payments = await LightningService.shared.listPayments() else { + let railStatus = await Self.railStatus(paymentHash: paymentHash) + guard case .unknown = railStatus else { return railStatus } + return .unavailable + } guard let payment = payments.first(where: { $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame }), payment.direction == .outbound else { - return .unknown + return await Self.railStatus(paymentHash: paymentHash) } switch payment.status { @@ -212,6 +216,20 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { return .succeeded(preimage: preimage) } } + + /// Spark payments are not in LDK's list, so a proof whose completion event was missed settles from Spark's own record. + static func railStatus(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + guard let payment = await LightningService.shared.channelFreeRailPayments().first(where: { + $0.paymentHash.caseInsensitiveCompare(paymentHash) == .orderedSame && $0.direction == .outbound + }) else { + return .unknown + } + switch payment.status { + case .pending: return .pending + case .failed: return .failed + case .succeeded: return .succeeded(preimage: payment.preimage) + } + } } private actor PaykitProofMutationLock { @@ -1257,6 +1275,8 @@ actor PaykitPaymentProofService { let underlyingError = (submissionError as? AppError)?.underlyingError ?? submissionError if underlyingError as? PaykitPaymentRequestError == .requestExpired { // The local deadline gate runs before submitting to the node. + } else if underlyingError is ChannelFreeRailSendRefused { + // Spark refused it before sending: not connected, not preparable, or over the fee limit. } else if let serviceError = underlyingError as? CustomServiceError { guard serviceError == .nodeNotSetup || serviceError == .nodeNotStarted else { return false } } else if let nodeError = underlyingError as? NodeError { diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 4a319d888..27d5f5996 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -683,7 +683,7 @@ struct PaykitPaymentRequestService { UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) }, canReceiveLightning: @escaping @MainActor @Sendable () -> Bool = { - LightningService.shared.channels?.contains(where: \.isUsable) ?? false + LightningService.shared.channels?.contains(where: \.isUsable) ?? false || SparkManager.shared.isReady }, marketRate: @escaping @MainActor @Sendable () -> PaykitExchangeRate? = { CurrencyService.shared.loadCachedRates()?.first { $0.quote == "USD" }.map { PaykitExchangeRate( diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 0beb64c7e..7f9316e43 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -78,7 +78,7 @@ extension PrivatePaykitService { @MainActor func walletHasUsableChannels(_ wallet: WalletViewModel) -> Bool { - wallet.hasUsableChannels + wallet.canReceiveLightning } @MainActor @@ -127,33 +127,35 @@ extension PrivatePaykitService { } func receivedSettledPaymentHashes() async -> Set { - guard let payments = await LightningService.shared.listPayments() else { return [] } - - return Set( - payments.compactMap { payment in - guard payment.direction == .inbound, - payment.status == .succeeded, - case .bolt11 = payment.kind - else { return nil } - - return payment.id - } - ) + let payments = await LightningService.shared.listPayments() ?? [] + let ldkHashes = payments.compactMap { payment -> String? in + guard payment.direction == .inbound, + payment.status == .succeeded, + case .bolt11 = payment.kind + else { return nil } + + return payment.id + } + let railHashes = await LightningService.shared.channelFreeRailPayments() + .filter { $0.direction == .inbound && $0.status == .succeeded } + .map(\.paymentHash) + return Set(ldkHashes + railHashes) } func attemptedOutboundBolt11PaymentHashes() async -> Set { - guard let payments = await LightningService.shared.listPayments() else { return [] } - - return Set( - payments.compactMap { payment in - guard payment.direction == .outbound, - payment.status != .failed, - case .bolt11 = payment.kind - else { return nil } - - return payment.id - } - ) + let payments = await LightningService.shared.listPayments() ?? [] + let ldkHashes = payments.compactMap { payment -> String? in + guard payment.direction == .outbound, + payment.status != .failed, + case .bolt11 = payment.kind + else { return nil } + + return payment.id + } + let railHashes = await LightningService.shared.channelFreeRailPayments() + .filter { $0.direction == .outbound && $0.status != .failed } + .map(\.paymentHash) + return Set(ldkHashes + railHashes) } func localInvoice(for publicKey: String) -> StoredInvoice? { diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index 3e57c941a..fe54e823d 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -70,10 +70,11 @@ struct AppStatusHelper { return .error } - if !hasChannels { - return .error - } else if wallet.hasUsableChannels { + // Spark carries Lightning without channels, so a Spark wallet's connection is ready. + if wallet.hasUsableChannels || SparkManager.shared.isReady { return .ready + } else if !hasChannels { + return .error } else { return .pending } diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index f1552bb33..8247418e4 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -69,7 +69,11 @@ final class QuickPayPaymentCoordinator { try await String(LightningService.shared.send(bolt11: bolt11)) } self.listRows = listRows ?? { - await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) + let railRows = await LightningService.shared.channelFreeRailPayments().map(QuickPayReconcileRow.init(railPayment:)) + guard let ldkRows = await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) else { + return railRows.isEmpty ? nil : railRows + } + return ldkRows + railRows } } diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index fceaf350f..154874010 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -144,6 +144,17 @@ struct QuickPayReconcileRow { } } + init(railPayment: ChannelFreeRailPayment) { + paymentId = railPayment.paymentHash + invoicePaymentHash = railPayment.paymentHash + isOutboundBolt11 = railPayment.direction == .outbound + status = switch railPayment.status { + case .succeeded: .succeeded + case .failed: .failed + case .pending: .pending + } + } + init(paymentId: String, invoicePaymentHash: String, isOutboundBolt11: Bool, status: Status) { self.paymentId = paymentId self.invoicePaymentHash = invoicePaymentHash diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1c6fc301e..ce446259f 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -106,6 +106,8 @@ struct ScanPaymentState { let totalLightningBalanceSats: UInt64 let hasChannels: Bool let hasUsableChannels: Bool + /// Spark sends Lightning without channels. + var hasChannelFreeRail = false } struct ScanPaymentOperations { @@ -121,9 +123,11 @@ struct ScanPaymentOperations { return ScanPaymentState( isNodeRunning: lightningService.status?.isRunning == true, spendableOnchainBalanceSats: lightningService.balances?.spendableOnchainBalanceSats ?? 0, - totalLightningBalanceSats: lightningService.balances?.totalLightningBalanceSats ?? 0, + totalLightningBalanceSats: (lightningService.balances?.totalLightningBalanceSats ?? 0) + + (lightningService.channelFreeRail?.balanceSats ?? 0), hasChannels: channels?.isEmpty == false, - hasUsableChannels: channels?.contains(where: \.isUsable) == true + hasUsableChannels: channels?.contains(where: \.isUsable) == true, + hasChannelFreeRail: lightningService.channelFreeRail?.isReady == true ) }, canSendLightning: lightningService.canSend @@ -204,6 +208,8 @@ class AppViewModel: ObservableObject { @AppStorage("hasSeenNotificationsIntro") var hasSeenNotificationsIntro: Bool = false @AppStorage("hasSeenQuickpayIntro") var hasSeenQuickpayIntro: Bool = false @AppStorage("hasSeenShopIntro") var hasSeenShopIntro: Bool = false + @AppStorage("hasSeenTransferIntro") var hasSeenTransferIntro: Bool = false + @AppStorage("hasSeenTransferToSpendingIntro") var hasSeenTransferToSpendingIntro: Bool = false @AppStorage("hasSeenTransferToSavingsIntro") var hasSeenTransferToSavingsIntro: Bool = false @AppStorage("hasSeenWidgetsIntro") var hasSeenWidgetsIntro: Bool = false @@ -555,6 +561,8 @@ class AppViewModel: ObservableObject { hasSeenNotificationsIntro = false hasSeenQuickpayIntro = false hasSeenShopIntro = false + hasSeenTransferIntro = false + hasSeenTransferToSpendingIntro = false hasSeenTransferToSavingsIntro = false hasSeenWidgetsIntro = false hasDismissedWidgetsOnboardingHint = false @@ -1075,13 +1083,13 @@ extension AppViewModel { let paymentAmount = requestedAmount ?? invoice.amountSatoshis // If user has no channels at all, they can never pay a pure lightning invoice. // Show insufficient spending toast and do not navigate to the send flow. - if !paymentState.hasChannels { + if !paymentState.hasChannels, !paymentState.hasChannelFreeRail { showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: paymentState.totalLightningBalanceSats) return } - // If channels are usable, validate capacity immediately - if paymentState.hasUsableChannels { + // If channels are usable or Spark can pay, validate capacity immediately + if paymentState.hasUsableChannels || paymentState.hasChannelFreeRail { guard scanPaymentOperations.canSendLightning(paymentAmount) else { showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: paymentState.totalLightningBalanceSats) return @@ -1255,8 +1263,9 @@ extension AppViewModel { return } + // Spark receives without inbound liquidity, so only a channel-only wallet can lack the capacity. let lightningBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - if lightningBalance < max(1, data.minWithdrawableSat) { + if lightningService.channelFreeRail?.isReady != true, lightningBalance < max(1, data.minWithdrawableSat) { toast( type: .warning, title: t("other__lnurl_withdr_error"), @@ -1693,6 +1702,12 @@ extension AppViewModel { return } + // Spark's withdrawal to Savings is a transfer from Spending, not a payment received. + if SparkManager.shared.isWithdrawal(txid: txid) { + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) + return + } + let shouldShow = await CoreService.shared.activity.shouldShowReceivedSheet(txid: txid, value: sats) guard shouldShow else { return } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 0c5d5e457..5bae41280 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -39,7 +39,17 @@ enum Route: Hashable { case subscriptions(showPayments: Bool) case paymentRequestDetail(PaykitPaymentRequest.ID) case subscriptionDetail(PaykitSubscription.ID) + case transferIntro case fundingOptions + case spendingIntro + case spendingIntroHw(walletId: String) + case spendingAmount + case spendingAmountHw(walletId: String) + case spendingHwSign(walletId: String) + case spendingHwSigned + case spendingConfirm + case transferLearnMore + case settingUp case fundManual(nodeUri: String?) case fundManualAmount(lnPeer: LnPeer) case fundManualConfirm(lnPeer: LnPeer, amountSats: UInt64) @@ -93,6 +103,7 @@ enum Route: Hashable { case addressTypePreference case connections case connectionDetail(channelId: String) + case sparkConnectionDetail case closeConnection(channel: ChannelDetails) case node case electrumSettings diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index ac91d07db..f65b63719 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -988,6 +988,8 @@ class SettingsViewModel: NSObject, ObservableObject { hasSeenNotificationsIntro: defaults.bool(forKey: "hasSeenNotificationsIntro"), hasSeenQuickpayIntro: defaults.bool(forKey: "hasSeenQuickpayIntro"), hasSeenShopIntro: defaults.bool(forKey: "hasSeenShopIntro"), + hasSeenTransferIntro: defaults.bool(forKey: "hasSeenTransferIntro"), + hasSeenTransferToSpendingIntro: defaults.bool(forKey: "hasSeenTransferToSpendingIntro"), hasSeenTransferToSavingsIntro: defaults.bool(forKey: "hasSeenTransferToSavingsIntro"), hasSeenWidgetsIntro: defaults.bool(forKey: "hasSeenWidgetsIntro"), hasDismissedWidgetsOnboardingHint: defaults.bool(forKey: "hasDismissedWidgetsOnboardingHint"), @@ -1015,6 +1017,8 @@ class SettingsViewModel: NSObject, ObservableObject { defaults.set(cache.hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") defaults.set(cache.hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") defaults.set(cache.hasSeenShopIntro, forKey: "hasSeenShopIntro") + defaults.set(cache.hasSeenTransferIntro, forKey: "hasSeenTransferIntro") + defaults.set(cache.hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") defaults.set(cache.hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") defaults.set(cache.hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") defaults.set(cache.hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift new file mode 100644 index 000000000..62b12e0ae --- /dev/null +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -0,0 +1,322 @@ +import Foundation +import Observation + +/// How fast Spark adds a transfer to the spending balance. +enum SparkTransferSpeed: Equatable { + /// The provider adds it before confirmations, for a fee up to `SparkClaimFees.instantMaxSats`. + case instant + /// Spark adds it after 3 confirmations for about `SparkClaimFees.laterSats`. + case later +} + +/// What a transfer to spending costs, priced for the amount on the confirm screen. +struct SparkTransferQuote: Equatable { + let address: String + let satsPerVbyte: UInt32 + let networkFeeSats: UInt64 + /// What reaches the deposit address: the amount, or for a max transfer the balance less the network fee. + let depositSats: UInt64 + let claimFees: SparkClaimFees + + func claimFeeSats(for speed: SparkTransferSpeed) -> UInt64 { + switch speed { + case .instant: claimFees.instantMaxSats + case .later: claimFees.laterSats + } + } + + /// What reaches the spending balance once Spark claims the deposit. + func toSpendingSats(for speed: SparkTransferSpeed) -> UInt64 { + depositSats.saturatingSub(claimFeeSats(for: speed)) + } + + /// What leaves savings. + var totalSats: UInt64 { + depositSats.saturatingAdd(networkFeeSats) + } +} + +/// Where a transfer to spending stands once its deposit is broadcast. +enum SparkClaimState: Equatable { + /// Instant was chosen and Spark has not listed the deposit yet. + case looking + /// Accepting the early claim. + case claiming + /// The early claim is accepted, so the deposit reaches the spending balance shortly. + case adding + /// The early claim now costs more than the user accepted. + case repriced(feeSats: UInt64) + /// No early claim is offered for this deposit, so Spark adds it after 3 confirmations. + case unavailable + /// Later was chosen (or the user chose to wait), so Spark adds it after 3 confirmations. + case later + /// Spark did not list the deposit within the polling window. + case notSeen + + var isDone: Bool { + self == .adding + } +} + +/// The claim decision after a broadcast, kept free of the SDK so it can be tested. +enum SparkClaimDecision { + /// What to do once Spark lists the deposit. `.claiming` means accept the early claim now. + static func onOffer(_ kind: SparkClaimOffer.Kind, speed: SparkTransferSpeed, ceilingSats: UInt64) -> SparkClaimState { + guard speed == .instant else { return .later } + switch kind { + case .automatic: + return .adding + case .standard: + return .unavailable + case let .choice(earlyFeeSats, _, _, _): + return earlyFeeSats <= ceilingSats ? .claiming : .repriced(feeSats: earlyFeeSats) + } + } + + /// What accepting the early claim did; nil keeps polling, since the offer comes with a later confirmation. + static func onClaim(_ claim: SparkEarlyClaim) -> SparkClaimState? { + switch claim { + case .claimed: .adding + case let .repriced(requiredFeeSats): .repriced(feeSats: requiredFeeSats) + case .declined: .unavailable + case .waiting: nil + } + } +} + +/// The Spark calls the deposit claim makes, injectable so the polling can be tested without the SDK. +struct SparkClaimOperations { + var offer: (String) async throws -> SparkClaimOffer? + var claimEarly: (SparkClaimOffer) async throws -> SparkEarlyClaim + var refresh: () async -> Void + var sleep: (Duration) async -> Void + + static var live: SparkClaimOperations { + SparkClaimOperations( + offer: { try await SparkManager.shared.depositClaimOffer(txid: $0) }, + claimEarly: { try await SparkManager.shared.claimEarly($0) }, + refresh: { await SparkManager.shared.refreshState() }, + sleep: { try? await Task.sleep(for: $0) } + ) + } +} + +/// Savings to Spending through Spark: the amount, speed and quote of the confirm screen, and the deposit's claim on the +/// setting-up screen. Lives for the navigation stack so the claim goes on after the user leaves the setting-up screen. +@Observable +@MainActor +final class SparkTransferFlow { + /// Looks for the deposit for 5 minutes, after which Spark still adds it after 3 confirmations. + nonisolated static let pollAttempts = 60 + static let pollInterval: Duration = .seconds(5) + /// Confirmations before Spark claims a deposit by itself, and roughly how long they take. + static let matureConfirmations = 3 + static let matureMinutes = 30 + /// Previews show the state they set up, so screens skip the SDK calls there. + static var isRunningInPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + var amountSats: UInt64 = 0 + var isMaxAmount = false + /// The paired hardware wallet that funds the transfer, or nil for savings. + private(set) var walletId: String? + /// Signs and broadcasts a transfer funded from a hardware wallet. + let hwSend = HwSendCoordinator() + var speed: SparkTransferSpeed = .instant + + var quote: SparkTransferQuote? + var isQuoting = false + var quoteError: String? + + private(set) var txid: String? + var claimState: SparkClaimState = .looking + @ObservationIgnored private var offer: SparkClaimOffer? + @ObservationIgnored private var ceilingSats: UInt64 = 0 + @ObservationIgnored private var watchTask: Task? + @ObservationIgnored private var quoteRequestId = 0 + @ObservationIgnored private let operations: SparkClaimOperations + @ObservationIgnored private let pollAttempts: Int + + init( + amountSats: UInt64 = 0, + isMaxAmount: Bool = false, + speed: SparkTransferSpeed = .instant, + quote: SparkTransferQuote? = nil, + claimState: SparkClaimState = .looking, + operations: SparkClaimOperations = .live, + pollAttempts: Int = SparkTransferFlow.pollAttempts + ) { + self.operations = operations + self.pollAttempts = pollAttempts + self.amountSats = amountSats + self.isMaxAmount = isMaxAmount + self.speed = speed + self.quote = quote + self.claimState = claimState + } + + func start(amountSats: UInt64, isMaxAmount: Bool, walletId: String? = nil) { + if self.amountSats != amountSats || self.isMaxAmount != isMaxAmount || self.walletId != walletId { + quote = nil + } + self.amountSats = amountSats + self.isMaxAmount = isMaxAmount + self.walletId = walletId + quoteError = nil + } + + /// A new transfer after a broadcast one starts from Instant with nothing priced; the earlier claim goes on. + func beginIfBroadcast() { + guard txid != nil else { return } + txid = nil + amountSats = 0 + isMaxAmount = false + speed = .instant + quote = nil + quoteError = nil + } + + // MARK: Quote + + /// Prices the transfer at the fast rate with fresh fee estimates, so the confirm screen never shows a stale fee. + @discardableResult + func refreshQuote( + wallet: WalletViewModel, + feeEstimatesManager: FeeEstimatesManager, + hwWalletManager: HwWalletManager? = nil + ) async -> SparkTransferQuote? { + quoteRequestId += 1 + let requestId = quoteRequestId + let amountSats = amountSats + let isMaxAmount = isMaxAmount + let walletId = walletId + isQuoting = true + defer { + if requestId == quoteRequestId { + isQuoting = false + } + } + do { + let address = try await SparkManager.shared.depositAddress() + guard let feeRates = await feeEstimatesManager.getEstimates(refresh: true) else { + throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "No fee estimates") + } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + let networkFeeSats: UInt64 + let depositSats: UInt64 + if let walletId, let hwWalletManager { + networkFeeSats = try await hwWalletManager.estimateOfflineFundingMiningFee( + walletId: walletId, + address: address, + sats: amountSats, + satsPerVByte: UInt64(rate) + ) + depositSats = amountSats + } else if isMaxAmount { + networkFeeSats = try await wallet.estimateSendAllFee(address: address, satsPerVByte: rate) + depositSats = UInt64(max(0, wallet.spendableOnchainBalanceSats)).saturatingSub(networkFeeSats) + } else { + networkFeeSats = try await wallet.calculateTotalFee(address: address, amountSats: amountSats, satsPerVByte: rate) + depositSats = amountSats + } + let claimFees = try await SparkManager.shared.depositClaimFees(amountSats: depositSats) + let fresh = SparkTransferQuote( + address: address, + satsPerVbyte: rate, + networkFeeSats: networkFeeSats, + depositSats: depositSats, + claimFees: claimFees + ) + guard requestId == quoteRequestId else { return nil } + quote = fresh + quoteError = nil + return fresh + } catch { + Logger.error("Failed to price the transfer to spending: \(error)", context: "SparkTransferFlow") + guard requestId == quoteRequestId else { return nil } + quote = nil + quoteError = t("lightning__spending_confirm__error_quote") + return nil + } + } + + // MARK: Claim + + /// Records the broadcast deposit and starts its claim for the chosen speed. + func onBroadcast(txid: String) { + self.txid = txid + ceilingSats = quote?.claimFees.instantMaxSats ?? 0 + offer = nil + claimState = speed == .instant ? .looking : .later + watchTask?.cancel() + watchTask = nil + if speed == .instant { + watch(txid: txid) + } + } + + /// Accepts the early claim at the repriced fee. + func payRepriced() { + guard case let .repriced(feeSats) = claimState, let txid else { return } + ceilingSats = feeSats + claimState = .claiming + watch(txid: txid) + } + + /// Leaves the deposit to the claim after 3 confirmations. + func waitForConfirmations() { + watchTask?.cancel() + watchTask = nil + claimState = .later + } + + /// Waits for the running claim to settle; for tests. + func waitForClaim() async { + await watchTask?.value + } + + private func watch(txid: String) { + watchTask?.cancel() + watchTask = Task { [weak self] in + await self?.pollDeposit(txid: txid) + } + } + + private func pollDeposit(txid: String) async { + for _ in 0 ..< pollAttempts { + if Task.isCancelled { + return + } + do { + let found = try await operations.offer(txid) + if found == nil, offer != nil { + // Listed before and no longer unclaimed: the early claim went through. + claimState = .adding + return + } + if let found { + offer = found + let next = SparkClaimDecision.onOffer(found.kind, speed: .instant, ceilingSats: ceilingSats) + guard next == .claiming else { + claimState = next + return + } + claimState = .claiming + let claim = try await operations.claimEarly(found) + if let state = SparkClaimDecision.onClaim(claim) { + claimState = state + return + } + } + } catch { + Logger.warn("Failed to claim Spark deposit \(txid) early: \(error)", context: "SparkTransferFlow") + } + await operations.refresh() + await operations.sleep(Self.pollInterval) + } + guard !Task.isCancelled else { return } + Logger.warn("Spark did not list deposit \(txid) in time", context: "SparkTransferFlow") + claimState = .notSeen + } +} diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 6bf25a561..18f6346d8 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -5,7 +5,15 @@ import SwiftUI @MainActor class TransferViewModel: ObservableObject { - @Published var selectedChannelIds: [String] = [] + /// Channels picked in the Advanced list of Transfer to Savings; nil transfers them all. + @Published var selectedChannelIds: Set? + /// Whether the Advanced list of Transfer to Savings picks Spark's balance. + @Published var isSparkSelected = true + /// Spark's fee to withdraw its balance to savings, for the confirm screen; nil until priced. + @Published var sparkWithdrawFeeSats: UInt64? + @Published var isPricingSparkWithdrawal = false + /// Why the last transfer's Spark withdrawal failed, so the progress screen shows it instead of success. + @Published private(set) var sparkWithdrawError: String? @Published var channelsToClose: [ChannelDetails] = [] /// How the LN -> onchain "transfer to savings" is executed. Closing a channel is the @@ -28,6 +36,8 @@ class TransferViewModel: ObservableObject { /// 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? + /// Whether the committed transfer withdraws Spark's balance to savings. + private var withdrawsSpark = false /// 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. @@ -36,6 +46,16 @@ class TransferViewModel: ObservableObject { 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 + /// Prices Spark's withdrawal for the confirm screen; tests replace it. + var priceSparkWithdrawal: @Sendable (String) async throws -> UInt64 = { try await SparkManager.shared.withdrawAllFeeSats(to: $0) } + /// How long the confirm screen waits for Spark's withdrawal fee before showing none. + var sparkFeeTimeout: TimeInterval = 15 + private var sparkFeeTask: Task? + /// The Spark balance the fee was priced for, so it is priced once per balance. + private var sparkFeeBalanceSats: UInt64? + /// One Savings address for Spark's withdrawal, priced on the confirm screen and then paid to, so the withdrawal + /// reuses the quote the fee came from. + private var sparkWithdrawAddress: String? /// Minimum sats held back from a swap to cover Lightning routing fees. private static let minLnRoutingFeeReserveSats: UInt64 = 10 @@ -226,17 +246,90 @@ class TransferViewModel: ObservableObject { // MARK: - Savings Transfer Methods - func setSelectedChannelIds(_ ids: [String]) { - selectedChannelIds = ids + /// Back to transferring everything, the default. + func selectAllForSavings() { + selectedChannelIds = nil + isSparkSelected = true + } + + /// What the current pick moves, out of the ready channels and Spark's balance. + func savingsSelection(readyChannelIds: [String], sparkSats: UInt64) -> SavingsSelection { + SavingsSelection.resolve( + readyChannelIds: readyChannelIds, + selectedChannelIds: selectedChannelIds, + isSparkSelected: isSparkSelected, + sparkSats: sparkSats + ) + } + + /// Prices withdrawing Spark's `balanceSats` to `savingsAddress`, once per balance, as `withdrawSparkToSavings` + /// then sends it. Gives up after `sparkFeeTimeout`, leaving the fee unpriced. + func loadSparkWithdrawFee(balanceSats: UInt64, savingsAddress: String) { + let isPriced = sparkFeeTask != nil || sparkWithdrawFeeSats != nil + if balanceSats == sparkFeeBalanceSats, isPriced { + return + } + sparkFeeTask?.cancel() + sparkFeeTask = nil + sparkFeeBalanceSats = balanceSats + sparkWithdrawFeeSats = nil + isPricingSparkWithdrawal = false + if sparkWithdrawAddress == nil, !savingsAddress.isEmpty { + sparkWithdrawAddress = savingsAddress + } + guard balanceSats > 0, let address = sparkWithdrawAddress else { + sparkFeeBalanceSats = nil + return + } + + isPricingSparkWithdrawal = true + let price = priceSparkWithdrawal + let timeout = sparkFeeTimeout + sparkFeeTask = Task { [weak self] in + let fee = await Self.withTimeout(seconds: timeout) { try await price(address) } + guard let self, !Task.isCancelled else { return } + sparkFeeTask = nil + isPricingSparkWithdrawal = false + if let fee { + sparkWithdrawFeeSats = fee + } else { + sparkFeeBalanceSats = nil + Logger.warn("Spark withdrawal fee not priced within \(timeout)s or failed", context: "TransferViewModel") + } + } + } + + /// The result of `operation`, or nil when it throws or takes longer than `seconds`. + private nonisolated static func withTimeout( + seconds: TimeInterval, + _ operation: @escaping @Sendable () async throws -> T + ) async -> T? { + await withTaskGroup(of: T?.self) { group in + group.addTask { + do { + return try await operation() + } catch { + Logger.error("Failed to price the Spark withdrawal", context: error.localizedDescription) + return nil + } + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + defer { group.cancelAll() } + return await group.next() ?? nil + } } /// 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) { + func onTransferToSavingsConfirm(channels: [ChannelDetails], withdrawsSpark: Bool, mode: SavingsTransferMode? = nil) { savingsTransferMode = mode ?? resolvedSavingsTransferMode - selectedChannelIds = [] + selectAllForSavings() channelsToClose = channels + self.withdrawsSpark = withdrawsSpark // Each swipe commit is a new transfer; the previous run's memo must not satisfy it. savingsSwapRun = nil } @@ -245,10 +338,79 @@ class TransferViewModel: ObservableObject { savingsSwapState.quote != nil ? .swap : .close } + /// Whether Transfer to Savings closes channels, rather than only withdrawing Spark's balance. + var hasChannelsToClose: Bool { + !channelsToClose.isEmpty + } + + /// Withdraws Spark's balance when selected, then closes the selected channels. A failed withdrawal doesn't stop + /// the closes; it is kept in `sparkWithdrawError` for the progress screen. func closeSelectedChannels() async throws -> [ChannelDetails] { + await withdrawSelectedSpark() return try await closeChannels(channels: channelsToClose) } + private func withdrawSelectedSpark() async { + sparkWithdrawError = nil + guard withdrawsSpark else { return } + do { + try await withdrawSparkToSavings() + } catch { + Logger.error("Failed to withdraw Spark to savings", context: error.localizedDescription) + sparkWithdrawError = error.localizedDescription + } + } + + private func withdrawSparkToSavings() async throws { + sparkFeeTask?.cancel() + sparkFeeTask = nil + isPricingSparkWithdrawal = false + let spark = SparkManager.shared + guard spark.balanceSats > 0 else { return } + let address: String = if let sparkWithdrawAddress { + sparkWithdrawAddress + } else { + try await lightningService.newAddress() + } + try await spark.withdrawAll(to: address) + sparkWithdrawAddress = nil + sparkFeeBalanceSats = nil + sparkWithdrawFeeSats = nil + } + + // MARK: - Spark Funding + + /// Sends `amountSats` of savings to the Spark deposit address and returns its txid. Spark claims the deposit + /// early when the provider offers it within the accepted fee, otherwise after 3 confirmations. + func fundSpark(address: String, amountSats: UInt64, satsPerVbyte: UInt32, txFee: UInt64, isMaxAmount: Bool) async throws -> String { + let walletId = OnchainSendAttemptService.walletId(index: lightningService.currentWalletIndex) + let attempts = OnchainSendAttemptService.shared + let result = try await attempts.send( + using: lightningService, + address: address, + amountSats: amountSats, + satsPerVbyte: satsPerVbyte, + utxosToSpend: nil, + isMaxAmount: isMaxAmount, + followupContext: OnchainSendFollowupContext( + feeSats: txFee, + feeRate: satsPerVbyte, + tags: [], + contact: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + ) + guard case let .accepted(txid) = result else { + throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark funding outcome: \(result)") + } + _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: walletId, observedTxid: txid) + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) + SparkManager.shared.onDepositBroadcast(txid: txid, amountSats: amountSats) + Logger.info("Funded Spark wallet with \(amountSats) sats in \(txid)", context: "TransferViewModel") + await onBalanceRefresh?() + return txid + } + func closeChannels(channels: [ChannelDetails]) async throws -> [ChannelDetails] { var failedChannels: [ChannelDetails] = [] var successfulChannels: [ChannelDetails] = [] @@ -528,6 +690,8 @@ class TransferViewModel: ObservableObject { } do { + // The swap moves only the channels' balance, so a selected Spark balance is withdrawn on its own. + await withdrawSelectedSpark() 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") @@ -671,6 +835,48 @@ actor SwapPaymentFailureCapture { } } +/// What Transfer to Savings moves: the channels to close or swap out and whether Spark's balance is withdrawn. +struct SavingsSelection: Equatable { + let channelIds: [String] + let withdrawsSpark: Bool + + var isEmpty: Bool { + channelIds.isEmpty && !withdrawsSpark + } + + /// Without a pick (`selectedChannelIds` nil) every ready channel moves. Spark moves when picked and holding a balance. + static func resolve( + readyChannelIds: [String], + selectedChannelIds: Set?, + isSparkSelected: Bool, + sparkSats: UInt64 + ) -> SavingsSelection { + SavingsSelection( + channelIds: readyChannelIds.filter { selectedChannelIds?.contains($0) ?? true }, + withdrawsSpark: isSparkSelected && sparkSats > 0 + ) + } +} + +/// What the progress screen shows once a transfer to savings ran. +enum SavingsTransferOutcome: Equatable { + case success + /// Channels failed to close cooperatively and are retried. + case channelsInterrupted + /// Spark's withdrawal failed; `channelsMoved` when the selected channels still moved. + case sparkFailed(channelsMoved: Bool) + + static func resolve(sparkFailed: Bool, movedChannels: Bool, channelsFailedToClose: Bool) -> SavingsTransferOutcome { + if channelsFailedToClose { + return .channelsInterrupted + } + if sparkFailed { + return .sparkFailed(channelsMoved: movedChannels) + } + return .success + } +} + /// Whether a transfer to savings swaps funds out or closes a channel (default). enum SavingsTransferMode { case swap diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index d4f559f37..1c36919dd 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -24,6 +24,7 @@ class WalletViewModel: ObservableObject { @Published private(set) var isReceiveInvoiceUpdating = false @Published private(set) var receiveInvoiceError: String? private var receiveRefreshID = UUID() + private var sparkReadiness = SparkReadinessTransition() private var receiveCommitID = UUID() var hasCoherentReceiveInvoice: Bool { @@ -173,6 +174,19 @@ class WalletViewModel: ObservableObject { } lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } + SparkManager.shared.onStateChanged = { [weak self] in + Task { @MainActor in + guard let self else { return } + // Paykit may have published without a Lightning endpoint before Spark connected, so republish once + // it becomes ready; that refresh also updates the balance and the receive invoice. + if self.sparkReadiness.becameReady(SparkManager.shared.isReady) { + await self.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "spark-ready refresh", forceRefreshLightning: true) + return + } + await self.updateBalanceState() + try? await self.refreshBip21(reportsError: false) + } + } lightningService.onchainActivityWillPersist = { [weak self] in guard let self else { return nil } let isSessionCurrent = publicPaykitPublicationAdmission() @@ -261,6 +275,8 @@ class WalletViewModel: ObservableObject { } syncState() + // Spark needs no node, so it starts alongside it and keeps running while the node stops. + Task { await SparkManager.shared.start(walletIndex: walletIndex) } do { let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl let rgsServerUrl = rgsConfigService.getCurrentServerUrl() @@ -1171,6 +1187,9 @@ class WalletViewModel: ObservableObject { } syncState() + Task { [weak self] in + await self?.finishWatchSendIfRailAlreadySettled(hash: hash, eventId: eventId) + } } } onCancel: { Task { @MainActor [weak self] in @@ -1179,6 +1198,22 @@ class WalletViewModel: ObservableObject { } } + /// A Spark-to-Spark payment settles inside the send call, so its completion event can fire before `watchSend` + /// starts listening; the rail's own payment list still has the outcome. + private func finishWatchSendIfRailAlreadySettled(hash: String, eventId: String) async { + let payment = await lightningService.channelFreeRailPayments().first { + $0.direction == .outbound && $0.paymentHash.caseInsensitiveCompare(hash) == .orderedSame + } + switch payment?.status { + case .succeeded: + finishWatchSend(id: eventId, result: .success(SettledLightningPayment(paymentHash: hash, feePaidSats: 0))) + case .failed: + finishWatchSend(id: eventId, result: .failure(AppError(paymentFailureReason: nil))) + case .pending, nil: + break + } + } + private func finishWatchSend(id: String, result: Result) { guard let continuation = watchSendContinuations.removeValue(forKey: id) else { return } removeOnEvent(id: id) @@ -1373,14 +1408,16 @@ class WalletViewModel: ObservableObject { do { try? await transferService.syncTransferStates() let state = try await balanceManager.deriveBalanceState() + let spark = SparkManager.shared balanceInTransferToSavings = Int(state.balanceInTransferToSavings) - balanceInTransferToSpending = Int(state.balanceInTransferToSpending) + balanceInTransferToSpending = Int(state.balanceInTransferToSpending + spark.pendingDepositSats) // Update display values with adjusted balances totalOnchainSats = Int(state.totalOnchainSats) - totalLightningSats = Int(state.totalLightningSats) - totalBalanceSats = Int(state.totalBalanceSats) - maxSendLightningSats = Int(state.maxSendLightningSats) + totalLightningSats = Int(state.totalLightningSats + spark.balanceSats) + totalBalanceSats = Int(state.totalBalanceSats + spark.balanceSats + spark.pendingDepositSats) + // A payment goes over one rail, so the larger of the two balances caps it. + maxSendLightningSats = Int(max(state.maxSendLightningSats, spark.sendableSats)) // Update channel fundable balance (excludes Legacy UTXOs and accounts for funding tx fee) let (selectedType, monitoredTypes) = LightningService.addressTypeStateFromUserDefaults() @@ -1436,13 +1473,18 @@ class WalletViewModel: ObservableObject { return channels?.contains(where: \.isUsable) ?? false } + /// Whether Lightning can be received, through a usable channel or Spark; Paykit publishes a Lightning endpoint only then. + var canReceiveLightning: Bool { + hasUsableChannels || SparkManager.shared.isReady + } + var canCreateReceiveLightningInvoice: Bool { let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil return canCreateReceiveLightningInvoice(amountSats: amountSats) } func canCreateReceiveLightningInvoice(amountSats: UInt64?) -> Bool { - ReceiveLiquidityDecision.canCreateLightningInvoice( + SparkManager.shared.isReady || ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels: hasReadyChannels, inboundCapacitySats: totalReadyInboundLightningSats, invoiceAmountSats: amountSats @@ -1515,7 +1557,7 @@ class WalletViewModel: ObservableObject { if attempt > 0 { guard try await canCommitInvoice() else { continue } } - if includeLightning, hasUsableChannels { + if includeLightning, canReceiveLightning { let hasReusableInvoice = await hasReusablePublicPaykitInvoice() guard try await canCommitInvoice() else { continue } let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice @@ -1612,7 +1654,7 @@ class WalletViewModel: ObservableObject { if sharesPublicPaykitEndpoints { do { - try await publishPublicPaykitEndpoints(refreshIfNeeded: hasUsableChannels, isSessionCurrent: isSessionCurrent) + try await publishPublicPaykitEndpoints(refreshIfNeeded: canReceiveLightning, isSessionCurrent: isSessionCurrent) } catch { Logger.warn("Failed to refresh public Paykit endpoints after channel availability changed: \(error)", context: "WalletViewModel") } @@ -2011,6 +2053,8 @@ class WalletViewModel: ObservableObject { try await nodeLifecycle.wipeStorage(walletIndex: 0) } + await SparkManager.shared.wipe(walletIndex: 0) + probeOutcomes.removeAll() // Reset AppStorage display values @@ -2032,3 +2076,13 @@ class WalletViewModel: ObservableObject { Logger.warn("Wallet wipe completed", context: "WalletViewModel") } } + +/// Tells when Spark goes from not ready to ready, so work that ran without it (Paykit endpoints) runs once more. +struct SparkReadinessTransition { + private var wasReady = false + + mutating func becameReady(_ isReady: Bool) -> Bool { + defer { wasReady = isReady } + return isReady && !wasReady + } +} diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index 5f343e3e6..a5421446b 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -555,7 +555,7 @@ struct PaymentRequestDetailsView: View { } private var enabledEndpoints: [String] { - PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.hasUsableChannels) + PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.canReceiveLightning) } private var selectedEnabledEndpoints: [String] { diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index 58e3374ed..434ff9946 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -82,8 +82,7 @@ struct AdvancedSettingsView: View { NavigationLink(value: Route.connections) { SettingsRow( title: t("settings__adv__lightning_connections"), - iconName: "bolt-hollow", - rightText: String(wallet.channels?.count ?? 0) + iconName: "bolt-hollow" ) } .accessibilityIdentifier("Channels") diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 559e3f1b9..35e4605b9 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -54,7 +54,11 @@ struct LightningConnectionsView: View { .resizable() .frame(width: 22, height: 22) .foregroundColor(.white) - TitleText(formatNumber(receivingCapacity), textColor: .white) + // Spark receives without inbound liquidity, so no channel caps receiving. + TitleText( + SparkManager.shared.isReady ? t("lightning__spark_receiving_unlimited") : formatNumber(receivingCapacity), + textColor: .white + ) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -67,6 +71,12 @@ struct LightningConnectionsView: View { EmptyStateView() .padding(.top, 16) } else { + if SparkConnectionRow.isVisible { + SettingsSectionHeader(t("lightning__spark")) + SparkConnectionRow() + .padding(.bottom, 16) + } + // Pending Connections section if !pendingConnections.isEmpty { SettingsSectionHeader(t("lightning__conn_pending")) @@ -230,8 +240,9 @@ struct LightningConnectionsView: View { // MARK: - Computed Properties private var spendingBalance: UInt64 { - guard let balanceDetails = wallet.balanceDetails else { return 0 } - return balanceDetails.totalLightningBalanceSats + let sparkSats = SparkManager.shared.balanceSats + guard let balanceDetails = wallet.balanceDetails else { return sparkSats } + return balanceDetails.totalLightningBalanceSats + sparkSats } private var receivingCapacity: UInt64 { @@ -244,7 +255,7 @@ struct LightningConnectionsView: View { } private var shouldShowEmptyState: Bool { - openChannels.isEmpty && pendingConnections.isEmpty && closedChannels.isEmpty + !SparkConnectionRow.isVisible && openChannels.isEmpty && pendingConnections.isEmpty && closedChannels.isEmpty } private var buttonText: String { @@ -291,6 +302,10 @@ struct LightningConnectionsView: View { } } + group.addTask { + await SparkManager.shared.refreshState() + } + // Sync wallet channels group.addTask { do { diff --git a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift new file mode 100644 index 000000000..e673479a9 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift @@ -0,0 +1,250 @@ +import SwiftUI + +/// The Spark wallet as a connection: shown once it holds a balance or a deposit, and always on regtest so testers +/// reach its deposit address to fund it from the faucet. +struct SparkConnectionRow: View { + @EnvironmentObject var navigation: NavigationViewModel + + private let spark = SparkManager.shared + + static var isVisible: Bool { + let spark = SparkManager.shared + return spark.isReady && (spark.balanceSats > 0 || !spark.deposits.isEmpty || Env.network == .regtest) + } + + var body: some View { + Button { + navigation.navigate(.sparkConnectionDetail) + } label: { + VStack(spacing: 0) { + HStack { + SubtitleText(t("lightning__spark_provider_value")) + Spacer() + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + } + .padding(.bottom, 6) + + LightningChannel( + capacity: spark.balanceSats, + localBalance: spark.balanceSats, + remoteBalance: 0, + status: .open, + showReceiving: false + ) + .padding(.bottom, 16) + + ForEach(spark.deposits.filter { !$0.isClaimed }) { deposit in + SparkDepositRow(deposit: deposit) + .padding(.bottom, 16) + } + + CustomDivider() + } + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("SparkConnection") + } +} + +/// A deposit into Spark or a withdrawal out of it, with its amount, confirmations while pending, and a link to +/// its transaction. +struct SparkDepositRow: View { + @Environment(\.openURL) private var openURL + + let title: String + let txid: String + let amountSats: UInt64 + var confirmations: UInt32? + + init(deposit: SparkDeposit) { + title = t("lightning__spark_deposit") + txid = deposit.txid + amountSats = deposit.amountSats + confirmations = deposit.confirmations + } + + init(withdrawal: SparkWithdrawal) { + title = t("wallet__activity_transfer_to_savings") + txid = withdrawal.txid + amountSats = withdrawal.amountSats + } + + var body: some View { + Button { + if let url = URL(string: "\(Env.blockExplorerUrl)/tx/\(txid)") { + openURL(url) + } + } label: { + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + CaptionBText(title, textColor: .textPrimary) + CaptionBText(txid) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + MoneyText(sats: Int(amountSats), size: .captionB, symbol: true) + if let confirmations { + CaptionBText( + t("lightning__spark_confirmations", variables: ["count": "\(min(confirmations, 3))"]), + textColor: .purpleAccent + ) + } + } + } + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("SparkDeposit") + } +} + +struct SparkConnectionDetailView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + @State private var depositAddress: String? + + private let spark = SparkManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + LightningChannel( + capacity: spark.balanceSats, + localBalance: spark.balanceSats, + remoteBalance: 0, + status: .open, + showReceiving: false + ) + .padding(.bottom, 28) + + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { + CustomDivider() + SettingsSectionHeader(t("lightning__spark_provider")) + detailRow(label: t("lightning__spark_provider"), value: t("lightning__spark_provider_value")) + BodySText(t("lightning__spark_trust")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 12) + + if let depositAddress { + Button { + UIPasteboard.general.string = depositAddress + Haptics.play(.copiedToClipboard) + app.toast(type: .success, title: t("common__copied"), description: depositAddress) + } label: { + detailRow(label: t("wallet__activity_address"), value: depositAddress) + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("SparkDepositAddress") + } + } + + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__balance")) + amountRow(label: t("lightning__spending_label"), amount: spark.balanceSats) + } + + let pending = spark.deposits.filter { !$0.isClaimed } + if !pending.isEmpty { + VStack(alignment: .leading, spacing: 16) { + SettingsSectionHeader(t("lightning__spark_deposits_pending")) + ForEach(pending) { SparkDepositRow(deposit: $0) } + } + } + + let claimed = spark.deposits.filter(\.isClaimed) + if !claimed.isEmpty { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__spark_deposits_claimed")) + ForEach(claimed) { deposit in + SparkDepositRow(deposit: deposit) + .padding(.bottom, 8) + amountRow(label: t("lightning__spark_claim_fee"), amount: deposit.feeSats) + } + } + } + + if !spark.withdrawals.isEmpty { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__savings_confirm__label")) + ForEach(spark.withdrawals) { withdrawal in + SparkDepositRow(withdrawal: withdrawal) + .padding(.bottom, 8) + amountRow(label: t("wallet__activity_fee"), amount: withdrawal.feeSats) + } + } + } + } + .padding(.bottom, 32) + } + } + + CustomButton(title: t("lightning__transfer_to_savings_button")) { + if app.hasSeenTransferToSavingsIntro { + navigation.navigate(.savingsAvailability) + } else { + navigation.navigate(.savingsIntro) + } + } + .disabled(spark.balanceSats == 0) + .accessibilityIdentifier("SparkTransferToSavings") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .refreshable { + await spark.refreshState() + } + .task { + await loadDepositAddress() + } + } + + /// On regtest, shows Spark's deposit address so testers can fund Spark from Lightspark's regtest faucet. + private func loadDepositAddress() async { + guard Env.network == .regtest else { return } + do { + depositAddress = try await spark.depositAddress() + } catch { + Logger.warn("Failed to load Spark deposit address: \(error)", context: "SparkConnectionDetailView") + } + } + + private func detailRow(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 16) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + CaptionBText(value, textColor: .textPrimary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(height: 50) + CustomDivider() + } + } + + private func amountRow(label: String, amount: UInt64) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + MoneyText(sats: Int(amount), size: .captionB, symbol: true) + } + .frame(height: 50) + CustomDivider() + } + } +} diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift index 31b84c1a2..7b0715b72 100644 --- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift +++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift @@ -40,8 +40,13 @@ private struct StatusItemView: View { struct DataBackupsScreen: View { @StateObject private var viewModel = BackupViewModel() + /// Spark's exit data shows only where Spark runs. + private var categories: [BackupCategory] { + BackupCategory.allCases.filter { $0 != .sparkExit || SparkManager.shared.isSupported } + } + private var allSynced: Bool { - BackupCategory.allCases.allSatisfy { category in + categories.allSatisfy { category in let status = viewModel.getStatus(for: category) return !status.running && !status.isRequired } @@ -65,7 +70,7 @@ struct DataBackupsScreen: View { } .padding(.top, 16) - ForEach(BackupCategory.allCases, id: \.self) { category in + ForEach(categories, id: \.self) { category in let status = viewModel.getStatus(for: category) let statusText = viewModel.formatStatusText(for: category) let iconColor = viewModel.iconColor(for: status) diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift index edb0a4220..08718c2d1 100644 --- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -120,7 +120,7 @@ struct CreateSubscriptionView: View { } private var availableEndpoints: [String] { - PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.hasUsableChannels) + PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.canReceiveLightning) .filter { settings.isUsdtEnabled || $0 != PublicPaykitService.MethodId.usdtArbitrum.rawValue } } diff --git a/Bitkit/Views/Transfer/FundingOptions.swift b/Bitkit/Views/Transfer/FundingOptions.swift index 2cf7144c6..39962f6d5 100644 --- a/Bitkit/Views/Transfer/FundingOptions.swift +++ b/Bitkit/Views/Transfer/FundingOptions.swift @@ -1,7 +1,17 @@ import SwiftUI struct FundingOptions: View { + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var sheets: SheetViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var showNoFundsAlert = false + + /// Spark keeps Spending fundable from savings or another wallet without a channel. + private var canFundWithSpark: Bool { + SparkManager.shared.isReady + } var body: some View { VStack(spacing: 0) { @@ -14,11 +24,27 @@ struct FundingOptions: View { ) .padding(.bottom, 8) - BodyMText(t("lightning__transfer_unavailable__text")) + BodyMText(t(canFundWithSpark ? "lightning__funding__text" : "lightning__transfer_unavailable__text")) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 32) VStack(spacing: 8) { + if canFundWithSpark { + RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), testID: "FundTransfer") { + if wallet.totalOnchainSats == 0 { + showNoFundsAlert = true + } else if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmount) + } else { + navigation.navigate(.spendingIntro) + } + } + RectangleButton(icon: "qr", title: t("lightning__funding__button2"), testID: "FundReceive") { + navigation.reset() + sheets.showSheet(.receive) + } + } + RectangleButton( icon: "pencil", title: t("lightning__funding__button3"), @@ -27,14 +53,18 @@ struct FundingOptions: View { navigation.navigate(.fundManual(nodeUri: nil)) } - RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), isDisabled: true, testID: "FundTransfer") {} - RectangleButton( - icon: "device-tablet-speaker", - title: t("lightning__funding__button_hw"), - isDisabled: true, - testID: "FundHardware" - ) {} - RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} + if !canFundWithSpark { + RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), isDisabled: true, testID: "FundTransfer") {} + } + if !canFundWithSpark { + RectangleButton( + icon: "device-tablet-speaker", + title: t("lightning__funding__button_hw"), + isDisabled: true, + testID: "FundHardware" + ) {} + RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} + } } Spacer() @@ -49,5 +79,18 @@ struct FundingOptions: View { } .navigationBarHidden(true) .padding(.horizontal, 16) + .alert( + t("lightning__no_funds__title"), + isPresented: $showNoFundsAlert + ) { + Button(t("lightning__no_funds__fund")) { + navigation.reset() + sheets.showSheet(.receive, data: ReceiveConfig(view: .qr(tab: .savings))) + } + Button(t("common__cancel"), role: .cancel) {} + } message: { + Text(t("lightning__no_funds__description")) + } + .tint(.purpleAccent) } } diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift new file mode 100644 index 000000000..963083d0f --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -0,0 +1,177 @@ +import SwiftUI + +/// Amount entry for a transfer to spending funded from a paired hardware wallet. Mirrors `SpendingAmount`, but the +/// available/MAX/quarter limits come from the device's balance, and Continue advances to the on-device Sign step. +struct SpendingAmountHw: View { + let walletId: String + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @Environment(HwWalletManager.self) private var hwWalletManager + @Environment(SparkTransferFlow.self) private var flow + + @State private var amountViewModel = AmountInputViewModel() + @State private var isPricingFailed = false + @State private var maxAttempt = 0 + + private var amountSats: UInt64 { + amountViewModel.amountSats + } + + private var isLoading: Bool { + flow.hwSend.isFundingSourceLoading && !isPricingFailed + } + + private var maxAllowed: UInt64 { + flow.hwSend.availableSats + } + + private var isValidAmount: Bool { + !isLoading && !isPricingFailed && amountSats > 0 && amountSats <= maxAllowed + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__spending_amount__title"), accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) + + NumberPadTextField(viewModel: amountViewModel, showConversion: false) + .onTapGesture { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + .padding(.top, 32) + + Spacer() + + HStack(alignment: .bottom) { + availableAmount + + Spacer() + + actionButtons + } + .padding(.bottom, 12) + + Divider() + + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey, + isDisabled: isLoading || isPricingFailed + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + + CustomButton(title: t("common__continue"), isDisabled: !isValidAmount) { + flow.start(amountSats: amountSats, isMaxAmount: amountSats >= maxAllowed, walletId: walletId) + navigation.navigate(.spendingHwSign(walletId: walletId)) + } + .accessibilityIdentifier("HardwareTransferAmountContinue") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("HardwareTransferAmount") + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .onAppear { flow.beginIfBroadcast() } + .task(id: maxAttempt) { + await loadAvailable() + } + .onChange(of: maxAllowed, initial: true) { updateInputCap() } + .onChange(of: isLoading) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } + } + + @ViewBuilder + private var availableAmount: some View { + if isLoading { + HStack(spacing: 4) { + CaptionMText(t("wallet__send_available")) + ProgressView() + .scaleEffect(0.7) + } + } else if isPricingFailed { + Button { + maxAttempt += 1 + } label: { + CaptionMText(t("lightning__spending_amount__error_fee"), textColor: .redAccent) + .multilineTextAlignment(.leading) + } + .accessibilityIdentifier("HardwareTransferAmountRetry") + } else { + AvailableAmount( + label: t("wallet__send_available"), + amount: Int(maxAllowed), + testIdentifier: "HardwareTransferAmountAvailable" + ) + } + } + + private func loadAvailable() async { + isPricingFailed = false + flow.hwSend.selectWallet(walletId, initialAvailableSats: 0, showsLoading: true) + do { + let address = try await SparkManager.shared.depositAddress() + guard let feeRates = await feeEstimatesManager.getEstimates(refresh: false) else { + throw AppError(message: t("lightning__spending_amount__error_fee"), debugMessage: "No fee estimates") + } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + await flow.hwSend.refreshAvailable(manager: hwWalletManager, destinationAddress: address, satsPerVByte: UInt64(rate)) + isPricingFailed = false + } catch { + Logger.warn("Failed to estimate the most a hardware transfer to spending can send: \(error)", context: "SpendingAmountHw") + isPricingFailed = true + } + } + + private func updateInputCap() { + guard !isLoading else { return } + amountViewModel.maxAmountOverride = maxAllowed + } + + private func onMaxExceeded() { + amountViewModel.updateFromSats(maxAllowed, currency: currency) + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_max__title"), + description: t( + "lightning__spending_amount__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(maxAllowed)] + ), + visibilityTime: Toast.visibilityTimeShort, + accessibilityIdentifier: "HardwareTransferAmountExceededToast" + ) + } + + private var actionButtons: some View { + HStack(spacing: 8) { + NumberPadActionButton( + text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency, + imageName: "arrow-up-down" + ) { + withAnimation { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + } + + NumberPadActionButton(text: t("lightning__spending_amount__quarter")) { + let quarter = hwWalletManager.fundingBalance(walletId: walletId) / 4 + amountViewModel.updateFromSats(min(quarter, maxAllowed), currency: currency) + } + .disabled(isLoading || isPricingFailed) + .accessibilityIdentifier("HardwareTransferAmountQuarter") + + NumberPadActionButton(text: t("common__max")) { + amountViewModel.updateFromSats(maxAllowed, currency: currency) + } + .disabled(isLoading || isPricingFailed) + .accessibilityIdentifier("HardwareTransferAmountMax") + } + } +} diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift new file mode 100644 index 000000000..3075b8db4 --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -0,0 +1,247 @@ +import SwiftUI + +/// Sign a transfer to spending on the paired hardware wallet: the device pays the Spark deposit address, then the +/// setting-up screen claims the deposit at the chosen speed. +struct SpendingHwSign: View { + let walletId: String + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager + @Environment(SparkTransferFlow.self) private var flow + + @State private var signingTask: Task? + @State private var passphraseTask: Task? + + private var hwSend: HwSendCoordinator { + flow.hwSend + } + + private struct QuoteInputs: Equatable { + let amountSats: UInt64 + let speed: SparkTransferSpeed + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .disabled(!hwSend.canLeave) + .padding(.bottom, 16) + + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText( + t(hwSend.hasPendingBroadcast ? "lightning__transfer_hw__signed_title" : "lightning__transfer_hw__sign_title"), + accentColor: .purpleAccent + ) + + SpendingConfirmFees(quote: flow.quote, speed: flow.speed, isLoading: flow.quote == nil && flow.quoteError == nil) + .padding(.top, 16) + + if let error = flow.quoteError { + BodyMText(error, textColor: .redAccent) + .padding(.bottom, 16) + } + + SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) + .disabled(isBusy || hwSend.hasPendingBroadcast) + .padding(.horizontal, -16) + .padding(.bottom, 16) + + CustomButton(title: t("common__learn_more"), size: .small, isDisabled: isBusy || hwSend.hasPendingBroadcast) { + navigation.navigate(.transferLearnMore) + } + .accessibilityIdentifier("HardwareTransferSignLearnMore") + + Spacer(minLength: 16) + + Image(vendor.signImageName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 200, maxHeight: 200) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + + Spacer(minLength: 16) + } + // Fills the free space on tall screens so the picture sits centred in it, and scrolls on short ones. + .frame(minHeight: geometry.size.height) + // The speed rows run edge to edge, so the scroll spans the screen and pads its own content. + .padding(.horizontal, 16) + } + .padding(.horizontal, -16) + } + + CustomButton( + title: hwSend.hasPendingBroadcast ? t("common__retry") : vendor.sendSignButtonTitle, + isDisabled: isBusy || flow.quote == nil, + isLoading: isBusy + ) { + startSigning() + } + .accessibilityIdentifier("HardwareTransferOpenTrezorConnect") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("HardwareTransferSign") + .navigationBarHidden(true) + .allowSwipeBack(hwSend.canLeave) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .task(id: QuoteInputs(amountSats: flow.amountSats, speed: flow.speed)) { + guard !SparkTransferFlow.isRunningInPreview, !hwSend.hasPendingBroadcast else { return } + hwWalletManager.warmUpConnection(walletId: walletId) + await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager) + } + .sheet(isPresented: passphrasePromptBinding) { + HwPassphrasePromptSheet( + isVerifying: hwSend.isVerifyingPassphrase, + onSubmit: reconnectWithPassphrase, + onCancel: dismissPassphrase + ) + } + .onDisappear { + let stillInFlow = navigation.path.contains { + if case .spendingHwSign = $0 { + return true + } + return false + } + guard !stillInFlow, !hwSend.isBroadcastUnresolved else { return } + signingTask?.cancel() + signingTask = nil + passphraseTask?.cancel() + passphraseTask = nil + hwSend.cancel() + } + } + + private var isBusy: Bool { + hwSend.isSigning || flow.isQuoting + } + + private var vendor: HwWalletVendor { + hwWalletManager.wallets.first { $0.id == walletId }?.vendor ?? .trezor + } + + private var passphrasePromptBinding: Binding { + Binding( + get: { hwSend.isPassphraseRequired }, + set: { + if !$0 { + dismissPassphrase() + } + } + ) + } + + private func startSigning() { + guard signingTask == nil else { return } + signingTask = Task { @MainActor in + defer { signingTask = nil } + guard let quote = await quoteToSign() else { return } + do { + let result = try await PaykitPaymentProofService.shared.withHardwarePaymentOwnership(walletId: walletId) { + try await hwSend.signAndBroadcast( + manager: hwWalletManager, + address: quote.address, + sats: quote.depositSats, + satsPerVByte: UInt64(quote.satsPerVbyte) + ) + } + await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: result.txId, + address: quote.address, + amount: quote.depositSats, + fee: result.miningFeeSats, + feeRate: UInt32(clamping: result.feeRate), + isTransfer: true, + walletId: walletId + ) + hwSend.completeBroadcast() + SparkManager.shared.onDepositBroadcast(txid: result.txId, amountSats: quote.depositSats) + flow.onBroadcast(txid: result.txId) + Logger.info("Funded Spark wallet with \(quote.depositSats) sats from hardware wallet in \(result.txId)", context: "SpendingHwSign") + navigation.navigate(.spendingHwSigned) + } catch is CancellationError { + return + } catch is HwPassphraseError { + hwSend.requestPassphrase() + } catch let error as HwTransferError { + app.toast(error) + } catch { + showHardwareError(error) + } + } + } + + /// A fresh quote to sign with, or nil when it failed or the fees went up since the screen showed them. A retry of a + /// signed transaction keeps the quote it was signed with. + private func quoteToSign() async -> SparkTransferQuote? { + guard let shown = flow.quote else { return nil } + if hwSend.hasPendingBroadcast { + return shown + } + guard let fresh = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager) + else { return nil } + let speed = flow.speed + let shownCost = shown.networkFeeSats.saturatingAdd(shown.claimFeeSats(for: speed)) + let freshCost = fresh.networkFeeSats.saturatingAdd(fresh.claimFeeSats(for: speed)) + guard freshCost <= shownCost else { + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: freshCost - shownCost)] + ) + ) + return nil + } + return fresh + } + + private func reconnectWithPassphrase(_ passphrase: String) { + guard passphraseTask == nil else { return } + passphraseTask = Task { @MainActor in + defer { passphraseTask = nil } + do { + try await hwSend.reconnectWithPassphrase(passphrase, manager: hwWalletManager) + startSigning() + } catch is CancellationError { + return + } catch HwPassphraseError.mismatch { + app.toast(HwTransferError.passphraseMismatch) + } catch { + showHardwareError(error) + } + } + } + + private func dismissPassphrase() { + passphraseTask?.cancel() + passphraseTask = nil + hwSend.dismissPassphrase() + } + + private func showHardwareError(_ error: Error) { + if error.isHwUserCancellation() { + return + } + if error is HwWalletMismatchError { + app.toast(HwTransferError.walletMismatch) + } else if let vendor = error.hwBusyVendor { + app.toast(HwTransferError.deviceBusy(vendor)) + } else if error.isHwFirmwareError() { + app.toast(HwTransferError.firmwareReconnect) + } else if hwSend.hasPendingBroadcast, error.isBroadcastConnectivityFailure() { + app.toast(HwTransferError.broadcastConnectivity) + } else { + app.toast(error) + } + } +} diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift new file mode 100644 index 000000000..749df76ef --- /dev/null +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// "Transaction signed" — a brief confirmation of the signed transfer before auto-forwarding to the shared +/// "Funds in Transfer" progress screen. +struct SpendingHwSigned: View { + @EnvironmentObject var navigation: NavigationViewModel + @Environment(SparkTransferFlow.self) private var flow + + /// Figma handoff delay before forwarding from the signed confirmation. + private let autoForwardDelay: Duration = .seconds(1) + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title"), showBackButton: false) + .padding(.bottom, 16) + + DisplayText(t("lightning__transfer_hw__signed_title"), accentColor: .purpleAccent) + + SpendingConfirmFees(quote: flow.quote, speed: flow.speed) + .padding(.top, 16) + + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("HardwareTransferSigned") + + Spacer() + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + guard !SparkTransferFlow.isRunningInPreview else { return } + try? await Task.sleep(for: autoForwardDelay) + guard !Task.isCancelled else { return } + navigation.navigate(.settingUp) + } + } +} + +#Preview { + NavigationStack { + SpendingHwSigned() + .environmentObject(CurrencyViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(SettingsViewModel.shared) + .environment(SparkTransferFlow(amountSats: 100_000)) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Transfer/SavingsAdvancedView.swift b/Bitkit/Views/Transfer/SavingsAdvancedView.swift index d0719874f..2e5c60a8a 100644 --- a/Bitkit/Views/Transfer/SavingsAdvancedView.swift +++ b/Bitkit/Views/Transfer/SavingsAdvancedView.swift @@ -8,15 +8,25 @@ struct SavingsAdvancedView: View { @EnvironmentObject var wallet: WalletViewModel @Environment(\.dismiss) var dismiss - private var totalSelectedBalance: UInt64 { - guard let channels = wallet.channels else { return 0 } + private var channels: [ChannelDetails] { + wallet.channels?.filter(\.isChannelReady) ?? [] + } - return - channels - .filter { transfer.selectedChannelIds.contains($0.channelId) } - .reduce(0) { total, channel in - return total + channel.balanceOnCloseSats - } + private var sparkSats: UInt64 { + SparkManager.shared.balanceSats + } + + private var selection: SavingsSelection { + transfer.savingsSelection(readyChannelIds: channels.map(\.channelId), sparkSats: sparkSats) + } + + private var totalSelectedBalance: UInt64 { + let channelSats = channels + .filter { selection.channelIds.contains($0.channelId) } + .reduce(0) { total, channel in + return total + channel.balanceOnCloseSats + } + return channelSats + (selection.withdrawsSpark ? sparkSats : 0) } var body: some View { @@ -30,10 +40,12 @@ struct SavingsAdvancedView: View { BodyMText(t("lightning__savings_advanced__text")) .padding(.bottom, 16) - if let channels = wallet.channels { - ForEach(Array(channels.enumerated()), id: \.element.channelId) { index, channel in - channelView(channel: channel, index: index, isLast: index == channels.count - 1) - } + ForEach(Array(channels.enumerated()), id: \.element.channelId) { index, channel in + channelView(channel: channel, index: index, isLast: index == channels.count - 1) + } + + if sparkSats > 0 { + sparkView } Spacer() @@ -46,7 +58,7 @@ struct SavingsAdvancedView: View { CustomButton( title: t("common__continue"), - isDisabled: transfer.selectedChannelIds.isEmpty + isDisabled: selection.isEmpty ) { dismiss() } @@ -55,8 +67,8 @@ struct SavingsAdvancedView: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .task { - if transfer.selectedChannelIds.isEmpty, let channels = wallet.channels { - transfer.selectedChannelIds = channels.map(\.channelId) + if transfer.selectedChannelIds == nil { + transfer.selectedChannelIds = Set(channels.map(\.channelId)) } } } @@ -78,16 +90,16 @@ struct SavingsAdvancedView: View { "", isOn: Binding( get: { - transfer.selectedChannelIds.contains(channel.channelId) + selection.channelIds.contains(channel.channelId) }, set: { isSelected in + var ids = transfer.selectedChannelIds ?? Set(channels.map(\.channelId)) if isSelected { - if !transfer.selectedChannelIds.contains(channel.channelId) { - transfer.selectedChannelIds.append(channel.channelId) - } + ids.insert(channel.channelId) } else { - transfer.selectedChannelIds.removeAll { $0 == channel.channelId } + ids.remove(channel.channelId) } + transfer.selectedChannelIds = ids } ) ) @@ -98,6 +110,26 @@ struct SavingsAdvancedView: View { Divider() } + + private var sparkView: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("lightning__spark")) + MoneyText(sats: Int(sparkSats), size: .bodySSB, symbol: true) + } + + Spacer() + + Toggle("", isOn: $transfer.isSparkSelected) + .toggleStyle(SwitchToggleStyle(tint: .brandAccent)) + .accessibilityIdentifier("SavingsSparkToggle") + } + .padding(.vertical, 16) + + Divider() + } + } } #Preview { diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index 8fcf963e2..f8705aee7 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -10,36 +10,47 @@ struct SavingsConfirmView: View { @State private var hideSwipeButton = false - private var hasMultipleChannels: Bool { - guard let channels = wallet.channels else { return false } - return channels.filter(\.isChannelReady).count > 1 + private var readyChannels: [ChannelDetails] { + wallet.channels?.filter(\.isChannelReady) ?? [] } - private var hasSelectedChannels: Bool { - return !transfer.selectedChannelIds.isEmpty + private var sparkSats: UInt64 { + SparkManager.shared.balanceSats } - private var channels: [ChannelDetails] { - guard let channels = wallet.channels else { return [] } - let usableChannels = channels.filter(\.isChannelReady) + /// Each ready channel and a Spark balance is a row of the Advanced list. + private var hasMultipleItems: Bool { + readyChannels.count + (sparkSats > 0 ? 1 : 0) > 1 + } - if transfer.selectedChannelIds.isEmpty { - return usableChannels - } else { - return usableChannels.filter { transfer.selectedChannelIds.contains($0.channelId) } - } + private var hasSelectedItems: Bool { + transfer.selectedChannelIds != nil || !transfer.isSparkSelected + } + + private var selection: SavingsSelection { + transfer.savingsSelection(readyChannelIds: readyChannels.map(\.channelId), sparkSats: sparkSats) + } + + private var channels: [ChannelDetails] { + let ids = selection.channelIds + return readyChannels.filter { ids.contains($0.channelId) } } private var totalSats: UInt64 { channels.reduce(0) { $0 + $1.balanceOnCloseSats } } + /// What the selected channels can send, so a swap never falls back to paying from Spark. + private var channelsSendableSats: UInt64 { + channels.filter(\.isUsable).reduce(0) { $0 + $1.nextOutboundHtlcLimitMsat } / 1000 + } + private var swapState: SavingsSwapState { transfer.savingsSwapState } private var headlineSats: UInt64 { - swapState.quote?.amountSat ?? totalSats + (swapState.quote?.amountSat ?? totalSats) + (selection.withdrawsSpark ? sparkSats : 0) } var body: some View { @@ -60,11 +71,21 @@ struct SavingsConfirmView: View { quoteSection(quote) } - if hasMultipleChannels { + if selection.withdrawsSpark, transfer.sparkWithdrawFeeSats != nil || transfer.isPricingSparkWithdrawal { + FeeDisplayRow( + label: t("lightning__savings_confirm__spark_fee"), + amount: transfer.sparkWithdrawFeeSats ?? 0, + isLoading: transfer.isPricingSparkWithdrawal + ) + .padding(.top, swapState.quote == nil ? 24 : 16) + .accessibilityIdentifier("SavingsSparkFee") + } + + if hasMultipleItems { HStack(spacing: 16) { - if hasSelectedChannels { + if hasSelectedItems { CustomButton(title: t("lightning__savings_confirm__transfer_all"), size: .small) { - transfer.setSelectedChannelIds([]) + transfer.selectAllForSavings() } } else { CustomButton(title: t("common__advanced"), size: .small) { @@ -94,7 +115,7 @@ struct SavingsConfirmView: View { Spacer() - if !hideSwipeButton { + if !hideSwipeButton, !selection.isEmpty { SwipeButton( title: t("lightning__transfer__swipe"), accentColor: .brandAccent, @@ -104,7 +125,7 @@ struct SavingsConfirmView: View { // otherwise falls back to the pre-swap behaviour of closing the channel, so it // is never inert. do { - transfer.onTransferToSavingsConfirm(channels: channels) + transfer.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark) try await Task.sleep(nanoseconds: 300_000_000) @@ -123,7 +144,7 @@ struct SavingsConfirmView: View { 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.onTransferToSavingsConfirm(channels: channels, mode: .close) + transfer.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark, mode: .close) navigation.navigate(.savingsProgress) } .padding(.top, 12) @@ -142,9 +163,13 @@ struct SavingsConfirmView: View { await wallet.syncStateAsync() await transfer.loadSavingsSwapQuote( requestedSat: totalSats, - spendableSats: UInt64(max(0, wallet.maxSendLightningSats)) + spendableSats: channelsSendableSats ) } + .onChange(of: selection.withdrawsSpark ? sparkSats : nil, initial: true) { _, balanceSats in + guard let balanceSats else { return } + transfer.loadSparkWithdrawFee(balanceSats: balanceSats, savingsAddress: wallet.onchainAddress) + } } private func quoteSection(_ quote: SavingsSwapQuote) -> some View { diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index b13b8e17c..a9cbb7c4e 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -1,12 +1,14 @@ import SwiftUI -enum SavingsProgressState { +enum SavingsProgressState: Equatable { 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 + /// Spark's balance didn't move; `channelsMoved` when the selected channels still did. + case sparkFailed(channelsMoved: Bool) } struct SavingsProgressContentView: View { @@ -23,7 +25,7 @@ struct SavingsProgressContentView: View { switch progressState { 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") + case .success, .sparkFailed: return t("lightning__transfer__nav_title") } } @@ -33,6 +35,7 @@ struct SavingsProgressContentView: View { 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") + case .sparkFailed: return t("lightning__savings_spark_failed__title") } } @@ -42,6 +45,16 @@ struct SavingsProgressContentView: View { 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") + case let .sparkFailed(channelsMoved): + return t(channelsMoved ? "lightning__savings_spark_failed__text_partial" : "lightning__savings_spark_failed__text") + } + } + + private var imageIdentifier: String? { + switch progressState { + case .success: "TransferSuccess" + case .sparkFailed: "SavingsSparkFailed" + default: nil } } @@ -98,13 +111,13 @@ struct SavingsProgressContentView: View { } } else { - Image(progressState == .failed ? "exclamation-mark" : "check") + Image(progressState == .success ? "check" : "exclamation-mark") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 256, height: 256) .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) - .accessibilityIdentifierIfPresent(progressState == .success ? "TransferSuccess" : nil) + .accessibilityIdentifierIfPresent(imageIdentifier) } Spacer() @@ -115,7 +128,7 @@ struct SavingsProgressContentView: View { ) { navigation.reset() } - .accessibilityIdentifierIfPresent(progressState == .success ? "TransferSuccess-button" : nil) + .accessibilityIdentifierIfPresent(imageIdentifier.map { "\($0)-button" }) } .navigationBarHidden(true) .allowSwipeBack(false) @@ -168,12 +181,12 @@ struct SavingsProgressView: View { case .success: await wallet.syncStateAsync() withAnimation { - progressState = .success + progressState = sparkFailedState(channelsMoved: true) ?? .success } case .pending: await wallet.syncStateAsync() withAnimation { - progressState = .settling + progressState = sparkFailedState(channelsMoved: true) ?? .settling } case let .failure(message): app.toast( @@ -185,21 +198,48 @@ struct SavingsProgressView: View { } } + /// The Spark failure state, with its error as a toast, when the transfer's Spark withdrawal failed. + private func sparkFailedState(channelsMoved: Bool) -> SavingsProgressState? { + guard transfer.sparkWithdrawError != nil else { return nil } + toastSparkError() + return .sparkFailed(channelsMoved: channelsMoved) + } + + private func toastSparkError() { + guard let message = transfer.sparkWithdrawError else { return } + app.toast(type: .error, title: t("lightning__savings_spark_failed__toast"), description: message) + } + /// 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) + if transfer.hasChannelsToClose { + try await Task.sleep(nanoseconds: 2_000_000_000) + } + let hadChannels = transfer.hasChannelsToClose let channelsFailedToCoopClose = try await transfer.closeSelectedChannels() + let outcome = SavingsTransferOutcome.resolve( + sparkFailed: transfer.sparkWithdrawError != nil, + movedChannels: hadChannels, + channelsFailedToClose: !channelsFailedToCoopClose.isEmpty + ) - if channelsFailedToCoopClose.isEmpty { + switch outcome { + case .success: // Re-enable screen timeout when we're done UIApplication.shared.isIdleTimerDisabled = false withAnimation { progressState = .success } - } else { + case let .sparkFailed(channelsMoved): + UIApplication.shared.isIdleTimerDisabled = false + withAnimation { + progressState = sparkFailedState(channelsMoved: channelsMoved) ?? .success + } + case .channelsInterrupted: + toastSparkError() withAnimation { progressState = .failed } @@ -239,6 +279,15 @@ struct SavingsProgressView: View { .preferredColorScheme(.dark) } +#Preview("Spark Failed") { + NavigationStack { + SavingsProgressContentView(progressState: .sparkFailed(channelsMoved: true)) + .environmentObject(AppViewModel()) + .environmentObject(TransferViewModel()) + } + .preferredColorScheme(.dark) +} + #Preview("Failed") { NavigationStack { SavingsProgressContentView(progressState: .failed) diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift new file mode 100644 index 000000000..ecc807d29 --- /dev/null +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -0,0 +1,178 @@ +import SwiftUI + +struct SettingUpView: View { + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + @Environment(SparkTransferFlow.self) private var flow + + /// Keep in state so we don't get a new random text on each render + @State private var randomOkText: String = localizedRandom("common__ok_random") + + private var state: SparkClaimState { + flow.claimState + } + + var isTransferring: Bool { + !state.isDone + } + + var navTitle: String { + isTransferring ? t("lightning__transfer__nav_title") : t("lightning__transfer_success__nav_title") + } + + var title: String { + isTransferring ? t("lightning__savings_progress__title") : t("lightning__transfer_success__title_spending") + } + + var text: String { + switch state { + case .looking: t("lightning__spark_deposit__looking") + case .claiming, .adding: t("lightning__spark_deposit__claimed") + case let .repriced(feeSats): t("lightning__spark_deposit__repriced", variables: ["fee": currency.primaryAmountText(sats: feeSats)]) + case .unavailable: t("lightning__spark_deposit__instant_unavailable") + case .later: t("lightning__spark_deposit__standard", variables: [ + "standard": "\(SparkTransferFlow.matureConfirmations)", + "minutes": "\(SparkTransferFlow.matureMinutes)", + ]) + case .notSeen: t("lightning__spark_deposit__not_seen") + } + } + + /// The speed the user chose, or Later once they chose to wait. + var speedText: String { + let isLater = flow.speed == .later || state == .later + return t("lightning__setting_up_speed", variables: [ + "speed": isLater ? t("lightning__spending_confirm__later") : t("fee__instant__title"), + ]) + } + + var buttonTitle: String { + isTransferring ? t("lightning__setting_up_button") : randomOkText + } + + /// Sent, seen by Spark, added to spending. + let steps = [ + t("lightning__setting_up_step1"), + t("lightning__setting_up_step2"), + t("lightning__setting_up_step_spark"), + ] + + private var currentStep: Int { + switch state { + case .looking, .notSeen: 1 + case .claiming, .repriced, .unavailable, .later: 2 + case .adding: 3 + } + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: navTitle, showBackButton: false) + .padding(.bottom, 16) + + VStack(alignment: .leading, spacing: 0) { + DisplayText(title, accentColor: .purpleAccent) + .padding(.bottom, 14) + .layoutPriority(1) + + BodyMText(text, accentColor: .white, accentFont: Fonts.bold) + .layoutPriority(1) + .accessibilityIdentifier("SettingUpText") + + if isTransferring { + CaptionMText(speedText) + .padding(.top, 8) + .accessibilityIdentifier("SettingUpSpeed") + } + + if isTransferring { + EllipseLoader(variant: .transfer) + .padding(.top, 32) + .padding(.horizontal, 16) + .accessibilityIdentifier("LightningSettingUp") + } else { + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("TransferSuccess") + } + + Spacer() + + if isTransferring { + ProgressSteps(steps: steps, currentStep: currentStep) + .padding(.vertical, 16) + } + + if case let .repriced(feeSats) = state { + repricedButtons(feeSats: feeSats) + } else { + CustomButton(title: buttonTitle) { + navigation.reset() + } + .accessibilityIdentifier("TransferSuccess-button") + } + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + + private func repricedButtons(feeSats: UInt64) -> some View { + HStack(spacing: 16) { + CustomButton(title: t("lightning__spark_deposit__wait_button"), variant: .secondary) { + flow.waitForConfirmations() + } + .accessibilityIdentifier("SparkDepositWait") + + CustomButton(title: t("lightning__spark_deposit__pay", variables: ["fee": currency.primaryAmountText(sats: feeSats)])) { + flow.payRepriced() + } + .accessibilityIdentifier("SparkDepositPay") + } + } +} + +#Preview("Looking") { + settingUpPreview(.looking) +} + +#Preview("Adding") { + settingUpPreview(.claiming) +} + +#Preview("Done") { + settingUpPreview(.adding) +} + +#Preview("Repriced") { + settingUpPreview(.repriced(feeSats: 2150)) +} + +#Preview("Instant unavailable") { + settingUpPreview(.unavailable) +} + +#Preview("Later") { + settingUpPreview(.later) +} + +#Preview("Not seen") { + settingUpPreview(.notSeen) +} + +@MainActor +private func settingUpPreview(_ state: SparkClaimState) -> some View { + NavigationStack { + SettingUpView() + .environmentObject(CurrencyViewModel()) + .environmentObject(NavigationViewModel()) + .environment(SparkTransferFlow(amountSats: 100_000, claimState: state)) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift new file mode 100644 index 000000000..f6adb1aa3 --- /dev/null +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -0,0 +1,209 @@ +import SwiftUI + +struct SpendingAmount: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(SparkTransferFlow.self) private var flow + + @State private var amountViewModel = AmountInputViewModel() + @State private var isCalculatingMax = true + @State private var maxTransferAmount: UInt64? + @State private var maxAttempt = 0 + + private var amountSats: UInt64 { + amountViewModel.amountSats + } + + private var isValidAmount: Bool { + guard let max = maxTransferAmount else { return false } + return amountSats > 0 && amountSats <= max + } + + /// Inputs the max depends on: a new balance or a retry restarts the calculation. + private struct MaxCalcInputs: Equatable { + let spendableOnchainBalanceSats: Int + let attempt: Int + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + DisplayText(t("lightning__spending_amount__title"), accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) + + NumberPadTextField( + viewModel: amountViewModel, + showConversion: false, + testIdentifier: "SpendingAmountNumberField" + ) + .onTapGesture { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + .padding(.top, 32) + + Spacer() + + HStack(alignment: .bottom) { + availableAmount + + Spacer() + + actionButtons + } + .padding(.bottom, 12) + + Divider() + + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey, + isDisabled: isCalculatingMax || maxTransferAmount == nil + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + + CustomButton( + title: t("common__continue"), + isDisabled: !isValidAmount + ) { + onContinue() + } + .accessibilityIdentifier("SpendingAmountContinue") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SpendingAmount") + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .onAppear { flow.beginIfBroadcast() } + .task(id: MaxCalcInputs(spendableOnchainBalanceSats: wallet.spendableOnchainBalanceSats, attempt: maxAttempt)) { + isCalculatingMax = true + await calculateMaxTransferAmount() + if !Task.isCancelled { + isCalculatingMax = false + } + } + .onChange(of: maxTransferAmount) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } + } + + @ViewBuilder + private var availableAmount: some View { + if isCalculatingMax { + HStack(spacing: 4) { + CaptionMText(t("wallet__send_available")) + ProgressView() + .scaleEffect(0.7) + } + } else if let max = maxTransferAmount { + AvailableAmount( + label: t("wallet__send_available"), + amount: Int(max), + testIdentifier: "SpendingAmountAvailable" + ) + } else { + Button { + maxAttempt += 1 + } label: { + CaptionMText(t("lightning__spending_amount__error_fee"), textColor: .redAccent) + .multilineTextAlignment(.leading) + } + .accessibilityIdentifier("SpendingAmountRetry") + } + } + + private func updateInputCap() { + amountViewModel.maxAmountOverride = maxTransferAmount + } + + private func onMaxExceeded() { + if let max = maxTransferAmount { + amountViewModel.updateFromSats(max, currency: currency) + } + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_max__title"), + description: t( + "lightning__spending_amount__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(maxTransferAmount ?? 0)] + ), + visibilityTime: Toast.visibilityTimeShort, + accessibilityIdentifier: "SpendingAmountExceededToast" + ) + } + + private var actionButtons: some View { + HStack(spacing: 8) { + NumberPadActionButton( + text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency, + imageName: "arrow-up-down" + ) { + withAnimation { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + } + + NumberPadActionButton(text: t("lightning__spending_amount__quarter")) { + guard let cap = maxTransferAmount else { return } + let quarter = UInt64(max(0, wallet.spendableOnchainBalanceSats)) / 4 + amountViewModel.updateFromSats(min(quarter, cap), currency: currency) + } + .disabled(maxTransferAmount == nil) + .accessibilityIdentifier("SpendingAmountQuarter") + + NumberPadActionButton(text: t("common__max")) { + guard let max = maxTransferAmount else { return } + amountViewModel.updateFromSats(max, currency: currency) + } + .disabled(maxTransferAmount == nil) + .accessibilityIdentifier("SpendingAmountMax") + } + } + + private func onContinue() { + guard let max = maxTransferAmount, isValidAmount else { return } + flow.start(amountSats: amountSats, isMaxAmount: amountSats >= max) + navigation.navigate(.spendingConfirm) + } + + /// The most savings can send to the Spark deposit address after the fast-rate network fee for sending all of it. + private func calculateMaxTransferAmount() async { + guard wallet.spendableOnchainBalanceSats > 0 else { + maxTransferAmount = 0 + return + } + do { + let address = try await SparkManager.shared.depositAddress() + guard let feeRates = await feeEstimatesManager.getEstimates(refresh: false) else { + throw AppError(message: t("lightning__spending_amount__error_fee"), debugMessage: "No fee estimates") + } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + let max = try await wallet.calculateMaxSendableAmount(address: address, satsPerVByte: rate) + guard !Task.isCancelled else { return } + maxTransferAmount = max + } catch { + Logger.warn("Failed to estimate the most a transfer to spending can send: \(error)", context: "SpendingAmount") + guard !Task.isCancelled else { return } + maxTransferAmount = nil + } + } +} + +#Preview { + NavigationStack { + SpendingAmount() + .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(FeeEstimatesManager()) + .environmentObject(NavigationViewModel()) + .environmentObject(WalletViewModel()) + .environment(SparkTransferFlow()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift new file mode 100644 index 000000000..62619b852 --- /dev/null +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -0,0 +1,286 @@ +import SwiftUI + +struct SpendingConfirm: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var transfer: TransferViewModel + @EnvironmentObject var wallet: WalletViewModel + @Environment(SparkTransferFlow.self) private var flow + + @State private var isPaying = false + @State private var hideSwipeButton = false + + /// Fee rates move while the screen is open, so the quote is redone this often as well as on every change. + private static let requoteInterval: Duration = .seconds(60) + + private struct QuoteInputs: Equatable { + let amountSats: UInt64 + let isMaxAmount: Bool + let speed: SparkTransferSpeed + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .disabled(isPaying) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) + + SpendingConfirmFees(quote: flow.quote, speed: flow.speed, isLoading: flow.quote == nil && flow.quoteError == nil) + .padding(.top, 32) + + if let error = flow.quoteError { + quoteError(error) + .padding(.bottom, 16) + } + + SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) + .disabled(isPaying) + .padding(.horizontal, -16) + .padding(.bottom, 16) + + CustomButton(title: t("common__learn_more"), size: .small) { + navigation.navigate(.transferLearnMore) + } + .disabled(isPaying) + .accessibilityIdentifier("SpendingConfirmMore") + } + // The speed rows run edge to edge, so the scroll spans the screen and pads its own content. + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .padding(.horizontal, -16) + + if !hideSwipeButton { + SwipeButton( + title: t("lightning__transfer__swipe"), + accentColor: .purpleAccent, + isLoading: isPaying + ) { + try await onConfirm() + } + .disabled(!canConfirm) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SpendingConfirm") + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .offlineOverlay(title: t("lightning__transfer__nav_title")) + .task(id: QuoteInputs(amountSats: flow.amountSats, isMaxAmount: flow.isMaxAmount, speed: flow.speed)) { + guard !SparkTransferFlow.isRunningInPreview else { return } + while !Task.isCancelled { + if !isPaying { + await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) + } + try? await Task.sleep(for: Self.requoteInterval) + } + } + } + + private var canConfirm: Bool { + guard let quote = flow.quote, !isPaying, !flow.isQuoting else { return false } + return quote.networkFeeSats > 0 && quote.toSpendingSats(for: flow.speed) > 0 + } + + private func quoteError(_ message: String) -> some View { + HStack(alignment: .center, spacing: 16) { + BodyMText(message, textColor: .redAccent) + .frame(maxWidth: .infinity, alignment: .leading) + CustomButton(title: t("common__retry"), size: .small, isLoading: flow.isQuoting) { + await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) + } + .fixedSize() + .accessibilityIdentifier("SpendingConfirmRetry") + } + } + + private func onConfirm() async throws { + guard let shown = flow.quote, !isPaying else { return } + isPaying = true + defer { isPaying = false } + + guard let fresh = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) else { + throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "Re-quote before sending failed") + } + let speed = flow.speed + let shownCost = shown.networkFeeSats.saturatingAdd(shown.claimFeeSats(for: speed)) + let freshCost = fresh.networkFeeSats.saturatingAdd(fresh.claimFeeSats(for: speed)) + if freshCost > shownCost { + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: freshCost - shownCost)] + ) + ) + throw SpendingFeesIncreasedError() + } + + do { + let txid = try await transfer.fundSpark( + address: fresh.address, + amountSats: fresh.depositSats, + satsPerVbyte: fresh.satsPerVbyte, + txFee: fresh.networkFeeSats, + isMaxAmount: flow.isMaxAmount + ) + flow.onBroadcast(txid: txid) + await wallet.updateBalanceState() + navigation.navigate(.settingUp) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + hideSwipeButton = true + } + } catch { + Logger.error("Failed to transfer to spending: \(error)", context: "SpendingConfirm") + app.toast( + type: .error, + title: t("wallet__send_error_tx_failed"), + description: AppError(error: error).message + ) + throw error + } + } +} + +/// The swipe reset after the fees went up while the screen was open; the toast says by how much. +struct SpendingFeesIncreasedError: Error {} + +/// Network fee, spending fee, what reaches spending and what leaves savings, in the confirm screen's grid. +struct SpendingConfirmFees: View { + let quote: SparkTransferQuote? + let speed: SparkTransferSpeed + var isLoading = false + + var body: some View { + VStack(spacing: 16) { + HStack { + FeeDisplayRow( + label: t("lightning__spending_confirm__network_fee"), + amount: quote?.networkFeeSats ?? 0, + isLoading: isLoading + ) + .frame(maxWidth: .infinity) + + FeeDisplayRow( + label: t("lightning__spending_confirm__spending_fee"), + amount: quote?.claimFeeSats(for: speed) ?? 0, + prefix: SpendingSpeedPicker.feePrefix(for: speed), + isLoading: isLoading + ) + .frame(maxWidth: .infinity) + } + + HStack { + FeeDisplayRow( + label: t("lightning__spending_confirm__amount"), + amount: quote?.toSpendingSats(for: speed) ?? 0, + isLoading: isLoading + ) + .frame(maxWidth: .infinity) + + FeeDisplayRow( + label: t("lightning__spending_confirm__total"), + amount: quote?.totalSats ?? 0, + isLoading: isLoading + ) + .frame(maxWidth: .infinity) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SpendingConfirmFees") + } +} + +/// Instant or Later, in the style of the send flow's fee-speed rows. +struct SpendingSpeedPicker: View { + let quote: SparkTransferQuote? + @Binding var speed: SparkTransferSpeed + + static func feePrefix(for speed: SparkTransferSpeed) -> String { + switch speed { + case .instant: t("lightning__spending_confirm__up_to") + case .later: "≈" + } + } + + var body: some View { + VStack(spacing: 0) { + FeeItem( + title: t("fee__instant__title"), + subtitle: t("lightning__spending_confirm__instant_text"), + iconName: TransactionSpeed.fast.iconName, + iconColor: .purpleAccent, + amount: quote?.claimFees.instantMaxSats ?? 0, + amountPrefix: Self.feePrefix(for: .instant), + isSelected: speed == .instant, + isDisabled: false + ) { + speed = .instant + } + .accessibilityIdentifier("SpendingConfirmInstant") + + FeeItem( + title: t("lightning__spending_confirm__later"), + subtitle: t("lightning__spending_confirm__later_text"), + iconName: TransactionSpeed.slow.iconName, + iconColor: .purpleAccent, + amount: quote?.claimFees.laterSats ?? 0, + amountPrefix: Self.feePrefix(for: .later), + isSelected: speed == .later, + isDisabled: false + ) { + speed = .later + } + .accessibilityIdentifier("SpendingConfirmLater") + + Divider() + .padding(.horizontal, 16) + } + } +} + +private extension SparkTransferQuote { + static let preview = SparkTransferQuote( + address: "bcrt1qpreview", + satsPerVbyte: 6, + networkFeeSats: 577, + depositSats: 100_000, + claimFees: SparkClaimFees(instantMaxSats: 1093, laterSats: 198) + ) +} + +#Preview("Instant") { + spendingConfirmPreview(SparkTransferFlow(amountSats: 100_000, speed: .instant, quote: .preview)) +} + +#Preview("Later") { + spendingConfirmPreview(SparkTransferFlow(amountSats: 100_000, speed: .later, quote: .preview)) +} + +#Preview("Loading") { + spendingConfirmPreview(SparkTransferFlow(amountSats: 100_000)) +} + +@MainActor +private func spendingConfirmPreview(_ flow: SparkTransferFlow) -> some View { + NavigationStack { + SpendingConfirm() + .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(FeeEstimatesManager()) + .environmentObject(NavigationViewModel()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(TransferViewModel()) + .environmentObject(WalletViewModel()) + .environment(flow) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Transfer/SpendingIntroView.swift b/Bitkit/Views/Transfer/SpendingIntroView.swift new file mode 100644 index 000000000..5b9ea5f00 --- /dev/null +++ b/Bitkit/Views/Transfer/SpendingIntroView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct SpendingIntroView: View { + /// When set, this intro is for a hardware-wallet transfer; Continue routes to the HW amount flow. + var walletId: String? + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + var body: some View { + OnboardingView( + navTitle: t("lightning__transfer__nav_title"), + title: t("lightning__spending_intro__title"), + description: t("lightning__spending_intro__text"), + imageName: "coin-stack-x", + buttonText: t("lightning__spending_intro__button"), + onButtonPress: { + app.hasSeenTransferToSpendingIntro = true + if let walletId { + navigation.navigate(.spendingAmountHw(walletId: walletId)) + } else { + navigation.navigate(.spendingAmount) + } + }, + accentColor: .purpleAccent, + imagePosition: .center, + testID: "SpendingIntro" + ) + .navigationBarHidden(true) + } +} + +#Preview { + NavigationStack { + SpendingIntroView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .preferredColorScheme(.dark) + } +} diff --git a/Bitkit/Views/Transfer/TransferIntroView.swift b/Bitkit/Views/Transfer/TransferIntroView.swift new file mode 100644 index 000000000..d5d077c08 --- /dev/null +++ b/Bitkit/Views/Transfer/TransferIntroView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct TransferIntroView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + + var body: some View { + OnboardingView( + navTitle: t("lightning__transfer__nav_title"), + title: t("lightning__transfer_intro__title"), + description: t("lightning__transfer_intro__text"), + imageName: "lightning", + buttonText: t("lightning__transfer_intro__button"), + onButtonPress: { + app.hasSeenTransferIntro = true + navigation.navigate(.fundingOptions) + }, + accentColor: .purpleAccent, + imagePosition: .center, + testID: "TransferIntro" + ) + .navigationBarHidden(true) + } +} + +#Preview { + NavigationStack { + TransferIntroView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .preferredColorScheme(.dark) + } +} diff --git a/Bitkit/Views/Transfer/TransferLearnMoreView.swift b/Bitkit/Views/Transfer/TransferLearnMoreView.swift new file mode 100644 index 000000000..e03490281 --- /dev/null +++ b/Bitkit/Views/Transfer/TransferLearnMoreView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct TransferLearnMoreView: View { + @Environment(SparkTransferFlow.self) private var flow + @Environment(\.dismiss) var dismiss + + private let spark = SparkManager.shared + + /// The spending balance once Spark adds this transfer at the selected speed. + private var spendingAfterSats: UInt64 { + spark.balanceSats.saturatingAdd(flow.quote?.toSpendingSats(for: flow.speed) ?? flow.amountSats) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__transfer__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_learn__title"), accentColor: .purpleAccent) + .padding(.bottom, 16) + + BodyMText(t("lightning__spark_learn__text")) + .padding(.bottom, 16) + + BodyMText(t("lightning__spark_trust")) + } + .padding(.bottom, 16) + } + + VStack(alignment: .leading, spacing: 16) { + SubtitleText(t("lightning__spark_learn__label")) + LightningChannel( + capacity: spendingAfterSats, + localBalance: spendingAfterSats, + remoteBalance: 0, + status: .open, + showLabels: true, + showReceiving: false + ) + } + + CustomButton(title: t("common__understood")) { + dismiss() + } + .padding(.top, 32) + .accessibilityIdentifier("LiquidityContinue") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } +} + +#Preview { + NavigationStack { + TransferLearnMoreView() + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .environment(SparkTransferFlow(amountSats: 100_000)) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 9034dceda..35c23d2c7 100644 --- a/Bitkit/Views/Wallets/HardwareWalletScreen.swift +++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift @@ -127,7 +127,13 @@ struct HardwareWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if !SparkManager.shared.isReady { + sheets.showSheet(.transferUnavailable) + } else if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmountHw(walletId: walletId)) + } else { + navigation.navigate(.spendingIntroHw(walletId: walletId)) + } } .accessibilityIdentifier("HardwareTransferToSpending") } diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index 61433e842..135bd9ae2 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -231,7 +231,11 @@ struct ReceiveEdit: View { } private func additionalLiquidityAction() -> ReceiveAdditionalLiquidityAction { - ReceiveLiquidityDecision.additionalLiquidityAction( + // Spark receives any amount without inbound liquidity, so no channel is needed. + if SparkManager.shared.isReady { + return .none + } + return ReceiveLiquidityDecision.additionalLiquidityAction( source: liquiditySource, invoiceAmountSats: amountViewModel.amountSats, inboundCapacitySats: wallet.totalReadyInboundLightningSats diff --git a/Bitkit/Views/Wallets/SavingsWalletScreen.swift b/Bitkit/Views/Wallets/SavingsWalletScreen.swift index d757856c3..5b3b92632 100644 --- a/Bitkit/Views/Wallets/SavingsWalletScreen.swift +++ b/Bitkit/Views/Wallets/SavingsWalletScreen.swift @@ -122,7 +122,13 @@ struct SavingsWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if !SparkManager.shared.isReady { + sheets.showSheet(.transferUnavailable) + } else if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmount) + } else { + navigation.navigate(.spendingIntro) + } } .accessibilityIdentifier("TransferToSpending") } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index 07e14e413..bd182f2ef 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -17,7 +17,8 @@ struct LnurlPayAmount: View { var maxAmount: UInt64 { // TODO: subtract fee guard let data = app.lnurlPayData else { return 0 } - return min(data.maxSendableSat, UInt64(wallet.totalLightningSats)) + // One payment goes over one rail, so the larger rail balance caps it. + return min(data.maxSendableSat, UInt64(wallet.maxSendLightningSats)) } var amount: UInt64 { @@ -46,9 +47,9 @@ struct LnurlPayAmount: View { Spacer() HStack(alignment: .bottom) { - AvailableAmount(label: t("wallet__send_available_spending"), amount: wallet.totalLightningSats) + AvailableAmount(label: t("wallet__send_available_spending"), amount: wallet.maxSendLightningSats) .onTapGesture { - amountViewModel.updateFromSats(UInt64(wallet.totalLightningSats), currency: currency) + amountViewModel.updateFromSats(UInt64(wallet.maxSendLightningSats), currency: currency) } Spacer() @@ -98,7 +99,7 @@ struct LnurlPayAmount: View { private func showMaxExceededToast() { guard let data = app.lnurlPayData else { return } // The cap is min(invoice max, spending balance); word the message for whichever bound is hit. - let isInvoiceCapped = data.maxSendableSat < UInt64(wallet.totalLightningSats) + let isInvoiceCapped = data.maxSendableSat < UInt64(wallet.maxSendLightningSats) app.toast( type: .warning, diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 139deaf83..8132cbfd9 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -317,7 +317,7 @@ struct SendSheet: View { // there is nothing to \"sync into\". Let validation/UX handle this as // an \"insufficient capacity / no channels\" case instead of a sync wait. let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 - guard hasAnyChannels else { return false } + guard hasAnyChannels, !sparkCanPayScannedInvoice else { return false } // We have channels but none are usable yet → show sync overlay return !wallet.hasUsableChannels @@ -326,6 +326,13 @@ struct SendSheet: View { return false } + /// Spark pays without channels, so a channel that is still reconnecting is no reason to wait. + private var sparkCanPayScannedInvoice: Bool { + let spark = SparkManager.shared + let amountSats = wallet.sendAmountSats ?? app.scannedLightningInvoice?.amountSatoshis ?? 0 + return spark.isReady && amountSats > 0 && spark.balanceSats >= amountSats + } + /// Identity for the sync timeout task. The timer only counts down while the overlay is visible /// and the device is online (while offline, `offlineSheetOverlay` covers the sheet and a timeout /// would act on stale state). Node readiness is part of the identity so the window restarts when @@ -784,7 +791,8 @@ struct SendSheet: View { return .failed } guard LightningService.shared.canSend(amountSats: requestedAmount) else { - let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 + let spendingBalance = (LightningService.shared.balances?.totalLightningBalanceSats ?? 0) + + (LightningService.shared.channelFreeRail?.balanceSats ?? 0) showInsufficientSpendingToast(invoiceAmount: requestedAmount, spendingBalance: spendingBalance) flow.hasValidatedAfterSync = true return .failed @@ -801,7 +809,7 @@ struct SendSheet: View { // usable, proceed with validation/fallback. // Use channelCount as fallback in case channels array is nil but count is cached let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 - if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait { + if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait, !sparkCanPayScannedInvoice { // We have channels but none usable yet → wait return .waiting } @@ -851,7 +859,8 @@ struct SendSheet: View { return .ready } else { // For pure lightning invoices, show error toast and dismiss sheet - let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 + let spendingBalance = (LightningService.shared.balances?.totalLightningBalanceSats ?? 0) + + (LightningService.shared.channelFreeRail?.balanceSats ?? 0) showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) flow.hasValidatedAfterSync = true return .failed diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index bae55e69a..3e8522614 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -41,7 +41,7 @@ struct SpendingWalletScreen: View { } if !shouldShowOnboarding { - if let channels = wallet.channels, !channels.isEmpty { + if wallet.channels?.isEmpty == false || SparkManager.shared.balanceSats > 0 { transferButton .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, 28) @@ -115,7 +115,13 @@ struct SpendingWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - sheets.showSheet(.transferUnavailable) + if !SparkManager.shared.isReady { + sheets.showSheet(.transferUnavailable) + } else if app.hasSeenTransferToSpendingIntro { + navigation.navigate(.spendingAmount) + } else { + navigation.navigate(.spendingIntro) + } } .accessibilityIdentifier("TransferFromSavings") } diff --git a/BitkitTests/AppCacheDataTests.swift b/BitkitTests/AppCacheDataTests.swift index 3b8061c75..d4506ccda 100644 --- a/BitkitTests/AppCacheDataTests.swift +++ b/BitkitTests/AppCacheDataTests.swift @@ -6,8 +6,6 @@ final class AppCacheDataTests: XCTestCase { let json = #""" { "hasSeenShopIntro": true, - "hasSeenTransferIntro": true, - "hasSeenTransferToSpendingIntro": true, "blocktankRefundAddress": {"address": "bcrt1refund", "index": 7}, "lastUsedTags": ["coffee"] } @@ -18,4 +16,14 @@ final class AppCacheDataTests: XCTestCase { XCTAssertTrue(cache.hasSeenShopIntro) XCTAssertEqual(cache.lastUsedTags, ["coffee"]) } + + func testTransferIntroFlagsRoundTrip() throws { + let json = #"{"hasSeenTransferIntro": true, "hasSeenTransferToSpendingIntro": true}"# + + let cache = try JSONDecoder().decode(AppCacheData.self, from: Data(json.utf8)) + let restored = try JSONDecoder().decode(AppCacheData.self, from: JSONEncoder().encode(cache)) + + XCTAssertTrue(restored.hasSeenTransferIntro) + XCTAssertTrue(restored.hasSeenTransferToSpendingIntro) + } } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift new file mode 100644 index 000000000..81d2865c0 --- /dev/null +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -0,0 +1,177 @@ +@testable import Bitkit +import LDKNode +import XCTest + +@MainActor +final class ChannelFreeRailTests: XCTestCase { + private var rail: FakeRail! + private var previousRail: Bitkit.ChannelFreeLightningRail? + + override func setUp() { + super.setUp() + rail = FakeRail() + previousRail = Bitkit.LightningService.shared.channelFreeRail + Bitkit.LightningService.shared.channelFreeRail = rail + } + + override func tearDown() { + Bitkit.LightningService.shared.channelFreeRail = previousRail + rail = nil + super.tearDown() + } + + func testRailEventsBeforeTheNodeCallbackAreDeliveredOnceItIsSet() { + let hash = "aa" + Bitkit.LightningService.shared.emitChannelFreeRailEvent( + .paymentReceived(paymentId: hash, paymentHash: hash, amountMsat: 1000, customRecords: []) + ) + var received: [String] = [] + + Bitkit.LightningService.shared.setEventCallback { event in + if case let .paymentReceived(_, paymentHash, _, _) = event { + received.append(paymentHash) + } + } + + XCTAssertEqual(received, [hash]) + } + + func testPaykitProofSettlesFromTheRailWhenTheEventWasMissed() async { + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "bb", direction: .outbound, status: .succeeded, preimage: "cc")] + + let status = await PaykitLightningPaymentProofLookup().status(paymentHash: "BB") + + guard case let .succeeded(preimage) = status else { return XCTFail("Expected a settled proof, got \(status)") } + XCTAssertEqual(preimage, "cc") + } + + func testPrivatePaykitSeesRailReceivesAndSends() async { + rail.payments = [ + Bitkit.ChannelFreeRailPayment(paymentHash: "in", direction: .inbound, status: .succeeded, preimage: nil), + Bitkit.ChannelFreeRailPayment(paymentHash: "out", direction: .outbound, status: .pending, preimage: nil), + Bitkit.ChannelFreeRailPayment(paymentHash: "failed", direction: .outbound, status: .failed, preimage: nil), + ] + + let received = await PrivatePaykitService.shared.receivedSettledPaymentHashes() + let attempted = await PrivatePaykitService.shared.attemptedOutboundBolt11PaymentHashes() + + XCTAssertTrue(received.contains("in")) + XCTAssertEqual(attempted.intersection(["out", "failed"]), ["out"]) + } + + func testLightningWaitFinishesWhenTheRailSettledBeforeWatching() async throws { + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "ff", direction: .outbound, status: .succeeded, preimage: nil)] + + let settled = try await WalletViewModel().waitForLightningPayment(hash: "ff", timeoutSeconds: 5) + + XCTAssertEqual(settled.paymentHash, "ff") + } + + func testQuickPayReconcilesRailPayments() { + let row = QuickPayReconcileRow(railPayment: Bitkit.ChannelFreeRailPayment( + paymentHash: "dd", direction: .outbound, status: .succeeded, preimage: nil + )) + + XCTAssertEqual(row.invoicePaymentHash, "dd") + XCTAssertTrue(row.isOutboundBolt11) + XCTAssertEqual(row.status, QuickPayReconcileRow.Status.succeeded) + } + + func testRailSendHonoursTheRoutingFeeLimit() async throws { + rail.routingFeeSats = 5 + let limit = RouteParametersConfig( + maxTotalRoutingFeeMsat: 1000, maxTotalCltvExpiryDelta: 1008, maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 + ) + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: limit) + XCTFail("Expected the fee limit to stop the payment") + } catch { + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused, "A refusal before sending must read as never left: \(error)") + XCTAssertNil(rail.sentBolt11) + } + + let roomy = RouteParametersConfig( + maxTotalRoutingFeeMsat: 10000, maxTotalCltvExpiryDelta: 1008, maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 + ) + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: roomy) + XCTAssertEqual(rail.sentBolt11, "lnbcrt1rail") + } +} + +@MainActor +extension ChannelFreeRailTests { + func testFailedFeeEstimateIsARefusalBeforeSending() async { + rail.estimateError = NSError(domain: "spark", code: 1) + let limit = RouteParametersConfig( + maxTotalRoutingFeeMsat: 1000, maxTotalCltvExpiryDelta: 1008, maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 + ) + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: limit) + XCTFail("Expected the failed estimate to stop the payment") + } catch { + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused) + XCTAssertNil(rail.sentBolt11) + } + } + + func testCanSendLeavesRoomForTheSparkFee() { + rail.balanceSats = 100_000 + let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000) + + XCTAssertEqual(rail.sendableSats, sendable) + XCTAssertTrue(Bitkit.LightningService.shared.canSend(amountSats: sendable)) + XCTAssertFalse(Bitkit.LightningService.shared.canSend(amountSats: 100_000)) + } + + func testSendableSatsFitsTheFeeBesideTheAmount() { + for balance: UInt64 in [0, 1, 400, 401, 9572, 100_000, 21_000_000] { + let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: balance) + let fee = (sendable * Bitkit.ChannelFreeRailFees.serviceFeePpm + 999_999) / 1_000_000 + XCTAssertLessThanOrEqual(sendable + fee, balance, "\(balance)") + let next = sendable + 1 + let nextFee = (next * Bitkit.ChannelFreeRailFees.serviceFeePpm + 999_999) / 1_000_000 + XCTAssertGreaterThan(next + nextFee, balance, "\(balance) leaves room for more") + } + XCTAssertEqual(Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000), 99750) + } + + func testSparkReadyTriggersOnceUntilItDropsAgain() { + var transition = SparkReadinessTransition() + XCTAssertFalse(transition.becameReady(false)) + XCTAssertTrue(transition.becameReady(true)) + XCTAssertFalse(transition.becameReady(true)) + XCTAssertFalse(transition.becameReady(false)) + XCTAssertTrue(transition.becameReady(true)) + } +} + +private final class FakeRail: Bitkit.ChannelFreeLightningRail { + var isReady = true + var balanceSats: UInt64 = 9572 + var payments: [Bitkit.ChannelFreeRailPayment] = [] + var routingFeeSats: UInt64 = 0 + var estimateError: Error? + var sentBolt11: String? + + func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { + "lnbcrt1fake" + } + + func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash { + sentBolt11 = bolt11 + return "ee" + } + + func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 { + if let estimateError { + throw estimateError + } + return routingFeeSats + } + + func lightningPayments() async -> [Bitkit.ChannelFreeRailPayment] { + payments + } +} diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index e78b9ca58..c9db89075 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1723,6 +1723,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { NodeError.InvalidInvoice(message: "invalid"), NodeError.InvalidAmount(message: "invalid"), Bitkit.AppError(error: NodeError.PaymentSendingFailed(message: "no route")), + Bitkit.ChannelFreeRailSendRefused(reason: "Spark routing fee exceeds the limit"), + Bitkit.AppError(error: Bitkit.ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment")), ] for error in errors { let record = try paymentRequestRecord() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 5fe26294b..0f4a676aa 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5041,6 +5041,40 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestPresentation(request)) } + func testResolutionFailureEndsARequestedPresentationWithAToast() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.requestPresentation(request)) + + let feedback = try XCTUnwrap( + IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: manager + ) + ) + + XCTAssertNil(manager.requestedPresentationId) + XCTAssertFalse(manager.isWaitingForPresentationRetry(request)) + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertNotNil(feedback.toast) + } + + func testResolutionFailureLeavesAutomaticPresentationToRetry() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + + XCTAssertNil( + IncomingPaykitPaymentRequestPresentationDispatcher.finishRequestedPresentationAfterResolutionFailure( + for: request, + with: manager + ) + ) + } + func testPendingPrivateLinkRecoveryStopsAutomaticPresentationWithoutToast() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) diff --git a/BitkitTests/SavingsSelectionTests.swift b/BitkitTests/SavingsSelectionTests.swift new file mode 100644 index 000000000..6db3bd2f0 --- /dev/null +++ b/BitkitTests/SavingsSelectionTests.swift @@ -0,0 +1,69 @@ +@testable import Bitkit +import XCTest + +final class SavingsSelectionTests: XCTestCase { + private let channels = ["a", "b"] + + func testDefaultMovesEveryChannelAndSpark() { + let selection = resolve(selected: nil, spark: true, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["a", "b"], withdrawsSpark: true)) + } + + func testSparkOnlyWithoutChannels() { + let selection = SavingsSelection.resolve(readyChannelIds: [], selectedChannelIds: nil, isSparkSelected: true, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: [], withdrawsSpark: true)) + XCTAssertFalse(selection.isEmpty) + } + + func testChannelsOnlyWithoutSparkBalance() { + let selection = resolve(selected: nil, spark: true, sparkSats: 0) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["a", "b"], withdrawsSpark: false)) + } + + func testDeselectedSparkIsNotWithdrawn() { + let selection = resolve(selected: ["a", "b"], spark: false, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["a", "b"], withdrawsSpark: false)) + } + + func testSparkAloneClosesNoChannel() { + let selection = resolve(selected: [], spark: true, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: [], withdrawsSpark: true)) + } + + func testPartialChannelSelection() { + let selection = resolve(selected: ["b"], spark: true, sparkSats: 5000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["b"], withdrawsSpark: true)) + } + + func testSelectedChannelThatIsNoLongerReadyIsDropped() { + let selection = resolve(selected: ["b", "gone"], spark: false, sparkSats: 5000) + + XCTAssertEqual(selection.channelIds, ["b"]) + } + + func testNothingSelectedIsEmpty() { + XCTAssertTrue(resolve(selected: [], spark: false, sparkSats: 5000).isEmpty) + } + + @MainActor + func testCommittingResetsThePickToAll() { + let transfer = TransferViewModel() + transfer.selectedChannelIds = ["a"] + transfer.isSparkSelected = false + + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: false) + + XCTAssertNil(transfer.selectedChannelIds) + XCTAssertTrue(transfer.isSparkSelected) + } + + private func resolve(selected: Set?, spark: Bool, sparkSats: UInt64) -> SavingsSelection { + SavingsSelection.resolve(readyChannelIds: channels, selectedChannelIds: selected, isSparkSelected: spark, sparkSats: sparkSats) + } +} diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift index 0720817a2..e4f92dc2f 100644 --- a/BitkitTests/SavingsSwapTests.swift +++ b/BitkitTests/SavingsSwapTests.swift @@ -52,7 +52,7 @@ final class SavingsSwapTests: XCTestCase { await transfer.loadSavingsSwapQuote(requestedSat: 100_000, spendableSats: 100_000) XCTAssertNil(transfer.savingsSwapState.quote) - transfer.onTransferToSavingsConfirm(channels: []) + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: false) XCTAssertEqual(transfer.savingsTransferMode, .close) } @@ -66,10 +66,10 @@ final class SavingsSwapTests: XCTestCase { maxSat: 100_000 ) - transfer.onTransferToSavingsConfirm(channels: []) + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: false) XCTAssertEqual(transfer.savingsTransferMode, .swap) - transfer.onTransferToSavingsConfirm(channels: [], mode: .close) + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: false, mode: .close) XCTAssertEqual(transfer.savingsTransferMode, .close) } diff --git a/BitkitTests/SparkClaimFeeTests.swift b/BitkitTests/SparkClaimFeeTests.swift new file mode 100644 index 000000000..4c3223166 --- /dev/null +++ b/BitkitTests/SparkClaimFeeTests.swift @@ -0,0 +1,10 @@ +@testable import Bitkit +import XCTest + +final class SparkClaimFeeTests: XCTestCase { + func testClaimFeeEstimateCoversTheClaimsSeenOnMainnet() { + // Mainnet claims so far took 198 and 297 sats; the cap at 1 sat/vB stays above both. + XCTAssertEqual(SparkManager.maxDepositClaimFeeSats(fastestFeeRate: 1, amountSats: 2500), 302) + XCTAssertEqual(SparkManager.maxDepositClaimFeeSats(fastestFeeRate: 1, amountSats: 15000), 327) + } +} diff --git a/BitkitTests/SparkClaimOfferTests.swift b/BitkitTests/SparkClaimOfferTests.swift new file mode 100644 index 000000000..9874f03eb --- /dev/null +++ b/BitkitTests/SparkClaimOfferTests.swift @@ -0,0 +1,55 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +final class SparkClaimOfferTests: XCTestCase { + func testOffersOnlyTheStandardClaimWhenNoEarlyClaimIsQuoted() { + let offer = SparkClaimOffer(txid: "txid", vout: 1, quote: quote(instant: nil)) + + XCTAssertEqual(offer.kind, .standard(feeSats: 907, confirmations: 3)) + } + + func testLeavesAnEarlyClaimWithinTheCapToTheSdk() { + let offer = SparkClaimOffer(txid: "txid", vout: 1, quote: quote(instant: early(feeSats: 900, maxFeeSats: 1000))) + + XCTAssertEqual(offer.kind, .automatic(feeSats: 900, confirmations: 0)) + } + + func testOffersAChoiceWhenTheEarlyClaimCostsMoreThanTheCap() { + let offer = SparkClaimOffer(txid: "txid", vout: 1, quote: quote(instant: early(feeSats: 1350, maxFeeSats: 1000, confirmations: 1))) + + XCTAssertEqual(offer.kind, .choice(earlyFeeSats: 1350, earlyConfirmations: 1, standardFeeSats: 907, standardConfirmations: 3)) + } + + func testOffersAChoiceWhenAutomaticClaimingIsOff() { + let offer = SparkClaimOffer(txid: "txid", vout: 1, quote: quote(instant: early(feeSats: 900, maxFeeSats: nil))) + + XCTAssertEqual(offer.kind, .choice(earlyFeeSats: 900, earlyConfirmations: 0, standardFeeSats: 907, standardConfirmations: 3)) + } + + func testMapsEachClaimOutcome() { + XCTAssertEqual(SparkEarlyClaim(.submitted), .claimed) + XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .noEarlyClaimAvailable)), .waiting) + XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .maxFeeExceeded(requiredFeeSats: 1500, maxFeeSats: 1350))), .repriced(requiredFeeSats: 1500)) + XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .providerDeclined(message: "busy"))), .declined) + } + + private func quote(instant: ClaimDepositQuote?) -> FetchClaimDepositQuoteResponse { + FetchClaimDepositQuoteResponse( + amountSats: 15000, + confirmations: 0, + instant: instant, + mature: ClaimDepositQuote(confirmationsRequired: 3, creditAmountSats: 14093, feeSats: 907, maxFeeSats: 1000, isEstimate: false) + ) + } + + private func early(feeSats: UInt64, maxFeeSats: UInt64?, confirmations: UInt32 = 0) -> ClaimDepositQuote { + ClaimDepositQuote( + confirmationsRequired: confirmations, + creditAmountSats: 15000 - feeSats, + feeSats: feeSats, + maxFeeSats: maxFeeSats, + isEstimate: false + ) + } +} diff --git a/BitkitTests/SparkConfigTests.swift b/BitkitTests/SparkConfigTests.swift new file mode 100644 index 000000000..267e1600b --- /dev/null +++ b/BitkitTests/SparkConfigTests.swift @@ -0,0 +1,15 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +final class SparkConfigTests: XCTestCase { + func testConnectsWithoutBreezDataSyncOrLightningAddressServer() { + for network in [BreezSdkSpark.Network.mainnet, .regtest] { + let config = SparkManager.sdkConfig(network: network, apiKey: "test-key") + + XCTAssertNil(config.realTimeSyncServerUrl, "\(network)") + XCTAssertNil(config.lnurlDomain, "\(network)") + XCTAssertEqual(config.apiKey, "test-key") + } + } +} diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift new file mode 100644 index 000000000..e451aed3d --- /dev/null +++ b/BitkitTests/SparkExitBackupTests.swift @@ -0,0 +1,51 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class SparkExitBackupTests: XCTestCase { + private let walletIndex = 99 + + override func tearDown() { + try? FileManager.default.removeItem(at: Env.sparkExitRestore(walletIndex: walletIndex)) + super.tearDown() + } + + func testUploadsTheExportedExitStateUnderItsOwnKey() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "{\"pedigrees\":[]}") + var uploads: [(String, Data)] = [] + let service = BackupService( + defaults: defaults, + backupData: { _ in try JSONEncoder().encode(backup) }, + uploadBackup: { uploads.append(($0, $1)) } + ) + + await service.triggerBackup(category: .sparkExit) + + let upload = try XCTUnwrap(uploads.first) + XCTAssertEqual(upload.0, "SPARK_EXIT") + let restored = try JSONDecoder().decode(SparkExitBackupV1.self, from: upload.1) + XCTAssertEqual(restored.exitState, backup.exitState) + XCTAssertEqual(restored.network, backup.network) + } + + func testStagesARestoredExitStateUntilSparkConnects() async throws { + guard Env.network == .regtest else { throw XCTSkip("Spark's test network is regtest") } + let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "state") + + try await SparkManager.shared.restoreExitBackup(backup, walletIndex: walletIndex) + + let staged = try String(contentsOf: Env.sparkExitRestore(walletIndex: walletIndex), encoding: .utf8) + XCTAssertEqual(staged, "state") + } + + func testSkipsAnExitStateFromAnotherNetwork() async throws { + let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "testnet", exitState: "state") + + try await SparkManager.shared.restoreExitBackup(backup, walletIndex: walletIndex) + + XCTAssertFalse(FileManager.default.fileExists(atPath: Env.sparkExitRestore(walletIndex: walletIndex).path)) + } +} diff --git a/BitkitTests/SparkLightningSendTests.swift b/BitkitTests/SparkLightningSendTests.swift new file mode 100644 index 000000000..b7fcb4811 --- /dev/null +++ b/BitkitTests/SparkLightningSendTests.swift @@ -0,0 +1,72 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +@MainActor +final class SparkLightningSendTests: XCTestCase { + func testLightningInvoiceWithoutChannelsOpensPaymentWhenSparkCanPay() async throws { + var checkedAmount: UInt64? + let app = makeApp(hasChannelFreeRail: true) { + checkedAmount = $0 + return true + } + + try await app.handleScannedData("lnbcrt1sparkonly") + + XCTAssertEqual(checkedAmount, 100) + XCTAssertNotNil(app.scannedLightningInvoice) + XCTAssertEqual(app.selectedWalletToPayFrom, .lightning) + XCTAssertFalse(app.didRejectScannedPayment) + } + + func testSparkSendReturnsBeforeCompletionSoCallersSeeTheCompletionEvent() { + guard case let .bolt11Invoice(_, completionTimeoutSecs) = SparkManager.bolt11SendOptions else { + return XCTFail("Expected bolt11 send options") + } + XCTAssertNil(completionTimeoutSecs) + } + + func testLightningInvoiceWithoutChannelsOrSparkIsRejected() async throws { + let app = makeApp(hasChannelFreeRail: false) { _ in false } + + try await app.handleScannedData("lnbcrt1sparkonly") + + XCTAssertTrue(app.didRejectScannedPayment) + } + + private func makeApp(hasChannelFreeRail: Bool, canSendLightning: @escaping (UInt64) -> Bool) -> AppViewModel { + AppViewModel( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 0, + totalLightningBalanceSats: 9572, + hasChannels: false, + hasUsableChannels: false, + hasChannelFreeRail: hasChannelFreeRail + ) + }, + canSendLightning: canSendLightning, + decodeInvoice: { _ in .lightning(invoice: self.invoice) } + ) + ) + } + + private var invoice: LightningInvoice { + LightningInvoice( + bolt11: "lnbcrt1sparkonly", + paymentHash: Data(), + amountSatoshis: 100, + timestampSeconds: UInt64(Date().timeIntervalSince1970), + expirySeconds: 3600, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + } +} diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift new file mode 100644 index 000000000..d37a1bcfb --- /dev/null +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -0,0 +1,48 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +final class SparkTransferActivityTests: XCTestCase { + func testDepositsAndWithdrawalsAreTransfersButFailedOnesAndLightningAreNot() { + let unclaimed = DepositInfo( + txid: "pending-deposit", vout: 0, amountSats: 7500, isMature: true, + refundTx: nil, refundTxId: nil, refundState: nil, claimError: nil, instantClaimStatus: nil, maxClaimFee: nil + ) + let payments = [ + payment(id: "1", status: .completed, details: .deposit(txId: "claimed-deposit", vout: 0)), + payment(id: "2", status: .completed, details: .withdraw(txId: "withdrawal")), + payment(id: "3", status: .failed, details: .withdraw(txId: "failed-withdrawal")), + payment(id: "4", status: .completed, details: nil), + ] + + let txIds = SparkManager.transferTxIds(unclaimed: [unclaimed], payments: payments) + + XCTAssertEqual(txIds, ["pending-deposit", "claimed-deposit", "withdrawal"]) + } + + func testListsWithdrawalsWithWhatSparkKept() { + let payments = [ + payment(id: "1", status: .completed, details: .withdraw(txId: "withdrawal"), fees: 1470), + payment(id: "2", status: .failed, details: .withdraw(txId: "failed-withdrawal"), fees: 1470), + payment(id: "3", status: .completed, details: .deposit(txId: "deposit", vout: 0)), + ] + + let withdrawals = SparkManager.withdrawals(payments) + + XCTAssertEqual(withdrawals, [SparkWithdrawal(txid: "withdrawal", amountSats: 1000, feeSats: 1470)]) + } + + private func payment(id: String, status: PaymentStatus, details: PaymentDetails?, fees: U128 = 0) -> Payment { + Payment( + id: id, + paymentType: .receive, + status: status, + amount: 1000, + fees: fees, + timestamp: 1, + method: .deposit, + details: details, + conversionDetails: nil + ) + } +} diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift new file mode 100644 index 000000000..82dc21bd2 --- /dev/null +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -0,0 +1,147 @@ +@testable import Bitkit +import XCTest + +final class SparkTransferFlowTests: XCTestCase { + private let ceiling: UInt64 = 1000 + + private func choice(earlyFee: UInt64) -> SparkClaimOffer.Kind { + .choice(earlyFeeSats: earlyFee, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3) + } + + func testInstantWithinCeilingClaimsEarly() { + XCTAssertEqual(SparkClaimDecision.onOffer(choice(earlyFee: 900), speed: .instant, ceilingSats: ceiling), .claiming) + XCTAssertEqual(SparkClaimDecision.onOffer(choice(earlyFee: ceiling), speed: .instant, ceilingSats: ceiling), .claiming) + } + + func testInstantOverCeilingAsksBeforePaying() { + XCTAssertEqual( + SparkClaimDecision.onOffer(choice(earlyFee: 1500), speed: .instant, ceilingSats: ceiling), + .repriced(feeSats: 1500) + ) + } + + func testInstantWithoutEarlyOfferWaitsForConfirmations() { + XCTAssertEqual( + SparkClaimDecision.onOffer(.standard(feeSats: 198, confirmations: 3), speed: .instant, ceilingSats: ceiling), + .unavailable + ) + } + + func testInstantClaimedBySdkAddsIt() { + XCTAssertEqual( + SparkClaimDecision.onOffer(.automatic(feeSats: 300, confirmations: 0), speed: .instant, ceilingSats: ceiling), + .adding + ) + } + + func testLaterNeverClaimsEarly() { + XCTAssertEqual(SparkClaimDecision.onOffer(choice(earlyFee: 10), speed: .later, ceilingSats: ceiling), .later) + XCTAssertEqual( + SparkClaimDecision.onOffer(.standard(feeSats: 198, confirmations: 3), speed: .later, ceilingSats: ceiling), + .later + ) + } + + func testClaimOutcomes() { + XCTAssertEqual(SparkClaimDecision.onClaim(.claimed), .adding) + XCTAssertEqual(SparkClaimDecision.onClaim(.repriced(requiredFeeSats: 1200)), .repriced(feeSats: 1200)) + XCTAssertEqual(SparkClaimDecision.onClaim(.declined), .unavailable) + XCTAssertNil(SparkClaimDecision.onClaim(.waiting)) + } + + @MainActor + func testLaterBroadcastSkipsTheClaim() { + let flow = SparkTransferFlow(amountSats: 100_000, speed: .later, quote: quote) + flow.onBroadcast(txid: "txid") + XCTAssertEqual(flow.claimState, .later) + } + + @MainActor + func testWaitFallsBackToLater() { + let flow = SparkTransferFlow(amountSats: 100_000, claimState: .repriced(feeSats: 1500)) + flow.waitForConfirmations() + XCTAssertEqual(flow.claimState, .later) + } + + @MainActor + func testTimeoutWhenSparkNeverListsTheDeposit() async { + let flow = pollingFlow(offers: [nil, nil, nil]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .notSeen) + } + + @MainActor + func testDepositListedThenGoneWasAdded() async { + let flow = pollingFlow(offers: [choice(earlyFee: 900), nil], claims: [.waiting]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .adding) + } + + @MainActor + func testWaitingKeepsPollingUntilClaimed() async { + let flow = pollingFlow(offers: [choice(earlyFee: 900), choice(earlyFee: 900)], claims: [.waiting, .claimed]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .adding) + } + + @MainActor + func testPayAfterRepriceClaimsAtTheNewFee() async { + let flow = pollingFlow(offers: [choice(earlyFee: 1500), choice(earlyFee: 1500)], claims: [.claimed]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .repriced(feeSats: 1500)) + + flow.payRepriced() + XCTAssertEqual(flow.claimState, .claiming) + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .adding) + } + + @MainActor + func testDeclinedEarlyClaimWaitsForConfirmations() async { + let flow = pollingFlow(offers: [choice(earlyFee: 900)], claims: [.declined]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .unavailable) + } + + /// A flow whose poll answers with `offers` in turn (nil once they run out) and whose claims answer with `claims`. + @MainActor + private func pollingFlow(offers: [SparkClaimOffer.Kind?], claims: [SparkEarlyClaim] = []) -> SparkTransferFlow { + var offers = offers + var claims = claims + let operations = SparkClaimOperations( + offer: { txid in + guard !offers.isEmpty, let kind = offers.removeFirst() else { return nil } + return SparkClaimOffer(txid: txid, vout: 0, kind: kind) + }, + claimEarly: { _ in claims.isEmpty ? .waiting : claims.removeFirst() }, + refresh: {}, + sleep: { _ in } + ) + return SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: offers.count + 1) + } + + func testQuoteTiles() { + XCTAssertEqual(quote.toSpendingSats(for: .instant), 100_000 - 1093) + XCTAssertEqual(quote.toSpendingSats(for: .later), 100_000 - 198) + XCTAssertEqual(quote.totalSats, 100_577) + } + + func testMatureClaimFee() { + XCTAssertEqual(SparkManager.matureDepositClaimFeeSats(halfHourFeeRate: 2), 198) + } + + private var quote: SparkTransferQuote { + SparkTransferQuote( + address: "bcrt1qtest", + satsPerVbyte: 6, + networkFeeSats: 577, + depositSats: 100_000, + claimFees: SparkClaimFees(instantMaxSats: 1093, laterSats: 198) + ) + } +} diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift new file mode 100644 index 000000000..dd23d7bec --- /dev/null +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -0,0 +1,116 @@ +@testable import Bitkit +import XCTest + +final class SparkWithdrawalTests: XCTestCase { + // MARK: - Quote reuse + + func testQuoteHoldsForTheSameAddressAndBalanceUntilShortlyBeforeItExpires() { + let quote = SparkWithdrawalQuote(address: "bcrt1q-savings", balanceSats: 99901, expiresAt: 1000) + + XCTAssertTrue(quote.holds(address: "bcrt1q-savings", balanceSats: 99901, now: 900)) + XCTAssertFalse(quote.holds(address: "bcrt1q-savings", balanceSats: 99901, now: 1000 - SparkWithdrawalQuote.expiryMarginSecs)) + XCTAssertFalse(quote.holds(address: "bcrt1q-other", balanceSats: 99901, now: 900)) + XCTAssertFalse(quote.holds(address: "bcrt1q-savings", balanceSats: 100_000, now: 900)) + } + + func testEstimateNeverHolds() { + let estimate = SparkWithdrawalQuote(address: "bcrt1q-savings", balanceSats: 99901, expiresAt: 0) + + XCTAssertFalse(estimate.holds(address: "bcrt1q-savings", balanceSats: 99901, now: 0)) + } + + // MARK: - Fee pricing + + @MainActor + func testPricesTheFeeOncePerBalanceToOneAddress() async { + let transfer = TransferViewModel() + let calls = PriceCalls() + transfer.priceSparkWithdrawal = { address in + await calls.record(address) + return 2190 + } + + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-first") + await waitUntilPriced(transfer) + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-second") + await waitUntilPriced(transfer) + + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2190) + let addresses = await calls.addresses + XCTAssertEqual(addresses, ["bcrt1q-first"]) + + transfer.loadSparkWithdrawFee(balanceSats: 50000, savingsAddress: "bcrt1q-second") + await waitUntilPriced(transfer) + + let repriced = await calls.addresses + XCTAssertEqual(repriced, ["bcrt1q-first", "bcrt1q-first"]) + } + + @MainActor + func testGivesUpOnASlowQuoteAndPricesAgainNextTime() async { + let transfer = TransferViewModel() + transfer.sparkFeeTimeout = 0.1 + transfer.priceSparkWithdrawal = { _ in + try await Task.sleep(nanoseconds: 5_000_000_000) + return 2190 + } + + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + XCTAssertTrue(transfer.isPricingSparkWithdrawal) + await waitUntilPriced(transfer) + + XCTAssertNil(transfer.sparkWithdrawFeeSats) + + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2190) + } + + // MARK: - Failed withdrawal + + func testFailedSparkWithdrawalIsShownAndNamesWhetherChannelsMoved() { + XCTAssertEqual( + SavingsTransferOutcome.resolve(sparkFailed: true, movedChannels: true, channelsFailedToClose: false), + .sparkFailed(channelsMoved: true) + ) + XCTAssertEqual( + SavingsTransferOutcome.resolve(sparkFailed: true, movedChannels: false, channelsFailedToClose: false), + .sparkFailed(channelsMoved: false) + ) + XCTAssertEqual( + SavingsTransferOutcome.resolve(sparkFailed: false, movedChannels: true, channelsFailedToClose: false), + .success + ) + XCTAssertEqual( + SavingsTransferOutcome.resolve(sparkFailed: true, movedChannels: true, channelsFailedToClose: true), + .channelsInterrupted + ) + } + + // MARK: - Withdrawal receipt + + func testWithdrawalReceiptIsMatchedByItsTxid() { + let listed = [SparkWithdrawal(txid: "listed", amountSats: 97711, feeSats: 2190)] + + XCTAssertTrue(SparkManager.isWithdrawal(txid: "sent", sent: ["sent"], withdrawals: [])) + XCTAssertTrue(SparkManager.isWithdrawal(txid: "listed", sent: [], withdrawals: listed)) + XCTAssertFalse(SparkManager.isWithdrawal(txid: "payment", sent: ["sent"], withdrawals: listed)) + } + + @MainActor + private func waitUntilPriced(_ transfer: TransferViewModel) async { + for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { + try? await Task.sleep(nanoseconds: 20_000_000) + } + } +} + +private actor PriceCalls { + private(set) var addresses: [String] = [] + + func record(_ address: String) { + addresses.append(address) + } +} diff --git a/Docs/RELEASE.md b/Docs/RELEASE.md index af7148739..3d97575c2 100644 --- a/Docs/RELEASE.md +++ b/Docs/RELEASE.md @@ -127,6 +127,8 @@ These translated notes are later pasted into App Store Connect when submitting f ## 2. Build and Upload to TestFlight +Before archiving, make sure `Secrets.xcconfig` exists at the repo root with `BREEZ_API_KEY` set (copy `Secrets.xcconfig.template`; ask the team for the key). Release builds fail without it, so Spark can't ship switched off. + ### Option A: Manual (Xcode) 1. Open `Bitkit.xcodeproj` in Xcode diff --git a/README.md b/README.md index ca7338f10..53fa3e7a7 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ The app automatically selects the network based on the build configuration: - **Debug builds** → Uses **Regtest** network (for local development and testing) - **Release builds** → Uses **Bitcoin Mainnet** network (for production) +### Build secrets + +Spark (Lightning without channels) needs a Breez API key on mainnet. Copy `Secrets.xcconfig.template` to `Secrets.xcconfig` (gitignored) and paste the key after `BREEZ_API_KEY =`. Without it the app builds and Spark stays off on mainnet; regtest needs no key. + ### Building for E2E tests To produce an E2E build (uses the local Electrum backend by default), pass the `E2E_BUILD` compilation flag: diff --git a/Secrets.xcconfig.template b/Secrets.xcconfig.template new file mode 100644 index 000000000..8c39d559c --- /dev/null +++ b/Secrets.xcconfig.template @@ -0,0 +1,3 @@ +// Copy to Secrets.xcconfig (gitignored) and paste the values. +// Breez SDK API key: enables Spark on mainnet builds; regtest needs none. +BREEZ_API_KEY = diff --git a/changelog.d/next/1128.added.md b/changelog.d/next/1128.added.md new file mode 100644 index 000000000..e1e6a1d79 --- /dev/null +++ b/changelog.d/next/1128.added.md @@ -0,0 +1 @@ +Spending keeps Lightning without channels: transfers from Savings now fund a Spark wallet that sends and receives Lightning payments. diff --git a/journeys/README.md b/journeys/README.md index 91d47b09c..53b04b44c 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -197,7 +197,7 @@ but appends the billing period (`-one-time` for a one-off), because every recurr subscription carries the same `paymentRequestId` and they can list together — Android stops at the id and lets those rows collide. Match on the prefix when a journey needs to work on both platforms. -Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `TransferUnavailable*`, `Fund*`, +Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount*`, `TransferUnavailable*`, `Fund*`, `External*`, `Hardware*`, `Widget*` — matches Android exactly. ## Capabilities @@ -215,6 +215,7 @@ journey PR, which is what made this file conflict on every merge. | USDT wallet navigation without funding | an onboarded build with USDT configured; wallet syncing remains silent while offline; payment-action errors remain available — [USDT QA](../Docs/USDT-QA.md) | | Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../Docs/USDT-QA.md) | | On-chain funds and blocks on regtest | `../bitkit-android/lsp` deposit and mine, borrowed from the sibling Android checkout until #694 lands an iOS copy — [Backend preconditions](#backend-preconditions) | +| Spending through Spark (transfer to spending, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key; transfers fund it from regtest Savings. Spark's claim of a deposit (Instant or after 3 confirmations) depends on the provider's quote, so the setting-up states are covered by previews and unit tests — [transfer](transfer/spark-transfer-to-spending.xml) | | Lightning channels | a channel opened through Manual Setup to the node of the LSP the `E2E_BUILD` app targets, as an external LN peer — [Backend preconditions](#backend-preconditions) | | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker`, reached through Trezor Bridge on the host; the simulator has no Bluetooth LE and iOS cannot do WebUSB, so BLE pairing and USB are not covered — [hardware-wallet](hardware-wallet/README.md) | | The OS notification-permission dialog | the one-shot `UNUserNotificationCenter` alert, reset with `xcrun simctl uninstall to.bitkit` and a rebuild | diff --git a/journeys/amount-limits/README.md b/journeys/amount-limits/README.md index ac906a37a..331c23c64 100644 --- a/journeys/amount-limits/README.md +++ b/journeys/amount-limits/README.md @@ -2,7 +2,10 @@ These journeys exercise the "block number pad input exceeding the max/available amount" behaviour. The same `AmountInputViewModel` cap + `maxExceededCount` effect path backs the amount-entry screens -for Send and the External node (Manual Setup) channel. +for Send, Transfer→Spending and the External node (Manual Setup) channel. + +The hardware Transfer To Spending amount shares that path. It needs a paired device, so its journey +lives in [`../hardware-wallet/transfer-to-spending-over-max.xml`](../hardware-wallet/transfer-to-spending-over-max.xml). `custom-fee-wallet-switch.xml` covers adjacent send preparation: a custom fee switches Spending to Savings, with manual or automatic coin selection after returning through the fee picker. It requires @@ -35,7 +38,12 @@ preparation. Do not submit a payment. then `../bitkit-android/lsp POST /regtest/chain/mine '{"count":3}'`. `mine` prints nothing on success — that is not a failure. There is no iOS copy of that helper yet — see #694 and the top-level README. -2. **The external-node flow needs a reachable Lightning peer.** The staging LSP node works +2. **The Transfer→Spending flow needs Spark connected** (a dev build reaches Spark's regtest with no + key); otherwise the transfer opens the Transfer Unavailable sheet. Its maximum is the Savings + balance less the fast-rate fee for sending all of it to the Spark deposit address, so it loads + behind a spinner — wait for it before typing. `transfer-spending-over-max.xml` has not been walked + on the Spark flow yet. +3. **The external-node flow needs a reachable Lightning peer.** The staging LSP node works (`../bitkit-android/lsp GET /info` for its id/host/port). ## Gotchas @@ -45,7 +53,8 @@ preparation. Do not submit a payment. an over-max state, so the capped value is valid and Continue stays **enabled**. - Toasts are short-lived (`Toast.visibilityTimeShort`) and will have faded before a snapshot round-trip completes. Assert one with `xcodebuildmcp ui-automation wait-for-ui` on its identifier - (`SendAmountExceededToast`, `ExternalAmountExceededToast`) immediately after the over-max keypress. + (`SendAmountExceededToast`, `SpendingAmountExceededToast`, `ExternalAmountExceededToast`) immediately + after the over-max keypress. - Prefer `snapshot-ui` and tap elements by identifier (`N9`, `NRemove`) over screen coordinates. ## Verified behaviour @@ -66,6 +75,9 @@ snapshot round-trip returns — race a `wait-for-ui` on the toast identifier aga - Send: screen `SendAmount`, field `SendNumberField`, available `AvailableAmount`, continue `ContinueAmount`; recipient `RecipientManual` / `RecipientInput` / `AddressContinue`; home Send button `Send`. +- Transfer→Spending: screen `SpendingAmount`, field `SpendingAmountNumberField`, + available `SpendingAmountAvailable` (or `SpendingAmountRetry` when the fee could not be priced), + 25% `SpendingAmountQuarter`, max `SpendingAmountMax`, continue `SpendingAmountContinue`. - External: funding `FundManual`; connection `NodeIdInput`/`HostInput`/`PortInput`/`ExternalContinue`; amount screen `ExternalAmount`, field `ExternalAmountNumberField`, available `ExternalAmountAvailable`, 25% `ExternalAmountQuarter`, max `ExternalAmountMax`, diff --git a/journeys/amount-limits/transfer-spending-over-max.xml b/journeys/amount-limits/transfer-spending-over-max.xml new file mode 100644 index 000000000..6763f6ca8 --- /dev/null +++ b/journeys/amount-limits/transfer-spending-over-max.xml @@ -0,0 +1,30 @@ + + + Verifies the "Transfer to Spending" amount number pad blocks input exceeding the maximum allowed + transfer amount, shows the "Spending Balance Maximum" warning toast, and still allows deleting + digits while at the cap. + + Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and Spark connected (a + dev build reaches Spark's regtest with no key), so the transfer opens the amount screen rather than + the Transfer Unavailable sheet. The maximum is the Savings balance less the fast-rate network fee for + sending all of it to the Spark deposit address; it loads behind a spinner — wait for it to populate. + Start on the wallet home screen. + + Enter via the Savings card, not the Spending card: the Spending screen only offers + "Transfer From Savings" while the spending balance is zero, and shows "Transfer To Savings" + once a balance exists. The Savings card's "Transfer To Spending" reaches the same screen in + both states. + + + Tap the Savings balance card on the home screen (id "ActivitySavings") + Tap "Transfer To Spending" (id "TransferToSpending") + If a transfer intro screen appears, tap "Get Started" + Verify the spending amount screen (id "SpendingAmount") is visible + Wait until the available/maximum amount (id "SpendingAmountAvailable") finishes loading and shows a positive value + Tap the "9" key (id "N9") nine times to enter an amount far larger than the maximum allowed + Verify a "Spending Balance Maximum" warning toast appears (id "SpendingAmountExceededToast") + Verify the amount in the input field (id "SpendingAmountNumberField") does not exceed the stated maximum + Tap the delete key (id "NRemove") once + Verify the amount in the input field (id "SpendingAmountNumberField") decreased after the delete + + diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 8916a48d2..ead51982a 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -1,7 +1,8 @@ # Hardware wallet journeys Cover pairing a Trezor, the home tile and detail screen, the Hardware Wallets settings surface, -passphrase (hidden) wallets, wallet-scoped activity, and the Transfer To Spending button, which opens the Transfer Unavailable sheet. +passphrase (hidden) wallets, wallet-scoped activity, and the watch-only Transfer To Spending flow, +which pays the Spark deposit address from the device (the Transfer Unavailable sheet while Spark is not ready). ## Transport: Bridge, not USB @@ -51,8 +52,10 @@ Several journeys mutate pairing state. Run them in this order, or re-pair betwee 1. `connect-home-tile.xml` — pairs the emulator; every other journey assumes it ran. 2. `settings-hardware-wallets.xml`, `connect-flow.xml`, `suggestion-intro-sheet.xml` — each forgets and re-pairs the device, ending paired. -3. `activity-blue-icons.xml`, `activity-detail-hw-tags.xml`, `reconnect.xml`, `send-onchain.xml`. -4. `passphrase-pairing.xml` → `passphrase-duplicate.xml` → `passphrase-settings-remove.xml`. +3. `activity-blue-icons.xml`, `activity-detail-hw-tags.xml`, `transfer-to-spending*.xml`, `reconnect.xml`, + `send-onchain.xml`. +4. `passphrase-pairing.xml` → `passphrase-duplicate.xml` → `passphrase-transfer-to-spending.xml` → + `passphrase-settings-remove.xml`. 5. `detail-overview.xml` **last** — its final step forgets the device. ## Approving prompts on the emulator @@ -107,8 +110,9 @@ Walked as far as a simulator allows, against a wallet with two Trezor identities `HardwareWalletRowName` has **no** separator, while `HardwareWalletRowDelete_` and `HardwareWalletRow_` use an underscore. The `` is the full `trezor:` device id. - Confirmed resolving without the emulator: `HardwareWalletsSettings`, `HardwareWalletsScreen`, - `AddHardwareWallet`, `HardwareWalletIntroScreen`/`Continue`/`Cancel`, `HardwareWalletScreen` and - `HardwareTransferToSpending`. + `AddHardwareWallet`, `HardwareWalletIntroScreen`/`Continue`/`Cancel`, `HardwareWalletScreen`, + `HardwareTransferToSpending`, `HardwareTransferAmount` and its Available/25%/MAX/Continue controls, + and the number pad keys. ## Identifiers used - Dev Trezor screen: `TrezorScanButton`, `TrezorDevice-bridge`, `TrezorKnownDeviceConnect-bridge`, @@ -126,7 +130,12 @@ Walked as far as a simulator allows, against a wallet with two Trezor identities `RenameHardwareWalletInput`, `RenameHardwareWalletSave`. - Home and detail: tile `ActivityHardware`, screen `HardwareWalletScreen`, `RemoveHardwareWallet`, `RemoveHwWalletDialog`, `HwRemoveKeepBackupToggle`. -- Transfer: `HardwareTransferToSpending` (opens the Transfer Unavailable sheet). +- Transfer: `HardwareTransferToSpending`, `HardwareTransferAmount`, + `HardwareTransferAmountAvailable`/`Quarter`/`Max`/`Continue`/`Retry`, + over-max toast `HardwareTransferAmountExceededToast`, `HardwareTransferSign`, + `HardwareTransferSignLearnMore`, speed rows `SpendingConfirmInstant`/`SpendingConfirmLater`, + `HardwareTransferOpenTrezorConnect`, `HardwareTransferSigned`, + `HwTransferPassphraseSheet`/`Input`/`Continue`/`Cancel`. - Activity: `ActivityShowAll` (home only). Rows use **two different schemes** — `ActivityShort-` in the home recent list, `Activity-` in All Activity and on the hardware wallet screen. Tabs `Tab-all`/`Tab-sent`/`Tab-received`/`Tab-other`, diff --git a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml new file mode 100644 index 000000000..baf8f5491 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml @@ -0,0 +1,36 @@ + + + Drives Transfer To Spending from a passphrase (hidden) wallet. While the Trezor session that + holds the passphrase is still open the transfer signs straight away; after the session is + dropped Bitkit asks for the passphrase again, and a wrong one is refused instead of signing + from whichever wallet the device happens to have open. Requires the emulator started with + passphrase protection enabled, the hidden wallet from passphrase-pairing.xml paired, and its + native-segwit account holding spendable regtest funds, and Spark connected. + + + Launch the Bitkit app and go to the wallet home screen + Tap the hidden wallet tile ("Hidden Trezor") and verify its detail screen opens (id "HardwareWalletScreen") + Tap "Transfer To Spending" (id "HardwareTransferToSpending"), and if the first-run intro is shown tap "Get Started" + Tap the "25%" quick button (id "HardwareTransferAmountQuarter"), then "Continue" (id "HardwareTransferAmountContinue") and wait for the fee estimate + Verify the sign screen opens (id "HardwareTransferSign") titled "SIGN WITH YOUR DEVICE" + Tap "Open Trezor Connect" (id "HardwareTransferOpenTrezorConnect") and verify no passphrase sheet appears, because the session opened at pairing still holds the hidden wallet + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order, and verify the transaction signed screen appears (id "HardwareTransferSigned") + Wait for the Funds In Transfer screen (id "LightningSettingUp"), tap "Continue Using Bitkit", and verify the app returns to the wallet home screen + Force-quit Bitkit: xcodebuildmcp simulator stop --bundle-id to.bitkit + Relaunch it: xcodebuildmcp simulator launch-app --bundle-id to.bitkit + Once the app is back on the home screen, open the hidden wallet tile and start Transfer To Spending again, setting an amount with the "25%" quick button and continuing to the sign screen + Tap "Open Trezor Connect" (id "HardwareTransferOpenTrezorConnect") and verify the passphrase sheet opens (id "HwTransferPassphraseSheet"), because the session that held the passphrase is gone + Type a wrong passphrase "not-the-one" into the input (id "HwTransferPassphraseInput") and tap "Continue" (id "HwTransferPassphraseContinue") + + Confirm on the emulated device for every account the passphrase session reads: the device + prompts once per address type and the call blocks until it is acknowledged. Either tap the + emulator UI or run: ../bitkit-docker/scripts/trezor-emulator send-json '{"type":"emulator-press-yes","id":1}' + + Verify an error toast says the passphrase opens a different wallet, that no signing prompt was shown on the emulator, and that no new transfer appears in the activity list + Navigate to the wallet home screen and verify the hardware tile count is unchanged: the wallet the wrong passphrase opened must not be added + Reopen the sign screen, tap "Open Trezor Connect", type the correct passphrase "bitkit-hidden" and tap "Continue" + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator, and verify the transaction signed screen appears (id "HardwareTransferSigned") + Scan the app group for both passphrases, treating only "no match" as clean: UDID=<the simulator this journey is running on>; GROUP=$(xcrun simctl get_app_container "$UDID" to.bitkit groups | awk '{print $2}'); [ -d "$GROUP" ] || { echo "LEAK_CHECK_INVALID: container not found"; exit 1; }; grep -rl -e "bitkit-hidden" -e "not-the-one" "$GROUP"; s=$?; case $s in 1) echo NO_PASSPHRASE_LEAK;; 0) echo "LEAK FOUND";; *) echo "LEAK_CHECK_INVALID: grep exit $s";; esac + Verify the previous command printed NO_PASSPHRASE_LEAK. LEAK_CHECK_INVALID means the check did not scan anything and the journey cannot conclude either way — treat it as a failed step, not a pass + + diff --git a/journeys/hardware-wallet/transfer-to-spending-over-max.xml b/journeys/hardware-wallet/transfer-to-spending-over-max.xml new file mode 100644 index 000000000..a91baa878 --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-over-max.xml @@ -0,0 +1,39 @@ + + + Verifies the hardware Transfer To Spending amount number pad enforces the transferable maximum: + an entry above it snaps down to the maximum with the "Spending Balance Maximum" warning toast, + and deleting digits still works while at the cap. + + iOS-only — there is no counterpart under `bitkit-android/journeys/hardware-wallet`. It was added + alongside the fix for the bug it covers. `SpendingAmountHw` observed `maxAllowed` without + `initial: true`, and since that value lives on the shared `TransferViewModel` and outlives the + screen, arriving with the limits already computed produced no change to observe. The freshly + created `AmountInputViewModel` then kept `maxAmountOverride` nil, leaving the pad bounded only by + the global 999 999 999 sat ceiling. Measured on an iPhone 17 simulator: nine taps on `N9` entered + 999 999 999 against an AVAILABLE of 610 015, with no toast and no snap. Continue stayed disabled, + so nothing unaffordable could be ordered — the gap was in the input, not in the order. + + Precondition: Spark connected (a dev build reaches Spark's regtest with no key) and a paired Bridge + emulator whose native-segwit account holds spendable regtest funds. Start on the wallet home screen. + + The screen is entered twice on purpose. A first entry that computes the limits from zero fires + the change either way, so it passes even with the bug; the second entry, where the limits are + already settled, is the one that regressed. + + + Tap the hardware wallet tile (id "ActivityHardware") beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (id "HardwareWalletScreen") + Tap the "Transfer To Spending" button (id "HardwareTransferToSpending") + If the first-run Transfer To Spending intro is shown, tap "Get Started" + Verify the transfer amount screen opens (id "HardwareTransferAmount") + Wait for the limits to load (id "N9", predicate enabled) — the number pad is held disabled behind the AVAILABLE spinner until the device balance and the fee to the Spark deposit address return + Verify the AVAILABLE amount (id "HardwareTransferAmountAvailable") shows a positive value + Tap the "9" key (id "N9") nine times to enter an amount far larger than the maximum allowed + Verify a "Spending Balance Maximum" warning toast appears (id "HardwareTransferAmountExceededToast") and names the maximum + Verify the amount in the input field snapped to the maximum and does not exceed the AVAILABLE amount — 999 999 999 here is the regression this journey exists for + Tap the delete key (id "NRemove") once, and verify the amount decreased + Tap back (id "NavigationBack") to return to the hardware wallet screen + Tap "Transfer To Spending" (id "HardwareTransferToSpending") again — this second entry arrives with the limits already computed, which is the case that regressed + Wait for the number pad (id "N9", predicate enabled), then tap the "9" key nine times again + Verify the toast appears again (id "HardwareTransferAmountExceededToast") and the amount snapped to the same maximum, not to 999 999 999 + + diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml new file mode 100644 index 000000000..a8b31eb8a --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -0,0 +1,28 @@ + + + Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With + Your Device -> Transaction Signed -> Funds In Transfer, then verifies the new transfer is + represented once in the hardware wallet's Core activity scope. The device pays the Spark deposit + address. Requires Spark connected (a dev build reaches Spark's regtest with no key), a paired + Bridge emulator whose native-segwit account holds spendable regtest funds, and at least one older + hardware receive row with a distinct amount. + + + Launch the Bitkit app and go to the wallet home screen + Verify the recent activity list contains an older blue hardware received row with a distinct amount + Tap the hardware wallet tile (id "ActivityHardware") beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (id "HardwareWalletScreen") + Tap the "Transfer To Spending" button (id "HardwareTransferToSpending") + If the first-run Transfer To Spending intro is shown, tap "Get Started" + Verify the transfer amount screen opens (id "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row (id "HardwareTransferAmountAvailable"), the 25% and MAX quick buttons, and a number pad + Tap the "25%" quick button (id "HardwareTransferAmountQuarter") to set a valid amount below the available limit + Tap "Continue" (id "HardwareTransferAmountContinue") and wait for the fee estimate + Verify the sign screen opens (id "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SPENDING FEE, TO SPENDING and TOTAL cells, the Instant and Later rows with Instant selected, the Learn More button, and the Trezor illustration + Tap "Open Trezor Connect" (id "HardwareTransferOpenTrezorConnect"). If fees rose since the screen priced them, verify a "Fees changed" toast and refreshed fee cells, and tap "Open Trezor Connect" again; otherwise signing starts immediately + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order + Verify the transaction signed screen appears (id "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration + Wait for the screen to auto-forward and verify the Funds In Transfer screen appears (id "LightningSettingUp") + Tap "Continue Using Bitkit" (id "TransferSuccess-button") and verify the app returns to the wallet home screen + Verify the recent activity list contains exactly one new blue hardware Transfer row, above the older blue received row + Tap the new blue Transfer row and verify its detail screen shows "TO SPENDING" + + diff --git a/journeys/transfer/savings-select-funds.xml b/journeys/transfer/savings-select-funds.xml new file mode 100644 index 000000000..c43c0cc1c --- /dev/null +++ b/journeys/transfer/savings-select-funds.xml @@ -0,0 +1,36 @@ + + + Verifies that Transfer to Savings lets the user pick what moves: each open channel and Spark are + rows on the Select funds list, all selected by default, and the confirm screen shows only what is + selected, with Spark's withdrawal fee only while Spark is selected. With a single item (Spark + alone, or one channel without a Spark balance) the confirm screen shows no Advanced button and + moves everything, as before Spark. + + Precondition: onboarded Debug build with a Spark balance and at least one open channel. On iOS the + two live on different regtest chains: open the channels through Manual Setup to Blocktank's staging + regtest node, funded from Savings, and fund Spark from Lightspark's web regtest faucet + (https://app.lightspark.com/regtest-faucet) to the address on Settings › Advanced › Lightning + Connections › Spark; Spark adds it to Spending within a few minutes. Start on the wallet home + screen. The journey stops before the swipe. Spark prices its fee on the confirm screen, so allow + about 20 seconds for "Spark fee" to show. + + Adapted from Android: a picked selection shows "Transfer all" in place of "Advanced" on both + platforms, so reaching the list again takes "Transfer all" first. + + + Tap the Spending balance card (testTag "ActivitySpending") + Tap "Transfer To Savings" (testTag "TransferToSavings") + If the transfer to savings intro appears, tap "Get Started" (testTag "SavingsIntro-button") + Tap "Continue" (testTag "AvailabilityContinue") on the funds availability screen + On the confirm screen, verify the amount is the channels' balance plus the Spark balance, a "Spark fee" amount shows, and an "Advanced" button shows + Tap "Advanced" + Verify the "Select funds to transfer" screen lists a "Spark" row with the Spark balance and one "Connection N" row per open channel, every switch on, and "Total selected" equals the confirm amount + Turn off the last "Connection N" row and verify "Total selected" drops by that row's amount + Tap "Continue" and verify the confirm amount equals the new total, "Spark fee" still shows, and the button reads "Transfer all" + Tap "Transfer all", then "Advanced", turn off the "Spark" row and every "Connection N" row, and verify "Continue" is disabled + Turn on the "Spark" row only and tap "Continue" + Verify the confirm amount equals the Spark balance and "Spark fee" shows + Tap "Transfer all" and verify the amount returns to the channels' balance plus the Spark balance, and the button reads "Advanced" + Tap "Advanced", turn off the "Spark" row, tap "Continue", and verify the amount equals the channels' balance with no "Spark fee" + + diff --git a/journeys/transfer/spark-transfer-to-spending.xml b/journeys/transfer/spark-transfer-to-spending.xml new file mode 100644 index 000000000..0827d2ead --- /dev/null +++ b/journeys/transfer/spark-transfer-to-spending.xml @@ -0,0 +1,27 @@ + + + Verifies the transfer to spending flow on Spark. With Spark ready, Funding leads with "Transfer from Savings" + (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") above "Manual Setup" (testTag + "FundManual"), and the Savings and Spending transfer buttons open the spending intro, then the amount screen + (testTag "SpendingAmount"), instead of the Transfer Unavailable sheet. The confirm screen shows the network fee, + the spending fee, what reaches spending and the total, and a choice between Instant and Later. What happens after + the swipe depends on the deposit Spark sees and the provider's quote; the setting-up states are covered by the + previews and unit tests, so this journey stops before the swipe. + + Precondition: dev build with Spark connected on its regtest (no key needed), a freshly onboarded wallet with a + positive on-chain Savings balance and no channels. Start on the wallet home screen. + + + Tap the Savings balance card (testTag "ActivitySavings"), tap "Transfer To Spending" (testTag "TransferToSpending") and verify the spending intro (testTag "SpendingIntro") opens, not the Transfer Unavailable sheet + Tap "Get Started" (testTag "SpendingIntro-button") and verify the amount screen (testTag "SpendingAmount") opens with the available amount + Tap "25%" (testTag "SpendingAmountQuarter") and verify the amount is about a quarter of the Savings balance; tap "Max" (testTag "SpendingAmountMax") and verify the amount equals the available amount + Tap "Continue" (testTag "SpendingAmountContinue") and verify the confirm screen shows "Network fees", "Spending fee", "To spending" and "Total", with "Instant" (testTag "SpendingConfirmInstant") selected and "Later" (testTag "SpendingConfirmLater") below it + Verify "Spending fee" reads "up to" an amount equal to the Instant row's fee, and "To spending" equals the amount less that fee + Tap "Later" and verify "Spending fee" changes to "≈" the Later row's fee and "To spending" grows by the difference, while "Network fees" and "Total" stay the same + Tap "Learn More" (testTag "SpendingConfirmMore"), verify the screen explains Instant and Later and shows a spending-only balance bar, then tap "Understood" (testTag "LiquidityContinue") + Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap "Transfer From Savings" (testTag "TransferFromSavings") and verify the amount screen opens without the intro + Go back to the wallet home, open Settings › Advanced › Lightning Connections and tap add connection (testTag "add_connection_button") + Verify "Transfer from Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") are enabled above "Manual Setup" (testTag "FundManual") + Tap "Transfer from Savings" and verify the amount screen (testTag "SpendingAmount") opens + + diff --git a/journeys/transfer/spending-confirm-amount-change.xml b/journeys/transfer/spending-confirm-amount-change.xml new file mode 100644 index 000000000..6b2813456 --- /dev/null +++ b/journeys/transfer/spending-confirm-amount-change.xml @@ -0,0 +1,35 @@ + + + Verifies that a transfer to spending never pays more than Spending Confirm shows. The screen prices + the network fee to the Spark deposit address when it opens, whenever the amount or the speed + changes, every minute while it stays open, and again at the swipe. A swipe whose fresh price costs + the same or less pays at once; one that costs more pays nothing: the screen shows the new amounts, a + "Fees changed" toast explains the increase, and the next swipe pays them. + + This journey covers the cheaper path and the re-quote after an amount change. A larger UTXO confirms + while Spending Confirm is open, so the fee priced at the swipe spends one input, costs less and pays + at once. The higher-total path (the "Fees changed" toast) needs fee rates to rise between the quote + and the swipe, which is not a listed capability. + + Precondition: a freshly onboarded Debug build on the staging regtest backend with Spark connected, + no spending balance and exactly two confirmed on-chain UTXOs of 150 000 sats each (two + ../bitkit-android/lsp deposits to the wallet's savings address, each followed by a mine). Start on + the wallet home screen. + + + Tap Receive (id "Receive"), tap the "Savings" receive tab if one is shown, tap "Show Details" (id "ShowDetails"), read the address from id "ReceiveOnchainAddress", then close the sheet to return to the home screen + Tap the Savings balance card (id "ActivitySavings"), then "Transfer To Spending" (id "TransferToSpending") + If the spending intro appears, tap "Get Started" (id "SpendingIntro-button") + Enter 100 000 on the number pad (ids "N1", "N0", "N0", "N0", "N0", "N0") and tap "Continue" (id "SpendingAmountContinue") + Verify the confirm screen is visible (id "SpendingConfirm"), wait until its network fee is shown, and note "To spending" and "Total" + Tap back (id "NavigationBack"), delete one digit (id "NRemove") and enter "N2", "N0" so the amount reads 200 000, then tap "Continue" (id "SpendingAmountContinue") + Verify "To spending" and "Total" now reflect 200 000 rather than the noted values, and wait until the network fee is shown + Note the amounts under "Network fees" and "Total" + Run in one command: ../bitkit-android/lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":500000}' && ../bitkit-android/lsp POST /regtest/chain/mine '{"count":1}' + Wait 30s for the wallet to sync the new UTXO + If the received sheet (id "ReceivedTransaction") is visible, tap its button (id "ReceivedTransactionButton") + Verify the confirm screen is visible; "Network fees" and "Total" may already show lower amounts than noted + Swipe the "Swipe To Transfer" handle (id "GRAB") fully to the right + Verify no "Fees changed" toast appears and the setting up screen (id "LightningSettingUp") opens within 30s + + diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml index a3ae0538a..4fc4ab0fd 100644 --- a/journeys/transfer/transfer-unavailable-sheet.xml +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -8,7 +8,9 @@ (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning path shows the same sheet content. - Precondition: onboarded wallet with a positive on-chain Savings balance and no channels. Start on the + Precondition: onboarded wallet with a positive on-chain Savings balance, no channels and Spark + not running (a build without a Breez API key, or Spark's regtest unreachable); with Spark ready, see + spark-transfer-to-spending.xml. Start on the wallet home screen. From 9f6fbd6144bae0a6fa56be84d23f58aba86fd777 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 05:26:13 +0100 Subject: [PATCH 003/378] feat: pay contacts through pubky swaps A spending-only wallet pays a Paykit contact whose only endpoint is on-chain through a Pubky Swaps provider found among its contacts. Behind PUBKY_SWAPS_ROUTES on regtest debug builds. Uses bitkit-core 0.8.0-swaps.2. --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Constants/Env.swift | 13 +++ .../Localization/ar.lproj/Localizable.strings | 1 + .../Localization/ca.lproj/Localizable.strings | 1 + .../Localization/cs.lproj/Localizable.strings | 1 + .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/el.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../es-419.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/nl.lproj/Localizable.strings | 1 + .../Localization/pl.lproj/Localizable.strings | 1 + .../pt-BR.lproj/Localizable.strings | 1 + .../Localization/pt.lproj/Localizable.strings | 1 + .../Localization/ru.lproj/Localizable.strings | 1 + Bitkit/Services/PubkySwapsRouteService.swift | 98 +++++++++++++++++++ Bitkit/ViewModels/AppViewModel.swift | 14 +++ .../Views/Wallets/Send/SendAmountView.swift | 40 ++++++++ .../Wallets/Send/SendConfirmationView.swift | 93 +++++++++++++++++- Bitkit/Views/Wallets/Send/SendSheet.swift | 4 +- 23 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 Bitkit/Services/PubkySwapsRouteService.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 507f9cb4e..506c381d5 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-rc11"; + version = "0.8.0-swaps.2"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8401f1e44..8ec52eb4e 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" : "22e0acc31a97b65205e71f4190cfc659fdecd3ca", - "version" : "0.8.0-rc11" + "revision" : "1abc3ff8bc40848951d19ae74259f004b78ce5be", + "version" : "0.8.0-swaps.2" } }, { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4cdd9ea87..cbfe04f82 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -228,6 +228,19 @@ enum Env { return electrumServerUrl(for: network) } + /// Whether a Paykit payment may route through a Pubky Swaps provider when no balance reaches the contact's + /// endpoints. Regtest debug builds with `PUBKY_SWAPS_ROUTES=true` only. + static var isPubkySwapsRouteEnabled: Bool { + isDebug && network == .regtest && boolConfigValue("PUBKY_SWAPS_ROUTES") + } + + /// Swap providers to ask besides the user's contacts, from `PUBKY_SWAPS_PROVIDERS` (comma-separated pubkys). + static var pubkySwapsProviders: [String] { + (configValue("PUBKY_SWAPS_PROVIDERS") ?? "").split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + static var trezorBridgeEnabled: Bool { (isDebug || isE2E) && boolConfigValue("TREZOR_BRIDGE") } diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 2ba6f3041..cbf00b22e 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1068,6 +1068,7 @@ "wallet__send_sync_long_title" = "مشاكل\nالاتصال"; "wallet__send_sync_long_description" = "يبدو أنك غير متصل. يرجى التحقق من اتصالك. سيحاول Bitkit إعادة الاتصال كل بضع ثوانٍ."; "wallet__send_to" = "إلى"; +"wallet__send_swap_route" = "المسار"; "wallet__recipient_contact" = "جهة الاتصال"; "wallet__recipient_invoice" = "لصق فاتورة"; "wallet__recipient_manual" = "إدخال يدوي"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 78a612fe0..f30ecdb69 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1187,6 +1187,7 @@ "wallet__send_sync_long_title" = "Problemes de\nconnexió"; "wallet__send_sync_long_description" = "Sembla que estàs desconnectat. Comprova la connexió. Bitkit intentarà tornar a connectar-se cada pocs segons."; "wallet__send_to" = "A"; +"wallet__send_swap_route" = "Ruta"; "wallet__recipient_contact" = "Contacte"; "wallet__recipient_invoice" = "Enganxa factura"; "wallet__recipient_manual" = "Introdueix manualment"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 755de70ef..79c71454e 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1294,6 +1294,7 @@ "wallet__send_sync_long_title" = "Problémy\ns připojením"; "wallet__send_sync_long_description" = "Zdá se, že jste odpojeni. Zkontrolujte prosím připojení. Bitkit se bude každých několik sekund pokoušet znovu připojit."; "wallet__send_to" = "Komu"; +"wallet__send_swap_route" = "Trasa"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Vložit fakturu"; "wallet__recipient_manual" = "Zadat ručně"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 41d543636..e28b7567a 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1292,6 +1292,7 @@ "wallet__send_sync_long_title" = "Verbindungs-\nprobleme"; "wallet__send_sync_long_description" = "Anscheinend bist du nicht verbunden. Bitte überprüfe deine Verbindung. Bitkit versucht alle paar Sekunden, sich erneut zu verbinden."; "wallet__send_to" = "An"; +"wallet__send_swap_route" = "Route"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Rechnung einfügen"; "wallet__recipient_manual" = "Manuell eingeben"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 3bcdf21e1..8c1fee2a8 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1086,6 +1086,7 @@ "wallet__send_sync_long_title" = "Προβλήματα\nσύνδεσης"; "wallet__send_sync_long_description" = "Φαίνεται ότι είστε αποσυνδεδεμένοι. Ελέγξτε τη σύνδεσή σας. Το Bitkit θα προσπαθεί να επανασυνδεθεί κάθε λίγα δευτερόλεπτα."; "wallet__send_to" = "Προς"; +"wallet__send_swap_route" = "Διαδρομή"; "wallet__recipient_contact" = "Επαφή"; "wallet__recipient_invoice" = "Επικόλληση Τιμολογίου"; "wallet__recipient_manual" = "Χειροκίνητη Εισαγωγή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9f299e68b..cbef20184 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1307,6 +1307,7 @@ "wallet__send_sync_long_title" = "Connection\nissues"; "wallet__send_sync_long_description" = "It appears you’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds."; "wallet__send_to" = "To"; +"wallet__send_swap_route" = "Route"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Paste Invoice"; "wallet__recipient_manual" = "Enter Manually"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 7de3e658a..26f80e816 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1297,6 +1297,7 @@ "wallet__send_sync_long_title" = "Problemas de\nconexión"; "wallet__send_sync_long_description" = "Parece que está desconectado. Revise su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Ruta"; "wallet__recipient_contact" = "Contacto"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 510f68918..c0859ad22 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1205,6 +1205,7 @@ "wallet__send_sync_long_title" = "Problemas de\nconexión"; "wallet__send_sync_long_description" = "Parece que está desconectado. Compruebe su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Ruta"; "wallet__recipient_contact" = "Contactar"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 4074d833e..a9e5c5603 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1305,6 +1305,7 @@ "wallet__send_sync_long_title" = "Problèmes de\nconnexion"; "wallet__send_sync_long_description" = "Il semble que vous soyez déconnecté. Veuillez vérifier votre connexion. Bitkit tentera de se reconnecter toutes les quelques secondes."; "wallet__send_to" = "Pour"; +"wallet__send_swap_route" = "Itinéraire"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Coller la facture"; "wallet__recipient_manual" = "Entrer manuellement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index ff531fab8..966c8eb74 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1270,6 +1270,7 @@ "wallet__send_sync_long_title" = "Problemi di\nconnessione"; "wallet__send_sync_long_description" = "Sembra che tu sia disconnesso. Controlla la connessione. Bitkit proverà a riconnettersi ogni pochi secondi."; "wallet__send_to" = "A"; +"wallet__send_swap_route" = "Percorso"; "wallet__recipient_contact" = "Contatto"; "wallet__recipient_invoice" = "Incolla fattura"; "wallet__recipient_manual" = "Inserisci Manualmente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 087be4fee..05327a774 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1300,6 +1300,7 @@ "wallet__send_sync_long_title" = "Verbindings\nproblemen"; "wallet__send_sync_long_description" = "Het lijkt erop dat u niet verbonden bent. Controleer uw verbinding. Bitkit probeert om de paar seconden opnieuw verbinding te maken."; "wallet__send_to" = "Naar"; +"wallet__send_swap_route" = "Route"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Factuur Plakken"; "wallet__recipient_manual" = "Handmatig Invoeren"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 1cd7e31c2..4981a4c1d 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1304,6 +1304,7 @@ "wallet__send_sync_long_title" = "Problemy\nz połączeniem"; "wallet__send_sync_long_description" = "Wygląda na to, że nie masz połączenia. Sprawdź połączenie. Bitkit będzie próbował połączyć się ponownie co kilka sekund."; "wallet__send_to" = "Do"; +"wallet__send_swap_route" = "Trasa"; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Wklej fakturę"; "wallet__recipient_manual" = "Wprowadź ręcznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index bd54df0b4..9608d304d 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1305,6 +1305,7 @@ "wallet__send_sync_long_title" = "Problemas de\nconexão"; "wallet__send_sync_long_description" = "Parece que você está desconectado. Verifique sua conexão. A Bitkit tentará se reconectar a cada poucos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Rota"; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 5ab90bafd..3b4c0052c 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1059,6 +1059,7 @@ "wallet__send_sync_long_title" = "Problemas\nde ligação"; "wallet__send_sync_long_description" = "Parece que está desligado. Verifique a sua ligação. A Bitkit vai tentar voltar a ligar a cada poucos segundos."; "wallet__send_to" = "Para"; +"wallet__send_swap_route" = "Rota"; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index d2df40a5b..1a4cc839f 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1289,6 +1289,7 @@ "wallet__send_sync_long_title" = "Проблемы\nс подключением"; "wallet__send_sync_long_description" = "Похоже, вы отключены. Проверьте подключение. Bitkit будет пытаться переподключиться каждые несколько секунд."; "wallet__send_to" = "Кому"; +"wallet__send_swap_route" = "Маршрут"; "wallet__recipient_contact" = "Контакт"; "wallet__recipient_invoice" = "Вставить Инвойс"; "wallet__recipient_manual" = "Ввести Вручную"; diff --git a/Bitkit/Services/PubkySwapsRouteService.swift b/Bitkit/Services/PubkySwapsRouteService.swift new file mode 100644 index 000000000..0b17a0be9 --- /dev/null +++ b/Bitkit/Services/PubkySwapsRouteService.swift @@ -0,0 +1,98 @@ +import BitkitCore +import Foundation + +/// A Paykit payment routed through a Pubky Swaps provider: the endpoint, the route once quoted, and its provider. +struct PaykitSwapRoute: Equatable { + let endpoint: PaykitEndpoint + var quote: PubkySwapRouteQuote? + var providerName: String? +} + +/// Routes a Paykit payment through a Pubky Swaps provider when no balance the wallet holds reaches an endpoint the +/// contact published. bitkit-core picks the provider and checks its quote, contract and hold invoice; the wallet pays +/// the hold invoice from spending and bitkit-core claims the provider's lockup straight to the contact's address. +enum PubkySwapsRouteService { + private static let claimFeeRateSatVb: UInt64 = 2 + private static let pollInterval: Duration = .seconds(2) + private static let claimTimeout: TimeInterval = 10 * 60 + + static var isEnabled: Bool { + Env.isPubkySwapsRouteEnabled + } + + /// A contact whose only endpoints are on-chain, paid when savings cannot cover it but spending can. + static func route(for context: ContactPaymentContext, address: String, onchainBalanceSats: UInt64, + lightningBalanceSats: UInt64) -> PaykitSwapRoute? + { + guard isEnabled, context.incomingPaymentRequest == nil, !address.isEmpty, + !context.endpoints.isEmpty, context.endpoints.allSatisfy({ $0.methodId.onchainNetwork != nil }), + onchainBalanceSats <= UInt64(Env.dustLimit), lightningBalanceSats > 0, + let methodId = context.endpoints.first?.methodId + else { return nil } + return PaykitSwapRoute(endpoint: PaykitEndpoint(methodId: methodId.rawValue, value: address)) + } + + static func quote( + amountSats: UInt64, + endpoint: PaykitEndpoint, + lightningSpendableSats: UInt64, + onchainSpendableSats: UInt64, + contacts: [String] + ) async throws -> PaykitRoute { + // Providers come from the user's own graph: every contact is asked for its swap offer. + let candidates = contacts + Env.pubkySwapsProviders + Logger.debug("Routing Paykit payment via \(candidates.map(PubkyPublicKeyFormat.redacted))", context: "PubkySwapsRoute") + let (mnemonic, passphrase) = try secrets() + return try await pubkySwapsRoutePaykitPayment( + request: PaykitRouteRequest( + amountSat: amountSats, + contactEndpoints: [endpoint], + lightningSpendableSat: lightningSpendableSats, + onchainSpendableSat: onchainSpendableSats, + providers: candidates, + feeRateSatVb: claimFeeRateSatVb + ), + network: Env.bitkitCoreNetwork, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + + static func commit(quoteId: String) async throws -> PubkySwapRoutePayment { + let (mnemonic, passphrase) = try secrets() + return try await pubkySwapsCommitRoute( + quoteId: quoteId, + electrumUrl: Env.electrumServerUrl, + mnemonic: mnemonic, + bip39Passphrase: passphrase + ) + } + + /// Advance the swap until bitkit-core claims the lockup to the contact, returning the claim txid. + static func awaitClaim(swapId: String) async throws -> String { + let deadline = Date().addingTimeInterval(claimTimeout) + while true { + let (mnemonic, passphrase) = try secrets() + let status = try await pubkySwapsAdvance(swapId: swapId, mnemonic: mnemonic, bip39Passphrase: passphrase) + switch status.state { + case let .claimed(txid): + return txid + case let .failed(reason): + throw AppError(message: reason, debugMessage: nil) + default: + Logger.debug("Pubky Swaps route \(swapId): \(status.state)", context: "PubkySwapsRoute") + } + guard Date() < deadline else { + throw AppError(message: "Swap \(swapId) not claimed yet", debugMessage: "\(status.state)") + } + try await Task.sleep(for: pollInterval) + } + } + + private static func secrets() throws -> (String, String?) { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { + throw AppError(message: "Mnemonic not found", debugMessage: nil) + } + return (mnemonic, try? Keychain.loadString(key: .bip39Passphrase(index: 0))) + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index ce446259f..902618763 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -167,6 +167,7 @@ class AppViewModel: ObservableObject { @Published var contactPaymentContext: ContactPaymentContext? private(set) var didRejectScannedPayment = false @Published var paykitUsesUsdt = false + @Published var paykitSwapRoute: PaykitSwapRoute? @Published var paykitReviewedAmount: PaykitAmount? @Published var paykitPaymentTerms: PaykitRequestPricing.Payment? @@ -1035,6 +1036,18 @@ extension AppViewModel { // Fallback to on-chain if address is available guard !invoice.address.isEmpty, validateIncomingOnchainPaymentRequestAmount(requestedAmount ?? invoice.amountSatoshis) else { return } + if let context = contactPaymentContext, let route = PubkySwapsRouteService.route( + for: context, + address: invoice.address, + onchainBalanceSats: max(paymentState.spendableOnchainBalanceSats, alternativeOnchainBalanceSats), + lightningBalanceSats: paymentState.totalLightningBalanceSats + ) { + handleScannedOnchainInvoice(invoice) + paykitSwapRoute = route + selectedWalletToPayFrom = .lightning + return + } + // If node is running, validate balance immediately if paymentState.isNodeRunning { let onchainBalance = max( @@ -1380,6 +1393,7 @@ extension AppViewModel { sendStateOwnerID = ownerID sendStateGeneration = UUID() paykitUsesUsdt = false + paykitSwapRoute = nil paykitReviewedAmount = nil paykitPaymentTerms = nil scannedLightningInvoice = nil diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index ba37f24e7..b5f3e153d 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -10,6 +10,7 @@ enum SendFundingSource: Equatable { struct SendAmountView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel @@ -43,6 +44,9 @@ struct SendAmountView: View { { return [.usdt] } + if app.paykitSwapRoute != nil { + return [.spending] + } var sources: [SendFundingSource] = [] if app.scannedLightningInvoice != nil || app.lnurlPayData != nil { sources.append(.spending) @@ -400,6 +404,12 @@ struct SendAmountView: View { return } + if let route = app.paykitSwapRoute { + guard await quoteSwapRoute(route) else { return } + navigationPath.append(.confirm) + return + } + navigationPath.append(app.lnurlPayData != nil && app.scannedLightningInvoice == nil ? .lnurlPayConfirm : .confirm) return } @@ -448,6 +458,36 @@ struct SendAmountView: View { } } + private func quoteSwapRoute(_ route: PaykitSwapRoute) async -> Bool { + do { + let paykitRoute = try await PubkySwapsRouteService.quote( + amountSats: amountSats, + endpoint: route.endpoint, + lightningSpendableSats: UInt64(wallet.maxSendLightningSats), + onchainSpendableSats: UInt64(wallet.spendableOnchainBalanceSats), + contacts: contactsManager.contacts.map(\.publicKey) + ) + switch paykitRoute { + case let .swap(quote): + var quoted = route + quoted.quote = quote + quoted.providerName = contactsManager.contacts + .first { PubkyPublicKeyFormat.matches($0.publicKey, quote.providerPubky) }?.displayName + app.paykitSwapRoute = quoted + return true + case let .none(reason): + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: reason) + case .direct: + app.paykitSwapRoute = nil + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) + } + } catch { + Logger.error("Failed to route Paykit payment through Pubky Swaps: \(error)", context: "SendAmountView") + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: error.localizedDescription) + } + return false + } + private func updateInputCap() { // Don't cap when nothing is sendable, so the pad stays usable (Continue stays disabled instead). amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 9e5f470f3..387766ecd 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -3,6 +3,8 @@ import LDKNode import SwiftUI struct SendConfirmationView: View { + private static let providerKeyLength = 12 + @Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager @EnvironmentObject var app: AppViewModel @EnvironmentObject var activityList: ActivityListViewModel @@ -331,6 +333,13 @@ struct SendConfirmationView: View { PaykitAmountText(amount: preparingRequest.amount, size: .display) .accessibilityIdentifier("ReviewAmount") } + } else if let quote = app.paykitSwapRoute?.quote { + MoneyStack( + sats: Int(wallet.sendAmountSats ?? quote.deliveredSat), + showSymbol: true, + testIdPrefix: "ReviewAmount", + onTap: navigateToAmount + ) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { MoneyStack( sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), @@ -352,7 +361,9 @@ struct SendConfirmationView: View { .padding(.bottom, oneOffPaymentRequest == nil ? 44 : 24) if showDetails { - if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { + if let route = app.paykitSwapRoute, let quote = route.quote { + swapRouteView(route, quote: quote) + } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { onchainView(invoice) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { lightningView(invoice) @@ -701,6 +712,62 @@ struct SendConfirmationView: View { } } + func swapRouteView(_ route: PaykitSwapRoute, quote: PubkySwapRouteQuote) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + SendSectionView(t("wallet__send_from")) { + NumberPadActionButton( + text: t("wallet__spending__title"), + color: .purpleAccent, + variant: .secondary, + disabled: true + ) {} + .accessibilityIdentifier("SendConfirmAssetButton") + } + + Spacer(minLength: 16) + + if let contact = contactPaymentContact { + SendSectionView(contactSectionTitle) { + contactRecipient(contact) + } + } + } + + HStack(alignment: .top, spacing: 16) { + SendSectionView(t("wallet__send_fee_and_speed")) { + HStack(spacing: 0) { + Image("bolt-hollow") + .foregroundColor(.purpleAccent) + .frame(width: 16, height: 16) + .padding(.trailing, 4) + BodySSBText("\(t("fee__instant__title")) (") + MoneyText(sats: Int(quote.totalFeeSat), size: .bodySSB, symbol: true, symbolColor: .textPrimary) + BodySSBText(")") + } + } + + SendSectionView(t("wallet__send_swap_route")) { + BodySSBText("Pubky Swaps · \(route.providerName ?? quote.providerPubky.ellipsis(maxLength: Self.providerKeyLength))") + .lineLimit(2) + .accessibilityIdentifier("ReviewSwapRoute") + } + } + + SendSectionView(t("wallet__tags")) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(tagManager.selectedTagsArray, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { tagManager.removeTagFromSelection(tag) }) + } + AddTagButton(onPress: { navigationPath.append(.tag) }) + .accessibilityIdentifier("TagsAddSend") + } + } + } + } + } + func lightningView(_ invoice: LightningInvoice) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -952,6 +1019,8 @@ struct SendConfirmationView: View { if hwSend.isActive { navigationPath.append(.hardwareSign) + } else if let quote = app.paykitSwapRoute?.quote { + await performSwapRoutedPayment(quote) } else { try await performPayment(isAutomatic: isAutomatic) } @@ -1220,6 +1289,28 @@ struct SendConfirmationView: View { return note } + private func performSwapRoutedPayment(_ quote: PubkySwapRouteQuote) async { + isSubmittingPayment = true + defer { isSubmittingPayment = false } + do { + let payment = try await PubkySwapsRouteService.commit(quoteId: quote.quoteId) + Logger.info("Committed Pubky Swaps route \(payment.swapId) to \(payment.destinationAddress)", context: "SendConfirmation") + // The hold invoice settles only after the claim reveals the preimage, so its payment stays pending until then. + let paymentHash = try await LightningService.shared.send(bolt11: payment.invoice) + let claimTxid = try await PubkySwapsRouteService.awaitClaim(swapId: payment.swapId) + Logger.info("Claimed Pubky Swaps route \(payment.swapId) in \(claimTxid)", context: "SendConfirmation") + navigationPath.append(.success(paymentId: String(paymentHash))) + } catch { + Logger.error("Pubky Swaps route payment failed: \(error)", context: "SendConfirmation") + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil + ))) + } + } + private func performPayment(isAutomatic: Bool) async throws { let paymentActivity = PaykitPaymentActivity.shared.begin() defer { PaykitPaymentActivity.shared.end(paymentActivity) } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 8132cbfd9..5c8f3e81d 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -872,8 +872,8 @@ struct SendSheet: View { } } - // Validate onchain payment balance (for pure onchain invoices) - if let onchainInvoice = app.scannedOnchainInvoice { + // Validate onchain payment balance (for pure onchain invoices); a swap route pays it from spending + if app.paykitSwapRoute == nil, let onchainInvoice = app.scannedOnchainInvoice { let onchainBalance = max( LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0, hwWalletManager.maximumFundingBalanceSats From c32983d09e0a5eb39e8c7b5bfa53ecc1d8959dd8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 05:26:13 +0100 Subject: [PATCH 004/378] docs: add the paykit swap route journey --- journeys/README.md | 1 + journeys/paykit-swap-route.xml | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 journeys/paykit-swap-route.xml diff --git a/journeys/README.md b/journeys/README.md index 53b04b44c..9cf9e0f4d 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -191,6 +191,7 @@ Known naming differences: | Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` (the same tag on every row) | `PubkyChoiceRingLookup_` (bare z32 key) | | Contact import overview profile and friend count | — *(no tag; read the "Import" title and the "N friends" text)* | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | | Lightning Connections add connection | `add_connection_button` | `NavigationAction` | +| Send contact row | `SendContact_` | `SendContact-` | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix but appends the billing period (`-one-time` for a one-off), because every recurring payment of one diff --git a/journeys/paykit-swap-route.xml b/journeys/paykit-swap-route.xml new file mode 100644 index 000000000..3499e9490 --- /dev/null +++ b/journeys/paykit-swap-route.xml @@ -0,0 +1,29 @@ + + + Proves that paying a Paykit contact whose only endpoint is on-chain, from a wallet that holds only a spending + balance, routes through a Pubky Swaps provider found among the user's own contacts, without the user picking a + swap, and that the contact receives the exact amount on chain. + + Preconditions: a Debug build with SWIFT_ACTIVE_COMPILATION_CONDITIONS including E2E_BUILD, E2E_BACKEND=local, + E2E_NETWORK=regtest and E2E_HOMESERVER_PUBKY set to the local marketplace homeserver, launched with the + -PUBKY_SWAPS_ROUTES=true argument; bitkit-docker's default stack with the marketplace, payment-requests and + lnurl-pay profiles; the provider running against bitkit-docker's LND and funded. The simulator reaches the host's + loopback directly, so it needs no port forwarding. + The wallet has a spending balance from an LNURL channel with bitkit-docker's LND and an empty savings balance, + a Pubky profile on the local homeserver, and both the provider's and the fixture-issuer's pubkys saved as contacts. + The contact is bitkit-docker's fixture-issuer, which publishes only a btc-regtest-p2wpkh endpoint. + + + Verify the savings balance (tag "ActivitySavings") shows 0 and the spending balance (tag "ActivitySpending") is above 25 000 + Tap Send (tag "Send"), then Contact (tag "RecipientContact") + Verify the contact list shows both the fixture-issuer and the provider, and tap the fixture-issuer (tag "SendContact-" followed by its pubky) + Verify the amount screen (tag "SendAmount") offers SPENDING as the source + Enter 20000 and tap Continue (tag "ContinueAmount") + Verify the Confirm screen (tag "SendConfirm") is visible, then tap Show Details (tag "SendConfirmToggleDetails") + Verify FROM is SPENDING, TO is the contact, and ROUTE (tag "ReviewSwapRoute") reads "Pubky Swaps" followed by the provider's shortened pubky + Swipe to pay (tag "GRAB") + Once the provider logs "HTLC funded; awaiting client claim", mine one block on bitkit-docker's bitcoind + Verify the Bitcoin Sent screen (tag "SendSuccess") is visible + Verify the provider logs "client claimed; hold invoice settled", and bitcoind shows the claim transaction paying exactly 0.0002 BTC to the fixture-issuer's address + + From 82f2bb1b4977221b4a44b104d048dd2faa66b667 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 05:45:19 +0100 Subject: [PATCH 005/378] chore: add changelog fragment --- changelog.d/next/1170.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1170.added.md diff --git a/changelog.d/next/1170.added.md b/changelog.d/next/1170.added.md new file mode 100644 index 000000000..d7dfa8025 --- /dev/null +++ b/changelog.d/next/1170.added.md @@ -0,0 +1 @@ +Bitkit can pay a contact who only accepts on-chain bitcoin from your spending balance, through a Pubky Swaps provider among your contacts. From 2ca81e4d9915f1c7df1b7f2ae2179a25569181da Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 13:33:25 +0100 Subject: [PATCH 006/378] chore: move to bitkit-core 0.8.0-swaps.3 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Bitkit/Services/PubkySwapsRouteService.swift | 6 ++++-- Bitkit/Views/Wallets/Send/SendAmountView.swift | 3 +++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 506c381d5..e4bc9fec8 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-swaps.2"; + version = "0.8.0-swaps.3"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8ec52eb4e..8dc479011 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" : "1abc3ff8bc40848951d19ae74259f004b78ce5be", - "version" : "0.8.0-swaps.2" + "revision" : "f701e77dd004f3e89c2f45dc21edf7e03963194a", + "version" : "0.8.0-swaps.3" } }, { diff --git a/Bitkit/Services/PubkySwapsRouteService.swift b/Bitkit/Services/PubkySwapsRouteService.swift index 0b17a0be9..5669d936e 100644 --- a/Bitkit/Services/PubkySwapsRouteService.swift +++ b/Bitkit/Services/PubkySwapsRouteService.swift @@ -37,7 +37,8 @@ enum PubkySwapsRouteService { endpoint: PaykitEndpoint, lightningSpendableSats: UInt64, onchainSpendableSats: UInt64, - contacts: [String] + contacts: [String], + serviceLegs: [ServiceRouteLeg] = [] ) async throws -> PaykitRoute { // Providers come from the user's own graph: every contact is asked for its swap offer. let candidates = contacts + Env.pubkySwapsProviders @@ -50,7 +51,8 @@ enum PubkySwapsRouteService { lightningSpendableSat: lightningSpendableSats, onchainSpendableSat: onchainSpendableSats, providers: candidates, - feeRateSatVb: claimFeeRateSatVb + feeRateSatVb: claimFeeRateSatVb, + serviceLegs: serviceLegs ), network: Env.bitkitCoreNetwork, mnemonic: mnemonic, diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index b5f3e153d..c46c57055 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -477,6 +477,9 @@ struct SendAmountView: View { return true case let .none(reason): app.toast(type: .error, title: t("slashtags__error_pay_title"), description: reason) + case let .service(leg): + Logger.error("Unexpected service route \(leg.service)", context: "SendAmountView") + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) case .direct: app.paykitSwapRoute = nil app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) From 4677e4f2d885cc64193da0ac7677fe937cb15818 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 13:38:31 +0100 Subject: [PATCH 007/378] feat: pay on-chain contacts from spark --- Bitkit/Managers/SparkManager.swift | 37 +++++++++++++++++ Bitkit/Services/PubkySwapsRouteService.swift | 34 +++++++++++++-- .../Views/Wallets/Send/SendAmountView.swift | 8 +++- .../Wallets/Send/SendConfirmationView.swift | 41 +++++++++++++++---- 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 332c17997..1d1f008b3 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -14,6 +14,8 @@ import Observation @Observable @MainActor final class SparkManager: ChannelFreeLightningRail { static let shared = SparkManager() + nonisolated static let routeService = "spark" + private nonisolated static let routeAtomicity = "trusted" private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject() /// Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. nonisolated static var exitStateChangedPublisher: AnyPublisher { @@ -324,6 +326,41 @@ final class SparkManager: ChannelFreeLightningRail { return response } + /// A Paykit route that withdraws exactly `amountSats` to the contact's on-chain `endpoint` at medium speed, with + /// Spark's fee on top, or nil while Spark cannot send it. bitkit-core weighs it against the Pubky Swaps providers. + func withdrawalRouteLeg(endpoint: PaykitEndpoint, amountSats: UInt64) async throws -> ServiceRouteLeg? { + guard isReady, balanceSats >= amountSats else { return nil } + let prepared = try await prepareSend(paymentRequest: endpoint.value, amountSats: amountSats) + guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { + throw AppError(message: "Unexpected Spark payment method", debugMessage: "\(prepared.paymentMethod)") + } + let debitSats = amountSats + feeQuote.speedMedium.userFeeSat + guard !feeQuote.isEstimate, balanceSats >= debitSats else { return nil } + return ServiceRouteLeg( + service: Self.routeService, + endpoint: endpoint, + deliveredSat: amountSats, + debitSat: debitSats, + atomicity: Self.routeAtomicity, + validUntilUnix: feeQuote.expiresAt + ) + } + + /// Withdraws exactly `amountSats` to the on-chain `address` at medium speed and returns the withdrawal's txid. + func withdraw(to address: String, amountSats: UInt64) async throws -> String { + let prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) + let response = try await requireSdk().sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium) + )) + Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") + try await refresh() + if case let .withdraw(txId) = response.payment.details { + return txId + } + return response.payment.id + } + /// What a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. func exportExitState() async throws -> String { try await requireSdk().exportUnilateralExitState().exitState diff --git a/Bitkit/Services/PubkySwapsRouteService.swift b/Bitkit/Services/PubkySwapsRouteService.swift index 5669d936e..2e13d9375 100644 --- a/Bitkit/Services/PubkySwapsRouteService.swift +++ b/Bitkit/Services/PubkySwapsRouteService.swift @@ -1,11 +1,21 @@ import BitkitCore import Foundation -/// A Paykit payment routed through a Pubky Swaps provider: the endpoint, the route once quoted, and its provider. +/// A Paykit payment routed through a Pubky Swaps provider or a service the wallet holds a balance on: the endpoint, +/// the route once quoted (a provider's quote or a service leg), and the provider's name. struct PaykitSwapRoute: Equatable { let endpoint: PaykitEndpoint var quote: PubkySwapRouteQuote? var providerName: String? + var serviceLeg: ServiceRouteLeg? + + var deliveredSat: UInt64? { + quote?.deliveredSat ?? serviceLeg?.deliveredSat + } + + var totalFeeSat: UInt64? { + quote?.totalFeeSat ?? serviceLeg.map { $0.debitSat - $0.deliveredSat } + } } /// Routes a Paykit payment through a Pubky Swaps provider when no balance the wallet holds reaches an endpoint the @@ -37,11 +47,11 @@ enum PubkySwapsRouteService { endpoint: PaykitEndpoint, lightningSpendableSats: UInt64, onchainSpendableSats: UInt64, - contacts: [String], - serviceLegs: [ServiceRouteLeg] = [] + contacts: [String] ) async throws -> PaykitRoute { // Providers come from the user's own graph: every contact is asked for its swap offer. let candidates = contacts + Env.pubkySwapsProviders + let serviceLegs = await serviceLegs(endpoint: endpoint, amountSats: amountSats) Logger.debug("Routing Paykit payment via \(candidates.map(PubkyPublicKeyFormat.redacted))", context: "PubkySwapsRoute") let (mnemonic, passphrase) = try secrets() return try await pubkySwapsRoutePaykitPayment( @@ -60,6 +70,24 @@ enum PubkySwapsRouteService { ) } + /// Legs through the services the wallet holds a balance on, which compete with the providers on fee. + private static func serviceLegs(endpoint: PaykitEndpoint, amountSats: UInt64) async -> [ServiceRouteLeg] { + do { + return try await SparkManager.shared.withdrawalRouteLeg(endpoint: endpoint, amountSats: amountSats).map { [$0] } ?? [] + } catch { + Logger.warn("No Spark route to \(endpoint.methodId): \(error)", context: "PubkySwapsRoute") + return [] + } + } + + /// Runs a service leg the router picked and returns the txid that pays the contact. + static func pay(_ leg: ServiceRouteLeg) async throws -> String { + guard leg.service == SparkManager.routeService else { + throw AppError(message: "Unsupported route service", debugMessage: leg.service) + } + return try await SparkManager.shared.withdraw(to: leg.endpoint.value, amountSats: leg.deliveredSat) + } + static func commit(quoteId: String) async throws -> PubkySwapRoutePayment { let (mnemonic, passphrase) = try secrets() return try await pubkySwapsCommitRoute( diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index c46c57055..7f2e1d159 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -471,6 +471,7 @@ struct SendAmountView: View { case let .swap(quote): var quoted = route quoted.quote = quote + quoted.serviceLeg = nil quoted.providerName = contactsManager.contacts .first { PubkyPublicKeyFormat.matches($0.publicKey, quote.providerPubky) }?.displayName app.paykitSwapRoute = quoted @@ -478,8 +479,11 @@ struct SendAmountView: View { case let .none(reason): app.toast(type: .error, title: t("slashtags__error_pay_title"), description: reason) case let .service(leg): - Logger.error("Unexpected service route \(leg.service)", context: "SendAmountView") - app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) + var quoted = route + quoted.quote = nil + quoted.serviceLeg = leg + app.paykitSwapRoute = quoted + return true case .direct: app.paykitSwapRoute = nil app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 387766ecd..8beaa7bc4 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -333,9 +333,9 @@ struct SendConfirmationView: View { PaykitAmountText(amount: preparingRequest.amount, size: .display) .accessibilityIdentifier("ReviewAmount") } - } else if let quote = app.paykitSwapRoute?.quote { + } else if let deliveredSat = app.paykitSwapRoute?.deliveredSat { MoneyStack( - sats: Int(wallet.sendAmountSats ?? quote.deliveredSat), + sats: Int(wallet.sendAmountSats ?? deliveredSat), showSymbol: true, testIdPrefix: "ReviewAmount", onTap: navigateToAmount @@ -361,8 +361,8 @@ struct SendConfirmationView: View { .padding(.bottom, oneOffPaymentRequest == nil ? 44 : 24) if showDetails { - if let route = app.paykitSwapRoute, let quote = route.quote { - swapRouteView(route, quote: quote) + if let route = app.paykitSwapRoute, let totalFeeSat = route.totalFeeSat { + swapRouteView(route, totalFeeSat: totalFeeSat) } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { onchainView(invoice) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { @@ -712,7 +712,7 @@ struct SendConfirmationView: View { } } - func swapRouteView(_ route: PaykitSwapRoute, quote: PubkySwapRouteQuote) -> some View { + func swapRouteView(_ route: PaykitSwapRoute, totalFeeSat: UInt64) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { SendSectionView(t("wallet__send_from")) { @@ -742,13 +742,13 @@ struct SendConfirmationView: View { .frame(width: 16, height: 16) .padding(.trailing, 4) BodySSBText("\(t("fee__instant__title")) (") - MoneyText(sats: Int(quote.totalFeeSat), size: .bodySSB, symbol: true, symbolColor: .textPrimary) + MoneyText(sats: Int(totalFeeSat), size: .bodySSB, symbol: true, symbolColor: .textPrimary) BodySSBText(")") } } SendSectionView(t("wallet__send_swap_route")) { - BodySSBText("Pubky Swaps · \(route.providerName ?? quote.providerPubky.ellipsis(maxLength: Self.providerKeyLength))") + BodySSBText(routeName(route)) .lineLimit(2) .accessibilityIdentifier("ReviewSwapRoute") } @@ -768,6 +768,13 @@ struct SendConfirmationView: View { } } + private func routeName(_ route: PaykitSwapRoute) -> String { + if let quote = route.quote { + return "Pubky Swaps · \(route.providerName ?? quote.providerPubky.ellipsis(maxLength: Self.providerKeyLength))" + } + return "Spark" + } + func lightningView(_ invoice: LightningInvoice) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -1019,6 +1026,8 @@ struct SendConfirmationView: View { if hwSend.isActive { navigationPath.append(.hardwareSign) + } else if let leg = app.paykitSwapRoute?.serviceLeg { + await performServiceRoutedPayment(leg) } else if let quote = app.paykitSwapRoute?.quote { await performSwapRoutedPayment(quote) } else { @@ -1289,6 +1298,24 @@ struct SendConfirmationView: View { return note } + private func performServiceRoutedPayment(_ leg: ServiceRouteLeg) async { + isSubmittingPayment = true + defer { isSubmittingPayment = false } + do { + let txid = try await PubkySwapsRouteService.pay(leg) + Logger.info("Paid Paykit contact through \(leg.service) in \(txid)", context: "SendConfirmation") + navigationPath.append(.success(paymentId: txid)) + } catch { + Logger.error("Service route payment failed: \(error)", context: "SendConfirmation") + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil + ))) + } + } + private func performSwapRoutedPayment(_ quote: PubkySwapRouteQuote) async { isSubmittingPayment = true defer { isSubmittingPayment = false } From 38e8239e6816353b7468940f58f0db5d605ee808 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 14:11:19 +0100 Subject: [PATCH 008/378] chore: add changelog fragment --- changelog.d/next/1191.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1191.added.md diff --git a/changelog.d/next/1191.added.md b/changelog.d/next/1191.added.md new file mode 100644 index 000000000..9dd64f6e8 --- /dev/null +++ b/changelog.d/next/1191.added.md @@ -0,0 +1 @@ +Paykit contacts who only accept on-chain bitcoin can be paid from Spark when that costs less than a swap. From 438d6ae53bdf2bdd70dcfa36524d3240a8c0b676 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 16:16:34 +0100 Subject: [PATCH 009/378] docs: add the paykit spark route journey --- journeys/paykit-spark-route.xml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 journeys/paykit-spark-route.xml diff --git a/journeys/paykit-spark-route.xml b/journeys/paykit-spark-route.xml new file mode 100644 index 000000000..9a0cd6d97 --- /dev/null +++ b/journeys/paykit-spark-route.xml @@ -0,0 +1,28 @@ + + + Proves that paying a Paykit contact whose only endpoint is on-chain, from a wallet whose spending balance is held + on Spark, routes through a Spark withdrawal straight to the contact's address, without the user picking a route, + and that the contact receives the exact amount on chain. + + Preconditions: a Debug build with SWIFT_ACTIVE_COMPILATION_CONDITIONS including E2E_BUILD, E2E_BACKEND=local, + E2E_NETWORK=regtest and E2E_HOMESERVER_PUBKY set to the local marketplace homeserver, launched with the + -PUBKY_SWAPS_ROUTES=true argument; bitkit-docker's default stack with the marketplace and payment-requests + profiles. The simulator reaches the host's loopback directly, so it needs no port forwarding. + The wallet has no channels and an empty savings balance, a Spark balance of at least 25 000 sats from Lightspark's + regtest faucet sent to the deposit address on the Spark screen, a Pubky profile on the local homeserver, and the + fixture-issuer's pubky saved as a contact. The contact is bitkit-docker's fixture-issuer, which publishes only a + btc-regtest-p2wpkh endpoint; Spark pays it on Lightspark's regtest chain. + + + Verify the savings balance (tag "ActivitySavings") shows 0 and the spending balance (tag "ActivitySpending") is above 25 000 + Tap Send (tag "Send"), then Contact (tag "RecipientContact") + Tap the fixture-issuer (tag "SendContact-" followed by its pubky) + Verify the amount screen (tag "SendAmount") offers SPENDING as the source + Enter 20000 and tap Continue (tag "ContinueAmount") + Verify the Confirm screen (tag "SendConfirm") is visible, then tap Show Details (tag "SendConfirmToggleDetails") + Verify FROM is SPENDING, TO is the contact, and ROUTE (tag "ReviewSwapRoute") reads "Spark" + Swipe to pay (tag "GRAB") + Verify the Bitcoin Sent screen (tag "SendSuccess") shows 20 000 + Verify Lightspark's regtest mempool shows the withdrawal transaction paying exactly 20 000 sats to the fixture-issuer's address + + From 354e54daf21fff61421394e43057f9780f282f4b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 4 Nov 2026 17:49:39 +0100 Subject: [PATCH 010/378] feat: channel balances move to spark at low fees --- Bitkit/MainNavView.swift | 6 + Bitkit/Managers/MoveToSparkManager.swift | 204 ++++++++++++++++++ .../TimedSheets/MoveToSparkTimedSheet.swift | 25 +++ .../TimedSheets/TimedSheetManager.swift | 2 + .../Localization/ar.lproj/Localizable.strings | 24 +++ .../Localization/ca.lproj/Localizable.strings | 24 +++ .../Localization/cs.lproj/Localizable.strings | 24 +++ .../Localization/de.lproj/Localizable.strings | 24 +++ .../Localization/el.lproj/Localizable.strings | 24 +++ .../Localization/en.lproj/Localizable.strings | 24 +++ .../es-419.lproj/Localizable.strings | 24 +++ .../Localization/es.lproj/Localizable.strings | 24 +++ .../Localization/fr.lproj/Localizable.strings | 24 +++ .../Localization/it.lproj/Localizable.strings | 24 +++ .../Localization/nl.lproj/Localizable.strings | 24 +++ .../Localization/pl.lproj/Localizable.strings | 24 +++ .../pt-BR.lproj/Localizable.strings | 24 +++ .../Localization/pt.lproj/Localizable.strings | 24 +++ .../Localization/ru.lproj/Localizable.strings | 24 +++ Bitkit/Services/LightningService.swift | 9 +- Bitkit/ViewModels/AppViewModel.swift | 8 +- Bitkit/ViewModels/NavigationViewModel.swift | 1 + Bitkit/ViewModels/SheetViewModel.swift | 13 ++ Bitkit/ViewModels/WalletViewModel.swift | 13 ++ Bitkit/Views/HomeScreen.swift | 10 + .../Advanced/LightningConnectionsView.swift | 11 + .../Settings/Advanced/MoveToSparkView.swift | 165 ++++++++++++++ Bitkit/Views/Sheets/MoveToSparkSheet.swift | 57 +++++ .../Views/Wallets/SpendingWalletScreen.swift | 12 ++ BitkitTests/MoveToSparkMathTests.swift | 33 +++ changelog.d/next/1206.added.md | 1 + journeys/transfer/move-to-spark.xml | 29 +++ 32 files changed, 955 insertions(+), 4 deletions(-) create mode 100644 Bitkit/Managers/MoveToSparkManager.swift create mode 100644 Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift create mode 100644 Bitkit/Views/Settings/Advanced/MoveToSparkView.swift create mode 100644 Bitkit/Views/Sheets/MoveToSparkSheet.swift create mode 100644 BitkitTests/MoveToSparkMathTests.swift create mode 100644 changelog.d/next/1206.added.md create mode 100644 journeys/transfer/move-to-spark.xml diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 0b7cf1540..3c20c384b 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -232,6 +232,11 @@ struct MainNavView: View { ) { config in SecuritySheet(config: config) } + .sheet(item: $sheets.moveToSparkSheetItem, onDismiss: { + sheets.hideSheetIfActive(.moveToSpark, reason: "Move to Spark intro dismissed") + }) { config in + MoveToSparkSheet(config: config) + } .sheet(item: $sheets.usdtIntroSheetItem, onDismiss: { sheets.hideSheetIfActive(.usdtIntro, reason: "USDT intro dismissed") }) { config in @@ -705,6 +710,7 @@ struct MainNavView: View { case .connections: LightningConnectionsView() case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId) case .sparkConnectionDetail: SparkConnectionDetailView() + case .moveToSpark: MoveToSparkView() case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift new file mode 100644 index 000000000..980d6ee42 --- /dev/null +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -0,0 +1,204 @@ +import Foundation +import LDKNode + +/// The amounts behind moving the channels' balance to Spark, kept apart so they can be tested without a node. +enum MoveToSparkMath { + /// Below this a move is not offered: what is left is about the routing fee. + static let minMoveSats: UInt64 = 1000 + /// The fee guessed for the first quote of a full move, as a share of the amount in parts per million. + private static let firstFeeGuessPpm: UInt64 = 5000 + /// The least fee guessed for the first quote of a full move. + private static let minFeeGuessSats: UInt64 = 10 + /// Room left above the quoted fee, in sats, for the fee moving between the quote and the payment. + private static let feeMarginSats: UInt64 = 2 + + static func movableSats(channels: [ChannelDetails]?) -> UInt64 { + (channels ?? []).filter(\.isUsable).map { $0.nextOutboundHtlcLimitMsat / 1000 }.reduce(0, +) + } + + /// Whether the channels hold a balance worth moving to a usable Spark wallet. + static func canMove(channels: [ChannelDetails]?, isSparkUsable: Bool) -> Bool { + isSparkUsable && movableSats(channels: channels) >= minMoveSats + } + + /// The first amount to quote: `wantedSats` when it leaves room for fees, else the limit less a guessed fee. + static func firstCandidate(wantedSats: UInt64, limitSats: UInt64) -> UInt64 { + let guessSats = max(limitSats.multipliedReportingOverflow(by: firstFeeGuessPpm).partialValue / 1_000_000, minFeeGuessSats) + return min(wantedSats, amountAfterFee(limitSats: limitSats, feeSats: guessSats)) + } + + /// The amount that fits under `limitSats` together with `feeSats` and its margin. + static func amountAfterFee(limitSats: UInt64, feeSats: UInt64) -> UInt64 { + let reserved = feeSats.addingReportingOverflow(feeMarginSats) + return reserved.overflow || reserved.partialValue >= limitSats ? 0 : limitSats - reserved.partialValue + } + + /// The routing fee cap for the payment: the quoted fee and its margin, so it never pays more than shown. + static func feeCapMsat(feeSats: UInt64) -> UInt64 { + (feeSats + feeMarginSats) * 1000 + } + + /// The smaller amount to offer after one did not route: half of it, or nothing below the minimum. + static func smallerAmount(_ sats: UInt64) -> UInt64? { + let half = sats / 2 + return half >= minMoveSats ? half : nil + } +} + +struct MoveToSparkQuote: Equatable { + let invoice: String + let amountSats: UInt64 + let feeSats: UInt64 +} + +enum MoveToSparkError: Error { + case nothingToMove + case sparkUnavailable + /// `amountSats` did not route from the channels to Spark in one payment. + case tooLarge(amountSats: UInt64) +} + +/// The payments that move a channel balance to Spark, whose send and receive the app does not announce as payments. +@MainActor +enum MoveToSparkPayments { + private static var paymentHashes = Set() + + static func insert(_ paymentHash: String) { + paymentHashes.insert(paymentHash) + } + + static func contains(_ paymentHash: String?) -> Bool { + paymentHash.map(paymentHashes.contains) ?? false + } +} + +enum MoveToSparkFailure { + case nothingToMove + case sparkUnavailable + case unknown +} + +enum MoveToSparkState: Equatable { + case quoting + case confirm(MoveToSparkQuote, isMoving: Bool) + /// `hasRest`: the channels can still send enough to move again, as after a smaller amount. + case done(amountSats: UInt64, feeSats: UInt64, closeChannel: ChannelDetails?, hasRest: Bool) + /// `amountSats` did not route in one payment; `smallerSats` is the next amount to try, if any is left. + case tooLarge(amountSats: UInt64, smallerSats: UInt64?) + case failed(MoveToSparkFailure) + + static func == (lhs: MoveToSparkState, rhs: MoveToSparkState) -> Bool { + switch (lhs, rhs) { + case (.quoting, .quoting): true + case let (.confirm(a, am), .confirm(b, bm)): a == b && am == bm + case let (.done(a, af, ac, ar), .done(b, bf, bc, br)): a == b && af == bf && ac?.channelId == bc?.channelId && ar == br + case let (.tooLarge(a, asm), .tooLarge(b, bsm)): a == b && asm == bsm + case let (.failed(a), .failed(b)): a == b + default: false + } + } +} + +/// Moves the channels' balance to the user's own Spark wallet, paying a Spark invoice over the channels only. +/// Blocktank is closing its channels, and this is the cheapest way to keep the balance in Spending. +@Observable @MainActor +final class MoveToSparkManager { + private static let invoiceDescription = "Move to Spark" + private static let invoiceExpirySecs: UInt32 = 3600 + private static let maxQuoteRounds = 4 + + private(set) var state: MoveToSparkState = .quoting + + private let lightningService: LightningService + private let spark: SparkManager + + init() { + lightningService = .shared + spark = .shared + } + + /// Quotes moving `targetSats`, or everything the channels can send. + func quote(wallet: WalletViewModel, targetSats: UInt64? = nil) async { + state = .quoting + do { + state = try await .confirm(makeQuote(channels: wallet.channels, targetSats: targetSats), isMoving: false) + } catch { + Logger.warn("Failed to quote move to Spark of '\(String(describing: targetSats))': \(error)", context: "MoveToSpark") + state = Self.state(for: error) + } + } + + func move(wallet: WalletViewModel) async { + guard case let .confirm(quote, isMoving) = state, !isMoving else { return } + state = .confirm(quote, isMoving: true) + do { + let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: quote.invoice).paymentHash() + MoveToSparkPayments.insert(String(describing: paymentHash)) + let settled = try await wallet.sendOverChannels( + bolt11: quote.invoice, + sats: quote.amountSats, + maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats) + ) + wallet.syncState() + let openChannels = (wallet.channels ?? []).filter(\.isChannelReady) + state = .done( + amountSats: quote.amountSats, + feeSats: settled.feePaidSats, + closeChannel: openChannels.count == 1 ? openChannels.first : nil, + hasRest: MoveToSparkMath.movableSats(channels: wallet.channels) >= MoveToSparkMath.minMoveSats + ) + } catch { + Logger.error("Failed to move '\(quote.amountSats)' sats to Spark: \(error)", context: "MoveToSpark") + state = .tooLarge(amountSats: quote.amountSats, smallerSats: MoveToSparkMath.smallerAmount(quote.amountSats)) + } + } + + func makeQuote(channels: [ChannelDetails]?, targetSats: UInt64?) async throws -> MoveToSparkQuote { + let limitSats = MoveToSparkMath.movableSats(channels: channels) + let wantedSats = min(targetSats ?? limitSats, limitSats) + guard wantedSats >= MoveToSparkMath.minMoveSats else { throw MoveToSparkError.nothingToMove } + guard spark.isReady, !spark.isExiting else { throw MoveToSparkError.sparkUnavailable } + + let invoice = try await spark.receive( + amountSats: nil, + description: Self.invoiceDescription, + expirySecs: Self.invoiceExpirySecs + ) + var amountSats = MoveToSparkMath.firstCandidate(wantedSats: wantedSats, limitSats: limitSats) + var fitting: MoveToSparkQuote? + // The first amount leaves room for a guessed fee; once the real fee is known, the amount grows to fit it. + for _ in 0 ..< Self.maxQuoteRounds where amountSats >= MoveToSparkMath.minMoveSats { + let feeSats: UInt64 + do { + feeSats = try await lightningService.estimateRoutingFees(bolt11: invoice, amountSats: amountSats, channelsOnly: true) + } catch { + if let fitting { + return fitting + } + throw MoveToSparkError.tooLarge(amountSats: amountSats) + } + let nextSats = min(wantedSats, MoveToSparkMath.amountAfterFee(limitSats: limitSats, feeSats: feeSats)) + if amountSats + feeSats <= limitSats { + let quote = MoveToSparkQuote(invoice: invoice, amountSats: amountSats, feeSats: feeSats) + fitting = quote + if nextSats <= amountSats { + return quote + } + } + amountSats = nextSats + } + if let fitting { + return fitting + } + throw MoveToSparkError.tooLarge(amountSats: amountSats) + } + + private static func state(for error: Error) -> MoveToSparkState { + switch error as? MoveToSparkError { + case let .tooLarge(amountSats): .tooLarge(amountSats: amountSats, smallerSats: MoveToSparkMath.smallerAmount(amountSats)) + case .nothingToMove: .failed(.nothingToMove) + case .sparkUnavailable: .failed(.sparkUnavailable) + case nil: .failed(.unknown) + } + } +} diff --git a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift new file mode 100644 index 000000000..826077406 --- /dev/null +++ b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Shown once after the update to users whose channels still hold a balance worth moving to Spark. +struct MoveToSparkTimedSheet: TimedSheetItem { + let id = SheetID.moveToSpark + let priority = TimedSheetPriority.critical + let sheetItem: any SheetItem = MoveToSparkSheetItem() + let app: AppViewModel + let wallet: WalletViewModel + + @MainActor + func shouldShow() async -> Bool { + guard !app.hasSeenMoveToSparkIntro else { return false } + let spark = SparkManager.shared + return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: spark.isReady && !spark.isExiting) + } + + func onShown() { + Task { @MainActor in + app.hasSeenMoveToSparkIntro = true + } + } + + func onDismissed() {} +} diff --git a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift index f3ba85bdf..bc9cf01de 100644 --- a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift +++ b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift @@ -6,6 +6,7 @@ enum TimedSheetPriority: Int, CaseIterable { case low = 1 case medium = 2 case high = 3 + case critical = 4 } /// Protocol for timed sheet items @@ -75,6 +76,7 @@ class TimedSheetManager: ObservableObject { registerSheet(notificationsSheet) registerSheet(UsdtIntroTimedSheet(wallet: walletViewModel, settings: settingsViewModel)) + registerSheet(MoveToSparkTimedSheet(app: appViewModel, wallet: walletViewModel)) let quickpaySheet = QuickpayTimedSheet(appViewModel: appViewModel, walletViewModel: walletViewModel) registerSheet(quickpaySheet) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 2ba6f3041..8292254a8 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -292,6 +292,30 @@ "lightning__transaction" = "المعاملة"; "lightning__order_fee" = "رسوم الطلب"; "lightning__balance" = "الرصيد"; +"lightning__move_to_spark__amount" = "المبلغ"; +"lightning__move_to_spark__close" = "إغلاق الاتصال"; +"lightning__move_to_spark__confirm_text" = "ينتقل رصيدك إلى Spark عبر Lightning، وهي أقل طريقة رسومًا للإبقاء عليه في الإنفاق. رسوم التوجيه أدناه هي أقصى ما تدفعه."; +"lightning__move_to_spark__confirm_title" = "الانتقال إلى Spark"; +"lightning__move_to_spark__done_rest_text" = "جزء من رصيدك الآن على Spark. انقل الباقي بالطريقة نفسها، ثم أغلق الاتصال الذي أصبح فارغًا."; +"lightning__move_to_spark__done_text" = "رصيدك الآن على Spark. أغلق الاتصال الذي أصبح فارغًا لإعادة احتياطيه إلى المدخرات."; +"lightning__move_to_spark__done_title" = "تم النقل إلى Spark"; +"lightning__move_to_spark__failed_nothing" = "لم يتبق في اتصالات Lightning لديك رصيد للنقل."; +"lightning__move_to_spark__failed_spark" = "Spark غير جاهز للاستلام بعد. حاول مرة أخرى بعد قليل."; +"lightning__move_to_spark__failed_title" = "تعذّر النقل"; +"lightning__move_to_spark__failed_unknown" = "حدث خطأ أثناء تحضير النقل. حاول مرة أخرى."; +"lightning__move_to_spark__fee" = "رسوم التوجيه"; +"lightning__move_to_spark__in_flight_text" = "الدفعة في طريقها وستظهر في Spark بمجرد إتمامها. ثم أغلق الاتصال الفارغ لإعادة احتياطيه إلى المدخرات."; +"lightning__move_to_spark__intro_text" = "يقوم Blocktank بإغلاق اتصالات Lightning الخاصة به. انقل رصيدك إلى Spark الآن: فهو الخيار الأقل رسومًا، ويبقى ضمن الإنفاق."; +"lightning__move_to_spark__intro_title" = "اتصالك قيد الإغلاق"; +"lightning__move_to_spark__move_rest" = "نقل الباقي"; +"lightning__move_to_spark__nav_title" = "النقل إلى Spark"; +"lightning__move_to_spark__quoting" = "جارٍ البحث عن مسار ورسومه…"; +"lightning__move_to_spark__swipe" = "اسحب للنقل"; +"lightning__move_to_spark__too_large_none" = "أغلق الاتصال بدلاً من ذلك لإعادة الرصيد إلى المدخرات."; +"lightning__move_to_spark__too_large_smaller" = "يمكنك نقله على دفعات أصغر."; +"lightning__move_to_spark__too_large_text" = "{amount} أكبر من أن يُوجَّه إلى Spark في دفعة واحدة."; +"lightning__move_to_spark__too_large_title" = "المبلغ كبير جدًا"; +"lightning__move_to_spark__try" = "نقل {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "رسوم المطالبة"; "lightning__spark_confirmations" = "{count}/3 تأكيدات"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 78a612fe0..a3c35f2e6 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -329,6 +329,30 @@ "lightning__transaction" = "Transacció"; "lightning__order_fee" = "Comissió de comanda"; "lightning__balance" = "Saldo"; +"lightning__move_to_spark__amount" = "Import"; +"lightning__move_to_spark__close" = "Tanca la connexió"; +"lightning__move_to_spark__confirm_text" = "El teu saldo passa a Spark per Lightning, la manera amb menys tarifes de mantenir-lo a Despesa. La tarifa d'enrutament de sota és el màxim que pagues."; +"lightning__move_to_spark__confirm_title" = "Moure a Spark"; +"lightning__move_to_spark__done_rest_text" = "Una part del teu saldo ja és a Spark. Mou la resta de la mateixa manera i després tanca la connexió buida."; +"lightning__move_to_spark__done_text" = "El teu saldo ja és a Spark. Tanca la connexió buida per tornar-ne la reserva als Estalvis."; +"lightning__move_to_spark__done_title" = "Mogut a Spark"; +"lightning__move_to_spark__failed_nothing" = "Les teves connexions Lightning ja no tenen saldo per moure."; +"lightning__move_to_spark__failed_spark" = "Spark encara no està a punt per rebre. Torna-ho a provar d'aquí a un moment."; +"lightning__move_to_spark__failed_title" = "No s'ha pogut moure"; +"lightning__move_to_spark__failed_unknown" = "Alguna cosa ha anat malament en preparar el moviment. Torna-ho a provar."; +"lightning__move_to_spark__fee" = "Tarifa d'enrutament"; +"lightning__move_to_spark__in_flight_text" = "El pagament està en camí i apareixerà a Spark quan s'hagi liquidat. Després tanca la connexió buida per tornar-ne la reserva als Estalvis."; +"lightning__move_to_spark__intro_text" = "Blocktank està tancant les seves connexions Lightning. Mou ara el teu saldo a Spark: és l'opció amb menys tarifes i es manté a Despesa."; +"lightning__move_to_spark__intro_title" = "La teva connexió es tanca"; +"lightning__move_to_spark__move_rest" = "Moure la resta"; +"lightning__move_to_spark__nav_title" = "Moure a Spark"; +"lightning__move_to_spark__quoting" = "Cercant una ruta i la seva tarifa…"; +"lightning__move_to_spark__swipe" = "Desplaça per moure"; +"lightning__move_to_spark__too_large_none" = "Tanca la connexió per tornar el saldo als Estalvis."; +"lightning__move_to_spark__too_large_smaller" = "El pots moure en parts més petites."; +"lightning__move_to_spark__too_large_text" = "{amount} és massa gran per enrutar-se a Spark en un sol pagament."; +"lightning__move_to_spark__too_large_title" = "Import massa gran"; +"lightning__move_to_spark__try" = "Moure {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comissió de reclamació"; "lightning__spark_confirmations" = "{count}/3 confirmacions"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 755de70ef..09fb959fc 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -345,6 +345,30 @@ "lightning__transaction" = "Transakce"; "lightning__order_fee" = "Poplatek za objednávku"; "lightning__balance" = "Zůstatek"; +"lightning__move_to_spark__amount" = "Částka"; +"lightning__move_to_spark__close" = "Uzavřít spojení"; +"lightning__move_to_spark__confirm_text" = "Váš zůstatek se přesune do Spark přes Lightning, což je způsob s nejnižšími poplatky, jak jej udržet v Útratách. Poplatek za směrování níže je maximum, které zaplatíte."; +"lightning__move_to_spark__confirm_title" = "Přesun do Spark"; +"lightning__move_to_spark__done_rest_text" = "Část vašeho zůstatku je nyní ve Spark. Zbytek přesuňte stejným způsobem a poté uzavřete vyprázdněné spojení."; +"lightning__move_to_spark__done_text" = "Váš zůstatek je nyní ve Spark. Uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor."; +"lightning__move_to_spark__done_title" = "Přesunuto do Spark"; +"lightning__move_to_spark__failed_nothing" = "Vaše spojení Lightning už nemají žádný zůstatek k přesunu."; +"lightning__move_to_spark__failed_spark" = "Spark zatím není připraven přijímat. Zkuste to za chvíli znovu."; +"lightning__move_to_spark__failed_title" = "Přesun se nezdařil"; +"lightning__move_to_spark__failed_unknown" = "Při přípravě přesunu se něco pokazilo. Zkuste to znovu."; +"lightning__move_to_spark__fee" = "Poplatek za směrování"; +"lightning__move_to_spark__in_flight_text" = "Platba je na cestě a ve Spark se zobrazí po vypořádání. Poté uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor."; +"lightning__move_to_spark__intro_text" = "Blocktank uzavírá svá spojení Lightning. Přesuňte svůj zůstatek do Spark hned: je to možnost s nejnižšími poplatky a zůstane v Útratách."; +"lightning__move_to_spark__intro_title" = "Vaše spojení se uzavírá"; +"lightning__move_to_spark__move_rest" = "Přesunout zbytek"; +"lightning__move_to_spark__nav_title" = "Přesun do Spark"; +"lightning__move_to_spark__quoting" = "Hledání trasy a jejího poplatku…"; +"lightning__move_to_spark__swipe" = "Přejetím přesuňte"; +"lightning__move_to_spark__too_large_none" = "Místo toho uzavřete spojení, aby se zůstatek vrátil do Úspor."; +"lightning__move_to_spark__too_large_smaller" = "Můžete jej přesunout po menších částech."; +"lightning__move_to_spark__too_large_text" = "{amount} je příliš velká částka na směrování do Spark v jedné platbě."; +"lightning__move_to_spark__too_large_title" = "Částka je příliš velká"; +"lightning__move_to_spark__try" = "Přesunout {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Poplatek za připsání"; "lightning__spark_confirmations" = "{count}/3 potvrzení"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 41d543636..e6159b281 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -343,6 +343,30 @@ "lightning__transaction" = "Transaktion"; "lightning__order_fee" = "Auftragsgebühr"; "lightning__balance" = "Guthaben"; +"lightning__move_to_spark__amount" = "Betrag"; +"lightning__move_to_spark__close" = "Verbindung schließen"; +"lightning__move_to_spark__confirm_text" = "Ihr Guthaben wechselt über Lightning zu Spark, dem günstigsten Weg, es im Ausgabenkonto zu behalten. Die Routing-Gebühr unten ist das Maximum, das Sie zahlen."; +"lightning__move_to_spark__confirm_title" = "Zu Spark verschieben"; +"lightning__move_to_spark__done_rest_text" = "Ein Teil Ihres Guthabens ist jetzt bei Spark. Verschieben Sie den Rest auf dieselbe Weise und schließen Sie dann die geleerte Verbindung."; +"lightning__move_to_spark__done_text" = "Ihr Guthaben ist jetzt bei Spark. Schließen Sie die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben."; +"lightning__move_to_spark__done_title" = "Zu Spark verschoben"; +"lightning__move_to_spark__failed_nothing" = "Ihre Lightning-Verbindungen haben kein Guthaben mehr zum Verschieben."; +"lightning__move_to_spark__failed_spark" = "Spark ist noch nicht bereit zum Empfangen. Versuchen Sie es gleich noch einmal."; +"lightning__move_to_spark__failed_title" = "Verschieben fehlgeschlagen"; +"lightning__move_to_spark__failed_unknown" = "Beim Vorbereiten der Verschiebung ist etwas schiefgelaufen. Versuchen Sie es erneut."; +"lightning__move_to_spark__fee" = "Routing-Gebühr"; +"lightning__move_to_spark__in_flight_text" = "Die Zahlung ist unterwegs und erscheint in Spark, sobald sie abgeschlossen ist. Schließen Sie dann die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben."; +"lightning__move_to_spark__intro_text" = "Blocktank schließt seine Lightning-Verbindungen. Verschieben Sie Ihr Guthaben jetzt zu Spark: Das ist die günstigste Option, und es bleibt im Ausgabenkonto."; +"lightning__move_to_spark__intro_title" = "Ihre Verbindung wird geschlossen"; +"lightning__move_to_spark__move_rest" = "Rest verschieben"; +"lightning__move_to_spark__nav_title" = "Zu Spark verschieben"; +"lightning__move_to_spark__quoting" = "Route und Gebühr werden gesucht…"; +"lightning__move_to_spark__swipe" = "Swipe zum Verschieben"; +"lightning__move_to_spark__too_large_none" = "Schließen Sie stattdessen die Verbindung, um das Guthaben an das Sparkonto zurückzugeben."; +"lightning__move_to_spark__too_large_smaller" = "Sie können es in kleineren Teilen verschieben."; +"lightning__move_to_spark__too_large_text" = "{amount} ist zu groß, um in einer Zahlung zu Spark geroutet zu werden."; +"lightning__move_to_spark__too_large_title" = "Betrag zu groß"; +"lightning__move_to_spark__try" = "{amount} verschieben"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Einlösegebühr"; "lightning__spark_confirmations" = "{count}/3 Bestätigungen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 3bcdf21e1..282d46583 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -316,6 +316,30 @@ "lightning__transaction" = "Συναλλαγή"; "lightning__order_fee" = "Τέλος παραγγελίας"; "lightning__balance" = "Υπόλοιπο"; +"lightning__move_to_spark__amount" = "Ποσό"; +"lightning__move_to_spark__close" = "Κλείσιμο σύνδεσης"; +"lightning__move_to_spark__confirm_text" = "Το υπόλοιπό σας μεταφέρεται στο Spark μέσω Lightning, τον τρόπο με τα χαμηλότερα τέλη για να παραμείνει στις Δαπάνες. Το τέλος δρομολόγησης παρακάτω είναι το μέγιστο που πληρώνετε."; +"lightning__move_to_spark__confirm_title" = "Μεταφορά στο Spark"; +"lightning__move_to_spark__done_rest_text" = "Ένα μέρος του υπολοίπου σας βρίσκεται πλέον στο Spark. Μεταφέρετε τα υπόλοιπα με τον ίδιο τρόπο και έπειτα κλείστε την άδεια σύνδεση."; +"lightning__move_to_spark__done_text" = "Το υπόλοιπό σας βρίσκεται πλέον στο Spark. Κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις."; +"lightning__move_to_spark__done_title" = "Μεταφέρθηκε στο Spark"; +"lightning__move_to_spark__failed_nothing" = "Οι συνδέσεις Lightning σας δεν έχουν πλέον υπόλοιπο για μεταφορά."; +"lightning__move_to_spark__failed_spark" = "Το Spark δεν είναι ακόμη έτοιμο να λάβει. Δοκιμάστε ξανά σε λίγο."; +"lightning__move_to_spark__failed_title" = "Αποτυχία μεταφοράς"; +"lightning__move_to_spark__failed_unknown" = "Κάτι πήγε στραβά κατά την προετοιμασία της μεταφοράς. Δοκιμάστε ξανά."; +"lightning__move_to_spark__fee" = "Τέλος δρομολόγησης"; +"lightning__move_to_spark__in_flight_text" = "Η πληρωμή βρίσκεται σε εξέλιξη και θα εμφανιστεί στο Spark μόλις διακανονιστεί. Έπειτα κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις."; +"lightning__move_to_spark__intro_text" = "Το Blocktank κλείνει τις συνδέσεις Lightning του. Μεταφέρετε το υπόλοιπό σας στο Spark τώρα: είναι η επιλογή με τα χαμηλότερα τέλη και παραμένει στις Δαπάνες."; +"lightning__move_to_spark__intro_title" = "Η σύνδεσή σας κλείνει"; +"lightning__move_to_spark__move_rest" = "Μεταφορά των υπολοίπων"; +"lightning__move_to_spark__nav_title" = "Μεταφορά στο Spark"; +"lightning__move_to_spark__quoting" = "Εύρεση διαδρομής και του τέλους της…"; +"lightning__move_to_spark__swipe" = "Σύρε για μεταφορά"; +"lightning__move_to_spark__too_large_none" = "Κλείστε αντί αυτού τη σύνδεση για να επιστρέψει το υπόλοιπο στις Αποταμιεύσεις."; +"lightning__move_to_spark__too_large_smaller" = "Μπορείτε να το μεταφέρετε σε μικρότερα μέρη."; +"lightning__move_to_spark__too_large_text" = "Το {amount} είναι πολύ μεγάλο για δρομολόγηση στο Spark με μία πληρωμή."; +"lightning__move_to_spark__too_large_title" = "Το ποσό είναι πολύ μεγάλο"; +"lightning__move_to_spark__try" = "Μεταφορά {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Χρέωση εξαργύρωσης"; "lightning__spark_confirmations" = "{count}/3 επιβεβαιώσεις"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9f299e68b..fb59f88ab 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -338,6 +338,30 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Order fee"; "lightning__balance" = "Balance"; +"lightning__move_to_spark__amount" = "Amount"; +"lightning__move_to_spark__close" = "Close Connection"; +"lightning__move_to_spark__confirm_text" = "Your balance moves to Spark over Lightning, the lowest-fee way to keep it in Spending. The routing fee below is the most you pay."; +"lightning__move_to_spark__confirm_title" = "Move to Spark"; +"lightning__move_to_spark__done_rest_text" = "Part of your balance is on Spark now. Move the rest the same way, then close the emptied connection."; +"lightning__move_to_spark__done_text" = "Your balance is on Spark now. Close the emptied connection to return its reserve to Savings."; +"lightning__move_to_spark__done_title" = "Moved to Spark"; +"lightning__move_to_spark__failed_nothing" = "Your Lightning connections have no balance left to move."; +"lightning__move_to_spark__failed_spark" = "Spark is not ready to receive yet. Try again in a moment."; +"lightning__move_to_spark__failed_title" = "Could not move"; +"lightning__move_to_spark__failed_unknown" = "Something went wrong while preparing the move. Try again."; +"lightning__move_to_spark__fee" = "Routing fee"; +"lightning__move_to_spark__in_flight_text" = "The payment is on its way and shows in Spark once it settles. Then close the emptied connection to return its reserve to Savings."; +"lightning__move_to_spark__intro_text" = "Blocktank is closing its Lightning connections. Move your balance to Spark now: it is the lowest-fee option, and it stays in Spending."; +"lightning__move_to_spark__intro_title" = "Your connection is closing"; +"lightning__move_to_spark__move_rest" = "Move The Rest"; +"lightning__move_to_spark__nav_title" = "Move To Spark"; +"lightning__move_to_spark__quoting" = "Finding a route and its fee…"; +"lightning__move_to_spark__swipe" = "Swipe To Move"; +"lightning__move_to_spark__too_large_none" = "Close the connection instead to return the balance to Savings."; +"lightning__move_to_spark__too_large_smaller" = "You can move it in smaller parts."; +"lightning__move_to_spark__too_large_text" = "{amount} is too large to route to Spark in one payment."; +"lightning__move_to_spark__too_large_title" = "Amount too large"; +"lightning__move_to_spark__try" = "Move {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Claim fee"; "lightning__spark_confirmations" = "{count}/3 confirmations"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 7de3e658a..bc525c9af 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -346,6 +346,30 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; +"lightning__move_to_spark__amount" = "Monto"; +"lightning__move_to_spark__close" = "Terminar conexión"; +"lightning__move_to_spark__confirm_text" = "Su saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que paga."; +"lightning__move_to_spark__confirm_title" = "Mover a Spark"; +"lightning__move_to_spark__done_rest_text" = "Una parte de su saldo ya está en Spark. Mueva el resto de la misma forma y luego termine la conexión vacía."; +"lightning__move_to_spark__done_text" = "Su saldo ya está en Spark. Termine la conexión vacía para devolver su reserva a Ahorro."; +"lightning__move_to_spark__done_title" = "Movido a Spark"; +"lightning__move_to_spark__failed_nothing" = "Sus conexiones Lightning ya no tienen saldo para mover."; +"lightning__move_to_spark__failed_spark" = "Spark aún no está listo para recibir. Inténtelo de nuevo en un momento."; +"lightning__move_to_spark__failed_title" = "No se pudo mover"; +"lightning__move_to_spark__failed_unknown" = "Algo salió mal al preparar el movimiento. Inténtelo de nuevo."; +"lightning__move_to_spark__fee" = "Comisión de enrutamiento"; +"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; +"lightning__move_to_spark__intro_text" = "Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto."; +"lightning__move_to_spark__intro_title" = "Su conexión se está cerrando"; +"lightning__move_to_spark__move_rest" = "Mover el resto"; +"lightning__move_to_spark__nav_title" = "Mover a Spark"; +"lightning__move_to_spark__quoting" = "Buscando una ruta y su comisión…"; +"lightning__move_to_spark__swipe" = "Deslizar para mover"; +"lightning__move_to_spark__too_large_none" = "Termine la conexión para devolver el saldo a Ahorro."; +"lightning__move_to_spark__too_large_smaller" = "Puede moverlo en partes más pequeñas."; +"lightning__move_to_spark__too_large_text" = "{amount} es demasiado grande para enrutarse a Spark en un solo pago."; +"lightning__move_to_spark__too_large_title" = "Monto demasiado grande"; +"lightning__move_to_spark__try" = "Mover {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comisión de reclamo"; "lightning__spark_confirmations" = "{count}/3 confirmaciones"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 510f68918..ce6cd6080 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -330,6 +330,30 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; +"lightning__move_to_spark__amount" = "Cantidad"; +"lightning__move_to_spark__close" = "Terminar conexión"; +"lightning__move_to_spark__confirm_text" = "Su saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que paga."; +"lightning__move_to_spark__confirm_title" = "Mover a Spark"; +"lightning__move_to_spark__done_rest_text" = "Una parte de su saldo ya está en Spark. Mueva el resto de la misma forma y luego termine la conexión vacía."; +"lightning__move_to_spark__done_text" = "Su saldo ya está en Spark. Termine la conexión vacía para devolver su reserva a Ahorro."; +"lightning__move_to_spark__done_title" = "Movido a Spark"; +"lightning__move_to_spark__failed_nothing" = "Sus conexiones Lightning ya no tienen saldo para mover."; +"lightning__move_to_spark__failed_spark" = "Spark aún no está listo para recibir. Inténtelo de nuevo en un momento."; +"lightning__move_to_spark__failed_title" = "No se pudo mover"; +"lightning__move_to_spark__failed_unknown" = "Algo salió mal al preparar el movimiento. Inténtelo de nuevo."; +"lightning__move_to_spark__fee" = "Comisión de enrutamiento"; +"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; +"lightning__move_to_spark__intro_text" = "Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto."; +"lightning__move_to_spark__intro_title" = "Su conexión se está cerrando"; +"lightning__move_to_spark__move_rest" = "Mover el resto"; +"lightning__move_to_spark__nav_title" = "Mover a Spark"; +"lightning__move_to_spark__quoting" = "Buscando una ruta y su comisión…"; +"lightning__move_to_spark__swipe" = "Deslizar para mover"; +"lightning__move_to_spark__too_large_none" = "Termine la conexión para devolver el saldo a Ahorro."; +"lightning__move_to_spark__too_large_smaller" = "Puede moverlo en partes más pequeñas."; +"lightning__move_to_spark__too_large_text" = "{amount} es demasiado grande para enrutarse a Spark en un solo pago."; +"lightning__move_to_spark__too_large_title" = "Cantidad demasiado grande"; +"lightning__move_to_spark__try" = "Mover {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comisión de reclamo"; "lightning__spark_confirmations" = "{count}/3 confirmaciones"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 4074d833e..48b53a2d3 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -346,6 +346,30 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Frais"; "lightning__balance" = "Solde"; +"lightning__move_to_spark__amount" = "Montant"; +"lightning__move_to_spark__close" = "Fermer la connexion"; +"lightning__move_to_spark__confirm_text" = "Votre solde passe à Spark via Lightning, le moyen le moins coûteux de le garder dans Dépenses. Les frais de routage ci-dessous sont le maximum que vous payez."; +"lightning__move_to_spark__confirm_title" = "Déplacer vers Spark"; +"lightning__move_to_spark__done_rest_text" = "Une partie de votre solde est maintenant sur Spark. Déplacez le reste de la même façon, puis fermez la connexion vidée."; +"lightning__move_to_spark__done_text" = "Votre solde est maintenant sur Spark. Fermez la connexion vidée pour rendre sa réserve à Épargnes."; +"lightning__move_to_spark__done_title" = "Déplacé vers Spark"; +"lightning__move_to_spark__failed_nothing" = "Vos connexions Lightning n'ont plus de solde à déplacer."; +"lightning__move_to_spark__failed_spark" = "Spark n'est pas encore prêt à recevoir. Réessayez dans un instant."; +"lightning__move_to_spark__failed_title" = "Échec du déplacement"; +"lightning__move_to_spark__failed_unknown" = "Un problème est survenu pendant la préparation du déplacement. Réessayez."; +"lightning__move_to_spark__fee" = "Frais de routage"; +"lightning__move_to_spark__in_flight_text" = "Le paiement est en route et apparaîtra sur Spark une fois réglé. Fermez ensuite la connexion vidée pour rendre sa réserve à Épargnes."; +"lightning__move_to_spark__intro_text" = "Blocktank ferme ses connexions Lightning. Déplacez votre solde vers Spark maintenant : c'est l'option la moins coûteuse, et il reste dans Dépenses."; +"lightning__move_to_spark__intro_title" = "Votre connexion se ferme"; +"lightning__move_to_spark__move_rest" = "Déplacer le reste"; +"lightning__move_to_spark__nav_title" = "Déplacer vers Spark"; +"lightning__move_to_spark__quoting" = "Recherche d'un itinéraire et de ses frais…"; +"lightning__move_to_spark__swipe" = "Glissez pour déplacer"; +"lightning__move_to_spark__too_large_none" = "Fermez plutôt la connexion pour rendre le solde à Épargnes."; +"lightning__move_to_spark__too_large_smaller" = "Vous pouvez le déplacer en plus petites parties."; +"lightning__move_to_spark__too_large_text" = "{amount} est trop élevé pour être acheminé vers Spark en un seul paiement."; +"lightning__move_to_spark__too_large_title" = "Montant trop élevé"; +"lightning__move_to_spark__try" = "Déplacer {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Frais de réclamation"; "lightning__spark_confirmations" = "{count}/3 confirmations"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index ff531fab8..0a1724b03 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -339,6 +339,30 @@ "lightning__transaction" = "Transazione"; "lightning__order_fee" = "Fee Ordine"; "lightning__balance" = "Saldo"; +"lightning__move_to_spark__amount" = "Importo"; +"lightning__move_to_spark__close" = "Chiudi Connessione"; +"lightning__move_to_spark__confirm_text" = "Il tuo saldo passa a Spark tramite Lightning, il modo a commissioni più basse per mantenerlo in Spesa. La commissione di instradamento qui sotto è il massimo che paghi."; +"lightning__move_to_spark__confirm_title" = "Sposta su Spark"; +"lightning__move_to_spark__done_rest_text" = "Una parte del tuo saldo è ora su Spark. Sposta il resto allo stesso modo, poi chiudi la connessione svuotata."; +"lightning__move_to_spark__done_text" = "Il tuo saldo è ora su Spark. Chiudi la connessione svuotata per restituire la sua riserva ai Risparmi."; +"lightning__move_to_spark__done_title" = "Spostato su Spark"; +"lightning__move_to_spark__failed_nothing" = "Le tue connessioni Lightning non hanno più saldo da spostare."; +"lightning__move_to_spark__failed_spark" = "Spark non è ancora pronto a ricevere. Riprova tra un momento."; +"lightning__move_to_spark__failed_title" = "Impossibile spostare"; +"lightning__move_to_spark__failed_unknown" = "Qualcosa è andato storto durante la preparazione dello spostamento. Riprova."; +"lightning__move_to_spark__fee" = "Commissione di instradamento"; +"lightning__move_to_spark__in_flight_text" = "Il pagamento è in arrivo e compare su Spark una volta completato. Poi chiudi la connessione svuotata per restituire la sua riserva ai Risparmi."; +"lightning__move_to_spark__intro_text" = "Blocktank sta chiudendo le sue connessioni Lightning. Sposta ora il tuo saldo su Spark: è l'opzione con le commissioni più basse e resta in Spesa."; +"lightning__move_to_spark__intro_title" = "La tua connessione si sta chiudendo"; +"lightning__move_to_spark__move_rest" = "Sposta Il Resto"; +"lightning__move_to_spark__nav_title" = "Sposta Su Spark"; +"lightning__move_to_spark__quoting" = "Ricerca di un percorso e della sua commissione…"; +"lightning__move_to_spark__swipe" = "Swipe Per Spostare"; +"lightning__move_to_spark__too_large_none" = "Chiudi invece la connessione per restituire il saldo ai Risparmi."; +"lightning__move_to_spark__too_large_smaller" = "Puoi spostarlo in parti più piccole."; +"lightning__move_to_spark__too_large_text" = "{amount} è troppo grande per essere instradato su Spark in un unico pagamento."; +"lightning__move_to_spark__too_large_title" = "Importo troppo grande"; +"lightning__move_to_spark__try" = "Sposta {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Commissione di riscatto"; "lightning__spark_confirmations" = "{count}/3 conferme"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 087be4fee..734d7a6e5 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -343,6 +343,30 @@ "lightning__transaction" = "Transactie"; "lightning__order_fee" = "Order kosten"; "lightning__balance" = "Saldo"; +"lightning__move_to_spark__amount" = "Bedrag"; +"lightning__move_to_spark__close" = "Verbinding sluiten"; +"lightning__move_to_spark__confirm_text" = "Uw saldo gaat via Lightning naar Spark, de goedkoopste manier om het op uw bestedingssaldo te houden. De routeringsvergoeding hieronder is het maximum dat u betaalt."; +"lightning__move_to_spark__confirm_title" = "Verplaatsen naar Spark"; +"lightning__move_to_spark__done_rest_text" = "Een deel van uw saldo staat nu op Spark. Verplaats de rest op dezelfde manier en sluit daarna de lege verbinding."; +"lightning__move_to_spark__done_text" = "Uw saldo staat nu op Spark. Sluit de lege verbinding om de reserve terug te geven aan uw spaargeld."; +"lightning__move_to_spark__done_title" = "Verplaatst naar Spark"; +"lightning__move_to_spark__failed_nothing" = "Uw Lightning-verbindingen hebben geen saldo meer om te verplaatsen."; +"lightning__move_to_spark__failed_spark" = "Spark is nog niet klaar om te ontvangen. Probeer het zo meteen opnieuw."; +"lightning__move_to_spark__failed_title" = "Verplaatsen mislukt"; +"lightning__move_to_spark__failed_unknown" = "Er is iets misgegaan bij het voorbereiden van de verplaatsing. Probeer het opnieuw."; +"lightning__move_to_spark__fee" = "Routeringsvergoeding"; +"lightning__move_to_spark__in_flight_text" = "De betaling is onderweg en verschijnt in Spark zodra ze is afgerond. Sluit daarna de lege verbinding om de reserve terug te geven aan uw spaargeld."; +"lightning__move_to_spark__intro_text" = "Blocktank sluit zijn Lightning-verbindingen. Verplaats uw saldo nu naar Spark: dit is de goedkoopste optie en het blijft op uw bestedingssaldo."; +"lightning__move_to_spark__intro_title" = "Uw verbinding wordt gesloten"; +"lightning__move_to_spark__move_rest" = "De rest verplaatsen"; +"lightning__move_to_spark__nav_title" = "Verplaatsen naar Spark"; +"lightning__move_to_spark__quoting" = "Route en vergoeding zoeken…"; +"lightning__move_to_spark__swipe" = "Veeg om te verplaatsen"; +"lightning__move_to_spark__too_large_none" = "Sluit in plaats daarvan de verbinding om het saldo terug te geven aan uw spaargeld."; +"lightning__move_to_spark__too_large_smaller" = "U kunt het in kleinere delen verplaatsen."; +"lightning__move_to_spark__too_large_text" = "{amount} is te groot om in één betaling naar Spark te routeren."; +"lightning__move_to_spark__too_large_title" = "Bedrag te groot"; +"lightning__move_to_spark__try" = "{amount} verplaatsen"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Claimkosten"; "lightning__spark_confirmations" = "{count}/3 bevestigingen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 1cd7e31c2..69ddd7a76 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -345,6 +345,30 @@ "lightning__transaction" = "Transakcja"; "lightning__order_fee" = "Opłata za zamówienie"; "lightning__balance" = "Saldo"; +"lightning__move_to_spark__amount" = "Kwota"; +"lightning__move_to_spark__close" = "Zamknij połączenie"; +"lightning__move_to_spark__confirm_text" = "Twoje saldo trafia do Spark przez Lightning, najtańszym sposobem na pozostawienie go w Wydatkach. Opłata za routing poniżej to maksimum, jakie zapłacisz."; +"lightning__move_to_spark__confirm_title" = "Przenieś do Spark"; +"lightning__move_to_spark__done_rest_text" = "Część Twojego salda jest teraz w Spark. Przenieś resztę w ten sam sposób, a następnie zamknij opróżnione połączenie."; +"lightning__move_to_spark__done_text" = "Twoje saldo jest teraz w Spark. Zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności."; +"lightning__move_to_spark__done_title" = "Przeniesiono do Spark"; +"lightning__move_to_spark__failed_nothing" = "Twoje połączenia Lightning nie mają już salda do przeniesienia."; +"lightning__move_to_spark__failed_spark" = "Spark nie jest jeszcze gotowy do odbioru. Spróbuj ponownie za chwilę."; +"lightning__move_to_spark__failed_title" = "Nie udało się przenieść"; +"lightning__move_to_spark__failed_unknown" = "Coś poszło nie tak podczas przygotowania przeniesienia. Spróbuj ponownie."; +"lightning__move_to_spark__fee" = "Opłata za routing"; +"lightning__move_to_spark__in_flight_text" = "Płatność jest w drodze i pojawi się w Spark po rozliczeniu. Następnie zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności."; +"lightning__move_to_spark__intro_text" = "Blocktank zamyka swoje połączenia Lightning. Przenieś teraz saldo do Spark: to opcja z najniższymi opłatami, a środki zostają w Wydatkach."; +"lightning__move_to_spark__intro_title" = "Twoje połączenie się zamyka"; +"lightning__move_to_spark__move_rest" = "Przenieś resztę"; +"lightning__move_to_spark__nav_title" = "Przenieś do Spark"; +"lightning__move_to_spark__quoting" = "Szukanie trasy i jej opłaty…"; +"lightning__move_to_spark__swipe" = "Przesuń, aby przenieść"; +"lightning__move_to_spark__too_large_none" = "Zamiast tego zamknij połączenie, aby zwrócić saldo do Oszczędności."; +"lightning__move_to_spark__too_large_smaller" = "Możesz przenieść je w mniejszych częściach."; +"lightning__move_to_spark__too_large_text" = "Kwota {amount} jest zbyt duża, aby przekazać ją do Spark jedną płatnością."; +"lightning__move_to_spark__too_large_title" = "Kwota zbyt duża"; +"lightning__move_to_spark__try" = "Przenieś {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Opłata za odbiór"; "lightning__spark_confirmations" = "{count}/3 potwierdzeń"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index bd54df0b4..7673ef186 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -345,6 +345,30 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; +"lightning__move_to_spark__amount" = "Valor"; +"lightning__move_to_spark__close" = "Fechar Conexão"; +"lightning__move_to_spark__confirm_text" = "Seu saldo vai para o Spark pela Lightning, a forma de menor taxa de mantê-lo em Gastos. A taxa de roteamento abaixo é o máximo que você paga."; +"lightning__move_to_spark__confirm_title" = "Mover para o Spark"; +"lightning__move_to_spark__done_rest_text" = "Parte do seu saldo agora está no Spark. Mova o resto da mesma forma e depois feche a conexão esvaziada."; +"lightning__move_to_spark__done_text" = "Seu saldo agora está no Spark. Feche a conexão esvaziada para devolver a reserva à Poupança."; +"lightning__move_to_spark__done_title" = "Movido para o Spark"; +"lightning__move_to_spark__failed_nothing" = "Suas conexões Lightning não têm mais saldo para mover."; +"lightning__move_to_spark__failed_spark" = "O Spark ainda não está pronto para receber. Tente novamente em instantes."; +"lightning__move_to_spark__failed_title" = "Não foi possível mover"; +"lightning__move_to_spark__failed_unknown" = "Algo deu errado ao preparar a movimentação. Tente novamente."; +"lightning__move_to_spark__fee" = "Taxa de roteamento"; +"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark assim que for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; +"lightning__move_to_spark__intro_text" = "O Blocktank está fechando suas conexões Lightning. Mova seu saldo para o Spark agora: é a opção de menor taxa e ele continua em Gastos."; +"lightning__move_to_spark__intro_title" = "Sua conexão está fechando"; +"lightning__move_to_spark__move_rest" = "Mover o Resto"; +"lightning__move_to_spark__nav_title" = "Mover Para o Spark"; +"lightning__move_to_spark__quoting" = "Buscando uma rota e sua taxa…"; +"lightning__move_to_spark__swipe" = "Deslizar Para Mover"; +"lightning__move_to_spark__too_large_none" = "Feche a conexão para devolver o saldo à Poupança."; +"lightning__move_to_spark__too_large_smaller" = "Você pode movê-lo em partes menores."; +"lightning__move_to_spark__too_large_text" = "{amount} é grande demais para ser roteado ao Spark em um único pagamento."; +"lightning__move_to_spark__too_large_title" = "Valor grande demais"; +"lightning__move_to_spark__try" = "Mover {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Taxa de resgate"; "lightning__spark_confirmations" = "{count}/3 confirmações"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 5ab90bafd..69c618da8 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -343,6 +343,30 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; +"lightning__move_to_spark__amount" = "Montante"; +"lightning__move_to_spark__close" = "Fechar Conexão"; +"lightning__move_to_spark__confirm_text" = "O seu saldo passa para o Spark através do Lightning, a forma com as taxas mais baixas de o manter em Gastos. A taxa de encaminhamento abaixo é o máximo que paga."; +"lightning__move_to_spark__confirm_title" = "Mover para o Spark"; +"lightning__move_to_spark__done_rest_text" = "Parte do seu saldo está agora no Spark. Mova o resto da mesma forma e depois feche a conexão esvaziada."; +"lightning__move_to_spark__done_text" = "O seu saldo está agora no Spark. Feche a conexão esvaziada para devolver a reserva à Poupança."; +"lightning__move_to_spark__done_title" = "Movido para o Spark"; +"lightning__move_to_spark__failed_nothing" = "As suas conexões Lightning já não têm saldo para mover."; +"lightning__move_to_spark__failed_spark" = "O Spark ainda não está pronto para receber. Tente novamente dentro de instantes."; +"lightning__move_to_spark__failed_title" = "Não foi possível mover"; +"lightning__move_to_spark__failed_unknown" = "Algo correu mal ao preparar a movimentação. Tente novamente."; +"lightning__move_to_spark__fee" = "Taxa de encaminhamento"; +"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark quando for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; +"lightning__move_to_spark__intro_text" = "O Blocktank está a fechar as suas conexões Lightning. Mova já o seu saldo para o Spark: é a opção com as taxas mais baixas e fica em Gastos."; +"lightning__move_to_spark__intro_title" = "A sua conexão está a fechar"; +"lightning__move_to_spark__move_rest" = "Mover o Resto"; +"lightning__move_to_spark__nav_title" = "Mover Para o Spark"; +"lightning__move_to_spark__quoting" = "A procurar uma rota e a respetiva taxa…"; +"lightning__move_to_spark__swipe" = "Deslizar Para Mover"; +"lightning__move_to_spark__too_large_none" = "Feche antes a conexão para devolver o saldo à Poupança."; +"lightning__move_to_spark__too_large_smaller" = "Pode movê-lo em partes mais pequenas."; +"lightning__move_to_spark__too_large_text" = "{amount} é demasiado grande para ser encaminhado para o Spark num só pagamento."; +"lightning__move_to_spark__too_large_title" = "Montante demasiado grande"; +"lightning__move_to_spark__try" = "Mover {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Taxa de resgate"; "lightning__spark_confirmations" = "{count}/3 confirmações"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index d2df40a5b..665e89f75 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -340,6 +340,30 @@ "lightning__transaction" = "Транзакция"; "lightning__order_fee" = "Комиссия Ордера"; "lightning__balance" = "Баланс"; +"lightning__move_to_spark__amount" = "Сумма"; +"lightning__move_to_spark__close" = "Закрыть Соединение"; +"lightning__move_to_spark__confirm_text" = "Ваш баланс переходит в Spark через Lightning — самым дешёвым по комиссиям способом сохранить его в Расходах. Комиссия за маршрутизацию ниже — максимум, который вы платите."; +"lightning__move_to_spark__confirm_title" = "Перенос в Spark"; +"lightning__move_to_spark__done_rest_text" = "Часть вашего баланса теперь в Spark. Перенесите остаток таким же способом, затем закройте опустевшее соединение."; +"lightning__move_to_spark__done_text" = "Ваш баланс теперь в Spark. Закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения."; +"lightning__move_to_spark__done_title" = "Перенесено в Spark"; +"lightning__move_to_spark__failed_nothing" = "На ваших соединениях Lightning больше нет баланса для переноса."; +"lightning__move_to_spark__failed_spark" = "Spark пока не готов к получению. Попробуйте ещё раз чуть позже."; +"lightning__move_to_spark__failed_title" = "Не удалось перенести"; +"lightning__move_to_spark__failed_unknown" = "Что-то пошло не так при подготовке переноса. Попробуйте ещё раз."; +"lightning__move_to_spark__fee" = "Комиссия за маршрутизацию"; +"lightning__move_to_spark__in_flight_text" = "Платёж в пути и появится в Spark после завершения. Затем закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения."; +"lightning__move_to_spark__intro_text" = "Blocktank закрывает свои соединения Lightning. Перенесите баланс в Spark сейчас: это вариант с самой низкой комиссией, и средства остаются в Расходах."; +"lightning__move_to_spark__intro_title" = "Ваше соединение закрывается"; +"lightning__move_to_spark__move_rest" = "Перенести Остаток"; +"lightning__move_to_spark__nav_title" = "Перенос в Spark"; +"lightning__move_to_spark__quoting" = "Поиск маршрута и его комиссии…"; +"lightning__move_to_spark__swipe" = "Проведите для Переноса"; +"lightning__move_to_spark__too_large_none" = "Вместо этого закройте соединение, чтобы вернуть баланс в Сбережения."; +"lightning__move_to_spark__too_large_smaller" = "Вы можете перенести его меньшими частями."; +"lightning__move_to_spark__too_large_text" = "{amount} — слишком большая сумма для маршрутизации в Spark одним платежом."; +"lightning__move_to_spark__too_large_title" = "Сумма слишком велика"; +"lightning__move_to_spark__try" = "Перенести {amount}"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Комиссия за зачисление"; "lightning__spark_confirmations" = "{count}/3 подтверждений"; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 97b7f4d40..0badc4556 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -936,13 +936,16 @@ class LightningService: NodeLifecycleService { } } + /// `channelsOnly` pays from the channels and never falls back to the channel-free rail, as moving a channel + /// balance to Spark needs. func send( bolt11: String, sats: UInt64? = nil, params: RouteParametersConfig? = nil, + channelsOnly: Bool = false, beforeSubmission: @escaping () throws -> Void = {} ) async throws -> PaymentHash { - if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { + if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { // Spark takes no route parameters, so a caller's routing fee cap is checked against its estimate instead. if let maxFeeMsat = params?.maxTotalRoutingFeeMsat { let feeSats: UInt64 @@ -1898,8 +1901,8 @@ extension LightningService { } } - func estimateRoutingFees(bolt11: String, amountSats: UInt64? = nil) async throws -> UInt64 { - if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: amountSats) { + func estimateRoutingFees(bolt11: String, amountSats: UInt64? = nil, channelsOnly: Bool = false) async throws -> UInt64 { + if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: amountSats) { return try await rail.estimateRoutingFees(bolt11: bolt11, amountSats: amountSats) } guard let node else { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index ce446259f..3b1508557 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -207,6 +207,7 @@ class AppViewModel: ObservableObject { @AppStorage("hasSeenProfileIntro") var hasSeenProfileIntro: Bool = false @AppStorage("hasSeenNotificationsIntro") var hasSeenNotificationsIntro: Bool = false @AppStorage("hasSeenQuickpayIntro") var hasSeenQuickpayIntro: Bool = false + @AppStorage("hasSeenMoveToSparkIntro") var hasSeenMoveToSparkIntro: Bool = false @AppStorage("hasSeenShopIntro") var hasSeenShopIntro: Bool = false @AppStorage("hasSeenTransferIntro") var hasSeenTransferIntro: Bool = false @AppStorage("hasSeenTransferToSpendingIntro") var hasSeenTransferToSpendingIntro: Bool = false @@ -560,6 +561,7 @@ class AppViewModel: ObservableObject { hasSeenProfileIntro = false hasSeenNotificationsIntro = false hasSeenQuickpayIntro = false + hasSeenMoveToSparkIntro = false hasSeenShopIntro = false hasSeenTransferIntro = false hasSeenTransferToSpendingIntro = false @@ -1779,7 +1781,11 @@ extension AppViewModel { func handleLdkNodeEvent(_ event: Event) { switch event { - case let .paymentReceived(paymentId, _, amountMsat, _): + case let .paymentReceived(paymentId, paymentHash, amountMsat, _): + // A move to Spark reports its outcome on its own screen. + if MoveToSparkPayments.contains(paymentHash) { + break + } Task { if let paymentId { if await CoreService.shared.activity.isActivitySeen(id: paymentId) { diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 5bae41280..1d1ad44a4 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -104,6 +104,7 @@ enum Route: Hashable { case connections case connectionDetail(channelId: String) case sparkConnectionDetail + case moveToSpark case closeConnection(channel: ChannelDetails) case node case electrumSettings diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index b94d45bfd..728459caf 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -17,6 +17,7 @@ enum SheetID: String, CaseIterable { case subscription case quickpay case usdtIntro + case moveToSpark case receive case receivedTx case btcpayConnection @@ -395,6 +396,18 @@ class SheetViewModel: ObservableObject { } } + var moveToSparkSheetItem: MoveToSparkSheetItem? { + get { + guard activeSheetConfiguration?.id == .moveToSpark else { return nil } + return MoveToSparkSheetItem() + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + var quickpaySheetItem: QuickpaySheetItem? { get { guard let config = activeSheetConfiguration, config.id == .quickpay else { return nil } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1c36919dd..26b9f061f 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1156,6 +1156,19 @@ class WalletViewModel: ObservableObject { return try await watchSend(hash: String(hash)) } + /// Pays from the channels only, with the routing fee capped at `maxFeeMsat`, and waits for the payment to settle. + func sendOverChannels(bolt11: String, sats: UInt64, maxFeeMsat: UInt64) async throws -> SettledLightningPayment { + // LDK's default route parameters, with the routing fee capped. + let params = RouteParametersConfig( + maxTotalRoutingFeeMsat: maxFeeMsat, + maxTotalCltvExpiryDelta: 1008, + maxPathCount: 10, + maxChannelSaturationPowerOfHalf: 2 + ) + let hash = try await lightningService.send(bolt11: bolt11, sats: sats, params: params, channelsOnly: true) + return try await watchSend(hash: String(hash)) + } + /// Observes task cancellation so `waitForLightningPayment`'s task group can unwind after its /// timeout instead of awaiting a continuation that only an LDK event would resume. private func watchSend(hash: String) async throws -> SettledLightningPayment { diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index 54bd62d78..fb7e2b1dc 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -9,6 +9,10 @@ struct HomeScreen: View { @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel + private var canMoveToSpark: Bool { + MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: SparkManager.shared.isReady && !SparkManager.shared.isExiting) + } + @State private var scrollPosition: Int? = 0 @State private var isEditingWidgets = false @State private var pullRefreshState = HomePullRefreshState() @@ -103,6 +107,12 @@ struct HomeScreen: View { .onDisappear { TimedSheetManager.shared.onPrimaryScreenExited() } + // Channels and Spark load after Home first checks its sheets, so the Move to Spark intro is checked again. + .onChange(of: canMoveToSpark) { _, canMove in + if canMove { + TimedSheetManager.shared.reevaluate() + } + } .onChange(of: app.requestedHomePage) { _, _ in consumeRequestedHomePage() } diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 35e4605b9..fba0b981c 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -71,6 +71,17 @@ struct LightningConnectionsView: View { EmptyStateView() .padding(.top, 16) } else { + if MoveToSparkMath.canMove( + channels: wallet.channels, + isSparkUsable: SparkManager.shared.isReady && !SparkManager.shared.isExiting + ) { + CustomButton(title: t("lightning__move_to_spark__nav_title")) { + navigation.navigate(.moveToSpark) + } + .accessibilityIdentifier("MoveToSpark") + .padding(.vertical, 16) + } + if SparkConnectionRow.isVisible { SettingsSectionHeader(t("lightning__spark")) SparkConnectionRow() diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift new file mode 100644 index 000000000..87c3d6c81 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -0,0 +1,165 @@ +import LDKNode +import SwiftUI + +/// Moves the channels' balance to the user's Spark wallet over Lightning, then offers to close the emptied channel. +struct MoveToSparkView: View { + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + @State private var manager = MoveToSparkManager() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__move_to_spark__nav_title")) + .padding(.bottom, 16) + + switch manager.state { + case .quoting: + quoting + case let .confirm(quote, isMoving): + confirm(quote: quote, isMoving: isMoving) + case let .done(amountSats, feeSats, closeChannel, hasRest): + done(amountSats: amountSats, feeSats: feeSats, closeChannel: closeChannel, hasRest: hasRest) + case let .tooLarge(amountSats, smallerSats): + tooLarge(amountSats: amountSats, smallerSats: smallerSats) + case let .failed(failure): + failed(failure) + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await manager.quote(wallet: wallet) + } + } + + private func header(title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 16) { + DisplayText(title, accentColor: .purpleAccent) + BodyMText(text) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func illustration(_ name: String) -> some View { + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 256, maxHeight: 256) + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + } + + private var quoting: some View { + VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__confirm_title"), text: t("lightning__move_to_spark__quoting")) + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func confirm(quote: MoveToSparkQuote, isMoving: Bool) -> some View { + VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__confirm_title"), text: t("lightning__move_to_spark__confirm_text")) + .padding(.bottom, 32) + SparkExitAmountRow(label: t("lightning__move_to_spark__amount"), sats: quote.amountSats) + .accessibilityIdentifier("MoveToSparkAmount") + SparkExitAmountRow(label: t("lightning__move_to_spark__fee"), sats: quote.feeSats) + .accessibilityIdentifier("MoveToSparkFee") + illustration("lightning") + SwipeButton(title: t("lightning__move_to_spark__swipe"), accentColor: .purpleAccent, isLoading: isMoving) { + await manager.move(wallet: wallet) + } + .accessibilityIdentifier("MoveToSparkSwipe") + } + } + + private func done(amountSats: UInt64, feeSats: UInt64, closeChannel: ChannelDetails?, hasRest: Bool) -> some View { + VStack(alignment: .leading, spacing: 0) { + header( + title: t("lightning__move_to_spark__done_title"), + text: hasRest ? t("lightning__move_to_spark__done_rest_text") : t("lightning__move_to_spark__done_text") + ) + .padding(.bottom, 32) + SparkExitAmountRow(label: t("lightning__move_to_spark__amount"), sats: amountSats) + .accessibilityIdentifier("MoveToSparkMoved") + SparkExitAmountRow(label: t("lightning__move_to_spark__fee"), sats: feeSats) + .accessibilityIdentifier("MoveToSparkFeePaid") + illustration("check") + HStack(spacing: 16) { + CustomButton(title: t("common__ok"), variant: .secondary) { + navigation.navigateBack() + } + if hasRest { + CustomButton(title: t("lightning__move_to_spark__move_rest")) { + await manager.quote(wallet: wallet) + } + .accessibilityIdentifier("MoveToSparkMoveRest") + } else { + CustomButton(title: t("lightning__move_to_spark__close")) { + // Closing returns to Lightning Connections through the channel's detail, as a close from there does. + navigation.navigateBack() + navigation.navigate(.connections) + if let closeChannel { + navigation.navigate(.connectionDetail(channelId: closeChannel.channelIdString)) + navigation.navigate(.closeConnection(channel: closeChannel)) + } + } + .accessibilityIdentifier("MoveToSparkClose") + } + } + } + } + + private func tooLarge(amountSats: UInt64, smallerSats: UInt64?) -> some View { + let next = smallerSats == nil ? t("lightning__move_to_spark__too_large_none") : t("lightning__move_to_spark__too_large_smaller") + let amount = t("lightning__move_to_spark__too_large_text", variables: ["amount": currency.primaryAmountText(sats: amountSats)]) + return VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__too_large_title"), text: "\(amount) \(next)") + illustration("exclamation-mark") + if let smallerSats { + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + navigation.navigateBack() + } + CustomButton(title: t("lightning__move_to_spark__try", variables: ["amount": currency.primaryAmountText(sats: smallerSats)])) { + await manager.quote(wallet: wallet, targetSats: smallerSats) + } + .accessibilityIdentifier("MoveToSparkTrySmaller") + } + } else { + CustomButton(title: t("common__back")) { + navigation.navigateBack() + } + } + } + } + + private func failed(_ failure: MoveToSparkFailure) -> some View { + let text = switch failure { + case .nothingToMove: t("lightning__move_to_spark__failed_nothing") + case .sparkUnavailable: t("lightning__move_to_spark__failed_spark") + case .unknown: t("lightning__move_to_spark__failed_unknown") + } + return VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__failed_title"), text: text) + illustration("cross") + if failure == .nothingToMove { + CustomButton(title: t("common__back")) { + navigation.navigateBack() + } + } else { + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + navigation.navigateBack() + } + CustomButton(title: t("common__try_again")) { + await manager.quote(wallet: wallet) + } + .accessibilityIdentifier("MoveToSparkRetry") + } + } + } + } +} diff --git a/Bitkit/Views/Sheets/MoveToSparkSheet.swift b/Bitkit/Views/Sheets/MoveToSparkSheet.swift new file mode 100644 index 000000000..dd775a94b --- /dev/null +++ b/Bitkit/Views/Sheets/MoveToSparkSheet.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct MoveToSparkSheetItem: SheetItem { + let id: SheetID = .moveToSpark + let size: SheetSize = .large +} + +/// Tells channel holders that Blocktank closes its channels and that moving the balance to Spark costs least. +struct MoveToSparkSheet: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var sheets: SheetViewModel + let config: MoveToSparkSheetItem + + var body: some View { + Sheet(id: .moveToSpark, data: config) { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("lightning__move_to_spark__nav_title")) + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 32) { + Image("lightning") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 8) { + DisplayText(t("lightning__move_to_spark__intro_title"), accentColor: .purpleAccent) + BodyMText(t("lightning__move_to_spark__intro_text")) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(minHeight: geometry.size.height, alignment: .bottom) + } + } + let buttonLayout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(spacing: 16)) + : AnyLayout(HStackLayout(spacing: 16)) + buttonLayout { + CustomButton(title: t("common__later"), variant: .secondary, shouldExpand: true) { + sheets.hideSheet() + } + .accessibilityIdentifier("MoveToSparkIntroLater") + CustomButton(title: t("lightning__move_to_spark__nav_title"), shouldExpand: true) { + sheets.hideSheet() + navigation.navigate(.moveToSpark) + } + .accessibilityIdentifier("MoveToSparkIntroMove") + } + .padding(.top, 32) + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("MoveToSparkIntro") + } + } +} diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index 3e8522614..7a5381403 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -7,6 +7,10 @@ struct SpendingWalletScreen: View { @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel + private var canMoveToSpark: Bool { + MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: SparkManager.shared.isReady && !SparkManager.shared.isExiting) + } + private var shouldShowOnboarding: Bool { let hasLightningActivities = activity.lightningActivities?.isEmpty == false return wallet.totalLightningSats == 0 && !hasLightningActivities @@ -41,6 +45,14 @@ struct SpendingWalletScreen: View { } if !shouldShowOnboarding { + if canMoveToSpark { + CustomButton(title: t("lightning__move_to_spark__nav_title")) { + navigation.navigate(.moveToSpark) + } + .accessibilityIdentifier("MoveToSpark") + .padding(.top, 28) + } + if wallet.channels?.isEmpty == false || SparkManager.shared.balanceSats > 0 { transferButton .transition(.move(edge: .leading).combined(with: .opacity)) diff --git a/BitkitTests/MoveToSparkMathTests.swift b/BitkitTests/MoveToSparkMathTests.swift new file mode 100644 index 000000000..0f90725f9 --- /dev/null +++ b/BitkitTests/MoveToSparkMathTests.swift @@ -0,0 +1,33 @@ +@testable import Bitkit +import XCTest + +final class MoveToSparkMathTests: XCTestCase { + func testFirstCandidateLeavesRoomForAGuessedFee() { + let amount = MoveToSparkMath.firstCandidate(wantedSats: 100_000, limitSats: 100_000) + XCTAssertLessThan(amount, 100_000) + XCTAssertEqual(amount, 100_000 - 500 - 2) + } + + func testFirstCandidateKeepsASmallerTarget() { + XCTAssertEqual(MoveToSparkMath.firstCandidate(wantedSats: 50000, limitSats: 100_000), 50000) + } + + func testAmountAfterFeeFitsUnderTheLimit() { + XCTAssertEqual(MoveToSparkMath.amountAfterFee(limitSats: 1_000_000, feeSats: 9000), 990_998) + XCTAssertEqual(MoveToSparkMath.amountAfterFee(limitSats: 5, feeSats: 9000), 0) + } + + func testFeeCapCoversTheQuotedFeeAndItsMargin() { + XCTAssertEqual(MoveToSparkMath.feeCapMsat(feeSats: 30), 32000) + } + + func testSmallerAmountHalvesUntilTheMinimum() { + XCTAssertEqual(MoveToSparkMath.smallerAmount(100_000), 50000) + XCTAssertNil(MoveToSparkMath.smallerAmount(MoveToSparkMath.minMoveSats)) + } + + func testMoveIsOfferedOnlyWithAUsableSpark() { + XCTAssertFalse(MoveToSparkMath.canMove(channels: [], isSparkUsable: true)) + XCTAssertFalse(MoveToSparkMath.canMove(channels: nil, isSparkUsable: false)) + } +} diff --git a/changelog.d/next/1206.added.md b/changelog.d/next/1206.added.md new file mode 100644 index 000000000..4562661bb --- /dev/null +++ b/changelog.d/next/1206.added.md @@ -0,0 +1 @@ +Move to Spark: move a Lightning connection's balance to Spark at the lowest fee, then close the emptied connection to return its reserve to Savings. diff --git a/journeys/transfer/move-to-spark.xml b/journeys/transfer/move-to-spark.xml new file mode 100644 index 000000000..83e2502e0 --- /dev/null +++ b/journeys/transfer/move-to-spark.xml @@ -0,0 +1,29 @@ + + + Verifies the post-update "Move to Spark" sheet and the move itself. With an LDK channel that can send at least + 1,000 sats and Spark connected, Home shows the "Move To Spark" sheet once (testTag "MoveToSparkIntro"); Spending + and Lightning Connections show a "Move To Spark" button (testTag "MoveToSpark") while the channel can still send. + The move quotes the routing fee over the channel only, pays the amount to a new invoice of the user's own Spark + wallet, and then offers to close the emptied connection. A wallet without such a channel never gets the sheet or + the button. + + The "too large to route" state (testTag "MoveToSparkTrySmaller") needs a route that cannot carry the balance; on a + direct channel to the Spark service provider every amount routes, so it is covered by MoveToSparkRepoTest and the + MoveToSparkScreen previews rather than here. + + Precondition: dev build pointed at a local Spark regtest (spark-sdk regtest/local), and a wallet with an open + channel to that stack's SSP Lightning node that holds most of its capacity on the wallet's side (open it from + Lightning Connections, Manual Setup, with the node URI the stack prints). Start on the wallet home screen with the + app freshly launched. + + + Wait on Home and verify the "Move To Spark" sheet (testTag "MoveToSparkIntro") shows, saying Blocktank is closing its Lightning connections + Tap "Later" (testTag "MoveToSparkIntroLater") and verify the sheet closes + Relaunch the app, wait on Home and verify the sheet does not show again + Open the Spending wallet and verify the "Move To Spark" button (testTag "MoveToSpark") shows + Tap "Move To Spark" and verify the screen shows an amount (testTag "MoveToSparkAmount") and a routing fee (testTag "MoveToSparkFee") + Swipe to move (testTag "MoveToSparkSwipe") and verify "Moved to Spark" shows the moved amount (testTag "MoveToSparkMoved") and "Close Connection" (testTag "MoveToSparkClose") + Tap "Close Connection", then "Close" on the close confirmation, and verify Lightning Connections shows no open connection and the Spark balance grew by the moved amount + Go back to Home, open Spending and verify the "Move To Spark" button is gone + + From 46533445fcbe9089aea880d834606a621ed2ab1d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 4 Nov 2026 19:20:40 +0100 Subject: [PATCH 011/378] test: keep the move to spark journey platform neutral --- journeys/transfer/move-to-spark.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/journeys/transfer/move-to-spark.xml b/journeys/transfer/move-to-spark.xml index 83e2502e0..4a47e1307 100644 --- a/journeys/transfer/move-to-spark.xml +++ b/journeys/transfer/move-to-spark.xml @@ -8,8 +8,8 @@ the button. The "too large to route" state (testTag "MoveToSparkTrySmaller") needs a route that cannot carry the balance; on a - direct channel to the Spark service provider every amount routes, so it is covered by MoveToSparkRepoTest and the - MoveToSparkScreen previews rather than here. + direct channel to the Spark service provider every amount routes, so it is covered by the quote's unit tests and the + PR's manual test rather than here. Precondition: dev build pointed at a local Spark regtest (spark-sdk regtest/local), and a wallet with an open channel to that stack's SSP Lightning node that holds most of its capacity on the wallet's side (open it from From 25d82e8ecfce8a8433c39ec77ccca2e14b61b1a0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 4 Nov 2026 19:29:22 +0100 Subject: [PATCH 012/378] fix: list move to spark after the channels in connections --- .../Advanced/LightningConnectionsView.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index fba0b981c..cbef45199 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -71,17 +71,6 @@ struct LightningConnectionsView: View { EmptyStateView() .padding(.top, 16) } else { - if MoveToSparkMath.canMove( - channels: wallet.channels, - isSparkUsable: SparkManager.shared.isReady && !SparkManager.shared.isExiting - ) { - CustomButton(title: t("lightning__move_to_spark__nav_title")) { - navigation.navigate(.moveToSpark) - } - .accessibilityIdentifier("MoveToSpark") - .padding(.vertical, 16) - } - if SparkConnectionRow.isVisible { SettingsSectionHeader(t("lightning__spark")) SparkConnectionRow() @@ -166,6 +155,17 @@ struct LightningConnectionsView: View { } } + if MoveToSparkMath.canMove( + channels: wallet.channels, + isSparkUsable: SparkManager.shared.isReady && !SparkManager.shared.isExiting + ) { + CustomButton(title: t("lightning__move_to_spark__nav_title")) { + navigation.navigate(.moveToSpark) + } + .accessibilityIdentifier("MoveToSpark") + .padding(.vertical, 16) + } + // Closed Connections section if showClosedConnections && !closedChannels.isEmpty { SettingsSectionHeader(t("lightning__conn_closed")) From ada7ad68ca20f046f1495979a6c51429509da647 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 4 Nov 2026 20:23:51 +0100 Subject: [PATCH 013/378] fix: fit move to spark labels and headlines to small screens --- .../Localization/ar.lproj/Localizable.strings | 14 +++++++------- .../Localization/ca.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/cs.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/de.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/el.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/en.lproj/Localizable.strings | 16 ++++++++-------- .../es-419.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/es.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/fr.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/it.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/nl.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/pl.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/pt-BR.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/pt.lproj/Localizable.strings | 16 ++++++++-------- .../Localization/ru.lproj/Localizable.strings | 16 ++++++++-------- .../Settings/Advanced/MoveToSparkView.swift | 6 +++--- Bitkit/Views/Sheets/MoveToSparkSheet.swift | 2 +- 17 files changed, 123 insertions(+), 123 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 8292254a8..2b25b4605 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -293,20 +293,21 @@ "lightning__order_fee" = "رسوم الطلب"; "lightning__balance" = "الرصيد"; "lightning__move_to_spark__amount" = "المبلغ"; -"lightning__move_to_spark__close" = "إغلاق الاتصال"; "lightning__move_to_spark__confirm_text" = "ينتقل رصيدك إلى Spark عبر Lightning، وهي أقل طريقة رسومًا للإبقاء عليه في الإنفاق. رسوم التوجيه أدناه هي أقصى ما تدفعه."; -"lightning__move_to_spark__confirm_title" = "الانتقال إلى Spark"; +"lightning__move_to_spark__confirm_title" = "الانتقال إلى\nSpark"; "lightning__move_to_spark__done_rest_text" = "جزء من رصيدك الآن على Spark. انقل الباقي بالطريقة نفسها، ثم أغلق الاتصال الذي أصبح فارغًا."; "lightning__move_to_spark__done_text" = "رصيدك الآن على Spark. أغلق الاتصال الذي أصبح فارغًا لإعادة احتياطيه إلى المدخرات."; -"lightning__move_to_spark__done_title" = "تم النقل إلى Spark"; +"lightning__move_to_spark__done_title" = "تم النقل إلى\nSpark"; "lightning__move_to_spark__failed_nothing" = "لم يتبق في اتصالات Lightning لديك رصيد للنقل."; "lightning__move_to_spark__failed_spark" = "Spark غير جاهز للاستلام بعد. حاول مرة أخرى بعد قليل."; -"lightning__move_to_spark__failed_title" = "تعذّر النقل"; +"lightning__move_to_spark__failed_title" = "تعذّر إتمام\nالنقل"; "lightning__move_to_spark__failed_unknown" = "حدث خطأ أثناء تحضير النقل. حاول مرة أخرى."; "lightning__move_to_spark__fee" = "رسوم التوجيه"; "lightning__move_to_spark__in_flight_text" = "الدفعة في طريقها وستظهر في Spark بمجرد إتمامها. ثم أغلق الاتصال الفارغ لإعادة احتياطيه إلى المدخرات."; "lightning__move_to_spark__intro_text" = "يقوم Blocktank بإغلاق اتصالات Lightning الخاصة به. انقل رصيدك إلى Spark الآن: فهو الخيار الأقل رسومًا، ويبقى ضمن الإنفاق."; -"lightning__move_to_spark__intro_title" = "اتصالك قيد الإغلاق"; +"lightning__move_to_spark__intro_title" = "اتصالك على وشك\nالإغلاق"; +"lightning__move_to_spark__move_half" = "نقل النصف"; +"lightning__move_to_spark__move_now" = "نقل الآن"; "lightning__move_to_spark__move_rest" = "نقل الباقي"; "lightning__move_to_spark__nav_title" = "النقل إلى Spark"; "lightning__move_to_spark__quoting" = "جارٍ البحث عن مسار ورسومه…"; @@ -314,8 +315,7 @@ "lightning__move_to_spark__too_large_none" = "أغلق الاتصال بدلاً من ذلك لإعادة الرصيد إلى المدخرات."; "lightning__move_to_spark__too_large_smaller" = "يمكنك نقله على دفعات أصغر."; "lightning__move_to_spark__too_large_text" = "{amount} أكبر من أن يُوجَّه إلى Spark في دفعة واحدة."; -"lightning__move_to_spark__too_large_title" = "المبلغ كبير جدًا"; -"lightning__move_to_spark__try" = "نقل {amount}"; +"lightning__move_to_spark__too_large_title" = "المبلغ كبير\nجدًا"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "رسوم المطالبة"; "lightning__spark_confirmations" = "{count}/3 تأكيدات"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index a3c35f2e6..792e69310 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -330,29 +330,29 @@ "lightning__order_fee" = "Comissió de comanda"; "lightning__balance" = "Saldo"; "lightning__move_to_spark__amount" = "Import"; -"lightning__move_to_spark__close" = "Tanca la connexió"; "lightning__move_to_spark__confirm_text" = "El teu saldo passa a Spark per Lightning, la manera amb menys tarifes de mantenir-lo a Despesa. La tarifa d'enrutament de sota és el màxim que pagues."; -"lightning__move_to_spark__confirm_title" = "Moure a Spark"; +"lightning__move_to_spark__confirm_title" = "Moure a\nSpark"; "lightning__move_to_spark__done_rest_text" = "Una part del teu saldo ja és a Spark. Mou la resta de la mateixa manera i després tanca la connexió buida."; "lightning__move_to_spark__done_text" = "El teu saldo ja és a Spark. Tanca la connexió buida per tornar-ne la reserva als Estalvis."; -"lightning__move_to_spark__done_title" = "Mogut a Spark"; +"lightning__move_to_spark__done_title" = "Mogut a\nSpark"; "lightning__move_to_spark__failed_nothing" = "Les teves connexions Lightning ja no tenen saldo per moure."; "lightning__move_to_spark__failed_spark" = "Spark encara no està a punt per rebre. Torna-ho a provar d'aquí a un moment."; -"lightning__move_to_spark__failed_title" = "No s'ha pogut moure"; +"lightning__move_to_spark__failed_title" = "No s'ha pogut\nmoure"; "lightning__move_to_spark__failed_unknown" = "Alguna cosa ha anat malament en preparar el moviment. Torna-ho a provar."; "lightning__move_to_spark__fee" = "Tarifa d'enrutament"; "lightning__move_to_spark__in_flight_text" = "El pagament està en camí i apareixerà a Spark quan s'hagi liquidat. Després tanca la connexió buida per tornar-ne la reserva als Estalvis."; "lightning__move_to_spark__intro_text" = "Blocktank està tancant les seves connexions Lightning. Mou ara el teu saldo a Spark: és l'opció amb menys tarifes i es manté a Despesa."; -"lightning__move_to_spark__intro_title" = "La teva connexió es tanca"; -"lightning__move_to_spark__move_rest" = "Moure la resta"; +"lightning__move_to_spark__intro_title" = "La teva connexió\nes tanca"; +"lightning__move_to_spark__move_half" = "Moure meitat"; +"lightning__move_to_spark__move_now" = "Moure ara"; +"lightning__move_to_spark__move_rest" = "Moure resta"; "lightning__move_to_spark__nav_title" = "Moure a Spark"; "lightning__move_to_spark__quoting" = "Cercant una ruta i la seva tarifa…"; "lightning__move_to_spark__swipe" = "Desplaça per moure"; "lightning__move_to_spark__too_large_none" = "Tanca la connexió per tornar el saldo als Estalvis."; "lightning__move_to_spark__too_large_smaller" = "El pots moure en parts més petites."; "lightning__move_to_spark__too_large_text" = "{amount} és massa gran per enrutar-se a Spark en un sol pagament."; -"lightning__move_to_spark__too_large_title" = "Import massa gran"; -"lightning__move_to_spark__try" = "Moure {amount}"; +"lightning__move_to_spark__too_large_title" = "Import massa\ngran"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comissió de reclamació"; "lightning__spark_confirmations" = "{count}/3 confirmacions"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 09fb959fc..27b9677e0 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -346,29 +346,29 @@ "lightning__order_fee" = "Poplatek za objednávku"; "lightning__balance" = "Zůstatek"; "lightning__move_to_spark__amount" = "Částka"; -"lightning__move_to_spark__close" = "Uzavřít spojení"; "lightning__move_to_spark__confirm_text" = "Váš zůstatek se přesune do Spark přes Lightning, což je způsob s nejnižšími poplatky, jak jej udržet v Útratách. Poplatek za směrování níže je maximum, které zaplatíte."; -"lightning__move_to_spark__confirm_title" = "Přesun do Spark"; +"lightning__move_to_spark__confirm_title" = "Přesun do\nSpark"; "lightning__move_to_spark__done_rest_text" = "Část vašeho zůstatku je nyní ve Spark. Zbytek přesuňte stejným způsobem a poté uzavřete vyprázdněné spojení."; "lightning__move_to_spark__done_text" = "Váš zůstatek je nyní ve Spark. Uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor."; -"lightning__move_to_spark__done_title" = "Přesunuto do Spark"; +"lightning__move_to_spark__done_title" = "Přesunuto do\nSpark"; "lightning__move_to_spark__failed_nothing" = "Vaše spojení Lightning už nemají žádný zůstatek k přesunu."; "lightning__move_to_spark__failed_spark" = "Spark zatím není připraven přijímat. Zkuste to za chvíli znovu."; -"lightning__move_to_spark__failed_title" = "Přesun se nezdařil"; +"lightning__move_to_spark__failed_title" = "Přesun se\nnezdařil"; "lightning__move_to_spark__failed_unknown" = "Při přípravě přesunu se něco pokazilo. Zkuste to znovu."; "lightning__move_to_spark__fee" = "Poplatek za směrování"; "lightning__move_to_spark__in_flight_text" = "Platba je na cestě a ve Spark se zobrazí po vypořádání. Poté uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor."; "lightning__move_to_spark__intro_text" = "Blocktank uzavírá svá spojení Lightning. Přesuňte svůj zůstatek do Spark hned: je to možnost s nejnižšími poplatky a zůstane v Útratách."; -"lightning__move_to_spark__intro_title" = "Vaše spojení se uzavírá"; -"lightning__move_to_spark__move_rest" = "Přesunout zbytek"; +"lightning__move_to_spark__intro_title" = "Vaše spojení\nse uzavírá"; +"lightning__move_to_spark__move_half" = "Přesun půlku"; +"lightning__move_to_spark__move_now" = "Přesun hned"; +"lightning__move_to_spark__move_rest" = "Zbytek"; "lightning__move_to_spark__nav_title" = "Přesun do Spark"; "lightning__move_to_spark__quoting" = "Hledání trasy a jejího poplatku…"; "lightning__move_to_spark__swipe" = "Přejetím přesuňte"; "lightning__move_to_spark__too_large_none" = "Místo toho uzavřete spojení, aby se zůstatek vrátil do Úspor."; "lightning__move_to_spark__too_large_smaller" = "Můžete jej přesunout po menších částech."; "lightning__move_to_spark__too_large_text" = "{amount} je příliš velká částka na směrování do Spark v jedné platbě."; -"lightning__move_to_spark__too_large_title" = "Částka je příliš velká"; -"lightning__move_to_spark__try" = "Přesunout {amount}"; +"lightning__move_to_spark__too_large_title" = "Částka je příliš\nvelká"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Poplatek za připsání"; "lightning__spark_confirmations" = "{count}/3 potvrzení"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index e6159b281..bc1567b02 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -344,29 +344,29 @@ "lightning__order_fee" = "Auftragsgebühr"; "lightning__balance" = "Guthaben"; "lightning__move_to_spark__amount" = "Betrag"; -"lightning__move_to_spark__close" = "Verbindung schließen"; "lightning__move_to_spark__confirm_text" = "Ihr Guthaben wechselt über Lightning zu Spark, dem günstigsten Weg, es im Ausgabenkonto zu behalten. Die Routing-Gebühr unten ist das Maximum, das Sie zahlen."; -"lightning__move_to_spark__confirm_title" = "Zu Spark verschieben"; +"lightning__move_to_spark__confirm_title" = "Verschieben zu\nSpark"; "lightning__move_to_spark__done_rest_text" = "Ein Teil Ihres Guthabens ist jetzt bei Spark. Verschieben Sie den Rest auf dieselbe Weise und schließen Sie dann die geleerte Verbindung."; "lightning__move_to_spark__done_text" = "Ihr Guthaben ist jetzt bei Spark. Schließen Sie die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben."; -"lightning__move_to_spark__done_title" = "Zu Spark verschoben"; +"lightning__move_to_spark__done_title" = "Verschoben zu\nSpark"; "lightning__move_to_spark__failed_nothing" = "Ihre Lightning-Verbindungen haben kein Guthaben mehr zum Verschieben."; "lightning__move_to_spark__failed_spark" = "Spark ist noch nicht bereit zum Empfangen. Versuchen Sie es gleich noch einmal."; -"lightning__move_to_spark__failed_title" = "Verschieben fehlgeschlagen"; +"lightning__move_to_spark__failed_title" = "Das Verschieben\nschlug fehl"; "lightning__move_to_spark__failed_unknown" = "Beim Vorbereiten der Verschiebung ist etwas schiefgelaufen. Versuchen Sie es erneut."; "lightning__move_to_spark__fee" = "Routing-Gebühr"; "lightning__move_to_spark__in_flight_text" = "Die Zahlung ist unterwegs und erscheint in Spark, sobald sie abgeschlossen ist. Schließen Sie dann die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben."; "lightning__move_to_spark__intro_text" = "Blocktank schließt seine Lightning-Verbindungen. Verschieben Sie Ihr Guthaben jetzt zu Spark: Das ist die günstigste Option, und es bleibt im Ausgabenkonto."; -"lightning__move_to_spark__intro_title" = "Ihre Verbindung wird geschlossen"; -"lightning__move_to_spark__move_rest" = "Rest verschieben"; +"lightning__move_to_spark__intro_title" = "Ihre Verbindung wird\ngeschlossen"; +"lightning__move_to_spark__move_half" = "Halb senden"; +"lightning__move_to_spark__move_now" = "Jetzt senden"; +"lightning__move_to_spark__move_rest" = "Rest senden"; "lightning__move_to_spark__nav_title" = "Zu Spark verschieben"; "lightning__move_to_spark__quoting" = "Route und Gebühr werden gesucht…"; "lightning__move_to_spark__swipe" = "Swipe zum Verschieben"; "lightning__move_to_spark__too_large_none" = "Schließen Sie stattdessen die Verbindung, um das Guthaben an das Sparkonto zurückzugeben."; "lightning__move_to_spark__too_large_smaller" = "Sie können es in kleineren Teilen verschieben."; "lightning__move_to_spark__too_large_text" = "{amount} ist zu groß, um in einer Zahlung zu Spark geroutet zu werden."; -"lightning__move_to_spark__too_large_title" = "Betrag zu groß"; -"lightning__move_to_spark__try" = "{amount} verschieben"; +"lightning__move_to_spark__too_large_title" = "Betrag ist\nzu groß"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Einlösegebühr"; "lightning__spark_confirmations" = "{count}/3 Bestätigungen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 282d46583..bbb5cabf6 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -317,29 +317,29 @@ "lightning__order_fee" = "Τέλος παραγγελίας"; "lightning__balance" = "Υπόλοιπο"; "lightning__move_to_spark__amount" = "Ποσό"; -"lightning__move_to_spark__close" = "Κλείσιμο σύνδεσης"; "lightning__move_to_spark__confirm_text" = "Το υπόλοιπό σας μεταφέρεται στο Spark μέσω Lightning, τον τρόπο με τα χαμηλότερα τέλη για να παραμείνει στις Δαπάνες. Το τέλος δρομολόγησης παρακάτω είναι το μέγιστο που πληρώνετε."; -"lightning__move_to_spark__confirm_title" = "Μεταφορά στο Spark"; +"lightning__move_to_spark__confirm_title" = "Μεταφορά στο\nSpark"; "lightning__move_to_spark__done_rest_text" = "Ένα μέρος του υπολοίπου σας βρίσκεται πλέον στο Spark. Μεταφέρετε τα υπόλοιπα με τον ίδιο τρόπο και έπειτα κλείστε την άδεια σύνδεση."; "lightning__move_to_spark__done_text" = "Το υπόλοιπό σας βρίσκεται πλέον στο Spark. Κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις."; -"lightning__move_to_spark__done_title" = "Μεταφέρθηκε στο Spark"; +"lightning__move_to_spark__done_title" = "Μεταφέρθηκε στο\nSpark"; "lightning__move_to_spark__failed_nothing" = "Οι συνδέσεις Lightning σας δεν έχουν πλέον υπόλοιπο για μεταφορά."; "lightning__move_to_spark__failed_spark" = "Το Spark δεν είναι ακόμη έτοιμο να λάβει. Δοκιμάστε ξανά σε λίγο."; -"lightning__move_to_spark__failed_title" = "Αποτυχία μεταφοράς"; +"lightning__move_to_spark__failed_title" = "Η μεταφορά\nαπέτυχε"; "lightning__move_to_spark__failed_unknown" = "Κάτι πήγε στραβά κατά την προετοιμασία της μεταφοράς. Δοκιμάστε ξανά."; "lightning__move_to_spark__fee" = "Τέλος δρομολόγησης"; "lightning__move_to_spark__in_flight_text" = "Η πληρωμή βρίσκεται σε εξέλιξη και θα εμφανιστεί στο Spark μόλις διακανονιστεί. Έπειτα κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις."; "lightning__move_to_spark__intro_text" = "Το Blocktank κλείνει τις συνδέσεις Lightning του. Μεταφέρετε το υπόλοιπό σας στο Spark τώρα: είναι η επιλογή με τα χαμηλότερα τέλη και παραμένει στις Δαπάνες."; -"lightning__move_to_spark__intro_title" = "Η σύνδεσή σας κλείνει"; -"lightning__move_to_spark__move_rest" = "Μεταφορά των υπολοίπων"; +"lightning__move_to_spark__intro_title" = "Η σύνδεσή σας\nκλείνει"; +"lightning__move_to_spark__move_half" = "Το μισό"; +"lightning__move_to_spark__move_now" = "Μεταφορά"; +"lightning__move_to_spark__move_rest" = "Τα υπόλοιπα"; "lightning__move_to_spark__nav_title" = "Μεταφορά στο Spark"; "lightning__move_to_spark__quoting" = "Εύρεση διαδρομής και του τέλους της…"; "lightning__move_to_spark__swipe" = "Σύρε για μεταφορά"; "lightning__move_to_spark__too_large_none" = "Κλείστε αντί αυτού τη σύνδεση για να επιστρέψει το υπόλοιπο στις Αποταμιεύσεις."; "lightning__move_to_spark__too_large_smaller" = "Μπορείτε να το μεταφέρετε σε μικρότερα μέρη."; "lightning__move_to_spark__too_large_text" = "Το {amount} είναι πολύ μεγάλο για δρομολόγηση στο Spark με μία πληρωμή."; -"lightning__move_to_spark__too_large_title" = "Το ποσό είναι πολύ μεγάλο"; -"lightning__move_to_spark__try" = "Μεταφορά {amount}"; +"lightning__move_to_spark__too_large_title" = "Το ποσό είναι\nπολύ μεγάλο"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Χρέωση εξαργύρωσης"; "lightning__spark_confirmations" = "{count}/3 επιβεβαιώσεις"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index fb59f88ab..290f12f1c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -339,29 +339,29 @@ "lightning__order_fee" = "Order fee"; "lightning__balance" = "Balance"; "lightning__move_to_spark__amount" = "Amount"; -"lightning__move_to_spark__close" = "Close Connection"; "lightning__move_to_spark__confirm_text" = "Your balance moves to Spark over Lightning, the lowest-fee way to keep it in Spending. The routing fee below is the most you pay."; -"lightning__move_to_spark__confirm_title" = "Move to Spark"; +"lightning__move_to_spark__confirm_title" = "Move to\nSpark"; "lightning__move_to_spark__done_rest_text" = "Part of your balance is on Spark now. Move the rest the same way, then close the emptied connection."; "lightning__move_to_spark__done_text" = "Your balance is on Spark now. Close the emptied connection to return its reserve to Savings."; -"lightning__move_to_spark__done_title" = "Moved to Spark"; +"lightning__move_to_spark__done_title" = "Moved to\nSpark"; "lightning__move_to_spark__failed_nothing" = "Your Lightning connections have no balance left to move."; "lightning__move_to_spark__failed_spark" = "Spark is not ready to receive yet. Try again in a moment."; -"lightning__move_to_spark__failed_title" = "Could not move"; +"lightning__move_to_spark__failed_title" = "Could not\nmove"; "lightning__move_to_spark__failed_unknown" = "Something went wrong while preparing the move. Try again."; "lightning__move_to_spark__fee" = "Routing fee"; "lightning__move_to_spark__in_flight_text" = "The payment is on its way and shows in Spark once it settles. Then close the emptied connection to return its reserve to Savings."; "lightning__move_to_spark__intro_text" = "Blocktank is closing its Lightning connections. Move your balance to Spark now: it is the lowest-fee option, and it stays in Spending."; -"lightning__move_to_spark__intro_title" = "Your connection is closing"; -"lightning__move_to_spark__move_rest" = "Move The Rest"; +"lightning__move_to_spark__intro_title" = "Your connection\nis closing"; +"lightning__move_to_spark__move_half" = "Move Half"; +"lightning__move_to_spark__move_now" = "Move Now"; +"lightning__move_to_spark__move_rest" = "Move Rest"; "lightning__move_to_spark__nav_title" = "Move To Spark"; "lightning__move_to_spark__quoting" = "Finding a route and its fee…"; "lightning__move_to_spark__swipe" = "Swipe To Move"; "lightning__move_to_spark__too_large_none" = "Close the connection instead to return the balance to Savings."; "lightning__move_to_spark__too_large_smaller" = "You can move it in smaller parts."; "lightning__move_to_spark__too_large_text" = "{amount} is too large to route to Spark in one payment."; -"lightning__move_to_spark__too_large_title" = "Amount too large"; -"lightning__move_to_spark__try" = "Move {amount}"; +"lightning__move_to_spark__too_large_title" = "Amount\ntoo large"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Claim fee"; "lightning__spark_confirmations" = "{count}/3 confirmations"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index bc525c9af..e60b8669b 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -347,29 +347,29 @@ "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; "lightning__move_to_spark__amount" = "Monto"; -"lightning__move_to_spark__close" = "Terminar conexión"; "lightning__move_to_spark__confirm_text" = "Su saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que paga."; -"lightning__move_to_spark__confirm_title" = "Mover a Spark"; +"lightning__move_to_spark__confirm_title" = "Mover a\nSpark"; "lightning__move_to_spark__done_rest_text" = "Una parte de su saldo ya está en Spark. Mueva el resto de la misma forma y luego termine la conexión vacía."; "lightning__move_to_spark__done_text" = "Su saldo ya está en Spark. Termine la conexión vacía para devolver su reserva a Ahorro."; -"lightning__move_to_spark__done_title" = "Movido a Spark"; +"lightning__move_to_spark__done_title" = "Movido a\nSpark"; "lightning__move_to_spark__failed_nothing" = "Sus conexiones Lightning ya no tienen saldo para mover."; "lightning__move_to_spark__failed_spark" = "Spark aún no está listo para recibir. Inténtelo de nuevo en un momento."; -"lightning__move_to_spark__failed_title" = "No se pudo mover"; +"lightning__move_to_spark__failed_title" = "No se pudo\nmover"; "lightning__move_to_spark__failed_unknown" = "Algo salió mal al preparar el movimiento. Inténtelo de nuevo."; "lightning__move_to_spark__fee" = "Comisión de enrutamiento"; "lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; "lightning__move_to_spark__intro_text" = "Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto."; -"lightning__move_to_spark__intro_title" = "Su conexión se está cerrando"; -"lightning__move_to_spark__move_rest" = "Mover el resto"; +"lightning__move_to_spark__intro_title" = "Su conexión\nse cierra"; +"lightning__move_to_spark__move_half" = "Mover mitad"; +"lightning__move_to_spark__move_now" = "Mover ahora"; +"lightning__move_to_spark__move_rest" = "Mover resto"; "lightning__move_to_spark__nav_title" = "Mover a Spark"; "lightning__move_to_spark__quoting" = "Buscando una ruta y su comisión…"; "lightning__move_to_spark__swipe" = "Deslizar para mover"; "lightning__move_to_spark__too_large_none" = "Termine la conexión para devolver el saldo a Ahorro."; "lightning__move_to_spark__too_large_smaller" = "Puede moverlo en partes más pequeñas."; "lightning__move_to_spark__too_large_text" = "{amount} es demasiado grande para enrutarse a Spark en un solo pago."; -"lightning__move_to_spark__too_large_title" = "Monto demasiado grande"; -"lightning__move_to_spark__try" = "Mover {amount}"; +"lightning__move_to_spark__too_large_title" = "El monto es\nexcesivo"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comisión de reclamo"; "lightning__spark_confirmations" = "{count}/3 confirmaciones"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index ce6cd6080..643c37078 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -331,29 +331,29 @@ "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; "lightning__move_to_spark__amount" = "Cantidad"; -"lightning__move_to_spark__close" = "Terminar conexión"; "lightning__move_to_spark__confirm_text" = "Su saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que paga."; -"lightning__move_to_spark__confirm_title" = "Mover a Spark"; +"lightning__move_to_spark__confirm_title" = "Mover a\nSpark"; "lightning__move_to_spark__done_rest_text" = "Una parte de su saldo ya está en Spark. Mueva el resto de la misma forma y luego termine la conexión vacía."; "lightning__move_to_spark__done_text" = "Su saldo ya está en Spark. Termine la conexión vacía para devolver su reserva a Ahorro."; -"lightning__move_to_spark__done_title" = "Movido a Spark"; +"lightning__move_to_spark__done_title" = "Movido a\nSpark"; "lightning__move_to_spark__failed_nothing" = "Sus conexiones Lightning ya no tienen saldo para mover."; "lightning__move_to_spark__failed_spark" = "Spark aún no está listo para recibir. Inténtelo de nuevo en un momento."; -"lightning__move_to_spark__failed_title" = "No se pudo mover"; +"lightning__move_to_spark__failed_title" = "No se pudo\nmover"; "lightning__move_to_spark__failed_unknown" = "Algo salió mal al preparar el movimiento. Inténtelo de nuevo."; "lightning__move_to_spark__fee" = "Comisión de enrutamiento"; "lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; "lightning__move_to_spark__intro_text" = "Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto."; -"lightning__move_to_spark__intro_title" = "Su conexión se está cerrando"; -"lightning__move_to_spark__move_rest" = "Mover el resto"; +"lightning__move_to_spark__intro_title" = "Su conexión\nse cierra"; +"lightning__move_to_spark__move_half" = "Mover mitad"; +"lightning__move_to_spark__move_now" = "Mover ahora"; +"lightning__move_to_spark__move_rest" = "Mover resto"; "lightning__move_to_spark__nav_title" = "Mover a Spark"; "lightning__move_to_spark__quoting" = "Buscando una ruta y su comisión…"; "lightning__move_to_spark__swipe" = "Deslizar para mover"; "lightning__move_to_spark__too_large_none" = "Termine la conexión para devolver el saldo a Ahorro."; "lightning__move_to_spark__too_large_smaller" = "Puede moverlo en partes más pequeñas."; "lightning__move_to_spark__too_large_text" = "{amount} es demasiado grande para enrutarse a Spark en un solo pago."; -"lightning__move_to_spark__too_large_title" = "Cantidad demasiado grande"; -"lightning__move_to_spark__try" = "Mover {amount}"; +"lightning__move_to_spark__too_large_title" = "La cantidad es\nexcesiva"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comisión de reclamo"; "lightning__spark_confirmations" = "{count}/3 confirmaciones"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 48b53a2d3..f9e31ea35 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -347,29 +347,29 @@ "lightning__order_fee" = "Frais"; "lightning__balance" = "Solde"; "lightning__move_to_spark__amount" = "Montant"; -"lightning__move_to_spark__close" = "Fermer la connexion"; "lightning__move_to_spark__confirm_text" = "Votre solde passe à Spark via Lightning, le moyen le moins coûteux de le garder dans Dépenses. Les frais de routage ci-dessous sont le maximum que vous payez."; -"lightning__move_to_spark__confirm_title" = "Déplacer vers Spark"; +"lightning__move_to_spark__confirm_title" = "Déplacer vers\nSpark"; "lightning__move_to_spark__done_rest_text" = "Une partie de votre solde est maintenant sur Spark. Déplacez le reste de la même façon, puis fermez la connexion vidée."; "lightning__move_to_spark__done_text" = "Votre solde est maintenant sur Spark. Fermez la connexion vidée pour rendre sa réserve à Épargnes."; -"lightning__move_to_spark__done_title" = "Déplacé vers Spark"; +"lightning__move_to_spark__done_title" = "Déplacé vers\nSpark"; "lightning__move_to_spark__failed_nothing" = "Vos connexions Lightning n'ont plus de solde à déplacer."; "lightning__move_to_spark__failed_spark" = "Spark n'est pas encore prêt à recevoir. Réessayez dans un instant."; -"lightning__move_to_spark__failed_title" = "Échec du déplacement"; +"lightning__move_to_spark__failed_title" = "Le déplacement\na échoué"; "lightning__move_to_spark__failed_unknown" = "Un problème est survenu pendant la préparation du déplacement. Réessayez."; "lightning__move_to_spark__fee" = "Frais de routage"; "lightning__move_to_spark__in_flight_text" = "Le paiement est en route et apparaîtra sur Spark une fois réglé. Fermez ensuite la connexion vidée pour rendre sa réserve à Épargnes."; "lightning__move_to_spark__intro_text" = "Blocktank ferme ses connexions Lightning. Déplacez votre solde vers Spark maintenant : c'est l'option la moins coûteuse, et il reste dans Dépenses."; -"lightning__move_to_spark__intro_title" = "Votre connexion se ferme"; -"lightning__move_to_spark__move_rest" = "Déplacer le reste"; +"lightning__move_to_spark__intro_title" = "Votre connexion\nse ferme"; +"lightning__move_to_spark__move_half" = "Moitié"; +"lightning__move_to_spark__move_now" = "Déplacer"; +"lightning__move_to_spark__move_rest" = "Le reste"; "lightning__move_to_spark__nav_title" = "Déplacer vers Spark"; "lightning__move_to_spark__quoting" = "Recherche d'un itinéraire et de ses frais…"; "lightning__move_to_spark__swipe" = "Glissez pour déplacer"; "lightning__move_to_spark__too_large_none" = "Fermez plutôt la connexion pour rendre le solde à Épargnes."; "lightning__move_to_spark__too_large_smaller" = "Vous pouvez le déplacer en plus petites parties."; "lightning__move_to_spark__too_large_text" = "{amount} est trop élevé pour être acheminé vers Spark en un seul paiement."; -"lightning__move_to_spark__too_large_title" = "Montant trop élevé"; -"lightning__move_to_spark__try" = "Déplacer {amount}"; +"lightning__move_to_spark__too_large_title" = "Le montant est\ntrop élevé"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Frais de réclamation"; "lightning__spark_confirmations" = "{count}/3 confirmations"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 0a1724b03..a30b36f4f 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -340,29 +340,29 @@ "lightning__order_fee" = "Fee Ordine"; "lightning__balance" = "Saldo"; "lightning__move_to_spark__amount" = "Importo"; -"lightning__move_to_spark__close" = "Chiudi Connessione"; "lightning__move_to_spark__confirm_text" = "Il tuo saldo passa a Spark tramite Lightning, il modo a commissioni più basse per mantenerlo in Spesa. La commissione di instradamento qui sotto è il massimo che paghi."; -"lightning__move_to_spark__confirm_title" = "Sposta su Spark"; +"lightning__move_to_spark__confirm_title" = "Sposta su\nSpark"; "lightning__move_to_spark__done_rest_text" = "Una parte del tuo saldo è ora su Spark. Sposta il resto allo stesso modo, poi chiudi la connessione svuotata."; "lightning__move_to_spark__done_text" = "Il tuo saldo è ora su Spark. Chiudi la connessione svuotata per restituire la sua riserva ai Risparmi."; -"lightning__move_to_spark__done_title" = "Spostato su Spark"; +"lightning__move_to_spark__done_title" = "Spostato su\nSpark"; "lightning__move_to_spark__failed_nothing" = "Le tue connessioni Lightning non hanno più saldo da spostare."; "lightning__move_to_spark__failed_spark" = "Spark non è ancora pronto a ricevere. Riprova tra un momento."; -"lightning__move_to_spark__failed_title" = "Impossibile spostare"; +"lightning__move_to_spark__failed_title" = "Impossibile\nspostare"; "lightning__move_to_spark__failed_unknown" = "Qualcosa è andato storto durante la preparazione dello spostamento. Riprova."; "lightning__move_to_spark__fee" = "Commissione di instradamento"; "lightning__move_to_spark__in_flight_text" = "Il pagamento è in arrivo e compare su Spark una volta completato. Poi chiudi la connessione svuotata per restituire la sua riserva ai Risparmi."; "lightning__move_to_spark__intro_text" = "Blocktank sta chiudendo le sue connessioni Lightning. Sposta ora il tuo saldo su Spark: è l'opzione con le commissioni più basse e resta in Spesa."; -"lightning__move_to_spark__intro_title" = "La tua connessione si sta chiudendo"; -"lightning__move_to_spark__move_rest" = "Sposta Il Resto"; +"lightning__move_to_spark__intro_title" = "La tua connessione\nsi sta chiudendo"; +"lightning__move_to_spark__move_half" = "Sposta Metà"; +"lightning__move_to_spark__move_now" = "Sposta Ora"; +"lightning__move_to_spark__move_rest" = "Sposta Resto"; "lightning__move_to_spark__nav_title" = "Sposta Su Spark"; "lightning__move_to_spark__quoting" = "Ricerca di un percorso e della sua commissione…"; "lightning__move_to_spark__swipe" = "Swipe Per Spostare"; "lightning__move_to_spark__too_large_none" = "Chiudi invece la connessione per restituire il saldo ai Risparmi."; "lightning__move_to_spark__too_large_smaller" = "Puoi spostarlo in parti più piccole."; "lightning__move_to_spark__too_large_text" = "{amount} è troppo grande per essere instradato su Spark in un unico pagamento."; -"lightning__move_to_spark__too_large_title" = "Importo troppo grande"; -"lightning__move_to_spark__try" = "Sposta {amount}"; +"lightning__move_to_spark__too_large_title" = "Importo troppo\ngrande"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Commissione di riscatto"; "lightning__spark_confirmations" = "{count}/3 conferme"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 734d7a6e5..0c0501135 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -344,29 +344,29 @@ "lightning__order_fee" = "Order kosten"; "lightning__balance" = "Saldo"; "lightning__move_to_spark__amount" = "Bedrag"; -"lightning__move_to_spark__close" = "Verbinding sluiten"; "lightning__move_to_spark__confirm_text" = "Uw saldo gaat via Lightning naar Spark, de goedkoopste manier om het op uw bestedingssaldo te houden. De routeringsvergoeding hieronder is het maximum dat u betaalt."; -"lightning__move_to_spark__confirm_title" = "Verplaatsen naar Spark"; +"lightning__move_to_spark__confirm_title" = "Verplaatsen naar\nSpark"; "lightning__move_to_spark__done_rest_text" = "Een deel van uw saldo staat nu op Spark. Verplaats de rest op dezelfde manier en sluit daarna de lege verbinding."; "lightning__move_to_spark__done_text" = "Uw saldo staat nu op Spark. Sluit de lege verbinding om de reserve terug te geven aan uw spaargeld."; -"lightning__move_to_spark__done_title" = "Verplaatst naar Spark"; +"lightning__move_to_spark__done_title" = "Verplaatst naar\nSpark"; "lightning__move_to_spark__failed_nothing" = "Uw Lightning-verbindingen hebben geen saldo meer om te verplaatsen."; "lightning__move_to_spark__failed_spark" = "Spark is nog niet klaar om te ontvangen. Probeer het zo meteen opnieuw."; -"lightning__move_to_spark__failed_title" = "Verplaatsen mislukt"; +"lightning__move_to_spark__failed_title" = "Verplaatsen\nmislukt"; "lightning__move_to_spark__failed_unknown" = "Er is iets misgegaan bij het voorbereiden van de verplaatsing. Probeer het opnieuw."; "lightning__move_to_spark__fee" = "Routeringsvergoeding"; "lightning__move_to_spark__in_flight_text" = "De betaling is onderweg en verschijnt in Spark zodra ze is afgerond. Sluit daarna de lege verbinding om de reserve terug te geven aan uw spaargeld."; "lightning__move_to_spark__intro_text" = "Blocktank sluit zijn Lightning-verbindingen. Verplaats uw saldo nu naar Spark: dit is de goedkoopste optie en het blijft op uw bestedingssaldo."; -"lightning__move_to_spark__intro_title" = "Uw verbinding wordt gesloten"; -"lightning__move_to_spark__move_rest" = "De rest verplaatsen"; +"lightning__move_to_spark__intro_title" = "Uw verbinding wordt\ngesloten"; +"lightning__move_to_spark__move_half" = "Stuur helft"; +"lightning__move_to_spark__move_now" = "Stuur nu"; +"lightning__move_to_spark__move_rest" = "Stuur rest"; "lightning__move_to_spark__nav_title" = "Verplaatsen naar Spark"; "lightning__move_to_spark__quoting" = "Route en vergoeding zoeken…"; "lightning__move_to_spark__swipe" = "Veeg om te verplaatsen"; "lightning__move_to_spark__too_large_none" = "Sluit in plaats daarvan de verbinding om het saldo terug te geven aan uw spaargeld."; "lightning__move_to_spark__too_large_smaller" = "U kunt het in kleinere delen verplaatsen."; "lightning__move_to_spark__too_large_text" = "{amount} is te groot om in één betaling naar Spark te routeren."; -"lightning__move_to_spark__too_large_title" = "Bedrag te groot"; -"lightning__move_to_spark__try" = "{amount} verplaatsen"; +"lightning__move_to_spark__too_large_title" = "Bedrag is\nte groot"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Claimkosten"; "lightning__spark_confirmations" = "{count}/3 bevestigingen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 69ddd7a76..36d0453b9 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -346,29 +346,29 @@ "lightning__order_fee" = "Opłata za zamówienie"; "lightning__balance" = "Saldo"; "lightning__move_to_spark__amount" = "Kwota"; -"lightning__move_to_spark__close" = "Zamknij połączenie"; "lightning__move_to_spark__confirm_text" = "Twoje saldo trafia do Spark przez Lightning, najtańszym sposobem na pozostawienie go w Wydatkach. Opłata za routing poniżej to maksimum, jakie zapłacisz."; -"lightning__move_to_spark__confirm_title" = "Przenieś do Spark"; +"lightning__move_to_spark__confirm_title" = "Przenieś do\nSpark"; "lightning__move_to_spark__done_rest_text" = "Część Twojego salda jest teraz w Spark. Przenieś resztę w ten sam sposób, a następnie zamknij opróżnione połączenie."; "lightning__move_to_spark__done_text" = "Twoje saldo jest teraz w Spark. Zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności."; -"lightning__move_to_spark__done_title" = "Przeniesiono do Spark"; +"lightning__move_to_spark__done_title" = "Przeniesiono do\nSpark"; "lightning__move_to_spark__failed_nothing" = "Twoje połączenia Lightning nie mają już salda do przeniesienia."; "lightning__move_to_spark__failed_spark" = "Spark nie jest jeszcze gotowy do odbioru. Spróbuj ponownie za chwilę."; -"lightning__move_to_spark__failed_title" = "Nie udało się przenieść"; +"lightning__move_to_spark__failed_title" = "Nie udało się\nprzenieść"; "lightning__move_to_spark__failed_unknown" = "Coś poszło nie tak podczas przygotowania przeniesienia. Spróbuj ponownie."; "lightning__move_to_spark__fee" = "Opłata za routing"; "lightning__move_to_spark__in_flight_text" = "Płatność jest w drodze i pojawi się w Spark po rozliczeniu. Następnie zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności."; "lightning__move_to_spark__intro_text" = "Blocktank zamyka swoje połączenia Lightning. Przenieś teraz saldo do Spark: to opcja z najniższymi opłatami, a środki zostają w Wydatkach."; -"lightning__move_to_spark__intro_title" = "Twoje połączenie się zamyka"; -"lightning__move_to_spark__move_rest" = "Przenieś resztę"; +"lightning__move_to_spark__intro_title" = "Twoje połączenie\nsię zamyka"; +"lightning__move_to_spark__move_half" = "Połowę"; +"lightning__move_to_spark__move_now" = "Przenieś"; +"lightning__move_to_spark__move_rest" = "Resztę"; "lightning__move_to_spark__nav_title" = "Przenieś do Spark"; "lightning__move_to_spark__quoting" = "Szukanie trasy i jej opłaty…"; "lightning__move_to_spark__swipe" = "Przesuń, aby przenieść"; "lightning__move_to_spark__too_large_none" = "Zamiast tego zamknij połączenie, aby zwrócić saldo do Oszczędności."; "lightning__move_to_spark__too_large_smaller" = "Możesz przenieść je w mniejszych częściach."; "lightning__move_to_spark__too_large_text" = "Kwota {amount} jest zbyt duża, aby przekazać ją do Spark jedną płatnością."; -"lightning__move_to_spark__too_large_title" = "Kwota zbyt duża"; -"lightning__move_to_spark__try" = "Przenieś {amount}"; +"lightning__move_to_spark__too_large_title" = "Kwota jest\nzbyt duża"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Opłata za odbiór"; "lightning__spark_confirmations" = "{count}/3 potwierdzeń"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 7673ef186..4d8cbf80e 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -346,29 +346,29 @@ "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; "lightning__move_to_spark__amount" = "Valor"; -"lightning__move_to_spark__close" = "Fechar Conexão"; "lightning__move_to_spark__confirm_text" = "Seu saldo vai para o Spark pela Lightning, a forma de menor taxa de mantê-lo em Gastos. A taxa de roteamento abaixo é o máximo que você paga."; -"lightning__move_to_spark__confirm_title" = "Mover para o Spark"; +"lightning__move_to_spark__confirm_title" = "Mover para o\nSpark"; "lightning__move_to_spark__done_rest_text" = "Parte do seu saldo agora está no Spark. Mova o resto da mesma forma e depois feche a conexão esvaziada."; "lightning__move_to_spark__done_text" = "Seu saldo agora está no Spark. Feche a conexão esvaziada para devolver a reserva à Poupança."; -"lightning__move_to_spark__done_title" = "Movido para o Spark"; +"lightning__move_to_spark__done_title" = "Movido para o\nSpark"; "lightning__move_to_spark__failed_nothing" = "Suas conexões Lightning não têm mais saldo para mover."; "lightning__move_to_spark__failed_spark" = "O Spark ainda não está pronto para receber. Tente novamente em instantes."; -"lightning__move_to_spark__failed_title" = "Não foi possível mover"; +"lightning__move_to_spark__failed_title" = "Não foi possível\nmover"; "lightning__move_to_spark__failed_unknown" = "Algo deu errado ao preparar a movimentação. Tente novamente."; "lightning__move_to_spark__fee" = "Taxa de roteamento"; "lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark assim que for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; "lightning__move_to_spark__intro_text" = "O Blocktank está fechando suas conexões Lightning. Mova seu saldo para o Spark agora: é a opção de menor taxa e ele continua em Gastos."; -"lightning__move_to_spark__intro_title" = "Sua conexão está fechando"; -"lightning__move_to_spark__move_rest" = "Mover o Resto"; +"lightning__move_to_spark__intro_title" = "Sua conexão está\nfechando"; +"lightning__move_to_spark__move_half" = "Mover Metade"; +"lightning__move_to_spark__move_now" = "Mover Agora"; +"lightning__move_to_spark__move_rest" = "Mover Resto"; "lightning__move_to_spark__nav_title" = "Mover Para o Spark"; "lightning__move_to_spark__quoting" = "Buscando uma rota e sua taxa…"; "lightning__move_to_spark__swipe" = "Deslizar Para Mover"; "lightning__move_to_spark__too_large_none" = "Feche a conexão para devolver o saldo à Poupança."; "lightning__move_to_spark__too_large_smaller" = "Você pode movê-lo em partes menores."; "lightning__move_to_spark__too_large_text" = "{amount} é grande demais para ser roteado ao Spark em um único pagamento."; -"lightning__move_to_spark__too_large_title" = "Valor grande demais"; -"lightning__move_to_spark__try" = "Mover {amount}"; +"lightning__move_to_spark__too_large_title" = "Valor grande\ndemais"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Taxa de resgate"; "lightning__spark_confirmations" = "{count}/3 confirmações"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 69c618da8..08d2dec28 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -344,29 +344,29 @@ "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; "lightning__move_to_spark__amount" = "Montante"; -"lightning__move_to_spark__close" = "Fechar Conexão"; "lightning__move_to_spark__confirm_text" = "O seu saldo passa para o Spark através do Lightning, a forma com as taxas mais baixas de o manter em Gastos. A taxa de encaminhamento abaixo é o máximo que paga."; -"lightning__move_to_spark__confirm_title" = "Mover para o Spark"; +"lightning__move_to_spark__confirm_title" = "Mover para o\nSpark"; "lightning__move_to_spark__done_rest_text" = "Parte do seu saldo está agora no Spark. Mova o resto da mesma forma e depois feche a conexão esvaziada."; "lightning__move_to_spark__done_text" = "O seu saldo está agora no Spark. Feche a conexão esvaziada para devolver a reserva à Poupança."; -"lightning__move_to_spark__done_title" = "Movido para o Spark"; +"lightning__move_to_spark__done_title" = "Movido para o\nSpark"; "lightning__move_to_spark__failed_nothing" = "As suas conexões Lightning já não têm saldo para mover."; "lightning__move_to_spark__failed_spark" = "O Spark ainda não está pronto para receber. Tente novamente dentro de instantes."; -"lightning__move_to_spark__failed_title" = "Não foi possível mover"; +"lightning__move_to_spark__failed_title" = "Não foi possível\nmover"; "lightning__move_to_spark__failed_unknown" = "Algo correu mal ao preparar a movimentação. Tente novamente."; "lightning__move_to_spark__fee" = "Taxa de encaminhamento"; "lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark quando for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; "lightning__move_to_spark__intro_text" = "O Blocktank está a fechar as suas conexões Lightning. Mova já o seu saldo para o Spark: é a opção com as taxas mais baixas e fica em Gastos."; -"lightning__move_to_spark__intro_title" = "A sua conexão está a fechar"; -"lightning__move_to_spark__move_rest" = "Mover o Resto"; +"lightning__move_to_spark__intro_title" = "A sua conexão\nvai fechar"; +"lightning__move_to_spark__move_half" = "Mover Metade"; +"lightning__move_to_spark__move_now" = "Mover Agora"; +"lightning__move_to_spark__move_rest" = "Mover Resto"; "lightning__move_to_spark__nav_title" = "Mover Para o Spark"; "lightning__move_to_spark__quoting" = "A procurar uma rota e a respetiva taxa…"; "lightning__move_to_spark__swipe" = "Deslizar Para Mover"; "lightning__move_to_spark__too_large_none" = "Feche antes a conexão para devolver o saldo à Poupança."; "lightning__move_to_spark__too_large_smaller" = "Pode movê-lo em partes mais pequenas."; "lightning__move_to_spark__too_large_text" = "{amount} é demasiado grande para ser encaminhado para o Spark num só pagamento."; -"lightning__move_to_spark__too_large_title" = "Montante demasiado grande"; -"lightning__move_to_spark__try" = "Mover {amount}"; +"lightning__move_to_spark__too_large_title" = "Montante demasiado\ngrande"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Taxa de resgate"; "lightning__spark_confirmations" = "{count}/3 confirmações"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 665e89f75..108035862 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -341,29 +341,29 @@ "lightning__order_fee" = "Комиссия Ордера"; "lightning__balance" = "Баланс"; "lightning__move_to_spark__amount" = "Сумма"; -"lightning__move_to_spark__close" = "Закрыть Соединение"; "lightning__move_to_spark__confirm_text" = "Ваш баланс переходит в Spark через Lightning — самым дешёвым по комиссиям способом сохранить его в Расходах. Комиссия за маршрутизацию ниже — максимум, который вы платите."; -"lightning__move_to_spark__confirm_title" = "Перенос в Spark"; +"lightning__move_to_spark__confirm_title" = "Перенос в\nSpark"; "lightning__move_to_spark__done_rest_text" = "Часть вашего баланса теперь в Spark. Перенесите остаток таким же способом, затем закройте опустевшее соединение."; "lightning__move_to_spark__done_text" = "Ваш баланс теперь в Spark. Закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения."; -"lightning__move_to_spark__done_title" = "Перенесено в Spark"; +"lightning__move_to_spark__done_title" = "Перенесено в\nSpark"; "lightning__move_to_spark__failed_nothing" = "На ваших соединениях Lightning больше нет баланса для переноса."; "lightning__move_to_spark__failed_spark" = "Spark пока не готов к получению. Попробуйте ещё раз чуть позже."; -"lightning__move_to_spark__failed_title" = "Не удалось перенести"; +"lightning__move_to_spark__failed_title" = "Не удалось\nперенести"; "lightning__move_to_spark__failed_unknown" = "Что-то пошло не так при подготовке переноса. Попробуйте ещё раз."; "lightning__move_to_spark__fee" = "Комиссия за маршрутизацию"; "lightning__move_to_spark__in_flight_text" = "Платёж в пути и появится в Spark после завершения. Затем закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения."; "lightning__move_to_spark__intro_text" = "Blocktank закрывает свои соединения Lightning. Перенесите баланс в Spark сейчас: это вариант с самой низкой комиссией, и средства остаются в Расходах."; -"lightning__move_to_spark__intro_title" = "Ваше соединение закрывается"; -"lightning__move_to_spark__move_rest" = "Перенести Остаток"; +"lightning__move_to_spark__intro_title" = "Ваше соединение\nзакрывается"; +"lightning__move_to_spark__move_half" = "Половину"; +"lightning__move_to_spark__move_now" = "Перенести"; +"lightning__move_to_spark__move_rest" = "Остаток"; "lightning__move_to_spark__nav_title" = "Перенос в Spark"; "lightning__move_to_spark__quoting" = "Поиск маршрута и его комиссии…"; "lightning__move_to_spark__swipe" = "Проведите для Переноса"; "lightning__move_to_spark__too_large_none" = "Вместо этого закройте соединение, чтобы вернуть баланс в Сбережения."; "lightning__move_to_spark__too_large_smaller" = "Вы можете перенести его меньшими частями."; "lightning__move_to_spark__too_large_text" = "{amount} — слишком большая сумма для маршрутизации в Spark одним платежом."; -"lightning__move_to_spark__too_large_title" = "Сумма слишком велика"; -"lightning__move_to_spark__try" = "Перенести {amount}"; +"lightning__move_to_spark__too_large_title" = "Сумма слишком\nвелика"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Комиссия за зачисление"; "lightning__spark_confirmations" = "{count}/3 подтверждений"; diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift index 87c3d6c81..b2ebbd892 100644 --- a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -88,7 +88,7 @@ struct MoveToSparkView: View { .accessibilityIdentifier("MoveToSparkFeePaid") illustration("check") HStack(spacing: 16) { - CustomButton(title: t("common__ok"), variant: .secondary) { + CustomButton(title: t("common__later"), variant: .secondary) { navigation.navigateBack() } if hasRest { @@ -97,7 +97,7 @@ struct MoveToSparkView: View { } .accessibilityIdentifier("MoveToSparkMoveRest") } else { - CustomButton(title: t("lightning__move_to_spark__close")) { + CustomButton(title: t("common__close")) { // Closing returns to Lightning Connections through the channel's detail, as a close from there does. navigation.navigateBack() navigation.navigate(.connections) @@ -123,7 +123,7 @@ struct MoveToSparkView: View { CustomButton(title: t("common__cancel"), variant: .secondary) { navigation.navigateBack() } - CustomButton(title: t("lightning__move_to_spark__try", variables: ["amount": currency.primaryAmountText(sats: smallerSats)])) { + CustomButton(title: t("lightning__move_to_spark__move_half")) { await manager.quote(wallet: wallet, targetSats: smallerSats) } .accessibilityIdentifier("MoveToSparkTrySmaller") diff --git a/Bitkit/Views/Sheets/MoveToSparkSheet.swift b/Bitkit/Views/Sheets/MoveToSparkSheet.swift index dd775a94b..b29e62db9 100644 --- a/Bitkit/Views/Sheets/MoveToSparkSheet.swift +++ b/Bitkit/Views/Sheets/MoveToSparkSheet.swift @@ -41,7 +41,7 @@ struct MoveToSparkSheet: View { sheets.hideSheet() } .accessibilityIdentifier("MoveToSparkIntroLater") - CustomButton(title: t("lightning__move_to_spark__nav_title"), shouldExpand: true) { + CustomButton(title: t("lightning__move_to_spark__move_now"), shouldExpand: true) { sheets.hideSheet() navigation.navigate(.moveToSpark) } From 03e5d35f65ca43864f63db489e393ab74858b95a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 4 Nov 2026 20:50:17 +0100 Subject: [PATCH 014/378] fix: keep the move to spark sheet copy whole on small screens --- Bitkit/Views/Sheets/MoveToSparkSheet.swift | 27 +++++++++------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/Bitkit/Views/Sheets/MoveToSparkSheet.swift b/Bitkit/Views/Sheets/MoveToSparkSheet.swift index b29e62db9..318efa3a7 100644 --- a/Bitkit/Views/Sheets/MoveToSparkSheet.swift +++ b/Bitkit/Views/Sheets/MoveToSparkSheet.swift @@ -16,23 +16,18 @@ struct MoveToSparkSheet: View { Sheet(id: .moveToSpark, data: config) { VStack(alignment: .leading, spacing: 0) { SheetHeader(title: t("lightning__move_to_spark__nav_title")) - GeometryReader { geometry in - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 32) { - Image("lightning") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity) - VStack(alignment: .leading, spacing: 8) { - DisplayText(t("lightning__move_to_spark__intro_title"), accentColor: .purpleAccent) - BodyMText(t("lightning__move_to_spark__intro_text")) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(minHeight: geometry.size.height, alignment: .bottom) - } + // The illustration shrinks on small screens so the copy below it stays whole. + Image("lightning") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 256, maxHeight: 256) + .frame(maxWidth: .infinity, maxHeight: .infinity) + VStack(alignment: .leading, spacing: 8) { + DisplayText(t("lightning__move_to_spark__intro_title"), accentColor: .purpleAccent) + BodyMText(t("lightning__move_to_spark__intro_text")) + .fixedSize(horizontal: false, vertical: true) } + .padding(.top, 32) let buttonLayout = dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 16)) : AnyLayout(HStackLayout(spacing: 16)) From 32f13bc1797b45c4ce5139b9f657959d205b21bf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 4 Nov 2026 21:09:05 +0100 Subject: [PATCH 015/378] fix: keep move to spark headlines whole on small iphones --- Bitkit/Views/Settings/Advanced/MoveToSparkView.swift | 1 + Bitkit/Views/Sheets/MoveToSparkSheet.swift | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift index b2ebbd892..db5ac202d 100644 --- a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -37,6 +37,7 @@ struct MoveToSparkView: View { private func header(title: String, text: String) -> some View { VStack(alignment: .leading, spacing: 16) { DisplayText(title, accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) BodyMText(text) .fixedSize(horizontal: false, vertical: true) } diff --git a/Bitkit/Views/Sheets/MoveToSparkSheet.swift b/Bitkit/Views/Sheets/MoveToSparkSheet.swift index 318efa3a7..5e2d41099 100644 --- a/Bitkit/Views/Sheets/MoveToSparkSheet.swift +++ b/Bitkit/Views/Sheets/MoveToSparkSheet.swift @@ -24,10 +24,12 @@ struct MoveToSparkSheet: View { .frame(maxWidth: .infinity, maxHeight: .infinity) VStack(alignment: .leading, spacing: 8) { DisplayText(t("lightning__move_to_spark__intro_title"), accentColor: .purpleAccent) + .fixedSize(horizontal: false, vertical: true) BodyMText(t("lightning__move_to_spark__intro_text")) .fixedSize(horizontal: false, vertical: true) } .padding(.top, 32) + .layoutPriority(1) let buttonLayout = dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 16)) : AnyLayout(HStackLayout(spacing: 16)) From fa535a9ddc5c94541845786e7ed9fd07f3ef824e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 03:14:32 +0100 Subject: [PATCH 016/378] fix: build move to spark without the spark exit --- Bitkit/Managers/MoveToSparkManager.swift | 2 +- .../TimedSheets/MoveToSparkTimedSheet.swift | 2 +- Bitkit/Views/HomeScreen.swift | 2 +- .../Advanced/LightningConnectionsView.swift | 2 +- .../Settings/Advanced/MoveToSparkView.swift | 26 ++++++++++++++++--- .../Views/Wallets/SpendingWalletScreen.swift | 2 +- 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 980d6ee42..f0a211e47 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -157,7 +157,7 @@ final class MoveToSparkManager { let limitSats = MoveToSparkMath.movableSats(channels: channels) let wantedSats = min(targetSats ?? limitSats, limitSats) guard wantedSats >= MoveToSparkMath.minMoveSats else { throw MoveToSparkError.nothingToMove } - guard spark.isReady, !spark.isExiting else { throw MoveToSparkError.sparkUnavailable } + guard spark.isReady else { throw MoveToSparkError.sparkUnavailable } let invoice = try await spark.receive( amountSats: nil, diff --git a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift index 826077406..a32c0c625 100644 --- a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift +++ b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift @@ -12,7 +12,7 @@ struct MoveToSparkTimedSheet: TimedSheetItem { func shouldShow() async -> Bool { guard !app.hasSeenMoveToSparkIntro else { return false } let spark = SparkManager.shared - return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: spark.isReady && !spark.isExiting) + return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: spark.isReady) } func onShown() { diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index fb7e2b1dc..e71f51710 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -10,7 +10,7 @@ struct HomeScreen: View { @EnvironmentObject var wallet: WalletViewModel private var canMoveToSpark: Bool { - MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: SparkManager.shared.isReady && !SparkManager.shared.isExiting) + MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: SparkManager.shared.isReady) } @State private var scrollPosition: Int? = 0 diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index cbef45199..6b14fb842 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -157,7 +157,7 @@ struct LightningConnectionsView: View { if MoveToSparkMath.canMove( channels: wallet.channels, - isSparkUsable: SparkManager.shared.isReady && !SparkManager.shared.isExiting + isSparkUsable: SparkManager.shared.isReady ) { CustomButton(title: t("lightning__move_to_spark__nav_title")) { navigation.navigate(.moveToSpark) diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift index db5ac202d..b043afb4d 100644 --- a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -64,9 +64,9 @@ struct MoveToSparkView: View { VStack(alignment: .leading, spacing: 0) { header(title: t("lightning__move_to_spark__confirm_title"), text: t("lightning__move_to_spark__confirm_text")) .padding(.bottom, 32) - SparkExitAmountRow(label: t("lightning__move_to_spark__amount"), sats: quote.amountSats) + MoveToSparkAmountRow(label: t("lightning__move_to_spark__amount"), sats: quote.amountSats) .accessibilityIdentifier("MoveToSparkAmount") - SparkExitAmountRow(label: t("lightning__move_to_spark__fee"), sats: quote.feeSats) + MoveToSparkAmountRow(label: t("lightning__move_to_spark__fee"), sats: quote.feeSats) .accessibilityIdentifier("MoveToSparkFee") illustration("lightning") SwipeButton(title: t("lightning__move_to_spark__swipe"), accentColor: .purpleAccent, isLoading: isMoving) { @@ -83,9 +83,9 @@ struct MoveToSparkView: View { text: hasRest ? t("lightning__move_to_spark__done_rest_text") : t("lightning__move_to_spark__done_text") ) .padding(.bottom, 32) - SparkExitAmountRow(label: t("lightning__move_to_spark__amount"), sats: amountSats) + MoveToSparkAmountRow(label: t("lightning__move_to_spark__amount"), sats: amountSats) .accessibilityIdentifier("MoveToSparkMoved") - SparkExitAmountRow(label: t("lightning__move_to_spark__fee"), sats: feeSats) + MoveToSparkAmountRow(label: t("lightning__move_to_spark__fee"), sats: feeSats) .accessibilityIdentifier("MoveToSparkFeePaid") illustration("check") HStack(spacing: 16) { @@ -164,3 +164,21 @@ struct MoveToSparkView: View { } } } + +/// A labelled amount, as the Move to Spark screens list the amount and its routing fee. +struct MoveToSparkAmountRow: View { + let label: String + let sats: UInt64 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + CaptionBText(label, textColor: .textPrimary) + Spacer() + MoneyText(sats: Int(sats), size: .captionB, symbol: true) + } + .frame(height: 50) + CustomDivider() + } + } +} diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index 7a5381403..3f684a303 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -8,7 +8,7 @@ struct SpendingWalletScreen: View { @EnvironmentObject var wallet: WalletViewModel private var canMoveToSpark: Bool { - MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: SparkManager.shared.isReady && !SparkManager.shared.isExiting) + MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: SparkManager.shared.isReady) } private var shouldShowOnboarding: Bool { From 46ae75561cd6e178b7aa0a7c39029d7f6b477c7c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 04:03:19 +0100 Subject: [PATCH 017/378] fix: rebuild a spark exit whose fee chain lost a race --- Bitkit/Managers/SparkExitManager.swift | 4 + .../SparkExit/SparkExitBackgroundRun.swift | 4 + .../SparkExit/SparkExitBroadcaster.swift | 35 ++++- .../Services/SparkExit/SparkExitDriver.swift | 80 +++++++++-- .../Services/SparkExit/SparkExitFunding.swift | 9 ++ .../Services/SparkExit/SparkExitRecord.swift | 6 + .../Settings/Advanced/SparkExitViews.swift | 9 +- BitkitTests/SparkExitTests.swift | 128 ++++++++++++++++++ 8 files changed, 256 insertions(+), 19 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 5e8fbd210..32c4a9d91 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -165,6 +165,10 @@ final class SparkExitManager { tipHeight = try? await chain.tipHeight() await refreshFundingSeen(chain: chain, record: current) var updated = try await driver.tick(current) + // A tick that adopted the funding and came up short would otherwise keep showing that payment as seen. + if updated.phase != current.phase { + await refreshFundingSeen(chain: chain, record: updated) + } // The result may have been opened while this tick ran. if record?.isResultSeen == true, updated.isResultSeen != true { updated.isResultSeen = true diff --git a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift index ebfc54f38..e8179febd 100644 --- a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift +++ b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift @@ -145,6 +145,10 @@ private struct TimedSparkExitChain: SparkExitChain { try await base.txStatus(txid: txid) } + func isKnown(txid: String) async throws -> Bool { + try await base.isKnown(txid: txid) + } + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { await timings.time(.broadcast) { await base.broadcast(tx) } } diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index 5819cbabf..5f12c4ed5 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -46,6 +46,9 @@ protocol SparkExitChain: Sendable { func tipHeight() async throws -> UInt32 func utxos(address: String) async throws -> [EsploraUtxo] func txStatus(txid: String) async throws -> EsploraTxStatus + /// Whether the transaction is in a block or in the mempool; false once every host answers it has no such + /// transaction. + func isKnown(txid: String) async throws -> Bool func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome } @@ -92,6 +95,12 @@ struct EsploraSparkExitChain: SparkExitChain { try await JSONDecoder().decode(EsploraTxStatus.self, from: get("/tx/\(txid)/status")) } + /// `/tx/{txid}/hex` looks in the mempool and the chain alike; `/status` of an unknown transaction may answer + /// unconfirmed instead of not found. + func isKnown(txid: String) async throws -> Bool { + try await getIfFound("/tx/\(txid)/hex") != nil + } + /// Tries each host until one accepts. Otherwise a node's refusal wins over a host that was only unavailable. func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { var outcomes: [SparkExitBroadcastOutcome] = [] @@ -147,19 +156,34 @@ struct EsploraSparkExitChain: SparkExitChain { } private func get(_ path: String) async throws -> Data { - var lastError: Error = URLError(.cannotFindHost) + guard let data = try await getIfFound(path) else { throw URLError(.fileDoesNotExist) } + return data + } + + /// nil when every host answers 404; a host that fails otherwise makes it throw instead. + private func getIfFound(_ path: String) async throws -> Data? { + var lastError: Error? for host in hosts { var request = URLRequest(url: host.appendingPathComponent(path)) request.timeoutInterval = 20 do { let (data, response) = try await http.send(request) + if response.statusCode == 404 { + continue + } guard (200 ..< 300).contains(response.statusCode) else { throw URLError(.badServerResponse) } return data } catch { lastError = error } } - throw lastError + if hosts.isEmpty { + throw URLError(.cannotFindHost) + } + if let lastError { + throw lastError + } + return nil } } @@ -242,6 +266,13 @@ enum SparkExitBroadcastClassifier { status >= 500 || status == 429 || status == 403 ? .retryLater("HTTP \(status)") : nil } + /// The step paid no fee of its own and its CPFP child did not pay for it (`min relay fee not met, 0 < 17`): the + /// child is missing or invalid, such as one spending the change of an earlier child that lost a race. + static func isUnpaid(_ outcome: SparkExitBroadcastOutcome) -> Bool { + guard case let .rejected(text) = outcome else { return false } + return text.range(of: #"min relay fee not met,\s*0\s*<"#, options: .regularExpression) != nil + } + static func classify(error text: String) -> SparkExitBroadcastOutcome { if knownMessages.contains(where: text.contains) || hasRpcCode(-27, in: text) { return .accepted diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index d04a65958..5a6b062ed 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -35,6 +35,8 @@ struct SparkExitDriver { /// Rebuilds in a row that reproduce a step whose input another transaction spent before its leaf counts as lost: /// an older state of the wallet claimed it, and every rebuild would send the same refund again. static let strikesToLoseLeaf = 2 + /// Fee chain failures in a row of one step, its parents confirmed, that rebuild the exit once. + static let feeChainFailuresToRebuild = 3 @discardableResult func tick(_ record: SparkExitRecord) async throws -> SparkExitRecord { @@ -227,16 +229,62 @@ struct SparkExitDriver { record.recovery = StoredRecovery(checked.recovery) switch checked.verdict { case .valid: + // The SDK sees no divergence when another wallet's CPFP child confirmed a node our leaves share. + let dead = await deadCpfpChildren(checked.recovery, stored: stored) + if !dead.isEmpty { + Logger.warn("Spark exit fee chain lost a race (dead children \(dead)), rebuilding", context: "SparkExitDriver") + try await rebuild(&record, recovering: recovering) + return + } try await broadcastReady(&record) + let rebuilt = Set(record.feeChainRebuildTxids ?? []) + if let stuck = record.broadcastLog.first(where: { + $0.generation == record.generation && ($0.feeChainFailures ?? 0) >= Self.feeChainFailuresToRebuild && !rebuilt.contains($0.txid) + }) { + Logger.warn("Spark exit step \(stuck.txid) keeps failing its fee chain, rebuilding once", context: "SparkExitDriver") + record.feeChainRebuildTxids = (record.feeChainRebuildTxids ?? []) + [stuck.txid] + try await rebuild(&record, recovering: recovering) + } case .done: await finish(&record, phase: .done) case .redo: Logger.warn("Spark exit diverged on-chain, rebuilding generation \(record.generation + 1)", context: "SparkExitDriver") - record.isRebuilding = true - try save(record) - guard !Task.isCancelled, isRebuildDue(record) else { return } - try await build(&record, recovering: recovering) + try await rebuild(&record, recovering: recovering) + } + } + + /// Saved as rebuilding first, so nothing of the replaced generation is broadcast again, then rebuilt once due. + private func rebuild(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + record.isRebuilding = true + try save(record) + guard !Task.isCancelled, isRebuildDue(record) else { return } + try await build(&record, recovering: recovering) + } + + /// Our CPFP children that a step still to confirm builds on, and that can never confirm: the node each pays for + /// is in a block, yet the child is neither in a block nor in the mempool. Another wallet's child won the race + /// for a node both exits share, so the change ours would leave never exists. A child the hosts could not be + /// asked about counts as alive. + private func deadCpfpChildren(_ recovery: RecoverFundsResponse, stored: RecoverFundsResponse) async -> [String] { + let confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + var stepOfChild: [String: String] = [:] + for tx in stored.transactions { + if let child = tx.cpfpTxHex.flatMap(SparkExitTxid.txid(hex:)) { + stepOfChild[child] = tx.txid + } + } + var suspects = Set() + for tx in stored.transactions where !confirmed.contains(tx.txid) { + guard let inputs = tx.cpfpTxHex.flatMap(SparkExitTxid.inputTxids(hex:)) else { continue } + suspects.formUnion(inputs.filter { stepOfChild[$0].map(confirmed.contains) == true }) } + var dead: [String] = [] + for child in suspects.sorted() where !Task.isCancelled { + if await (try? chain.isKnown(txid: child)) == false { + dead.append(child) + } + } + return dead } /// Marks the next steps the chain host shows in a block as confirmed. The SDK asks the chain about a step only @@ -435,18 +483,22 @@ struct SparkExitDriver { return !last.isAccepted || now().timeIntervalSince(last.lastAttemptAt) >= Self.rebroadcastInterval } - /// With every parent of the step confirmed, missing inputs mean another transaction spent one. + /// With every parent of the step confirmed, missing inputs mean another transaction spent one. A failed fee + /// chain is no rejection until it already rebuilt the exit once. private func broadcast(_ tx: RecoveryTransaction, into record: inout SparkExitRecord, hasConfirmedParents: Bool) async { let outcome = await chain.broadcast(tx) Logger.info("Spark exit broadcast \(tx.kind) \(tx.txid): \(outcome.summary)", context: "SparkExitDriver") let isAccepted = outcome == .accepted - let isInputSpent: Bool? = if hasConfirmedParents, case .inputsMissing = outcome { + let isInputsMissing = if case .inputsMissing = outcome { true } else { - nil + false } + let isInputSpent: Bool? = hasConfirmedParents && isInputsMissing ? true : nil + let isUnpaid = SparkExitBroadcastClassifier.isUnpaid(outcome) + let isFeeChainFailure = hasConfirmedParents && (isUnpaid || isInputsMissing) let isRejected = if case .rejected = outcome { - true + !(isFeeChainFailure && !(record.feeChainRebuildTxids ?? []).contains(tx.txid)) } else { false } @@ -457,6 +509,7 @@ struct SparkExitDriver { record.broadcastLog[i].lastOutcome = outcome.summary record.broadcastLog[i].isAccepted = isAccepted record.broadcastLog[i].isInputSpent = isInputSpent + record.broadcastLog[i].feeChainFailures = isFeeChainFailure ? (record.broadcastLog[i].feeChainFailures ?? 0) + 1 : nil } else { record.broadcastLog.append(.init( generation: record.generation, @@ -466,7 +519,8 @@ struct SparkExitDriver { lastAttemptAt: now(), lastOutcome: outcome.summary, isAccepted: isAccepted, - isInputSpent: isInputSpent + isInputSpent: isInputSpent, + feeChainFailures: isFeeChainFailure ? 1 : nil )) } } @@ -536,6 +590,14 @@ enum SparkExitTxid { recovery.transactions.compactMap { $0.cpfpTxHex.flatMap(txid(hex:)) } } + /// The txids a serialized transaction spends; nil when the hex does not decode. + static func inputTxids(hex: String) -> [String]? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + return reader.inputs()?.inputs.map(\.txid) + } + /// Each input's BIP68 block lock with the txid it spends; nil when the hex does not decode. Version 1 /// transactions and inputs whose sequence disables the lock or counts time carry none. static func relativeBlockLocks(hex: String) -> [SparkExitTimelock.Lock]? { diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 63465193f..4acdaab31 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -285,6 +285,15 @@ enum SparkExitMath { /// The least a P2WPKH funding output can hold: nodes refuse a payment below it. static let fundingDustSats: UInt64 = 294 + /// What the exit says it still needs: the ask, or, while a payment is awaited or before the funding address was + /// read, the shortfall. Never under the dust limit. + static func neededSats(_ record: SparkExitRecord, state: SparkExitFundingState?) -> UInt64? { + if case let .ask(sats, _) = state { + return sats + } + return record.fundingShortfallSats.map { max($0, fundingDustSats) } + } + /// What a partial payment leaves to send, at least the dust limit: nil before any new funding arrives and once /// it covers `requiredSats`. static func partialFundingSats(receivedSats: UInt64, requiredSats: UInt64) -> UInt64? { diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 23ab2f4f7..79ebc7623 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -50,6 +50,9 @@ struct SparkExitRecord: Codable, Equatable { /// Its input was spent by another transaction, the operators' version of the step: the keyless pass stops /// sending it and the next SDK check rebuilds. var isInputSpent: Bool? + /// Attempts in a row, with the step's parents confirmed, that its fee chain failed: its CPFP child did not + /// pay for it (`min relay fee not met, 0 < N`) or its inputs were missing. Reset by any other outcome. + var feeChainFailures: Int? } /// A leaf an older state of the wallet claimed on-chain first, which this exit cannot recover. @@ -117,6 +120,9 @@ struct SparkExitRecord: Codable, Equatable { /// Per step txid, how many rebuilds in a row reproduced it after another transaction spent its input. var spentStepStrikes: [String: Int]? var lostLeaves: [LostLeaf]? + /// The steps whose repeated fee chain failures already rebuilt the exit once; from then on they count as + /// rejections. + var feeChainRebuildTxids: [String]? var leftBehindLeaves: [LeftBehindLeaf]? /// Set once the user opened the result of an exit that failed or lost leaves. var isResultSeen: Bool? diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 59ca12c2d..5bd021f8b 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -480,19 +480,12 @@ struct SparkExitProgressView: View { case .needsFunding: t( "lightning__spark_exit__needs_funding_text", - variables: ["amount": currency.primaryAmountText(sats: askSats ?? record?.fundingShortfallSats ?? 0)] + variables: ["amount": currency.primaryAmountText(sats: record.flatMap { SparkExitMath.neededSats($0, state: fundingState) } ?? 0)] ) default: t("lightning__spark_exit__progress_text") } } - private var askSats: UInt64? { - if case let .ask(sats, _) = fundingState { - return sats - } - return nil - } - var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__spark_exit__nav_title")) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 6c30dca75..0e9a2b1f7 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -104,6 +104,13 @@ enum SparkExitFixtures { ) } + /// A version 3 transaction spending output 0 of each of `inputs`, with one output. + static func rawTx(spending inputs: [String]) -> String { + "03000000" + String(format: "%02x", inputs.count) + + inputs.map { Data($0.hexaBytes.reversed()).hex + "00000000" + "00" + "ffffffff" }.joined() + + "01" + "e803000000000000" + "16" + "0014" + String(repeating: "22", count: 20) + "00000000" + } + static func utxo(_ txid: String, vout: UInt32 = 0, value: UInt64, confirmed: Bool = true) -> EsploraUtxo { EsploraUtxo(txid: txid, vout: vout, value: value, status: EsploraTxStatus(confirmed: confirmed, blockHeight: confirmed ? 100 : nil)) } @@ -114,6 +121,7 @@ final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { var tip: UInt32 = 1000 var utxosByAddress: [String: [EsploraUtxo]] = [:] var confirmedTxids: Set = [] + var mempoolTxids: Set = [] var outcome: SparkExitBroadcastOutcome = .accepted var isUtxosUnreadable = false private(set) var broadcasts: [RecoveryTransaction] = [] @@ -134,6 +142,10 @@ final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { lock.withLock { EsploraTxStatus(confirmed: confirmedTxids.contains(txid), blockHeight: confirmedTxids.contains(txid) ? 900 : nil) } } + func isKnown(txid: String) async throws -> Bool { + lock.withLock { confirmedTxids.contains(txid) || mempoolTxids.contains(txid) } + } + func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { onBroadcast?() return lock.withLock { @@ -797,6 +809,92 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2", "n2"]) } + /// qa-03 shared-ancestor race, with the operators down so nothing else diverges: another wallet's CPFP child + /// confirmed node n1, ours never will, and n2's child spends our child's change. `checkRecoverFunds` answers + /// Valid, so the exit rebuilds from the same funding and never sends the dead chain again. + func testAFeeChainThatLostARaceRebuildsWithoutResendingIt() async throws { + let funding = String(repeating: "f0", count: 32) + let ourChild = SparkExitFixtures.rawTx(spending: [funding]) + let chained = try SparkExitFixtures.rawTx(spending: [XCTUnwrap(SparkExitTxid.txid(hex: ourChild))]) + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: funding, vout: 0, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: ourChild), + SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"], status: .waitingForDependencies), + ]), at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.confirmedTxids = ["n1"] + sdk.checks = [CheckRecoverFundsResponse(recovery: SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: ourChild, status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"]), + ]), verdict: .valid)] + let fresh = SparkExitFixtures.rawTx(spending: [funding]) + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: fresh, dependsOn: ["n1"]), + ])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(chain.broadcasts.map(\.cpfpTxHex), [fresh]) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: funding, vout: 0, valueSats: 20000, pubkey: "02ab")]) + XCTAssertNil(record.surfacedRejection) + } + + func testAChildStillInTheMempoolIsNotDead() async throws { + let ourChild = SparkExitFixtures.rawTx(spending: [String(repeating: "f0", count: 32)]) + let childTxid = try XCTUnwrap(SparkExitTxid.txid(hex: ourChild)) + let chained = SparkExitFixtures.rawTx(spending: [childTxid]) + var record = SparkExitFixtures.record(phase: .running) + let checked = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: ourChild, status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"]), + ]) + record.adopt(checked, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.confirmedTxids = ["n1"] + chain.mempoolTxids = [childTxid] + sdk.checks = [CheckRecoverFundsResponse(recovery: checked, verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(chain.broadcasts.map(\.cpfpTxHex), [chained]) + } + + /// The safety net: a step whose parents confirmed and whose child keeps failing to pay for it rebuilds the exit + /// once, without counting towards the rejection alert. After that rebuild its failures are rejections again. + func testAStepWhoseFeeChainKeepsFailingRebuildsOnceThenSurfaces() async throws { + var record = SparkExitFixtures.record(phase: .running) + let first = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"]), + ]) + let second = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03d", dependsOn: ["n1"]), + ]) + record.adopt(first, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.outcome = .rejected("min relay fee not met, 0 < 17") + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: first, verdict: .valid), count: 3) + + Array(repeating: CheckRecoverFundsResponse(recovery: second, verdict: .valid), count: 2) + sdk.builds = [second] + + record = try await driver().tick(record) + record = try await driver().tick(record) + XCTAssertEqual(record.broadcastLog.first?.feeChainFailures, 2) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0) + + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.feeChainRebuildTxids, ["n2"]) + + record = try await driver().tick(record) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.surfacedRejection, "rejected: min relay fee not met, 0 < 17") + } + /// qa-03 repro: an older state's refund took the leaf, so every rebuild reproduces the same refund, whose input /// is spent. After two such rebuilds the leaf is lost and the exit goes on with the rest. func testALeafAnOlderStateClaimedIsLostAfterTwoRebuildsThatReproduceItsRefund() async throws { @@ -1359,6 +1457,17 @@ final class SparkExitMathTests: XCTestCase { XCTAssertNil(SparkExitMath.fundingState(record, utxos: [])) } + /// qa-03: a short rebuild showed "₿ 68 more" for one tick before the ask's 294 floor applied. + func testTheNeededAmountIsNeverUnderTheDustLimit() { + var record = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 20068)) + record.funding = [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 0, valueSats: 20000)] + + XCTAssertEqual(SparkExitMath.neededSats(record, state: nil), 294) + XCTAssertEqual(SparkExitMath.neededSats(record, state: .seen(txid: "x")), 294) + XCTAssertEqual(SparkExitMath.neededSats(record, state: SparkExitMath.fundingState(record, utxos: [])), 294) + XCTAssertEqual(SparkExitMath.neededSats(record, state: .ask(sats: 500)), 500) + } + /// qa-03: 2,000 of 4,348 at the address showed no hint until the whole sum arrived. func testAPartialPaymentNamesWhatIsStillMissing() { XCTAssertNil(SparkExitMath.partialFundingSats(receivedSats: 0, requiredSats: 4348)) @@ -1623,6 +1732,25 @@ final class SparkExitBroadcasterTests: XCTestCase { ) } + func testKnowsATransactionOnlyWhileAHostHasIt() async throws { + let http = FakeHTTP() + let chain = EsploraSparkExitChain(hosts: [hostA, hostB], http: http) + + http.responses = [("a.example/api/tx/c/hex", 404, "Transaction not found"), ("b.example/api/tx/c/hex", 404, "Transaction not found")] + let isUnknown = try await chain.isKnown(txid: "c") + http.responses = [("a.example/api/tx/c/hex", 404, "Transaction not found"), ("b.example/api/tx/c/hex", 200, "0300")] + let isKnown = try await chain.isKnown(txid: "c") + http.responses = [("a.example/api/tx/c/hex", 404, "Transaction not found")] + let isUnreachable = await (try? chain.isKnown(txid: "c")) == nil + + XCTAssertFalse(isUnknown) + XCTAssertTrue(isKnown) + XCTAssertTrue(isUnreachable, "a host that could not answer is no proof it is gone") + XCTAssertTrue(SparkExitBroadcastClassifier.isUnpaid(.rejected("min relay fee not met, 0 < 17"))) + XCTAssertFalse(SparkExitBroadcastClassifier.isUnpaid(.rejected("min relay fee not met, 100 < 170"))) + XCTAssertFalse(SparkExitBroadcastClassifier.isUnpaid(.inputsMissing("min relay fee not met, 0 < 17"))) + } + func testReadsTipAndFundingUtxos() async throws { let http = FakeHTTP() http.responses = [ From 5d18a238b263b6a4cc5d94358a8d229afa7f80d7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 05:18:50 +0100 Subject: [PATCH 018/378] fix: one notification for a lost spark exit --- .../Services/SparkExit/SparkExitFunding.swift | 14 ++++-- BitkitTests/SparkExitTests.swift | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 4acdaab31..1ab6104dd 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -155,8 +155,8 @@ enum SparkExitMath { /// The reminders an exit needs now. Each timelocked step not yet published gets one at its maturity plus a /// grace; a step ready to publish gets one after the grace; needing fee funding or a step rejected repeatedly - /// reminds at once. A step published or confirmed needs none, so a background run that publishes a step drops - /// its reminder before it fires. + /// reminds at once. While a rebuild is pending, one reminder past its due time replaces the steps' own. A step + /// published or confirmed needs none, so a background run that publishes a step drops its reminder before it fires. static func reminders(_ record: SparkExitRecord, tip: UInt32?, now: Date, network: LDKNode.Network) -> [SparkExitReminder] { let lost = record.lostLeaves ?? [] var lostReminder = lost.isEmpty ? [] : [SparkExitReminder(id: "lost-\(lost.count)", fireAt: now, isLostLeaves: true)] @@ -170,14 +170,20 @@ enum SparkExitMath { guard case .running = record.phase, let recovery = record.recovery?.response else { return lostReminder } var reminders = lostReminder let generation = record.generation + let blockSeconds = secondsUntil(height: 1, tip: 0, network: network) + let grace = TimeInterval(reminderGraceBlocks) * blockSeconds + // A diverged generation is never sent again, so its steps need no reminder: only a rebuild that does not come. + if record.isRebuilding { + let due = (record.lastBuildAt ?? now).addingTimeInterval(SparkExitDriver.rebuildInterval) + reminders.append(SparkExitReminder(id: "\(generation)-rebuild", fireAt: max(due, now).addingTimeInterval(grace))) + return reminders + } if let rejected = record.broadcastLog.first(where: { $0.generation == generation && $0.rejections >= SparkExitDriver.rejectionsToSurface && !$0.isAccepted }) { reminders.append(SparkExitReminder(id: "rejected-\(generation)-\(rejected.txid)", fireAt: now)) } let accepted = Set(record.broadcastLog.filter { $0.generation == generation && $0.isAccepted }.map(\.txid)) - let blockSeconds = secondsUntil(height: 1, tip: 0, network: network) - let grace = TimeInterval(reminderGraceBlocks) * blockSeconds var heights = Set() var readyTxid: String? for tx in recovery.transactions where !accepted.contains(tx.txid) { diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 0e9a2b1f7..a822cba7a 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1371,6 +1371,56 @@ final class SparkExitMathTests: XCTestCase { XCTAssertTrue(SparkExitMath.reminders(SparkExitFixtures.record(phase: .done), tip: 1000, now: now, network: .bitcoin).isEmpty) } + /// qa-03 case 5 replayed on regtest: a single-leaf exit whose leaf an older state claimed. Generation 2's idle + /// reminder, planned when it was built, fired a second before the rebuild that found the leaf lost, so two + /// notifications arrived at once. A pending rebuild now replaces its generation's reminders, and the lost exit + /// gives only the lost one. + func testALostLeafExitGivesOneNotificationAcrossItsRebuilds() { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let recovery = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + ]) + var record = SparkExitFixtures.record(phase: .running) + var scheduled: [String: Date] = [:] + var fired: [String: Date] = [:] + var delivered: [String] = [] + // As `SparkExitManager.scheduleReminders`: one that fired is not posted again, one ahead keeps its earliest + // date, and every reminder no longer planned is removed. + func schedule(at seconds: TimeInterval) { + let now = start.addingTimeInterval(seconds) + for (id, fireAt) in scheduled.sorted(by: { $0.value < $1.value }) where fireAt <= now { + delivered.append(id) + fired[id] = fireAt + } + var next: [String: Date] = [:] + for reminder in SparkExitMath.reminders(record, tip: 1000, now: now, network: .regtest) where fired[reminder.id] == nil { + next[reminder.id] = min(reminder.fireAt, scheduled[reminder.id] ?? reminder.fireAt) + } + scheduled = next + } + + record.adopt(recovery, at: start) + schedule(at: 0) + XCTAssertEqual(Set(scheduled.keys), ["1-ready-r1", "1-idle-1-0"]) + // A check says Redo; the rebuild waits for its interval. + record.isRebuilding = true + schedule(at: 284) + XCTAssertEqual(Array(scheduled.keys), ["1-rebuild"]) + record.adopt(recovery, at: start.addingTimeInterval(645)) + schedule(at: 645) + record.isRebuilding = true + schedule(at: 705) + // The next rebuild reproduces the spent refund a second time: the leaf is lost and the exit ends. + record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 32768)] + record.isRebuilding = true + record.phase = .failed(reason: SparkExitRecord.lostReason) + schedule(at: 1246) + schedule(at: 1247) + + XCTAssertEqual(delivered, ["lost-1"]) + } + func testTimelocksMatureOnlyOnceNoRefundWaits() { let node = SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 5)) let waitingForParent = SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies) From 57118a699ca68320e38983f4b4d0b074eead4ee1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 11:23:38 +0100 Subject: [PATCH 019/378] fix: keep lost spark exit leaves out of later exits --- Bitkit/Constants/Env.swift | 7 + Bitkit/Managers/SparkExitManager.swift | 28 +++- .../Services/SparkExit/SparkExitDriver.swift | 18 ++- .../Services/SparkExit/SparkExitFunding.swift | 35 +++++ .../Services/SparkExit/SparkExitRecord.swift | 37 ++++++ BitkitTests/SparkExitTests.swift | 123 ++++++++++++++++++ 6 files changed, 237 insertions(+), 11 deletions(-) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index c2b1135a3..1a8a78bdd 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -293,6 +293,13 @@ enum Env { .appendingPathComponent("wallet\(walletIndex)/spark-exit.json") } + /// The leaves an older state of the wallet claimed in an earlier Spark exit, kept out of every later one. + static func sparkExitLostLeaves(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark-exit-lost-leaves.json") + } + /// Breez SDK API key, set with the `BREEZ_API_KEY` build setting. Spark on mainnet needs it; its regtest does not. static var breezApiKey: String? { infoPlistValue("BREEZ_API_KEY") diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 32c4a9d91..467931db5 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -62,6 +62,20 @@ final class SparkExitManager { SparkExitStore(walletIndex: walletIndex) } + private var lostLeafStore: SparkExitLostLeafStore { + SparkExitLostLeafStore(walletIndex: walletIndex) + } + + /// Saves the record, and keeps the leaves it lost to an older state for every later exit of the wallet. + private var saveRecord: (SparkExitRecord) throws -> Void { + let store = store + let lostLeafStore = lostLeafStore + return { + try store.save($0) + try lostLeafStore.add(lostIn: $0) + } + } + private var chain: SparkExitChain? { guard let network = SparkManager.exitNetwork else { return nil } return EsploraSparkExitChain(hosts: EsploraSparkExitChain.defaultHosts(for: network)) @@ -94,6 +108,7 @@ final class SparkExitManager { /// Forgets the exit of a wallet being wiped. func wipe() { try? store.delete() + try? lostLeafStore.delete() record = nil quote = nil pendingFundingIndex = nil @@ -159,7 +174,7 @@ final class SparkExitManager { chain: chain, recovering: SparkManager.shared.exitRecovering, makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, - save: { try store.save($0) } + save: saveRecord ) do { tipHeight = try? await chain.tipHeight() @@ -264,12 +279,12 @@ final class SparkExitManager { } else { try await LightningService.shared.newAddress() } - let prepared = try await recovering.prepare(PrepareRecoverFundsRequest( + let prepared = try await SparkExitQuote.prepare( + recovering, feeRateSatPerVbyte: feeRate, - fundingKind: .p2wpkh, destination: destination, - selection: .all - )) + lostLeafIds: (try? lostLeafStore.load()) ?? [] + ) let fundingIndex = pendingFundingIndex ?? nextFundingIndex() pendingDestination = destination pendingFeeRate = feeRate @@ -433,6 +448,7 @@ final class SparkExitManager { isTicking = true defer { isTicking = false } let store = store + let saveRecord = saveRecord let walletIndex = walletIndex let run = SparkExitBackgroundRun( load: { try store.load() }, @@ -441,7 +457,7 @@ final class SparkExitManager { chain: chain, recovering: recovering, makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, - save: { try store.save($0) } + save: saveRecord ) }, chain: chain, diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 5a6b062ed..2b4d30efd 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -176,11 +176,19 @@ struct SparkExitDriver { destination: record.destination, selection: isRecoverableOnly ? .recoverableOnly : .specific(leafIds: record.exitLeafIds) )) - if isRecoverableOnly { - let kept = Set(prepared.leaves.map(\.leafId)) - leaveBehind(record.exitLeafIds.filter { !kept.contains($0) }, reason: "Not recoverable at this fee rate", in: &record) - } - return prepared + guard isRecoverableOnly else { return prepared } + let kept = Set(prepared.leaves.map(\.leafId)) + leaveBehind(record.exitLeafIds.filter { !kept.contains($0) }, reason: "Not recoverable at this fee rate", in: &record) + // It also takes the wallet's leaves outside this exit, such as one an older state claimed in an + // earlier exit: the exit's own are prepared again on their own. + guard prepared.leaves.contains(where: { !record.exitLeafIds.contains($0.leafId) }) else { return prepared } + guard !record.exitLeafIds.isEmpty else { return nil } + return try await recovering.prepare(PrepareRecoverFundsRequest( + feeRateSatPerVbyte: record.feeRateSatPerVbyte, + fundingKind: .p2wpkh, + destination: record.destination, + selection: .specific(leafIds: record.exitLeafIds) + )) } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { if let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) { leaveBehind([leafId], reason: message, in: &record) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 1ab6104dd..cc8389e36 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -69,6 +69,41 @@ enum Base58 { struct SparkExitQuote: Equatable { let prepared: PrepareRecoverFundsResponse + /// Prepares every leaf less those an older state claimed in an earlier exit, which the operators still list. The + /// rest is prepared again on its own, so the amount, fees and funding leave the lost leaves out. With none left + /// the quote has no leaf, as when no leaf can be exited. + static func prepare( + _ recovering: SparkExitRecovering, + feeRateSatPerVbyte: UInt64, + destination: String, + lostLeafIds: Set + ) async throws -> PrepareRecoverFundsResponse { + func request(_ selection: ExitLeafSelection) -> PrepareRecoverFundsRequest { + PrepareRecoverFundsRequest(feeRateSatPerVbyte: feeRateSatPerVbyte, fundingKind: .p2wpkh, destination: destination, selection: selection) + } + let all = try await recovering.prepare(request(.all)) + guard all.leaves.contains(where: { lostLeafIds.contains($0.leafId) }) else { return all } + let remaining = all.leaves.map(\.leafId).filter { !lostLeafIds.contains($0) } + var prepared = all + if remaining.isEmpty { + prepared.leaves = [] + prepared.recoverableValueSats = 0 + prepared.totalFeeSats = 0 + prepared.cooperativeFeeSats = 0 + prepared.cpfpFeeSats = 0 + prepared.fanoutFeeSats = 0 + prepared.refundFeeSats = 0 + prepared.sweepFeeSats = 0 + prepared.funding = nil + } else { + prepared = try await recovering.prepare(request(.specific(leafIds: remaining))) + } + // `Specific` lists only its own leaves as skipped; the leaves `All` could not exit stay left behind. + let listed = Set(prepared.skipped.map(\.leafId)) + prepared.skipped += all.skipped.filter { !lostLeafIds.contains($0.leafId) && !listed.contains($0.leafId) } + return prepared + } + /// The value of the leaves the exit takes on-chain. var exitSats: UInt64 { prepared.recoverableValueSats diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 79ebc7623..5dd631e50 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -276,6 +276,43 @@ struct SparkExitStore { } } +/// The ids of the leaves an older state of the wallet claimed on-chain, across exits. The operators keep listing such +/// a leaf under the wallet, so without this a later exit would quote it and lose it again. Not backed up: a restored +/// wallet finds the loss again in one rebuild cycle. +struct SparkExitLostLeafStore { + let url: URL + + init(url: URL) { + self.url = url + } + + init(walletIndex: Int) { + url = Env.sparkExitLostLeaves(walletIndex: walletIndex) + } + + func load() throws -> Set { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + return try Set(JSONDecoder().decode([String].self, from: Data(contentsOf: url))) + } + + /// Adds the record's lost leaves, writing only when one is new. + func add(lostIn record: SparkExitRecord) throws { + let ids = (record.lostLeaves ?? []).map(\.leafId) + guard !ids.isEmpty else { return } + // An unreadable list is written anew: a leaf missing from it is found lost again in one rebuild cycle. + let stored = (try? load()) ?? [] + guard !stored.isSuperset(of: ids) else { return } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(stored.union(ids).sorted()) + .write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + func delete() throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } +} + enum SparkExitRecordCoder { struct UnsupportedVersion: Error, Equatable { let version: Int diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index a822cba7a..e5335bf73 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -341,6 +341,27 @@ final class SparkExitRecordTests: XCTestCase { XCTAssertEqual(try store.load(), SparkExitFixtures.record()) } + + /// qa-03 rerun: the operators keep listing a leaf an older state claimed, so its loss has to outlive the record. + func testLostLeavesOutliveTheExitRecord() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)") + let store = SparkExitStore(url: folder.appendingPathComponent("spark-exit.json")) + let lostLeafStore = SparkExitLostLeafStore(url: folder.appendingPathComponent("spark-exit-lost-leaves.json")) + defer { try? FileManager.default.removeItem(at: folder) } + XCTAssertEqual(try lostLeafStore.load(), []) + var lost = SparkExitFixtures.record(phase: .failed(reason: SparkExitRecord.lostReason)) + lost.lostLeaves = [.init(leafId: "leaf-1", valueSats: 65536)] + try store.save(lost) + try lostLeafStore.add(lostIn: lost) + + let next = SparkExitFixtures.record() + try store.save(next) + try lostLeafStore.add(lostIn: next) + + XCTAssertEqual(try lostLeafStore.load(), ["leaf-1"]) + try lostLeafStore.delete() + XCTAssertEqual(try lostLeafStore.load(), []) + } } // MARK: - Driver @@ -1057,6 +1078,62 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.generation, 2) } + /// `RecoverableOnly` also lists a leaf an older state claimed in an earlier exit, which this exit would rebuild + /// forever and never find lost: the exit's own leaves are prepared again on their own. + func testRecoverableOnlyLeavesOutTheLeavesOfOtherExits() async throws { + var record = twoLeafRecord() + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) + record.adopt(first, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.SparkError("A leaf cannot be exited")] + sdk.preparedByRequest = { request in + request.selection == .recoverableOnly + ? SparkExitFixtures.prepared(leaves: ["leaf-2", "lost-earlier"]) + : SparkExitFixtures.prepared(leaves: ["leaf-2"]) + } + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.map(\.selection).suffix(2), [.recoverableOnly, .specific(leafIds: ["leaf-2"])]) + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"]) + XCTAssertEqual(record.generation, 2) + } + + /// qa-03 rerun: the next exit quoted the leaf an earlier one lost, lost it again and told the user twice. + func testALeafLostInAnEarlierExitIsNotReportedAgain() async throws { + sdk.preparedByRequest = { request in + request.selection == .all ? SparkExitFixtures.prepared(leaves: ["leaf-1", "lost-earlier"]) : SparkExitFixtures.prepared() + } + let quote = try await SparkExitQuote(prepared: SparkExitQuote.prepare( + sdk, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + lostLeafIds: ["lost-earlier"] + )) + var record = SparkExitRecord( + network: "regtest", + createdAt: clock, + phase: .running, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + leafIds: quote.leafIds, + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)], + expectedSats: quote.incomingSats + ) + let stuck = SparkExitFixtures.recovery([SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: [])]) + record.adopt(stuck, at: .distantPast) + sdk.checks = [CheckRecoverFundsResponse(recovery: stuck, verdict: .redo(reason: .onChainStateDiverged))] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.refund("r1b", leafId: "leaf-1", dependsOn: [])])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.leafIds, ["leaf-1"]) + XCTAssertEqual(sdk.prepareRequests.last?.selection, .specific(leafIds: ["leaf-1"])) + XCTAssertNil(record.lostLeaves) + XCTAssertFalse(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).contains(where: \.isLostLeaves)) + } + func testAnExitWithNoLeafLeftToExitFails() async throws { var record = SparkExitFixtures.record(phase: .running) let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]) @@ -1279,6 +1356,52 @@ final class SparkExitMathTests: XCTestCase { XCTAssertEqual(SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: nil)).fundingSats, 0) } + func testQuoteLeavesOutTheLeavesEarlierExitsLost() async throws { + let sdk = FakeSparkExitRecovering() + sdk.preparedByRequest = { request in + guard request.selection == .all else { + var prepared = SparkExitFixtures.prepared(funding: 12000, leaves: ["leaf-1"]) + prepared.skipped = [] + return prepared + } + var prepared = SparkExitFixtures.prepared(funding: 24000, leaves: ["leaf-1", "lost"]) + prepared.recoverableValueSats = 200_000 + prepared.skipped += [SkippedLeaf(leafId: "lost", valueSats: 1000, reason: .feeExceedsValue)] + return prepared + } + + let quote = try await SparkExitQuote(prepared: SparkExitQuote.prepare( + sdk, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + lostLeafIds: ["lost"] + )) + + XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.all, .specific(leafIds: ["leaf-1"])]) + XCTAssertEqual(quote.leafIds, ["leaf-1"]) + XCTAssertEqual(quote.exitSats, 100_000) + XCTAssertEqual(quote.fundingSats, 12000) + XCTAssertEqual(quote.prepared.skipped.map(\.leafId), ["dust"], "what All could not exit stays left behind") + } + + func testQuoteOfOnlyLostLeavesHasNothingToExit() async throws { + let sdk = FakeSparkExitRecovering() + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["lost"]) + + let quote = try await SparkExitQuote(prepared: SparkExitQuote.prepare( + sdk, + feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", + lostLeafIds: ["lost"] + )) + + XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.all]) + XCTAssertTrue(quote.prepared.leaves.isEmpty) + XCTAssertEqual(quote.exitSats, 0) + XCTAssertEqual(quote.fundingSats, 0) + XCTAssertFalse(quote.isWorthExiting) + } + func testClaimableHeightIsTheLatestTimelock() { let recovery = SparkExitFixtures.recovery([ SparkExitFixtures.tx("a", status: .waitingForTimelock(spendableAtHeight: 2000)), From b277bebe033a71ec92ed706029c53912a63a7ed1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 12:34:16 +0100 Subject: [PATCH 020/378] feat: spark chain service on bitkit electrs --- Bitkit/Constants/Env.swift | 9 +++++++++ Bitkit/Managers/SparkManager.swift | 12 +++++++----- BitkitTests/SparkConfigTests.swift | 5 +++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4cdd9ea87..475fc8da1 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -221,6 +221,15 @@ enum Env { } } + /// Esplora REST API Spark reads the chain from and broadcasts through, in place of the SDK's default + /// (mempool.space with blockstream as fallback on mainnet); nil keeps the default. + static func sparkChainServiceUrl(for network: LDKNode.Network) -> String? { + switch network { + case .bitcoin: "https://bitkit.to/electrs" + default: nil + } + } + static var electrumServerUrl: String { if isE2E, e2eBackend == "local" { return "tcp://\(e2eLocalHost):60001" diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 332c17997..cc7a28af4 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -107,11 +107,13 @@ final class SparkManager: ChannelFreeLightningRail { let config = Self.sdkConfig(network: network, apiKey: Env.breezApiKey) let storageDir = Env.sparkStorage(walletIndex: walletIndex) try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) - let connected = try await BreezSdkSpark.connect(request: ConnectRequest( - config: config, - seed: .mnemonic(mnemonic: mnemonic, passphrase: passphrase), - storageDir: storageDir.path - )) + // What `BreezSdkSpark.connect` builds, plus our own chain service where Env names one. + let builder = SdkBuilder(config: config, seed: .mnemonic(mnemonic: mnemonic, passphrase: passphrase)) + await builder.withDefaultStorage(storageDir: storageDir.path) + if let chainServiceUrl = Env.sparkChainServiceUrl(for: Env.network) { + await builder.withRestChainService(url: chainServiceUrl, apiType: .esplora, credentials: nil) + } + let connected = try await builder.build() listenerId = await connected.addEventListener(listener: SparkEventListener { [weak self] event in await self?.onSdkEvent(event) }) diff --git a/BitkitTests/SparkConfigTests.swift b/BitkitTests/SparkConfigTests.swift index 267e1600b..f61ef0466 100644 --- a/BitkitTests/SparkConfigTests.swift +++ b/BitkitTests/SparkConfigTests.swift @@ -12,4 +12,9 @@ final class SparkConfigTests: XCTestCase { XCTAssertEqual(config.apiKey, "test-key") } } + + func testChainServiceIsBitkitElectrsOnMainnetOnly() { + XCTAssertEqual(Env.sparkChainServiceUrl(for: .bitcoin), "https://bitkit.to/electrs") + XCTAssertNil(Env.sparkChainServiceUrl(for: .regtest)) + } } From 7220fd00c054b97285aced125c494e5ef9928fa2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 12:38:01 +0100 Subject: [PATCH 021/378] feat: spark exit uses bitkit electrs on mainnet --- Bitkit/Managers/SparkExitManager.swift | 2 +- .../SparkExit/SparkExitBroadcaster.swift | 62 ++++++++++++++----- BitkitTests/SparkExitRegtestTests.swift | 4 +- BitkitTests/SparkExitTests.swift | 61 ++++++++++++++++++ 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 467931db5..1abf676e7 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -78,7 +78,7 @@ final class SparkExitManager { private var chain: SparkExitChain? { guard let network = SparkManager.exitNetwork else { return nil } - return EsploraSparkExitChain(hosts: EsploraSparkExitChain.defaultHosts(for: network)) + return EsploraSparkExitChain.network(network) } // MARK: Lifecycle diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index 5f12c4ed5..e721e85c6 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -64,18 +64,50 @@ struct URLSessionSparkExitHTTP: SparkExitHTTP { } } -/// esplora and mempool.space REST, on the hosts the Spark SDK uses by default: a transaction with a CPFP child goes -/// out as a package through `/v1/txs/package` (JSON array of hex), one without as a single `/tx`. +/// esplora and mempool.space REST: a transaction with a CPFP child goes out as a package (JSON array of hex) through +/// the host's package paths in turn, one without as a single `/tx`. struct EsploraSparkExitChain: SparkExitChain { - let hosts: [URL] + struct BroadcastHost: Equatable { + let url: URL + var packagePaths = ["/v1/txs/package", "/txs/package"] + } + + /// Reads go to these hosts in turn. + let readHosts: [URL] + /// Broadcasts try these hosts in turn. + let broadcastHosts: [BroadcastHost] var http: SparkExitHTTP = URLSessionSparkExitHTTP() - /// The Spark SDK's default chain service for each network (breez spark-sdk `default_rest_chain_services`). - static func defaultHosts(for network: BreezSdkSpark.Network) -> [URL] { + init(readHosts: [URL], broadcastHosts: [BroadcastHost], http: SparkExitHTTP = URLSessionSparkExitHTTP()) { + self.readHosts = readHosts + self.broadcastHosts = broadcastHosts + self.http = http + } + + /// Reads and broadcasts on the same hosts. + init(hosts: [URL], http: SparkExitHTTP = URLSessionSparkExitHTTP()) { + self.init(readHosts: hosts, broadcastHosts: hosts.map { BroadcastHost(url: $0) }, http: http) + } + + /// On mainnet the chain service Spark connects with (`Env.sparkChainServiceUrl`) answers every read and takes + /// broadcasts first, on its `/txs/package` (no `/v1`); the SDK's default hosts stay as broadcast fallbacks. On + /// regtest the SDK's default host (breez spark-sdk `default_rest_chain_services`) does both. + static func network(_ network: BreezSdkSpark.Network) -> EsploraSparkExitChain { switch network { - case .mainnet: [URL(string: "https://mempool.space/api")!, URL(string: "https://blockstream.info/api")!] - case .regtest: [URL(string: "https://regtest-mempool.us-west-2.sparkinfra.net/api")!] - default: [] + case .mainnet: + let electrs = URL(string: Env.sparkChainServiceUrl(for: .bitcoin)!)! + return EsploraSparkExitChain( + readHosts: [electrs], + broadcastHosts: [ + BroadcastHost(url: electrs, packagePaths: ["/txs/package"]), + BroadcastHost(url: URL(string: "https://mempool.space/api")!), + BroadcastHost(url: URL(string: "https://blockstream.info/api")!), + ] + ) + case .regtest: + return EsploraSparkExitChain(hosts: [URL(string: "https://regtest-mempool.us-west-2.sparkinfra.net/api")!]) + default: + return EsploraSparkExitChain(hosts: []) } } @@ -104,11 +136,11 @@ struct EsploraSparkExitChain: SparkExitChain { /// Tries each host until one accepts. Otherwise a node's refusal wins over a host that was only unavailable. func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { var outcomes: [SparkExitBroadcastOutcome] = [] - for host in hosts { + for host in broadcastHosts { let attempt: SparkExitBroadcastOutcome? = if let child = tx.cpfpTxHex { await submitPackage(host: host, hexes: [tx.txHex, child], txid: tx.txid) } else { - await submitSingle(host: host, hex: tx.txHex) + await submitSingle(host: host.url, hex: tx.txHex) } // nil: this host cannot take it (no package endpoint, unreachable). guard let attempt else { continue } @@ -126,9 +158,9 @@ struct EsploraSparkExitChain: SparkExitChain { } ?? outcomes.first ?? .retryLater("no chain host reachable") } - private func submitPackage(host: URL, hexes: [String], txid: String) async -> SparkExitBroadcastOutcome? { - for path in ["/v1/txs/package", "/txs/package"] { - var request = URLRequest(url: host.appendingPathComponent(path)) + private func submitPackage(host: BroadcastHost, hexes: [String], txid: String) async -> SparkExitBroadcastOutcome? { + for path in host.packagePaths { + var request = URLRequest(url: host.url.appendingPathComponent(path)) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONEncoder().encode(hexes) @@ -163,7 +195,7 @@ struct EsploraSparkExitChain: SparkExitChain { /// nil when every host answers 404; a host that fails otherwise makes it throw instead. private func getIfFound(_ path: String) async throws -> Data? { var lastError: Error? - for host in hosts { + for host in readHosts { var request = URLRequest(url: host.appendingPathComponent(path)) request.timeoutInterval = 20 do { @@ -177,7 +209,7 @@ struct EsploraSparkExitChain: SparkExitChain { lastError = error } } - if hosts.isEmpty { + if readHosts.isEmpty { throw URLError(.cannotFindHost) } if let lastError { diff --git a/BitkitTests/SparkExitRegtestTests.swift b/BitkitTests/SparkExitRegtestTests.swift index 69db04e73..16135a8b6 100644 --- a/BitkitTests/SparkExitRegtestTests.swift +++ b/BitkitTests/SparkExitRegtestTests.swift @@ -46,7 +46,7 @@ final class SparkExitRegtestTests: XCTestCase { XCTAssertTrue(prepared.leaves.isEmpty) XCTAssertFalse(SparkExitQuote(prepared: prepared).isWorthExiting) - let chain = EsploraSparkExitChain(hosts: EsploraSparkExitChain.defaultHosts(for: .regtest)) + let chain = EsploraSparkExitChain.network(.regtest) let tip = try await chain.tipHeight() XCTAssertGreaterThan(tip, 0) let utxos = try await chain.utxos(address: funding.address) @@ -69,7 +69,7 @@ final class SparkExitRegtestTests: XCTestCase { dependsOn: [], status: .ready ) - let chain = EsploraSparkExitChain(hosts: EsploraSparkExitChain.defaultHosts(for: .regtest)) + let chain = EsploraSparkExitChain.network(.regtest) let outcome = await chain.broadcast(tx) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index e5335bf73..ff4d8a4ac 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1924,6 +1924,67 @@ final class SparkExitBroadcasterTests: XCTestCase { XCTAssertFalse(SparkExitBroadcastClassifier.isUnpaid(.inputsMissing("min relay fee not met, 0 < 17"))) } + func testMainnetReadsOnlyBitkitElectrsAndBroadcastsThereFirst() throws { + let chain = EsploraSparkExitChain.network(.mainnet) + let electrs = try XCTUnwrap(URL(string: "https://bitkit.to/electrs")) + let mempool = try XCTUnwrap(URL(string: "https://mempool.space/api")) + let blockstream = try XCTUnwrap(URL(string: "https://blockstream.info/api")) + + XCTAssertEqual(chain.readHosts, [electrs]) + XCTAssertEqual(chain.broadcastHosts, [ + .init(url: electrs, packagePaths: ["/txs/package"]), + .init(url: mempool, packagePaths: ["/v1/txs/package", "/txs/package"]), + .init(url: blockstream, packagePaths: ["/v1/txs/package", "/txs/package"]), + ]) + XCTAssertEqual(EsploraSparkExitChain.network(.regtest).readHosts.map(\.host), ["regtest-mempool.us-west-2.sparkinfra.net"]) + } + + func testSendsAPackageToBitkitElectrsWithoutTheV1Path() async { + let http = FakeHTTP() + http.responses = [("bitkit.to/electrs/txs/package", 200, #"{"package_msg":"success"}"#)] + let mainnet = EsploraSparkExitChain.network(.mainnet) + let chain = EsploraSparkExitChain(readHosts: mainnet.readHosts, broadcastHosts: mainnet.broadcastHosts, http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("n1", cpfp: "03c")) + + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(http.requests.map { $0.url?.absoluteString }, ["https://bitkit.to/electrs/txs/package"]) + } + + func testBroadcastFallsBackFromBitkitElectrsButReadsNeverDo() async { + let http = FakeHTTP() + http.responses = [ + ("bitkit.to/electrs/tx", 503, ""), + ("mempool.space/api/tx", 200, "s1"), + ("bitkit.to/electrs/blocks/tip/height", 503, ""), + ] + let mainnet = EsploraSparkExitChain.network(.mainnet) + let chain = EsploraSparkExitChain(readHosts: mainnet.readHosts, broadcastHosts: mainnet.broadcastHosts, http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + let tip = try? await chain.tipHeight() + + XCTAssertEqual(outcome, .accepted) + XCTAssertNil(tip) + XCTAssertEqual(http.requests.compactMap { $0.url?.host }, ["bitkit.to", "mempool.space", "bitkit.to"]) + } + + func testOneReadHostOnlyCallsATransactionGoneOnA404() async throws { + let http = FakeHTTP() + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + http.responses = [("a.example/api/tx/c/hex", 404, "Transaction not found")] + let isUnknown = try await chain.isKnown(txid: "c") + http.responses = [("a.example/api/tx/c/hex", 503, "")] + let failedOnError = await (try? chain.isKnown(txid: "c")) == nil + http.responses = [] + let failedOnTimeout = await (try? chain.isKnown(txid: "c")) == nil + + XCTAssertFalse(isUnknown) + XCTAssertTrue(failedOnError, "a 5xx is no proof it is gone") + XCTAssertTrue(failedOnTimeout, "an unreachable host is no proof it is gone") + } + func testReadsTipAndFundingUtxos() async throws { let http = FakeHTTP() http.responses = [ From 1574688ecb30d7591ddc866f2db982ab77d1fa76 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 13:15:13 +0100 Subject: [PATCH 022/378] fix: spark exit lost leaves from before the update --- Bitkit/Managers/SparkExitManager.swift | 19 +++++-- .../Services/SparkExit/SparkExitFunding.swift | 31 ++++++++--- BitkitTests/SparkExitTests.swift | 53 ++++++++++++++++--- 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 1abf676e7..b748701a0 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -86,7 +86,7 @@ final class SparkExitManager { func load(walletIndex: Int) { self.walletIndex = walletIndex do { - record = try store.load() + record = try Self.loadRecord(store: store, lostLeafStore: lostLeafStore) } catch { // Kept aside for support, so a new exit never overwrites it; a new exit prepares from chain state. Logger.error("Failed to read the Spark exit record: \(error)", context: "SparkExitManager") @@ -105,6 +105,16 @@ final class SparkExitManager { } } + /// Loads the stored exit and adds its lost leaves to the lost-leaf list, ended exits included, so leaves an exit + /// lost before that list existed stay out of the next quote too. + nonisolated static func loadRecord(store: SparkExitStore, lostLeafStore: SparkExitLostLeafStore) throws -> SparkExitRecord? { + let record = try store.load() + if let record { + try? lostLeafStore.add(lostIn: record) + } + return record + } + /// Forgets the exit of a wallet being wiped. func wipe() { try? store.delete() @@ -279,7 +289,7 @@ final class SparkExitManager { } else { try await LightningService.shared.newAddress() } - let prepared = try await SparkExitQuote.prepare( + let newQuote = try await SparkExitQuote.prepare( recovering, feeRateSatPerVbyte: feeRate, destination: destination, @@ -289,7 +299,7 @@ final class SparkExitManager { pendingDestination = destination pendingFeeRate = feeRate pendingFundingIndex = fundingIndex - quote = SparkExitQuote(prepared: prepared) + quote = newQuote fundingAddress = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex).address } catch { Logger.error("Failed to quote the Spark exit: \(error)", context: "SparkExitManager") @@ -448,10 +458,11 @@ final class SparkExitManager { isTicking = true defer { isTicking = false } let store = store + let lostLeafStore = lostLeafStore let saveRecord = saveRecord let walletIndex = walletIndex let run = SparkExitBackgroundRun( - load: { try store.load() }, + load: { try Self.loadRecord(store: store, lostLeafStore: lostLeafStore) }, makeDriver: { chain, recovering in SparkExitDriver( chain: chain, diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index cc8389e36..867374c02 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -68,6 +68,8 @@ enum Base58 { /// What an exit costs and returns, from a `prepareRecoverFunds` quote. struct SparkExitQuote: Equatable { let prepared: PrepareRecoverFundsResponse + /// The values of the leaves an older state claimed that the operators still list, which the quote leaves out. + var lostLeafSats: [UInt64] = [] /// Prepares every leaf less those an older state claimed in an earlier exit, which the operators still list. The /// rest is prepared again on its own, so the amount, fees and funding leave the lost leaves out. With none left @@ -77,12 +79,16 @@ struct SparkExitQuote: Equatable { feeRateSatPerVbyte: UInt64, destination: String, lostLeafIds: Set - ) async throws -> PrepareRecoverFundsResponse { + ) async throws -> SparkExitQuote { func request(_ selection: ExitLeafSelection) -> PrepareRecoverFundsRequest { PrepareRecoverFundsRequest(feeRateSatPerVbyte: feeRateSatPerVbyte, fundingKind: .p2wpkh, destination: destination, selection: selection) } let all = try await recovering.prepare(request(.all)) - guard all.leaves.contains(where: { lostLeafIds.contains($0.leafId) }) else { return all } + let lostLeafSats = all.leaves.filter { lostLeafIds.contains($0.leafId) }.map(\.valueSats) + + all.skipped.filter { lostLeafIds.contains($0.leafId) }.map(\.valueSats) + guard all.leaves.contains(where: { lostLeafIds.contains($0.leafId) }) else { + return SparkExitQuote(prepared: all, lostLeafSats: lostLeafSats) + } let remaining = all.leaves.map(\.leafId).filter { !lostLeafIds.contains($0) } var prepared = all if remaining.isEmpty { @@ -101,7 +107,7 @@ struct SparkExitQuote: Equatable { // `Specific` lists only its own leaves as skipped; the leaves `All` could not exit stay left behind. let listed = Set(prepared.skipped.map(\.leafId)) prepared.skipped += all.skipped.filter { !lostLeafIds.contains($0.leafId) && !listed.contains($0.leafId) } - return prepared + return SparkExitQuote(prepared: prepared, lostLeafSats: lostLeafSats) } /// The value of the leaves the exit takes on-chain. @@ -352,10 +358,17 @@ enum SparkExitMath { /// SDK lists, such as a counter-swap still unclaimed. Nil when there is none. static func unexitableSats(sparkBalanceSats: UInt64, quote: SparkExitQuote) -> UInt64? { let listed = quote.leafTotalSats + quote.leftBehindSats - let unexitable = quote.leftBehindSats + sparkBalanceSats.saturatingSub(listed) + let beyond = beyondLostLeaves(sparkBalanceSats.saturatingSub(listed), lostLeafSats: quote.lostLeafSats) + let unexitable = quote.leftBehindSats + beyond return unexitable > 0 ? unexitable : nil } + /// `sats` less the lost leaves it still holds. Spark's balance keeps a leaf an older state claimed until a later + /// sync drops it, so each lost leaf, largest first, is taken out only while what is left still covers it. + static func beyondLostLeaves(_ sats: UInt64, lostLeafSats: [UInt64]) -> UInt64 { + lostLeafSats.sorted(by: >).reduce(sats) { left, lost in left >= lost ? left - lost : left } + } + /// Whether the SDK refused a `Specific` selection because a leaf in it cannot be exited. static func isUnexitableLeafError(_ message: String) -> Bool { message.contains("cannot be exited") @@ -374,10 +387,14 @@ enum SparkExitMath { return staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) } - /// What the Spark balance holds beyond the exit's leaves, in any phase: what a failed exit leaves on Spark. + /// What the Spark balance holds beyond the exit's leaves, in any phase: what a failed exit leaves on Spark. Lost + /// leaves count only while the balance still holds them (`beyondLostLeaves`). static func staysOnSparkSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { - guard let leafTotal = record.leafTotalSats, sparkBalanceSats > leafTotal else { return nil } - return sparkBalanceSats - leafTotal + guard let leafTotal = record.leafTotalSats else { return nil } + let liveLeafTotal = leafTotal.saturatingSub(record.lostSats) + guard sparkBalanceSats > liveLeafTotal else { return nil } + let stays = beyondLostLeaves(sparkBalanceSats - liveLeafTotal, lostLeafSats: (record.lostLeaves ?? []).map(\.valueSats)) + return stays > 0 ? stays : nil } /// About how long until `height`: 10-minute blocks, and Spark's regtest mines one every ~30 seconds. diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index ff4d8a4ac..ac3bc99e9 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -362,6 +362,25 @@ final class SparkExitRecordTests: XCTestCase { try lostLeafStore.delete() XCTAssertEqual(try lostLeafStore.load(), []) } + + /// qa-03 rerun of fix 10: a leaf an exit lost before the lost-leaf list existed joins it once the record loads, + /// so the first quote after the update leaves it out. + func testLoadingTheRecordAddsItsLostLeavesToTheList() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)") + let store = SparkExitStore(url: folder.appendingPathComponent("spark-exit.json")) + let lostLeafStore = SparkExitLostLeafStore(url: folder.appendingPathComponent("spark-exit-lost-leaves.json")) + defer { try? FileManager.default.removeItem(at: folder) } + + try store.save(SparkExitFixtures.record()) + XCTAssertEqual(try SparkExitManager.loadRecord(store: store, lostLeafStore: lostLeafStore), SparkExitFixtures.record()) + XCTAssertFalse(FileManager.default.fileExists(atPath: lostLeafStore.url.path)) + + var ended = SparkExitFixtures.record(phase: .failed(reason: SparkExitRecord.lostReason)) + ended.lostLeaves = [.init(leafId: "lost-before", valueSats: 65536)] + try store.save(ended) + XCTAssertEqual(try SparkExitManager.loadRecord(store: store, lostLeafStore: lostLeafStore), ended) + XCTAssertEqual(try lostLeafStore.load(), ["lost-before"]) + } } // MARK: - Driver @@ -1105,12 +1124,12 @@ final class SparkExitDriverTests: XCTestCase { sdk.preparedByRequest = { request in request.selection == .all ? SparkExitFixtures.prepared(leaves: ["leaf-1", "lost-earlier"]) : SparkExitFixtures.prepared() } - let quote = try await SparkExitQuote(prepared: SparkExitQuote.prepare( + let quote = try await SparkExitQuote.prepare( sdk, feeRateSatPerVbyte: 2, destination: "bcrt1qdestination", lostLeafIds: ["lost-earlier"] - )) + ) var record = SparkExitRecord( network: "regtest", createdAt: clock, @@ -1370,12 +1389,12 @@ final class SparkExitMathTests: XCTestCase { return prepared } - let quote = try await SparkExitQuote(prepared: SparkExitQuote.prepare( + let quote = try await SparkExitQuote.prepare( sdk, feeRateSatPerVbyte: 2, destination: "bcrt1qdestination", lostLeafIds: ["lost"] - )) + ) XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.all, .specific(leafIds: ["leaf-1"])]) XCTAssertEqual(quote.leafIds, ["leaf-1"]) @@ -1388,18 +1407,19 @@ final class SparkExitMathTests: XCTestCase { let sdk = FakeSparkExitRecovering() sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["lost"]) - let quote = try await SparkExitQuote(prepared: SparkExitQuote.prepare( + let quote = try await SparkExitQuote.prepare( sdk, feeRateSatPerVbyte: 2, destination: "bcrt1qdestination", lostLeafIds: ["lost"] - )) + ) XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.all]) XCTAssertTrue(quote.prepared.leaves.isEmpty) XCTAssertEqual(quote.exitSats, 0) XCTAssertEqual(quote.fundingSats, 0) XCTAssertFalse(quote.isWorthExiting) + XCTAssertEqual(quote.lostLeafSats, [100_000]) } func testClaimableHeightIsTheLatestTimelock() { @@ -1606,6 +1626,27 @@ final class SparkExitMathTests: XCTestCase { XCTAssertNil(SparkExitMath.outsideExitSats(sparkBalanceSats: 49865, record: record)) } + /// qa-03 rerun of fix 10: right after an exit lost a leaf, Spark's balance still counted it for about 10 minutes, + /// and a record from before the lost-leaf list also held a leaf the balance had dropped already. + func testLostLeavesStayOutOfWhatStaysOnSparkWhetherTheBalanceCountsThemOrNot() { + var record = SparkExitFixtures.record(phase: .failed(reason: SparkExitRecord.lostReason)) + record.leafTotalSats = 65536 + record.lostLeaves = [.init(leafId: "lost", valueSats: 65536)] + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 68928, record: record), 3392, "balance still counts it") + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 3392, record: record), 3392, "balance synced") + + record.leafTotalSats = 131_072 + record.lostLeaves = [.init(leafId: "lost-before", valueSats: 65536), .init(leafId: "lost", valueSats: 65536)] + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 68928, record: record), 3392) + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 3392, record: record), 3392) + + var prepared = SparkExitFixtures.prepared(leaves: []) + prepared.skipped = [SkippedLeaf(leafId: "small", valueSats: 3392, reason: .feeExceedsValue)] + let quote = SparkExitQuote(prepared: prepared, lostLeafSats: [65536, 65536]) + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 68928, quote: quote), 3392) + XCTAssertEqual(SparkExitMath.unexitableSats(sparkBalanceSats: 3392, quote: quote), 3392) + } + /// qa-03: once Bitkit paid the funding from savings, or a payment covering it is seen, the exit waits for it /// instead of asking again; only what stays short after confirmation is asked for. func testFundingStateWaitsForAPaymentInsteadOfAskingAgain() { From 35fdd91bbf52a3a133d78e7768ce9fc6f0698527 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 14:18:51 +0100 Subject: [PATCH 023/378] chore: drop the fee from the move changelog line --- changelog.d/next/1206.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/1206.added.md b/changelog.d/next/1206.added.md index 4562661bb..80633ba9b 100644 --- a/changelog.d/next/1206.added.md +++ b/changelog.d/next/1206.added.md @@ -1 +1 @@ -Move to Spark: move a Lightning connection's balance to Spark at the lowest fee, then close the emptied connection to return its reserve to Savings. +Move to Spark: move a Lightning connection's balance to Spark, then close the emptied connection to return its reserve to Savings. From b6612b779fc00d5c2d127c82c359deaafe350bb9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 15:16:11 +0100 Subject: [PATCH 024/378] refactor: gate move to spark on one spark check --- Bitkit/Managers/MoveToSparkManager.swift | 8 +++++++- Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift | 2 +- Bitkit/Views/HomeScreen.swift | 2 +- .../Settings/Advanced/LightningConnectionsView.swift | 2 +- Bitkit/Views/Wallets/SpendingWalletScreen.swift | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index f0a211e47..3f2a52e40 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -16,6 +16,12 @@ enum MoveToSparkMath { (channels ?? []).filter(\.isUsable).map { $0.nextOutboundHtlcLimitMsat / 1000 }.reduce(0, +) } + /// Whether Spark can take a move; every Move to Spark check goes through here. + @MainActor + static func isSparkUsable(_ spark: SparkManager) -> Bool { + spark.isReady + } + /// Whether the channels hold a balance worth moving to a usable Spark wallet. static func canMove(channels: [ChannelDetails]?, isSparkUsable: Bool) -> Bool { isSparkUsable && movableSats(channels: channels) >= minMoveSats @@ -157,7 +163,7 @@ final class MoveToSparkManager { let limitSats = MoveToSparkMath.movableSats(channels: channels) let wantedSats = min(targetSats ?? limitSats, limitSats) guard wantedSats >= MoveToSparkMath.minMoveSats else { throw MoveToSparkError.nothingToMove } - guard spark.isReady else { throw MoveToSparkError.sparkUnavailable } + guard MoveToSparkMath.isSparkUsable(spark) else { throw MoveToSparkError.sparkUnavailable } let invoice = try await spark.receive( amountSats: nil, diff --git a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift index a32c0c625..cf173e027 100644 --- a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift +++ b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift @@ -12,7 +12,7 @@ struct MoveToSparkTimedSheet: TimedSheetItem { func shouldShow() async -> Bool { guard !app.hasSeenMoveToSparkIntro else { return false } let spark = SparkManager.shared - return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: spark.isReady) + return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(spark)) } func onShown() { diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index e71f51710..6830e236e 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -10,7 +10,7 @@ struct HomeScreen: View { @EnvironmentObject var wallet: WalletViewModel private var canMoveToSpark: Bool { - MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: SparkManager.shared.isReady) + MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared)) } @State private var scrollPosition: Int? = 0 diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 6b14fb842..bd48beb19 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -157,7 +157,7 @@ struct LightningConnectionsView: View { if MoveToSparkMath.canMove( channels: wallet.channels, - isSparkUsable: SparkManager.shared.isReady + isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared) ) { CustomButton(title: t("lightning__move_to_spark__nav_title")) { navigation.navigate(.moveToSpark) diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index 3f684a303..f41fd0f6d 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -8,7 +8,7 @@ struct SpendingWalletScreen: View { @EnvironmentObject var wallet: WalletViewModel private var canMoveToSpark: Bool { - MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: SparkManager.shared.isReady) + MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared)) } private var shouldShowOnboarding: Bool { From d6d74631afa3ff468e4f2bf4b500b380d7109154 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 16:26:50 +0100 Subject: [PATCH 025/378] fix: spark exit rebuilds when it stops moving - rebuilds over refused, unpaid or missing-input steps and a dead CPFP child at most 3 times while no step confirms, then says the exit is not moving - shows a step accepted but unconfirmed for over 6 blocks as underpriced, without rebuilding - never sends a unilateral Unverified step from the SDK path; rebuilds over it once a block - waits for leaves skipped as Unverified, FundsNotFound or recently stopped, retrying at 6 to 144 blocks and letting go after 1,008 - only a Redo rebuild adds lost-leaf strikes - keeps the exit data exported before the first build and imports it before every rebuild - rebuilds a leftover sweep refused for its fee, and replaces one unconfirmed for 144 blocks - keeps an exit whose record can't be read, rebuilding it from the chain with its funding - counts down along the longest path of steps left, shows what reached savings, and adds each extra payment's input fee - ends a background run whose record can't be read before the first unlock, and reschedules --- Bitkit/Managers/SparkExitManager.swift | 36 +- Bitkit/Managers/SparkManager.swift | 13 + .../Localization/ar.lproj/Localizable.strings | 9 +- .../Localization/ca.lproj/Localizable.strings | 9 +- .../Localization/cs.lproj/Localizable.strings | 9 +- .../Localization/de.lproj/Localizable.strings | 9 +- .../Localization/el.lproj/Localizable.strings | 9 +- .../Localization/en.lproj/Localizable.strings | 9 +- .../es-419.lproj/Localizable.strings | 9 +- .../Localization/es.lproj/Localizable.strings | 9 +- .../Localization/fr.lproj/Localizable.strings | 9 +- .../Localization/it.lproj/Localizable.strings | 9 +- .../Localization/nl.lproj/Localizable.strings | 9 +- .../Localization/pl.lproj/Localizable.strings | 9 +- .../pt-BR.lproj/Localizable.strings | 9 +- .../Localization/pt.lproj/Localizable.strings | 9 +- .../Localization/ru.lproj/Localizable.strings | 9 +- .../SparkExit/SparkExitBackgroundRun.swift | 12 + .../SparkExit/SparkExitBroadcaster.swift | 9 + .../Services/SparkExit/SparkExitDriver.swift | 274 +++++++++-- .../Services/SparkExit/SparkExitFunding.swift | 96 +++- .../Services/SparkExit/SparkExitRecord.swift | 177 ++++++- .../Settings/Advanced/SparkExitViews.swift | 48 +- BitkitTests/SparkExitSweepTests.swift | 109 ++++- BitkitTests/SparkExitTests.swift | 453 ++++++++++++++++-- 25 files changed, 1260 insertions(+), 102 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index b748701a0..363d59332 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -18,6 +18,8 @@ final class SparkExitManager { /// Reminder ids with their fire dates, so one that fired is not posted again on the next run. private nonisolated static let remindersKey = "sparkExitReminders" private nonisolated static let walletIndexKey = "sparkExitWalletIndex" + /// The `bitkit_action` of the exit's notifications, so tapping one opens the exit. + nonisolated static let notificationAction = "spark_exit" private static let foregroundTickSeconds: UInt64 = 60 private(set) var record: SparkExitRecord? @@ -31,6 +33,8 @@ final class SparkExitManager { private(set) var fundingState: SparkExitFundingState? private(set) var fundingAddress: String? private(set) var lastError: String? + /// Set when the user tapped an exit notification, until the app shows the exit. + var opensProgress = false /// The funding choice made on the funding screen, used when the exit starts. @ObservationIgnored var fundFromSavings = false @@ -87,8 +91,12 @@ final class SparkExitManager { self.walletIndex = walletIndex do { record = try Self.loadRecord(store: store, lostLeafStore: lostLeafStore) + } catch is SparkExitStore.Unavailable { + // Before the first unlock the file is there but cannot be read yet: it is read again on the next load. + Logger.warn("The Spark exit record cannot be read yet", context: "SparkExitManager") + record = nil } catch { - // Kept aside for support, so a new exit never overwrites it; a new exit prepares from chain state. + // Names no active exit it could rebuild: kept aside for support, so a new exit never overwrites it. Logger.error("Failed to read the Spark exit record: \(error)", context: "SparkExitManager") try? store.setAside() record = nil @@ -107,11 +115,21 @@ final class SparkExitManager { /// Loads the stored exit and adds its lost leaves to the lost-leaf list, ended exits included, so leaves an exit /// lost before that list existed stay out of the next quote too. + /// A record of an unknown version, or one that does not decode, is not forgotten while it names an active exit: + /// what it still tells is kept, the exit rebuilds from the chain with its funding, and the original stays aside. nonisolated static func loadRecord(store: SparkExitStore, lostLeafStore: SparkExitLostLeafStore) throws -> SparkExitRecord? { - let record = try store.load() - if let record { - try? lostLeafStore.add(lostIn: record) + guard let data = try store.loadData() else { return nil } + let record: SparkExitRecord + do { + record = try SparkExitRecordCoder.decode(data) + } catch { + guard let salvaged = SparkExitRecordCoder.salvage(data) else { throw error } + Logger.warn("Rebuilding the Spark exit from the chain, its record could not be read: \(error)", context: "SparkExitManager") + try store.setAside() + try store.save(salvaged) + record = salvaged } + try? lostLeafStore.add(lostIn: record) return record } @@ -449,6 +467,13 @@ final class SparkExitManager { } if record == nil { load(walletIndex: UserDefaults.standard.integer(forKey: Self.walletIndexKey)) + // Before the first unlock after a restart the record cannot be read yet: this run ends, and the next one + // comes once it can. + if record == nil, FileManager.default.fileExists(atPath: store.url.path) { + Logger.info("Spark exit background run waits for the first unlock", context: "SparkExitManager") + Self.scheduleBackgroundRuns() + return .init(record: nil) + } } while isTicking, !Task.isCancelled, Date() < deadline { try? await Task.sleep(nanoseconds: 250_000_000) @@ -579,11 +604,14 @@ final class SparkExitManager { let fireAt = max(now.addingTimeInterval(1), min(reminder.fireAt, rememberedAt ?? reminder.fireAt)) kept[id] = fireAt.timeIntervalSince1970 let content = UNMutableNotificationContent() + content.userInfo = ["bitkit_action": Self.notificationAction] content.title = t("lightning__spark_exit__notification_title") content.body = if reminder.isLostLeaves { t("lightning__spark_exit__notification_lost", variables: ["amount": "₿ \(CurrencyFormatter.formatSats(record.lostSats))"]) } else if reminder.isFailed { t("lightning__spark_exit__notification_failed") + } else if reminder.isNotMoving { + t("lightning__spark_exit__notification_not_moving") } else { t(reminder.isFunding ? "lightning__spark_exit__notification_funding" : "lightning__spark_exit__notification_body") } diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index ba2e97fbd..3176764bb 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -907,6 +907,19 @@ struct BreezSparkExitRecovering: SparkExitRecovering { func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { try await sdk.checkRecoverFunds(request: CheckRecoverFundsRequest(recovery: recovery)) } + + func exportExitState() async throws -> String { + try await sdk.exportUnilateralExitState().exitState + } + + func importExitState(_ exitState: String) async throws { + let imported = try await sdk.importUnilateralExitState(request: ImportUnilateralExitStateRequest(exitState: exitState)) + Logger.info( + "Imported the Spark exit's own exit data: '\(imported.importedLeaves)' leaves, '\(imported.skippedForeignLeaves)' foreign, " + + "'\(imported.skippedConflictingLeaves)' conflicting, '\(imported.skippedChains)' already held", + context: "SparkManager" + ) + } } private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked Sendable { diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 8a3bdeba3..3357291f7 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -353,7 +353,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -383,6 +382,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index ecf7ed116..9d269c416 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -390,7 +390,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -420,6 +419,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index d0b878581..a3ed507f0 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -406,7 +406,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -436,6 +435,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 038541fb5..41db1f0a2 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -404,7 +404,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -434,6 +433,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 567df30b4..687c02419 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -377,7 +377,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -407,6 +406,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index ac8e5a67b..c98185646 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -399,7 +399,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -429,6 +428,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 086463eb8..4a43cde82 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -407,7 +407,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -437,6 +436,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 1ecb364c7..15f657697 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -391,7 +391,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -421,6 +420,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 648e43d09..4980c1a03 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -407,7 +407,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -437,6 +436,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 312ad4d0a..4afe1dd31 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -400,7 +400,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -430,6 +429,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 3a362c060..3f188c22b 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -404,7 +404,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -434,6 +433,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 5fda39a31..4075c131c 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -406,7 +406,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -436,6 +435,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 09166b0f0..235b6546d 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -406,7 +406,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -436,6 +435,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 87b1615f2..f7c7d8a6b 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -404,7 +404,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -434,6 +433,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 08600d84f..f22be78fb 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -401,7 +401,6 @@ "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; "lightning__spark_exit__step_sweep" = "Sending to savings"; "lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__errors" = "Some transactions were rejected: {error}"; "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; @@ -431,6 +430,14 @@ "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; +"lightning__spark_exit__done_received" = "{amount} reached your savings."; +"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; +"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift index e8179febd..885b21dc0 100644 --- a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift +++ b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift @@ -127,6 +127,14 @@ private struct TimedSparkExitRecovering: SparkExitRecovering { func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { try await timings.time(.check) { try await base.check(recovery) } } + + func exportExitState() async throws -> String { + try await timings.time(.sign) { try await base.exportExitState() } + } + + func importExitState(_ exitState: String) async throws { + try await timings.time(.sign) { try await base.importExitState(exitState) } + } } private struct TimedSparkExitChain: SparkExitChain { @@ -152,4 +160,8 @@ private struct TimedSparkExitChain: SparkExitChain { func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { await timings.time(.broadcast) { await base.broadcast(tx) } } + + func fastestFeeRate() async throws -> UInt64 { + try await base.fastestFeeRate() + } } diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index e721e85c6..b426def1a 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -50,6 +50,8 @@ protocol SparkExitChain: Sendable { /// transaction. func isKnown(txid: String) async throws -> Bool func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome + /// The rate for the next block, rounded up, at least 1 sat/vB. + func fastestFeeRate() async throws -> UInt64 } protocol SparkExitHTTP: Sendable { @@ -127,6 +129,13 @@ struct EsploraSparkExitChain: SparkExitChain { try await JSONDecoder().decode(EsploraTxStatus.self, from: get("/tx/\(txid)/status")) } + /// esplora's `/fee-estimates`, whose target of one block is mempool's `fastestFee`. + func fastestFeeRate() async throws -> UInt64 { + let estimates = try await JSONDecoder().decode([String: Double].self, from: get("/fee-estimates")) + guard let rate = estimates["1"] else { throw URLError(.cannotParseResponse) } + return max(1, UInt64(rate.rounded(.up))) + } + /// `/tx/{txid}/hex` looks in the mempool and the chain alike; `/status` of an unknown transaction may answer /// unconfirmed instead of not found. func isKnown(txid: String) async throws -> Bool { diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 2b4d30efd..71f227922 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -7,6 +7,8 @@ protocol SparkExitRecovering: Sendable { func prepare(_ request: PrepareRecoverFundsRequest) async throws -> PrepareRecoverFundsResponse func recover(_ request: RecoverFundsRequest, signer: CpfpSigner?) async throws -> RecoverFundsResponse func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse + func exportExitState() async throws -> String + func importExitState(_ exitState: String) async throws } /// Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready, @@ -35,8 +37,22 @@ struct SparkExitDriver { /// Rebuilds in a row that reproduce a step whose input another transaction spent before its leaf counts as lost: /// an older state of the wallet claimed it, and every rebuild would send the same refund again. static let strikesToLoseLeaf = 2 - /// Fee chain failures in a row of one step, its parents confirmed, that rebuild the exit once. + /// Fee chain failures in a row of one step, its parents confirmed, that rebuild the exit. static let feeChainFailuresToRebuild = 3 + /// Rebuilds over stuck steps with no step confirming in between, after which the exit says it is not moving. + static let stallRebuildsToSurface = 3 + /// Blocks an accepted step may stay unconfirmed before the exit says the network is busier than its rate. + static let unconfirmedBlocksToSurface: UInt32 = 6 + /// A leaf the operators' watchtower stopped is relabelled promptly; one still stopped after this many blocks + /// never will be. + static let stoppedLeafBlocks: UInt32 = 6 + /// A build waiting for a leaf that may still move tries again after this many blocks, doubling up to a day. + static let retryBlocks: UInt32 = 6 + static let retryMaxBlocks: UInt32 = 144 + /// About a week: what is still waiting then is let go, so the exit can end. + static let releaseBlocks: UInt32 = 1008 + /// Blocks an accepted leftover sweep may stay unconfirmed before it is replaced at a higher rate. + static let sweepReplaceBlocks: UInt32 = 144 @discardableResult func tick(_ record: SparkExitRecord) async throws -> SparkExitRecord { @@ -68,7 +84,12 @@ struct SparkExitDriver { try await broadcastWithoutSdk(&record) } } - record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response) } + // Without the tip the countdown keeps its last target rather than jump to another measure. + if let tip = try? await chain.tipHeight() { + record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response, tip: tip) } + } else if record.claimableAtHeight == nil { + record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response) } + } try save(record) return record } @@ -80,7 +101,11 @@ struct SparkExitDriver { private func adoptFunding(_ record: inout SparkExitRecord, requiredSats: UInt64) async throws -> Bool { guard let address = record.funding.first?.address, let pubkey = record.funding.first?.pubkey else { return false } let utxos = try await chain.utxos(address: address) - guard let topUp = SparkExitMath.fundingTopUp(SparkExitMath.newFunding(utxos, record: record), requiredSats: requiredSats) else { + guard let topUp = SparkExitMath.fundingTopUp( + SparkExitMath.newFunding(utxos, record: record), + requiredSats: requiredSats, + inputFeeSats: SparkExitMath.inputFeeSats(record) + ) else { return false } let index = record.fundingIndex @@ -96,20 +121,40 @@ struct SparkExitDriver { // MARK: Build private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + let tip = try? await chain.tipHeight() + // Waiting for a leaf that may still move: tried again at growing gaps of blocks, not every tick. + if let since = record.retrySince { + guard let tip, Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) else { return } + } + await useExitStateSnapshot(&record, recovering: recovering) guard let prepared = try await prepare(&record, recovering: recovering) else { // The last leaf lost on a tick that could not read the funding address ends here, still as lost. await finish(&record, phase: .failed(reason: record.isEveryLeafLost ? SparkExitRecord.lostReason : "No leaf can be exited")) return } + noteStoppedLeaves(prepared, tip: tip, in: &record) guard !prepared.leaves.isEmpty else { - // A leaf the chain could not be read for comes back on a later tick; finished leaves are not listed. - if prepared.skipped.contains(where: { $0.reason == .unverified }) { - return + // A leaf the chain could not be read for, or one the operators' watchtower just stopped, may still move: + // the build waits for it, for about a week. Finished leaves are not listed. + if prepared.skipped.contains(where: { isProvisional($0, prepared: prepared, record: record, tip: tip) }) { + guard let tip else { return } + let since = record.retrySince ?? tip + if tip.saturatingSub(since) < Self.releaseBlocks { + record.retrySince = since + record.retriedAt = tip + return + } + Logger.warn("Spark exit lets go of the leaves it waited about a week for", context: "SparkExitDriver") } + record.retrySince = nil + record.retriedAt = nil record.skippedLeafCount = max(record.skippedLeafCount, prepared.skipped.count) - await finish(&record, phase: record.recovery == nil ? .failed(reason: "Nothing left to recover") : .done) + let isRecovered = record.recovery != nil || record.isRebuiltFromChain == true + await finish(&record, phase: isRecovered ? .done : .failed(reason: "Nothing left to recover")) return } + record.retrySince = nil + record.retriedAt = nil let built: RecoverFundsResponse do { built = try await recovering.recover( @@ -142,7 +187,11 @@ struct SparkExitDriver { await finish(&record, phase: .failed(reason: "Nothing to recover")) return } - let strikes = record.recovery == nil ? [:] : record.strikes(rebuiltInto: built) + // A leaf the watchtower stopped moments ago is no older state's: it is never struck while it may still move. + let waiting = Set(built.transactions.compactMap(\.nodeId).filter { isStoppedRecently($0, prepared: prepared, record: record, tip: tip) }) + let strikes = record.recovery == nil || record.isRedoRebuild != true ? [:] : record.strikes(rebuiltInto: built).filter { txid, _ in + !built.transactions.contains { $0.txid == txid && $0.nodeId.map(waiting.contains) == true } + } let lost = lostLeaves(built, strikes: strikes, record: record) if !lost.isEmpty { Logger.warn("Spark exit leaves claimed by an older state: \(lost.map(\.leafId))", context: "SparkExitDriver") @@ -211,6 +260,62 @@ struct SparkExitDriver { } } + /// The SDK's exit data from just before the first build, so every rebuild sees the leaves as they were then. The + /// operators may stop reporting a leaf once it is being exited, and a later export would lack it. + private func useExitStateSnapshot(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async { + // An exit rebuilt from the chain is past its first build: an export now may already lack the moved leaves. + if record.recovery == nil, record.generation == 0, record.exitStateSnapshot == nil, record.isRebuiltFromChain != true { + do { + record.exitStateSnapshot = try await recovering.exportExitState() + } catch { + Logger.warn("Failed to export the Spark exit data before the first build: \(error)", context: "SparkExitDriver") + } + } else if let snapshot = record.exitStateSnapshot, record.recovery != nil || record.generation > 0 { + do { + try await recovering.importExitState(snapshot) + } catch { + Logger.warn("Failed to import the Spark exit data before a rebuild: \(error)", context: "SparkExitDriver") + } + } + } + + private func noteStoppedLeaves(_ prepared: PrepareRecoverFundsResponse, tip: UInt32?, in record: inout SparkExitRecord) { + guard let tip else { return } + let own = Set(record.exitLeafIds) + let fresh = prepared.exitChainState.stoppedLeafIds.filter { own.contains($0) && record.stoppedLeafSince?[$0] == nil } + guard !fresh.isEmpty else { return } + var since = record.stoppedLeafSince ?? [:] + for leafId in fresh { + since[leafId] = tip + } + record.stoppedLeafSince = since + } + + /// Stopped by the operators' watchtower fewer than `stoppedLeafBlocks` ago, or with no tip to tell. + private func isStoppedRecently(_ leafId: String, prepared: PrepareRecoverFundsResponse, record: SparkExitRecord, tip: UInt32?) -> Bool { + guard prepared.exitChainState.stoppedLeafIds.contains(leafId) else { return false } + guard let tip, let since = record.stoppedLeafSince?[leafId] else { return true } + return tip.saturatingSub(since) < Self.stoppedLeafBlocks + } + + /// A skip of one of the exit's leaves that may still move: the chain could not be read for it, its funds were not + /// found yet, or the watchtower stopped it moments ago. + private func isProvisional(_ skip: SkippedLeaf, prepared: PrepareRecoverFundsResponse, record: SparkExitRecord, tip: UInt32?) -> Bool { + guard record.exitLeafIds.contains(skip.leafId) else { return false } + switch skip.reason { + case .unverified, .fundsNotFound: return true + case .notRecoverable: return isStoppedRecently(skip.leafId, prepared: prepared, record: record, tip: tip) + case .feeExceedsValue: return false + } + } + + /// Six blocks after the wait starts, then at doubling gaps up to a day (Glow's `retryDue`). + static func isRetryDue(since: UInt32, retriedAt: UInt32, tip: UInt32) -> Bool { + let last = max(since, retriedAt) + let gap = min(retryMaxBlocks, max(retryBlocks, last - since)) + return tip.saturatingSub(last) >= gap + } + /// The leaves whose refund a rebuild reproduced after its input was spent by another transaction in each of the /// last generations. A refund's node id is its leaf. private func lostLeaves(_ built: RecoverFundsResponse, strikes: [String: Int], record: SparkExitRecord) -> [SparkExitRecord.LostLeaf] { @@ -235,32 +340,61 @@ struct SparkExitDriver { } let checked = try await recovering.check(withConfirmations(stored)) record.recovery = StoredRecovery(checked.recovery) + let tip = try? await chain.tipHeight() + // A step that confirmed since the last rebuild over a stuck step shows the exit moves again. + if let count = record.stallConfirmedCount, SparkExitMath.confirmedCount(checked.recovery) > count { + record.stallRebuilds = nil + record.stallConfirmedCount = nil + } switch checked.verdict { case .valid: + // A step the SDK could not read is settled by a fresh build, once a block, unless a step is still to send: + // a build now would conflict with it at the same fee. + if let tip, isUnverifiedRebuildDue(checked.recovery, record: record, tip: tip) { + Logger.warn("Spark exit has a step the SDK could not read, rebuilding over it", context: "SparkExitDriver") + record.unverifiedRebuiltAtHeight = tip + try save(record) + try await build(&record, recovering: recovering) + return + } // The SDK sees no divergence when another wallet's CPFP child confirmed a node our leaves share. - let dead = await deadCpfpChildren(checked.recovery, stored: stored) + let dead = record.isStalled ? [] : await deadCpfpChildren(checked.recovery, stored: stored) if !dead.isEmpty { Logger.warn("Spark exit fee chain lost a race (dead children \(dead)), rebuilding", context: "SparkExitDriver") - try await rebuild(&record, recovering: recovering) + try await rebuildStalled(&record, recovering: recovering) return } - try await broadcastReady(&record) - let rebuilt = Set(record.feeChainRebuildTxids ?? []) - if let stuck = record.broadcastLog.first(where: { - $0.generation == record.generation && ($0.feeChainFailures ?? 0) >= Self.feeChainFailuresToRebuild && !rebuilt.contains($0.txid) - }) { - Logger.warn("Spark exit step \(stuck.txid) keeps failing its fee chain, rebuilding once", context: "SparkExitDriver") - record.feeChainRebuildTxids = (record.feeChainRebuildTxids ?? []) + [stuck.txid] - try await rebuild(&record, recovering: recovering) + try await broadcastReady(&record, tip: tip) + if !record.isStalled, let stuck = record.stuckStep { + Logger.warn("Spark exit step \(stuck.txid) keeps being refused (\(stuck.lastOutcome)), rebuilding", context: "SparkExitDriver") + try await rebuildStalled(&record, recovering: recovering) } case .done: await finish(&record, phase: .done) case .redo: Logger.warn("Spark exit diverged on-chain, rebuilding generation \(record.generation + 1)", context: "SparkExitDriver") + record.isRedoRebuild = true try await rebuild(&record, recovering: recovering) } } + /// A rebuild over a chain that stopped moving: refused steps, missing inputs, unpaid children or a dead CPFP child. + /// Only `stallRebuildsToSurface` of them run while no step confirms, so a stale chain index or a step no build can + /// fix does not rebuild forever; the exit then says it is not moving. + private func rebuildStalled(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + record.isRedoRebuild = nil + record.stallRebuilds = (record.stallRebuilds ?? 0) + 1 + record.stallConfirmedCount = record.recovery.map { SparkExitMath.confirmedCount($0.response) } ?? 0 + try await rebuild(&record, recovering: recovering) + } + + /// A unilateral step the SDK could not read, nothing unilateral ready to send, and no such rebuild at this tip yet. + private func isUnverifiedRebuildDue(_ recovery: RecoverFundsResponse, record: SparkExitRecord, tip: UInt32) -> Bool { + let unilateral = recovery.transactions.filter { $0.kind != .cooperative } + return unilateral.contains { $0.status == .unverified } && !unilateral.contains { $0.status == .ready } + && record.unverifiedRebuiltAtHeight != tip + } + /// Saved as rebuilding first, so nothing of the replaced generation is broadcast again, then rebuilt once due. private func rebuild(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { record.isRebuilding = true @@ -366,8 +500,57 @@ struct SparkExitDriver { /// Sends the confirmed leftover at the funding address to the exit's savings address, signed with the funding /// key. Built and saved once before it is sent, then sent again on later ticks until it confirms. Waits while - /// the leftover is unconfirmed, and skips it when the fee would leave less than dust. + /// the leftover is unconfirmed, and skips it when the fee would leave less than dust. One refused for its fee, or + /// accepted but unconfirmed for `sweepReplaceBlocks`, is built again over the same coins at the network's current + /// rate, which replaces it (RBF). private func sweepLeftover(_ record: inout SparkExitRecord) async throws { + // A sweep it replaced may confirm instead: that ends the sweep as well. + for txid in record.leftoverSweep?.replacedTxids ?? [] where record.leftoverSweep?.isConfirmed != true { + if let status = try? await chain.txStatus(txid: txid), status.confirmed { + record.leftoverSweep?.isConfirmed = true + return + } + } + if let sweep = record.leftoverSweep, let oldTxid = sweep.txid, sweep.isConfirmed != true, !Task.isCancelled, + let address = record.funding.first?.address + { + let oldRate = sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte + var isReplaceDue = sweep.isFeeRejection + if !isReplaceDue, sweep.isAccepted, let sentAt = sweep.sentAtHeight, let tip = try? await chain.tipHeight(), + tip.saturatingSub(sentAt) >= Self.sweepReplaceBlocks + { + // A node that dropped it answers not found: that is unconfirmed too. + isReplaceDue = await (try? chain.txStatus(txid: oldTxid))?.confirmed != true + } + if isReplaceDue, let fastest = try? await chain.fastestFeeRate() { + let rate = max(fastest, oldRate + 1) + let utxos = record.leftover.map { + EsploraUtxo(txid: $0.txid, vout: $0.vout, value: $0.valueSats, status: EsploraTxStatus(confirmed: true, blockHeight: nil)) + } + Logger.info("Spark exit rebuilds its leftover sweep at \(rate) sat/vB", context: "SparkExitDriver") + let built = try await SparkExitLeftoverSweep.build( + utxos: utxos, + fundingAddress: address, + destination: record.destination, + feeRateSatPerVbyte: rate, + signer: makeSigner(record.fundingIndex) + ) + if let built { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + txid: built.txid, + txHex: built.txHex, + valueSats: built.valueSats, + feeRateSatPerVbyte: rate, + replacedTxids: sweep.isAccepted ? (sweep.replacedTxids ?? []) + [oldTxid] : sweep.replacedTxids + ) + try save(record) + } else if !sweep.isAccepted { + // Too small to send at the new rate, and nothing of it is in the mempool: kept where it is, as dust. + record.leftoverSweep = SparkExitRecord.LeftoverSweep(feeRateSatPerVbyte: rate, replacedTxids: sweep.replacedTxids) + try save(record) + } + } + } if record.leftoverSweep == nil { guard let address = record.funding.first?.address else { return } let utxos = try await chain.utxos(address: address) @@ -403,7 +586,11 @@ struct SparkExitDriver { )) Logger.info("Spark exit leftover sweep \(txid): \(outcome.summary)", context: "SparkExitDriver") switch outcome { - case .accepted: record.leftoverSweep?.isAccepted = true + case .accepted: + record.leftoverSweep?.isAccepted = true + if record.leftoverSweep?.sentAtHeight == nil { + record.leftoverSweep?.sentAtHeight = try? await chain.tipHeight() + } // Once accepted, a later refusal is a node that already has it or a conflict with it confirming. case let .rejected(reason) where !sweep.isAccepted: record.leftoverSweep?.rejection = reason default: break @@ -412,15 +599,20 @@ struct SparkExitDriver { // MARK: Broadcast - private func broadcastReady(_ record: inout SparkExitRecord) async throws { + /// A unilateral step the SDK could not read is never sent: a rebuild settles it. A cooperative one is, since nodes + /// refuse it if its funds already moved. + private func broadcastReady(_ record: inout SparkExitRecord, tip: UInt32? = nil) async throws { guard let recovery = record.recovery?.response else { return } let confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) - // Unverified: the SDK could not read its parent from the chain; nodes refuse it harmlessly if it is not final. - for tx in recovery.transactions where tx.status == .ready || tx.status == .unverified { + var tip = tip + for tx in recovery.transactions where tx.status == .ready || (tx.status == .unverified && tx.kind == .cooperative) { guard !Task.isCancelled else { return } guard isDue(tx.txid, in: record) else { continue } + if tip == nil { + tip = try? await chain.tipHeight() + } let hasConfirmedParents = tx.status == .ready && tx.dependsOn.allSatisfy(confirmed.contains) - await broadcast(tx, into: &record, hasConfirmedParents: hasConfirmedParents) + await broadcast(tx, into: &record, hasConfirmedParents: hasConfirmedParents, tip: tip) try save(record) } } @@ -475,7 +667,7 @@ struct SparkExitDriver { case .none, .at: break } - await broadcast(tx, into: &record, hasConfirmedParents: true) + await broadcast(tx, into: &record, hasConfirmedParents: true, tip: tip) record.recovery = StoredRecovery(recovery) try save(record) } @@ -492,8 +684,8 @@ struct SparkExitDriver { } /// With every parent of the step confirmed, missing inputs mean another transaction spent one. A failed fee - /// chain is no rejection until it already rebuilt the exit once. - private func broadcast(_ tx: RecoveryTransaction, into record: inout SparkExitRecord, hasConfirmedParents: Bool) async { + /// chain is no rejection: it counts on its own towards a rebuild. + private func broadcast(_ tx: RecoveryTransaction, into record: inout SparkExitRecord, hasConfirmedParents: Bool, tip: UInt32?) async { let outcome = await chain.broadcast(tx) Logger.info("Spark exit broadcast \(tx.kind) \(tx.txid): \(outcome.summary)", context: "SparkExitDriver") let isAccepted = outcome == .accepted @@ -506,11 +698,12 @@ struct SparkExitDriver { let isUnpaid = SparkExitBroadcastClassifier.isUnpaid(outcome) let isFeeChainFailure = hasConfirmedParents && (isUnpaid || isInputsMissing) let isRejected = if case .rejected = outcome { - !(isFeeChainFailure && !(record.feeChainRebuildTxids ?? []).contains(tx.txid)) + !isFeeChainFailure } else { false } if let i = record.broadcastLog.firstIndex(where: { $0.generation == record.generation && $0.txid == tx.txid }) { + record.broadcastLog[i].acceptedAtHeight = isAccepted ? record.broadcastLog[i].acceptedAtHeight ?? tip : nil record.broadcastLog[i].attempts += 1 record.broadcastLog[i].rejections += isRejected ? 1 : 0 record.broadcastLog[i].lastAttemptAt = now() @@ -528,7 +721,8 @@ struct SparkExitDriver { lastOutcome: outcome.summary, isAccepted: isAccepted, isInputSpent: isInputSpent, - feeChainFailures: isFeeChainFailure ? 1 : nil + feeChainFailures: isFeeChainFailure ? 1 : nil, + acceptedAtHeight: isAccepted ? tip : nil )) } } @@ -544,9 +738,9 @@ private extension RecoveryTransaction { } extension SparkExitRecord { - /// The rejection to show on the progress screen once a transaction was refused several times. + /// What keeps the exit from moving, once rebuilding over its stuck steps stopped helping. var surfacedRejection: String? { - broadcastLog.first { $0.generation == generation && $0.rejections >= SparkExitDriver.rejectionsToSurface && !$0.isAccepted }?.lastOutcome + isStalled ? stuckStep?.lastOutcome : nil } } @@ -624,6 +818,14 @@ enum SparkExitTxid { } } + /// Each output's value and script; nil when the hex does not decode. + static func outputs(hex: String) -> [(value: UInt64, script: Data)]? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + return reader.outputs() + } + /// The txid of a serialized transaction: double SHA-256 of it without witness data, byte-reversed. static func txid(hex: String) -> String? { let bytes = hex.hexaBytes @@ -675,6 +877,16 @@ enum SparkExitTxid { return (version, inputs) } + mutating func outputs() -> [(value: UInt64, script: Data)]? { + guard inputs() != nil, let count = varInt() else { return nil } + var outputs: [(value: UInt64, script: Data)] = [] + for _ in 0 ..< count.value { + guard let value = take(8), let script = varInt(), let scriptBytes = take(script.value) else { return nil } + outputs.append((value.enumerated().reduce(0) { $0 | UInt64($1.element) << (8 * $1.offset) }, Data(scriptBytes))) + } + return outputs + } + private func littleEndian(_ bytes: [UInt8]) -> UInt32 { bytes.enumerated().reduce(0) { $0 | UInt32($1.element) << (8 * $1.offset) } } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 867374c02..85214cfcd 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -160,6 +160,7 @@ struct SparkExitReminder: Equatable { var isFunding = false var isLostLeaves = false var isFailed = false + var isNotMoving = false } /// What the exit shows about its fee funding while it lacks some. @@ -173,9 +174,14 @@ enum SparkExitFundingState: Equatable { } enum SparkExitMath { - /// The countdown target: the latest block at which a timelocked step of the exit can confirm. - static func claimableAtHeight(_ recovery: RecoverFundsResponse) -> UInt32? { - recovery.transactions.compactMap { tx -> UInt32? in + /// The countdown target: the tip plus the blocks the longest path of steps left still needs, so the countdown + /// does not jump when a step's parent confirms and its own timelock starts. Without the tip, the latest block at + /// which a timelocked step can confirm. + static func claimableAtHeight(_ recovery: RecoverFundsResponse, tip: UInt32? = nil) -> UInt32? { + if let tip { + return blocksToFinish(recovery.transactions, tip: tip).map { tip.addingReportingOverflow($0).partialValue } + } + return recovery.transactions.compactMap { tx -> UInt32? in if case let .waitingForTimelock(height) = tx.status { return height } @@ -183,6 +189,33 @@ enum SparkExitMath { }.max() } + /// Blocks until the last step could confirm: the longest path through the steps left, each costing its remaining + /// timelock plus a block to confirm in (Glow's `blocksToFinish`). Nil once every step confirmed. The SDK lists + /// steps in broadcast order, so a parent always comes before what depends on it. + static func blocksToFinish(_ transactions: [RecoveryTransaction], tip: UInt32) -> UInt32? { + var after: [String: UInt32] = [:] + var longest: UInt32? + for tx in transactions { + let timelock: UInt32 + switch tx.status { + case .confirmed: + after[tx.txid] = 0 + continue + case .ready: + timelock = 0 + case let .waitingForTimelock(height?): + timelock = height > tip ? height - tip : 0 + default: + timelock = tx.csvTimelockBlocks ?? 0 + } + let blocks = (tx.dependsOn.compactMap { after[$0] }.max() ?? 0) + timelock + 1 + after[tx.txid] = blocks + longest = max(longest ?? 0, blocks) + } + // Everything confirmed: nothing left to wait for. + return longest + } + /// Blocks after a step matures before the operators' watchtower may publish their own version. static let operatorWindowBlocks: UInt32 = 50 /// Blocks after a refund matures before an older state of the wallet, with a refund 100 blocks shorter, can @@ -210,6 +243,10 @@ enum SparkExitMath { } guard case .running = record.phase, let recovery = record.recovery?.response else { return lostReminder } var reminders = lostReminder + // Waiting for a leaf that may still move is the background runs' job, for up to a week. + if record.isWaitingForLeaves { + return reminders + } let generation = record.generation let blockSeconds = secondsUntil(height: 1, tip: 0, network: network) let grace = TimeInterval(reminderGraceBlocks) * blockSeconds @@ -219,10 +256,12 @@ enum SparkExitMath { reminders.append(SparkExitReminder(id: "\(generation)-rebuild", fireAt: max(due, now).addingTimeInterval(grace))) return reminders } - if let rejected = record.broadcastLog.first(where: { - $0.generation == generation && $0.rejections >= SparkExitDriver.rejectionsToSurface && !$0.isAccepted - }) { - reminders.append(SparkExitReminder(id: "rejected-\(generation)-\(rejected.txid)", fireAt: now)) + if record.isStalled, let stuck = record.stuckStep { + reminders.append(SparkExitReminder(id: "rejected-\(generation)-\(stuck.txid)", fireAt: now, isNotMoving: true)) + } + // Accepted but not confirming: rebuilding would not help, so its reminder is planned once and never moved. + if let slow = record.underpricedStep(tip: tip) { + reminders.append(SparkExitReminder(id: "\(generation)-slow-\(slow.txid)", fireAt: now.addingTimeInterval(grace))) } let accepted = Set(record.broadcastLog.filter { $0.generation == generation && $0.isAccepted }.map(\.txid)) var heights = Set() @@ -231,7 +270,7 @@ enum SparkExitMath { switch tx.status { case let .waitingForTimelock(height?): heights.insert(height) - case .ready, .unverified: + case .ready: readyTxid = readyTxid ?? tx.txid default: break @@ -255,6 +294,18 @@ enum SparkExitMath { return reminders } + /// What the exit's sweep and cooperative recoveries paid to its savings address, and the sweep's txid, once it + /// ended done. Nil before, or when no output to savings can be read. + static func received(_ record: SparkExitRecord) -> (sats: UInt64, sweepTxid: String?)? { + guard record.phase == .done, let transactions = record.recovery?.response.transactions, + let destination = try? SparkExitAddress.scriptPubKey(record.destination) + else { return nil } + let paying = transactions.filter { $0.kind == .sweep || $0.kind == .cooperative } + let sats = paying.flatMap { SparkExitTxid.outputs(hex: $0.txHex) ?? [] }.filter { $0.script == destination }.reduce(0) { $0 + $1.value } + guard sats > 0 else { return nil } + return (sats, paying.first { $0.kind == .sweep }?.txid) + } + /// Whether every refund's timelock is behind it: none still waits for its parents or its height. static func areTimelocksMatured(_ transactions: [RecoveryTransaction]) -> Bool { let refunds = transactions.filter { $0.kind == .refund } @@ -292,22 +343,33 @@ enum SparkExitMath { } /// The confirmed new funding, all of it, once it covers `requiredSats` together: the SDK spends every input it is - /// given, so several small payments fund the exit as well as one. - static func fundingTopUp(_ newFunding: [EsploraUtxo], requiredSats: UInt64) -> [EsploraUtxo]? { + /// given, so several small payments fund the exit as well as one. `requiredSats` assumes one payment; each + /// further one adds its own input fee, `inputFeeSats`. + static func fundingTopUp(_ newFunding: [EsploraUtxo], requiredSats: UInt64, inputFeeSats: UInt64 = 0) -> [EsploraUtxo]? { let confirmed = newFunding.filter(\.status.confirmed) let total = confirmed.reduce(0) { $0 + $1.value } - return !confirmed.isEmpty && total >= requiredSats ? confirmed : nil + let needed = requiredSats + inputFeeSats * UInt64(max(0, confirmed.count - 1)) + return !confirmed.isEmpty && total >= needed ? confirmed : nil + } + + /// A signed P2WPKH input: each payment to the funding address is one more input for the build to pay for. + static let inputVbytes: UInt64 = 68 + + /// The fee one more funding input adds at the exit's rate (Glow `exitFee.ts`). + static func inputFeeSats(_ record: SparkExitRecord) -> UInt64 { + inputVbytes * record.feeRateSatPerVbyte } /// The funding total a rebuild asks for once the SDK refused it with `InsufficientCpfpFunds(requiredSats)`. /// `requiredSats` is what all the given inputs must hold together now, and what they hold now is the confirmed /// value at the funding address apart from new funding (spent inputs live on as chained CPFP change there). /// The phase counts against `fundedSats`, so the shortfall it shows is `requiredSats` less what the inputs hold. + /// The SDK priced the inputs it had, so the payment still to come adds its own input fee. static func neededFunding(requiredSats: UInt64, record: SparkExitRecord, utxos: [EsploraUtxo]?) -> UInt64 { - guard let utxos else { return max(requiredSats, record.fundedSats) } + guard let utxos else { return max(requiredSats, record.fundedSats) + inputFeeSats(record) } let fresh = Set(newFunding(utxos, record: record).map { "\($0.txid):\($0.vout)" }) let held = utxos.filter { $0.status.confirmed && !fresh.contains("\($0.txid):\($0.vout)") }.reduce(0) { $0 + $1.value } - return record.fundedSats + max(requiredSats.saturatingSub(held), 1) + return record.fundedSats + max(requiredSats.saturatingSub(held), 1) + inputFeeSats(record) } /// The funding state while the exit lacks fee funding, nil once it lacks none. A payment Bitkit made from savings @@ -322,11 +384,15 @@ enum SparkExitMath { } let fresh = utxos.map { newFunding($0, record: record) } ?? [] let seenSats = fresh.reduce(0) { $0 + $1.value } + // The shortfall assumes one payment: each one beyond it, and the one still to send, adds an input fee. + let inputFee = inputFeeSats(record) + let neededNow = shortfall + inputFee * UInt64(max(0, fresh.count - 1)) // Covered by confirmed funding alone, the next tick builds. - if seenSats >= shortfall, let pending = fresh.first(where: { !$0.status.confirmed }) ?? fresh.first { + if seenSats >= neededNow, let pending = fresh.first(where: { !$0.status.confirmed }) ?? fresh.first { return .seen(txid: pending.txid) } - return .ask(sats: max(shortfall.saturatingSub(seenSats), fundingDustSats), receivedSats: seenSats) + let needed = neededNow + (fresh.isEmpty ? 0 : inputFee) + return .ask(sats: max(needed.saturatingSub(seenSats), fundingDustSats), receivedSats: seenSats) } /// The least a P2WPKH funding output can hold: nodes refuse a payment below it. diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 5dd631e50..c16f02d5a 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -53,6 +53,9 @@ struct SparkExitRecord: Codable, Equatable { /// Attempts in a row, with the step's parents confirmed, that its fee chain failed: its CPFP child did not /// pay for it (`min relay fee not met, 0 < N`) or its inputs were missing. Reset by any other outcome. var feeChainFailures: Int? + /// The tip when a node first accepted it, kept while it stays accepted, so a step the network holds but does + /// not confirm shows as underpriced. + var acceptedAtHeight: UInt32? } /// A leaf an older state of the wallet claimed on-chain first, which this exit cannot recover. @@ -86,6 +89,17 @@ struct SparkExitRecord: Codable, Equatable { var rejection: String? /// Set once the chain host shows the accepted sweep in a block. var isConfirmed: Bool? + /// The rate it was built at; nil for the exit's own rate. + var feeRateSatPerVbyte: UInt64? + /// The tip when a node first accepted it, so one that does not confirm is replaced at a higher rate. + var sentAtHeight: UInt32? + /// The sweeps this one replaced: any of them confirming also ends the sweep. + var replacedTxids: [String]? + + /// A refusal for its fee is not final: the sweep is built again at the network's current rate. + var isFeeRejection: Bool { + rejection.map { $0.range(of: "fee", options: .caseInsensitive) != nil } ?? false + } } var version: Int = Self.currentVersion @@ -120,9 +134,25 @@ struct SparkExitRecord: Codable, Equatable { /// Per step txid, how many rebuilds in a row reproduced it after another transaction spent its input. var spentStepStrikes: [String: Int]? var lostLeaves: [LostLeaf]? - /// The steps whose repeated fee chain failures already rebuilt the exit once; from then on they count as - /// rejections. - var feeChainRebuildTxids: [String]? + /// Rebuilds in a row for steps that keep being refused or a fee chain that lost a race, with no step confirming + /// in between. At `SparkExitDriver.stallRebuildsToSurface` the exit stops rebuilding and says it is not moving. + var stallRebuilds: Int? + /// How many steps were confirmed when the last of those rebuilds started; more since then resets the count. + var stallConfirmedCount: Int? + /// The pending rebuild answers a divergence the SDK reported (`Redo`). Only such a rebuild adds lost-leaf strikes, + /// so rebuilds over a stalled chain or an unverified step never make a leaf lost. + var isRedoRebuild: Bool? + /// The tip of the last rebuild over a step the SDK could not read, so it rebuilds at most once a block. + var unverifiedRebuiltAtHeight: UInt32? + /// The tip at which each of the exit's leaves was first seen stopped (`exitChainState.stoppedLeafIds`). + var stoppedLeafSince: [String: UInt32]? + /// The tip at which a build first found nothing to build while a leaf may still move, and of its last try. + var retrySince: UInt32? + var retriedAt: UInt32? + /// The SDK's exit data from just before the first build, imported again before every rebuild. + var exitStateSnapshot: String? + /// The stored record could not be read, so the exit is rebuilt from the chain with the funding it kept. + var isRebuiltFromChain: Bool? var leftBehindLeaves: [LeftBehindLeaf]? /// Set once the user opened the result of an exit that failed or lost leaves. var isResultSeen: Bool? @@ -142,7 +172,7 @@ struct SparkExitRecord: Codable, Equatable { var isLeftoverSweepPending: Bool { guard !isActive, !leftover.isEmpty else { return false } guard let leftoverSweep else { return true } - return leftoverSweep.txid != nil && leftoverSweep.rejection == nil && leftoverSweep.isConfirmed != true + return leftoverSweep.txid != nil && (leftoverSweep.rejection == nil || leftoverSweep.isFeeRejection) && leftoverSweep.isConfirmed != true } static let lostReason = "Claimed by an older state" @@ -236,6 +266,44 @@ struct SparkExitRecord: Codable, Equatable { isRebuilding = false lastBuildAt = date failedLeafCount = built.failed.count + isRebuiltFromChain = nil + isRedoRebuild = nil + } + + /// Whether a build that found nothing to build is waiting for a leaf that may still move. + var isWaitingForLeaves: Bool { + retrySince != nil + } + + /// A step of this generation that keeps being refused, or whose fee chain keeps failing. + var stuckStep: BroadcastAttempt? { + broadcastLog.first { + $0.generation == generation && !$0.isAccepted + && ($0.rejections >= SparkExitDriver.rejectionsToSurface || ($0.feeChainFailures ?? 0) >= SparkExitDriver.feeChainFailuresToRebuild) + } + } + + /// Whether rebuilding over a stuck step already failed to move the exit often enough to stop and say so. + var isStalled: Bool { + (stallRebuilds ?? 0) >= SparkExitDriver.stallRebuildsToSurface + } + + /// A step the network accepted that has not confirmed for more blocks than a fair fee rate needs: the network is + /// busier than the exit's rate. Rebuilding at the same rate would not help. + func underpricedStep(tip: UInt32?) -> BroadcastAttempt? { + guard let tip, let transactions = recovery?.response.transactions else { return nil } + let confirmed = Set(transactions.filter { + if case .confirmed = $0.status { + return true + } + return false + }.map(\.txid)) + return broadcastLog.first { + guard $0.generation == generation, $0.isAccepted, let height = $0.acceptedAtHeight, !confirmed.contains($0.txid) else { + return false + } + return tip > height && tip - height > SparkExitDriver.unconfirmedBlocksToSurface + } } } @@ -254,9 +322,24 @@ struct SparkExitStore { url = Env.sparkExitRecord(walletIndex: walletIndex) } + /// The file exists but cannot be read now, such as before the first unlock after a restart: the record is left + /// as it is and read again later. + struct Unavailable: Error { + let underlying: Error + } + func load() throws -> SparkExitRecord? { + guard let data = try loadData() else { return nil } + return try SparkExitRecordCoder.decode(data) + } + + func loadData() throws -> Data? { guard FileManager.default.fileExists(atPath: url.path) else { return nil } - return try SparkExitRecordCoder.decode(Data(contentsOf: url)) + do { + return try Data(contentsOf: url) + } catch { + throw Unavailable(underlying: error) + } } /// Moves an unreadable record out of the way, keeping it for support. @@ -333,9 +416,91 @@ enum SparkExitRecordCoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .millisecondsSince1970 let version = try decoder.decode(VersionProbe.self, from: data).version - guard version == SparkExitRecord.currentVersion else { throw UnsupportedVersion(version: version) } + var data = data + if version != SparkExitRecord.currentVersion { + guard let convert = SparkExitRecordMigration.converters[version] else { throw UnsupportedVersion(version: version) } + data = try convert(data) + } return try decoder.decode(SparkExitRecord.self, from: data) } + + /// What a record that cannot be decoded still tells about its exit: enough to build it again from the chain with + /// the funding it kept. Nil when it names no active exit, or lacks what a build needs. + static func salvage(_ data: Data) -> SparkExitRecord? { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + guard let kept = try? decoder.decode(Salvaged.self, from: data), !kept.leafIds.isEmpty, !kept.funding.isEmpty else { return nil } + let phase = kept.phase + var record = SparkExitRecord( + network: kept.network, + createdAt: kept.createdAt, + phase: .running, + feeRateSatPerVbyte: kept.feeRateSatPerVbyte, + destination: kept.destination, + leafIds: kept.leafIds, + leafTotalSats: kept.leafTotalSats, + funding: kept.funding, + transferId: kept.transferId, + expectedSats: kept.expectedSats + ) + switch phase { + case .done, .failed: + return nil + case .awaitingFunding, .needsFunding: + record.phase = phase ?? .running + case .running, nil: + record.isRebuilding = true + } + record.lostLeaves = kept.lostLeaves + record.leftBehindLeaves = kept.leftBehindLeaves + record.exitStateSnapshot = kept.exitStateSnapshot + record.isRebuiltFromChain = true + return record + } + + private struct Salvaged: Decodable { + let network: String + let createdAt: Date + let phase: SparkExitRecord.Phase? + let feeRateSatPerVbyte: UInt64 + let destination: String + let leafIds: [String] + let leafTotalSats: UInt64? + let funding: [SparkExitRecord.Funding] + let transferId: String? + let expectedSats: UInt64 + let lostLeaves: [SparkExitRecord.LostLeaf]? + let leftBehindLeaves: [SparkExitRecord.LeftBehindLeaf]? + let exitStateSnapshot: String? + + private enum CodingKeys: String, CodingKey { + case network, createdAt, phase, feeRateSatPerVbyte, destination, leafIds, leafTotalSats, funding, transferId, expectedSats + case lostLeaves, leftBehindLeaves, exitStateSnapshot + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + network = try c.decode(String.self, forKey: .network) + createdAt = (try? c.decode(Date.self, forKey: .createdAt)) ?? Date() + phase = try? c.decodeIfPresent(SparkExitRecord.Phase.self, forKey: .phase) + feeRateSatPerVbyte = try c.decode(UInt64.self, forKey: .feeRateSatPerVbyte) + destination = try c.decode(String.self, forKey: .destination) + leafIds = try c.decode([String].self, forKey: .leafIds) + leafTotalSats = try? c.decodeIfPresent(UInt64.self, forKey: .leafTotalSats) + funding = try c.decode([SparkExitRecord.Funding].self, forKey: .funding) + transferId = try? c.decodeIfPresent(String.self, forKey: .transferId) + expectedSats = (try? c.decode(UInt64.self, forKey: .expectedSats)) ?? 0 + lostLeaves = try? c.decodeIfPresent([SparkExitRecord.LostLeaf].self, forKey: .lostLeaves) + leftBehindLeaves = try? c.decodeIfPresent([SparkExitRecord.LeftBehindLeaf].self, forKey: .leftBehindLeaves) + exitStateSnapshot = try? c.decodeIfPresent(String.self, forKey: .exitStateSnapshot) + } + } +} + +/// Converts a record written with another `version` to the current one before it is decoded. Add a converter here +/// before changing `SparkExitRecord.currentVersion`, so an exit written by the earlier version keeps running as it was. +enum SparkExitRecordMigration { + static let converters: [Int: (Data) throws -> Data] = [:] } // MARK: - RecoverFundsResponse in spark-sdk's serde shape diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 5bd021f8b..64efe39b5 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -356,6 +356,14 @@ struct SparkExitLeftoverStatus: View { variables: ["amount": currency.primaryAmountText(sats: leftoverSats), "address": address] )) } + } else if let sweep = record.leftoverSweep, !sweep.isFeeRejection { + // Refused for good: where the coins stay, and the path that spends them with the recovery phrase. + BodySText(t("lightning__spark_exit__leftover_rejected", variables: [ + "amount": currency.primaryAmountText(sats: leftoverSats), + "address": address, + "path": SparkExitFundingKey.path(index: record.fundingIndex, network: Env.bitkitCoreNetwork), + ])) + .fixedSize(horizontal: false, vertical: true) } else { ForEach(Array(record.leftover.enumerated()), id: \.offset) { _, leftover in BodySText(t( @@ -471,6 +479,9 @@ struct SparkExitProgressView: View { case .done: [ t("lightning__spark_exit__done_text"), + record.flatMap(SparkExitMath.received).map { + t("lightning__spark_exit__done_received", variables: ["amount": currency.primaryAmountText(sats: $0.sats)]) + }, (record?.skippedLeafCount ?? 0) > 0 ? t("lightning__spark_exit__partial_text", variables: ["count": "\(record?.skippedLeafCount ?? 0)"]) : nil, (record?.failedLeafCount ?? 0) > 0 @@ -503,14 +514,37 @@ struct SparkExitProgressView: View { if let record { SparkExitSteps(record: record, tipHeight: exit.tipHeight) + if let sweepTxid = SparkExitMath.received(record)?.sweepTxid { + BodySText(t("lightning__spark_exit__sweep_txid", variables: ["txid": sweepTxid])) + .lineLimit(2) + .truncationMode(.middle) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitSweepTxid") + } + + if record.isActive, record.isRebuiltFromChain == true { + BodySText(t("lightning__spark_exit__rebuilt_from_chain")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitRebuiltFromChain") + } + + if record.isActive, record.isWaitingForLeaves { + BodySText(t("lightning__spark_exit__waiting_leaves")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingLeaves") + } + if let address = record.funding.first?.address { switch fundingState { case let .ask(sats, receivedSats): + // The ask already counts the input fee of each payment, so it is what is missing. SparkExitFundingAddress( address: address, requiredSats: sats, receivedSats: receivedSats, - targetSats: record.fundingShortfallSats ?? sats + receivedSats, + targetSats: sats + receivedSats, showsSeen: false ) .padding(.top, 24) @@ -542,9 +576,17 @@ struct SparkExitProgressView: View { .accessibilityIdentifier("SparkExitLostLeaves") } - if let rejection = record.surfacedRejection { - BodySText(t("lightning__spark_exit__errors", variables: ["error": rejection]), textColor: .redAccent) + if record.isActive, let rejection = record.surfacedRejection { + BodySText(t("lightning__spark_exit__not_moving", variables: ["error": rejection]), textColor: .redAccent) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitNotMoving") + } + + if record.isActive, !record.isRebuilding, record.underpricedStep(tip: exit.tipHeight) != nil { + BodySText(t("lightning__spark_exit__fee_low", variables: ["rate": "\(record.feeRateSatPerVbyte)"])) + .fixedSize(horizontal: false, vertical: true) .padding(.top, 16) + .accessibilityIdentifier("SparkExitFeeLow") } if let outsideSats = SparkExitMath.outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) { diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index e790ac5f7..5c69dd865 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -152,7 +152,7 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertTrue(record.showsResult) var refused = endedRecord() - refused.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "min relay fee not met") + refused.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "non-mandatory-script-verify-flag") XCTAssertFalse(refused.needsDriving) XCTAssertTrue(refused.showsResult, "the leftover waits at the funding address") @@ -201,6 +201,113 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertTrue(record.showsResult) } + /// R7: a sweep refused for its fee is built again at the network's current rate; any other refusal is final. + func testASweepRefusedForItsFeeIsRebuiltAtTheCurrentRate() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + chain.outcome = .rejected("min relay fee not met, 300 < 330") + chain.fastestFee = 5 + + var record = try await driver().tick(endedRecord()) + let refused = try XCTUnwrap(record.leftoverSweep) + XCTAssertTrue(refused.isFeeRejection) + XCTAssertTrue(record.needsDriving, "a fee refusal is not final") + + chain.outcome = .accepted + record = try await driver().tick(record) + + let rebuilt = try XCTUnwrap(record.leftoverSweep) + XCTAssertNotEqual(rebuilt.txid, refused.txid) + XCTAssertEqual(rebuilt.feeRateSatPerVbyte, 5) + XCTAssertEqual(rebuilt.valueSats, 5035 - SparkExitLeftoverSweep.feeSats(inputs: 1, outputScriptLength: 22, feeRateSatPerVbyte: 5)) + XCTAssertTrue(rebuilt.isAccepted) + XCTAssertNil(rebuilt.rejection) + XCTAssertEqual(rebuilt.sentAtHeight, 1000) + } + + /// R7: an accepted sweep still unconfirmed 144 blocks later is replaced (RBF) at a higher rate, at least one + /// sat/vB over its own. + func testAnUnconfirmedSweepIsReplacedAfter144Blocks() async throws { + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] + chain.fastestFee = 1 + var record = try await driver().tick(endedRecord()) + let first = try XCTUnwrap(record.leftoverSweep) + XCTAssertEqual(first.sentAtHeight, 1000) + + chain.tip = 1143 + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.txid, first.txid) + + chain.tip = 1144 + record = try await driver().tick(record) + let replacement = try XCTUnwrap(record.leftoverSweep) + XCTAssertNotEqual(replacement.txid, first.txid) + XCTAssertEqual(replacement.feeRateSatPerVbyte, 3, "the old rate of 2 plus one, over the network's 1") + XCTAssertEqual(replacement.sentAtHeight, 1144) + try assertValidP2wpkhSpend( + .init(txid: XCTUnwrap(replacement.txid), txHex: XCTUnwrap(replacement.txHex), valueSats: replacement.valueSats), + utxos: chain.utxosByAddress[key.address] ?? [] + ) + } + + /// R7: a replacement never loses the sweep it replaces. One that would be dust keeps the accepted sweep; the + /// replaced sweep confirming ends it; a sweep the hosts no longer know counts as unconfirmed. + func testAReplacementKeepsTrackOfTheSweepItReplaces() async throws { + let utxo = SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035) + chain.utxosByAddress[key.address] = [utxo] + var record = try await driver().tick(endedRecord()) + let first = try XCTUnwrap(record.leftoverSweep?.txid) + + // The hosts dropped it: replaced, and the replacement remembers it. + chain.droppedTxids = [first] + chain.tip = 1144 + record = try await driver().tick(record) + let second = try XCTUnwrap(record.leftoverSweep?.txid) + XCTAssertNotEqual(second, first) + XCTAssertEqual(record.leftoverSweep?.replacedTxids, [first]) + + // The first one confirms after all: the sweep is done. + chain.droppedTxids = [] + chain.confirmedTxids = [first] + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.isConfirmed, true) + XCTAssertFalse(record.needsDriving) + + // A rate that would leave dust keeps the accepted sweep instead. + var accepted = endedRecord() + accepted.leftover = [.init(txid: utxo.txid, vout: 0, valueSats: 5035)] + accepted.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4800, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) + chain.confirmedTxids = [] + chain.fastestFee = 50 + accepted = try await driver().tick(accepted) + XCTAssertEqual(accepted.leftoverSweep?.txid, "s") + XCTAssertTrue(accepted.needsDriving) + } + + /// R11: the Done screen reads what the sweep paid to savings, and its txid. + func testDoneShowsWhatReachedSavingsAndTheSweepTxid() throws { + let script = try SparkExitAddress.scriptPubKey(destination) + let hex = "02000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "ffffffff" + + "02" + "a086010000000000" + String(format: "%02x", script.count) + script.hex + + "e803000000000000" + "16" + "0014" + String(repeating: "22", count: 20) + "00000000" + var record = endedRecord() + record.recovery = StoredRecovery(SparkExitFixtures.recovery([RecoveryTransaction( + kind: .sweep, + nodeId: nil, + txid: "sweep-txid", + txHex: hex, + cpfpTxHex: nil, + csvTimelockBlocks: nil, + dependsOn: [], + status: .confirmed(blockHeight: 50) + )])) + + let received = try XCTUnwrap(SparkExitMath.received(record)) + XCTAssertEqual(received.sats, 100_000) + XCTAssertEqual(received.sweepTxid, "sweep-txid") + record.phase = .running + XCTAssertNil(SparkExitMath.received(record)) + } + /// Each exit signs with the key at its own funding index, so a later exit never spends an earlier one's address. func testSignsWithTheKeyAtTheRecordsFundingIndex() async throws { let second = try SparkExitFundingKey.derive(index: 4, mnemonic: Self.mnemonic, passphrase: nil, network: .regtest) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index ac3bc99e9..1730d7abb 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -138,8 +138,14 @@ final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { return lock.withLock { utxosByAddress[address] ?? [] } } + /// Transactions every host answers not found for, as for one a node dropped. + var droppedTxids: Set = [] + func txStatus(txid: String) async throws -> EsploraTxStatus { - lock.withLock { EsploraTxStatus(confirmed: confirmedTxids.contains(txid), blockHeight: confirmedTxids.contains(txid) ? 900 : nil) } + if droppedTxids.contains(txid) { + throw URLError(.fileDoesNotExist) + } + return lock.withLock { EsploraTxStatus(confirmed: confirmedTxids.contains(txid), blockHeight: confirmedTxids.contains(txid) ? 900 : nil) } } func isKnown(txid: String) async throws -> Bool { @@ -153,6 +159,12 @@ final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { return outcome } } + + var fastestFee: UInt64 = 5 + + func fastestFeeRate() async throws -> UInt64 { + fastestFee + } } final class FakeSparkExitRecovering: SparkExitRecovering, @unchecked Sendable { @@ -187,6 +199,19 @@ final class FakeSparkExitRecovering: SparkExitRecovering, @unchecked Sendable { checkedRecoveries.append(recovery) return checks.removeFirst() } + + var exitState = "exit-state-1" + private(set) var exports = 0 + private(set) var imports: [String] = [] + + func exportExitState() async throws -> String { + exports += 1 + return exitState + } + + func importExitState(_ exitState: String) async throws { + imports.append(exitState) + } } final class StubCpfpSigner: CpfpSigner, @unchecked Sendable { @@ -331,6 +356,65 @@ final class SparkExitRecordTests: XCTestCase { } } + /// R8: a record of a version this build cannot read does not make a running exit disappear. What it still tells + /// is kept, the exit rebuilds from the chain with its funding and transfer, and the original stays aside. + func testARecordOfAnUnknownVersionKeepsItsExitToRebuildFromTheChain() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + let lostLeafStore = SparkExitLostLeafStore(url: dir.appendingPathComponent("spark-exit-lost-leaves.json")) + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 3, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: SparkExitRecordCoder.encode(record)) as? [String: Any]) + object["version"] = 2 + object["recovery"] = ["a format": "from the future"] + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: object).write(to: store.url) + + let loaded = try XCTUnwrap(SparkExitManager.loadRecord(store: store, lostLeafStore: lostLeafStore)) + + XCTAssertEqual(loaded.phase, .running) + XCTAssertTrue(loaded.isRebuilding) + XCTAssertEqual(loaded.isRebuiltFromChain, true) + XCTAssertNil(loaded.recovery) + XCTAssertEqual(loaded.transferId, "transfer-1") + XCTAssertEqual(loaded.fundingInputs, record.fundingInputs) + XCTAssertEqual(loaded.fundingIndex, 3) + XCTAssertEqual(loaded.exitLeafIds, ["leaf-1"]) + XCTAssertEqual(try store.load(), loaded) + let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) + XCTAssertTrue(files.contains { $0.contains("unreadable") }, "the original is kept for support") + XCTAssertTrue(SparkExitMath.orphanedTransferIds([], record: loaded).isEmpty) + } + + /// Before the first unlock the record file exists but cannot be read: that is not an unreadable record, so it is + /// neither salvaged nor set aside. + func testARecordThatCannotBeReadYetIsLeftAlone() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + // A directory where the file should be reads like a protected file: it exists and cannot be read. + try FileManager.default.createDirectory(at: store.url, withIntermediateDirectories: true) + + XCTAssertThrowsError(try SparkExitManager.loadRecord( + store: store, + lostLeafStore: SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")) + )) { error in + XCTAssertTrue(error is SparkExitStore.Unavailable) + } + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: dir.path), ["spark-exit.json"]) + } + + func testAnEndedOrUnusableRecordIsNotRevived() throws { + XCTAssertNil(try SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(SparkExitFixtures.record(phase: .done)))) + XCTAssertNil(SparkExitRecordCoder.salvage(Data("{\"version\":2}".utf8))) + XCTAssertNil(SparkExitRecordCoder.salvage(Data("not json".utf8))) + let funding = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(SparkExitFixtures.record()))) + XCTAssertEqual(funding.phase, .awaitingFunding(requiredSats: 20000)) + XCTAssertFalse(funding.isRebuilding) + } + func testStoreSavesAndLoads() throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-\(UUID().uuidString)/spark-exit.json") let store = SparkExitStore(url: url) @@ -456,25 +540,74 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2", "n2"]) XCTAssertEqual(record.broadcastLog.first?.attempts, 2) - XCTAssertEqual(record.claimableAtHeight, 3000) + // Tip 1000, critical path n2 (1 block) then r1 (2000 blocks of timelock, then 1): Glow's `blocksToFinish`. + XCTAssertEqual(record.claimableAtHeight, 3002) XCTAssertEqual(record.recovery?.response, checked) } - func testRetriesARejectedStepAndSurfacesItAfterRepeatedRejections() async throws { + /// R1: a step refused three times rebuilds the exit, at most three times while no step confirms. Then the exit + /// stops rebuilding and says it is not moving. + func testRefusedStepsRebuildThreeTimesThenTheExitIsNotMoving() async throws { var record = SparkExitFixtures.record(phase: .running) let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) chain.outcome = .rejected("min relay fee not met") - sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 3) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 30) + sdk.builds = Array(repeating: recovery, count: 3) - for _ in 0 ..< 3 { + for _ in 0 ..< 20 { + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) record = try await driver().tick(record) + if (record.stallRebuilds ?? 0) < SparkExitDriver.stallRebuildsToSurface { + XCTAssertNil(record.surfacedRejection) + } } - XCTAssertEqual(chain.broadcasts.count, 3) + XCTAssertEqual(sdk.recoverRequests.count, 3) + XCTAssertEqual(record.generation, 4) + XCTAssertEqual(record.stallRebuilds, 3) XCTAssertEqual(record.surfacedRejection, "rejected: min relay fee not met") } + func testAStepConfirmingResetsTheRebuildCap() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1"), SparkExitFixtures.tx("n2", dependsOn: ["n1"])])) + record.stallRebuilds = 2 + record.stallConfirmedCount = 0 + sdk.checks = [CheckRecoverFundsResponse(recovery: SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"]), + ]), verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertNil(record.stallRebuilds) + XCTAssertNil(record.stallConfirmedCount) + } + + /// R1: an accepted step that stays unconfirmed for more than six blocks is underpriced. Nothing rebuilds. + func testAnAcceptedStepThatDoesNotConfirmIsUnderpricedAndNotRebuilt() async throws { + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + record.adopt(recovery) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 2) + chain.tip = 1000 + + record = try await driver().tick(record) + XCTAssertEqual(record.broadcastLog.first?.acceptedAtHeight, 1000) + XCTAssertNil(record.underpricedStep(tip: 1006)) + XCTAssertEqual(record.underpricedStep(tip: 1007)?.txid, "n1") + + chain.tip = 1007 + clock = clock.addingTimeInterval(SparkExitDriver.rebroadcastInterval) + record = try await driver().tick(record) + XCTAssertEqual(record.broadcastLog.first?.acceptedAtHeight, 1000, "a rebroadcast keeps the first acceptance") + XCTAssertEqual(record.generation, 1) + XCTAssertTrue(sdk.recoverRequests.isEmpty) + let reminders = SparkExitMath.reminders(record, tip: 1007, now: clock, network: .regtest) + XCTAssertTrue(reminders.contains { $0.id == "1-slow-n1" }) + } + func testDoneFinishesAndReadsLeftoverFunding() async throws { var record = SparkExitFixtures.record(phase: .running) record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)])) @@ -545,31 +678,55 @@ final class SparkExitDriverTests: XCTestCase { record = try await driver().tick(record) - XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3000)) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) XCTAssertTrue(record.isActive) } - /// The headless regtest run: a quote for 8,541, five payments of 9,035 together, and the SDK asking for 9,085 - /// in all once it priced the five inputs. Only the 50 missing is asked for. - func testInsufficientFundsAsksOnlyForWhatTheInputsLack() async throws { + /// The headless regtest run: a quote for 8,541 and five payments of 9,035 together. The SDK priced each extra + /// input and asked for 9,085, a second funding round. With 68 vB per extra input at 2 sat/vB, 8,541 + 4 x 136 is + /// that same 9,085, so the exit now waits and asks for the rest before it builds. + func testEachExtraPaymentAddsItsInputFee() async throws { let values: [UInt64] = [5000, 2000, 1000, 535, 500] chain.utxosByAddress["bcrt1qfunding"] = values.enumerated().map { SparkExitFixtures.utxo("u\($0.offset)", value: $0.element) } + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 8541)) + + record = try await driver().tick(record) + + XCTAssertTrue(sdk.recoverRequests.isEmpty) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: chain.utxosByAddress["bcrt1qfunding"]), .ask(sats: 294, receivedSats: 9035)) + + chain.utxosByAddress["bcrt1qfunding", default: []].append(SparkExitFixtures.utxo("u5", value: 300)) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 6) + } + + /// A rebuild the SDK finds short: the shortfall is what the inputs lack plus the input fee of the payment still to + /// come (68 vB at 2 sat/vB), never under the dust limit. + func testInsufficientFundsAsksForWhatTheInputsLackAndTheNextInput() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("u0", value: 9035)] sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 9085, docsUrl: "") var record = try await driver().tick(SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 8541))) - XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 5) XCTAssertEqual(record.fundedSats, 9035) - XCTAssertEqual(record.phase, .needsFunding(requiredSats: 9085)) - XCTAssertEqual(record.fundingShortfallSats, 50) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 9221)) + XCTAssertEqual(record.fundingShortfallSats, 186) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: chain.utxosByAddress["bcrt1qfunding"]), .ask(sats: 294, receivedSats: 0)) - chain.utxosByAddress["bcrt1qfunding", default: []] += [SparkExitFixtures.utxo("t1", value: 30), SparkExitFixtures.utxo("t2", value: 30)] + chain.utxosByAddress["bcrt1qfunding", default: []] += [SparkExitFixtures.utxo("t1", value: 200), SparkExitFixtures.utxo("t2", value: 100)] + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 9221), "300 is short of 186 plus a second input's 136") + + chain.utxosByAddress["bcrt1qfunding", default: []].append(SparkExitFixtures.utxo("t3", value: 300)) sdk.recoverError = nil sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] record = try await driver().tick(record) XCTAssertEqual(record.phase, .running) - XCTAssertEqual(sdk.recoverRequests.last?.fundingInputs.count, 7) + XCTAssertEqual(sdk.recoverRequests.last?.fundingInputs.count, 4) } /// After a Redo the first inputs are partly spent: what they hold now is the CPFP change at the address. @@ -584,8 +741,8 @@ final class SparkExitDriverTests: XCTestCase { record = try await driver().tick(record) - XCTAssertEqual(record.phase, .needsFunding(requiredSats: 22000)) - XCTAssertEqual(record.fundingShortfallSats, 2000) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 22136)) + XCTAssertEqual(record.fundingShortfallSats, 2136) } /// Gen 1's change outlives a rebuild that needed no funding; a later shortfall still counts it as held. @@ -602,7 +759,7 @@ final class SparkExitDriverTests: XCTestCase { record = try await driver().tick(record) XCTAssertEqual(record.earlierCpfpTxids, [SparkExitFixtures.parentTxid]) - XCTAssertEqual(record.fundingShortfallSats, 2000) + XCTAssertEqual(record.fundingShortfallSats, 2136) record = try await driver().tick(record) XCTAssertEqual(record.funding.count, 1, "the change is not adopted as a top-up") } @@ -902,9 +1059,9 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.cpfpTxHex), [chained]) } - /// The safety net: a step whose parents confirmed and whose child keeps failing to pay for it rebuilds the exit - /// once, without counting towards the rejection alert. After that rebuild its failures are rejections again. - func testAStepWhoseFeeChainKeepsFailingRebuildsOnceThenSurfaces() async throws { + /// The safety net: a step whose parents confirmed and whose child keeps failing to pay for it rebuilds the exit, + /// counting towards the rebuild cap and never towards rejections. + func testAStepWhoseFeeChainKeepsFailingRebuilds() async throws { var record = SparkExitFixtures.record(phase: .running) let first = SparkExitFixtures.recovery([ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), @@ -927,12 +1084,13 @@ final class SparkExitDriverTests: XCTestCase { record = try await driver().tick(record) XCTAssertEqual(record.generation, 2) - XCTAssertEqual(record.feeChainRebuildTxids, ["n2"]) + XCTAssertEqual(record.stallRebuilds, 1) record = try await driver().tick(record) record = try await driver().tick(record) XCTAssertEqual(record.generation, 2) - XCTAssertEqual(record.surfacedRejection, "rejected: min relay fee not met, 0 < 17") + XCTAssertEqual(record.broadcastLog.first?.rejections, 0, "an unpaid child is never a rejection") + XCTAssertNil(record.surfacedRejection) } /// qa-03 repro: an older state's refund took the leaf, so every rebuild reproduces the same refund, whose input @@ -1219,6 +1377,239 @@ final class SparkExitDriverTests: XCTestCase { ) } + // MARK: Glow-informed hardening + + /// R2: a unilateral step the SDK could not read is never sent; with nothing unilateral ready, the exit rebuilds + /// over it once a block, with the same funding. + func testAnUnverifiedUnilateralStepRebuildsInsteadOfBeingSent() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"], status: .unverified), + ]) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2b", cpfp: "03d", dependsOn: ["n1"]), + ])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.unverifiedRebuiltAtHeight, 1000) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2b"]) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: "f0", vout: 1, valueSats: 20000, pubkey: "02ab")]) + XCTAssertNil(record.stallRebuilds, "not a rebuild over a stalled chain") + + // Once a block: the same tip does not rebuild again. + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + record = try await driver().tick(record) + XCTAssertEqual(sdk.recoverRequests.count, 1) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2b"], "the unverified step is still not sent") + } + + /// R2: while a unilateral step is ready, a rebuild would conflict with it, so nothing rebuilds; a cooperative step + /// the SDK could not read is still sent. + func testAReadyStepHoldsTheUnverifiedRebuildAndCooperativeStepsAreStillSent() async throws { + var record = SparkExitFixtures.record(phase: .running) + let checked = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("coop", kind: .cooperative, status: .unverified), + SparkExitFixtures.tx("n1", status: .unverified), + SparkExitFixtures.tx("n2"), + ]) + record.adopt(checked) + sdk.checks = [CheckRecoverFundsResponse(recovery: checked, verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertTrue(sdk.recoverRequests.isEmpty) + XCTAssertNil(record.unverifiedRebuiltAtHeight) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["coop", "n2"]) + } + + private func rebuildingRecord() -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("old")]), at: .distantPast) + record.isRebuilding = true + return record + } + + private func preparedSkipping(_ reason: SkippedLeafReason, stopped: [String] = []) -> PrepareRecoverFundsResponse { + var prepared = SparkExitFixtures.prepared(leaves: []) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: reason)] + prepared.exitChainState.stoppedLeafIds = stopped + return prepared + } + + /// R3: funds not found yet is no end: the build waits, tries again after 6 blocks, at doubling gaps, and lets go + /// after about a week (1,008 blocks). + func testABuildWaitsForFundsNotFoundAndLetsGoAfterAWeek() async throws { + var record = rebuildingRecord() + sdk.preparedResponse = preparedSkipping(.fundsNotFound) + + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.retrySince, 1000) + XCTAssertTrue(record.isWaitingForLeaves) + XCTAssertEqual(sdk.prepareRequests.count, 1) + + chain.tip = 1005 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 1, "not before 6 blocks") + + chain.tip = 1006 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 2) + XCTAssertEqual(record.retriedAt, 1006) + + chain.tip = 1011 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 2) + chain.tip = 1012 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 3) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1012, now: clock, network: .regtest).isEmpty, "the wait needs no reminder") + + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertNil(record.retrySince) + } + + func testIsRetryDueDoublesUpToADay() { + XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 105)) + XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 106)) + XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 124, tip: 147)) + XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 124, tip: 148)) + XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 500, tip: 643)) + XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 500, tip: 644)) + } + + /// R3: a leaf the watchtower stopped quotes as not recoverable until the operators relabel it. It waits 6 blocks + /// from first sight, then the exit lets it go; one not listed as stopped ends the exit at once. + func testAStoppedLeafWaitsSixBlocksThenIsLetGo() async throws { + var record = rebuildingRecord() + sdk.preparedResponse = preparedSkipping(.notRecoverable(message: "stopped"), stopped: ["leaf-1"]) + + record = try await driver().tick(record) + XCTAssertEqual(record.stoppedLeafSince, ["leaf-1": 1000]) + XCTAssertEqual(record.phase, .running) + + chain.tip = 1006 + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + + var other = rebuildingRecord() + sdk.preparedResponse = preparedSkipping(.notRecoverable(message: "no")) + other = try await driver().tick(other) + XCTAssertEqual(other.phase, .done) + } + + /// R3 with the 2-strike rule: a refund rebuilt over a spent input is no strike while its leaf is stopped and + /// still waiting. + func testAStoppedLeafThatStillWaitsIsNeverStruck() async throws { + var record = rebuildingRecord() + let stuck = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + ]) + record.adopt(stuck, at: .distantPast) + record.isRebuilding = true + record.isRedoRebuild = true + record.spentStepStrikes = ["r1": 1] + record.broadcastLog = [.init( + generation: record.generation, + txid: "r1", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "inputs missing", + isAccepted: false, + isInputSpent: true + )] + var prepared = SparkExitFixtures.prepared() + prepared.exitChainState.stoppedLeafIds = ["leaf-1"] + sdk.preparedResponse = prepared + sdk.builds = [stuck] + + record = try await driver().tick(record) + + XCTAssertNil(record.lostLeaves) + XCTAssertNil(record.spentStepStrikes) + XCTAssertEqual(record.generation, 3) + } + + /// A rebuild over a stalled chain is no divergence the SDK reported: it reproduces a refund whose input is spent + /// without striking its leaf, so only `Redo` rebuilds can find a leaf lost. + func testARebuildOverAStalledChainStrikesNoLeaf() async throws { + var record = SparkExitFixtures.record(phase: .running) + let stuck = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.refund("r1", leafId: "leaf-1", dependsOn: ["n1"]), + ]) + record.adopt(stuck, at: .distantPast) + record.spentStepStrikes = ["r1": 1] + chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: stuck, verdict: .valid), count: 3) + sdk.builds = [stuck] + + for _ in 0 ..< 3 { + record = try await driver().tick(record) + } + + XCTAssertEqual(record.stallRebuilds, 1) + XCTAssertEqual(record.generation, 2) + XCTAssertNil(record.lostLeaves) + XCTAssertNil(record.spentStepStrikes) + } + + /// R4: the SDK's exit data is exported once, right before the first build, and imported before every rebuild. + func testExportsTheExitDataBeforeTheFirstBuildAndImportsItBeforeEveryRebuild() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)] + let first = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")]) + sdk.builds = [first, SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + + var record = try await driver().tick(SparkExitFixtures.record()) + XCTAssertEqual(sdk.exports, 1) + XCTAssertEqual(record.exitStateSnapshot, "exit-state-1") + XCTAssertTrue(sdk.imports.isEmpty) + + sdk.exitState = "exit-state-later" + sdk.checks = [CheckRecoverFundsResponse(recovery: first, verdict: .redo(reason: .onChainStateDiverged))] + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(sdk.exports, 1) + XCTAssertEqual(sdk.imports, ["exit-state-1"]) + XCTAssertEqual(record.exitStateSnapshot, "exit-state-1") + } + + /// R11: the countdown follows the longest path of steps left, so it holds still as a parent confirms and its + /// child's timelock starts. + func testTheCountdownHoldsWhenAParentConfirms() { + let before = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1"), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies, csv: 100), + ]) + let after = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 1001)), + SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForTimelock(spendableAtHeight: 1101), csv: 100), + ]) + + XCTAssertEqual(SparkExitMath.claimableAtHeight(before, tip: 1000), 1102) + XCTAssertEqual(SparkExitMath.claimableAtHeight(after, tip: 1001), 1102) + XCTAssertEqual(SparkExitMath.claimableAtHeight(before), nil, "without a tip, only timelocks already counting") + XCTAssertNil(SparkExitMath.blocksToFinish(after.transactions.map { + var tx = $0 + tx.status = .confirmed(blockHeight: 1200) + return tx + }, tip: 1200)) + } + func testDoesNothingOnceFinished() async throws { let record = try await driver().tick(SparkExitFixtures.record(phase: .done)) @@ -1506,8 +1897,11 @@ final class SparkExitMathTests: XCTestCase { lastOutcome: "rejected", isAccepted: false )] + // Refused steps rebuild first; only an exit that stopped moving after its rebuilds reminds at once. + XCTAssertFalse(SparkExitMath.reminders(record, tip: 1000, now: now, network: .bitcoin).contains { $0.id == "rejected-1-n1" }) + record.stallRebuilds = SparkExitDriver.stallRebuildsToSurface XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: now, network: .bitcoin), [ - SparkExitReminder(id: "rejected-1-n1", fireAt: now), + SparkExitReminder(id: "rejected-1-n1", fireAt: now, isNotMoving: true), SparkExitReminder(id: "1-ready-n1", fireAt: now.addingTimeInterval(TimeInterval(SparkExitMath.reminderGraceBlocks) * 600)), SparkExitReminder(id: "1-idle-0-0", fireAt: now.addingTimeInterval(4 * 60 * 60)), ]) @@ -1602,6 +1996,10 @@ final class SparkExitMathTests: XCTestCase { XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999)?.map(\.txid), ["a", "b"]) XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 2000)) XCTAssertNil(SparkExitMath.fundingTopUp([], requiredSats: 0)) + // Each payment beyond the first is one more input to pay for: 68 vB at the exit's rate. + XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999, inputFeeSats: 136)) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1863, inputFeeSats: 136)?.map(\.txid), ["a", "b"]) + XCTAssertEqual(SparkExitMath.inputFeeSats(SparkExitFixtures.record()), 136) } func testShowsTheSparkBalanceBeyondTheExitsLeaves() { @@ -1653,14 +2051,15 @@ final class SparkExitMathTests: XCTestCase { var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 9000)) XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .ask(sats: 9000)) - XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("a", value: 4000)]), .ask(sats: 5000, receivedSats: 4000)) + // The payment still to send adds its input fee. + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("a", value: 4000)]), .ask(sats: 5136, receivedSats: 4000)) XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("x", value: 9000, confirmed: false)]), .seen(txid: "x")) record.fundingPaymentTxid = "pay" XCTAssertEqual(SparkExitMath.fundingState(record, utxos: nil), .paid(txid: "pay")) XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .paid(txid: "pay")) XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("pay", value: 9000, confirmed: false)]), .paid(txid: "pay")) - XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("pay", value: 6000)]), .ask(sats: 3000, receivedSats: 6000)) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: [SparkExitFixtures.utxo("pay", value: 6000)]), .ask(sats: 3136, receivedSats: 6000)) // Adopted, and spent by a rebuild's CPFP: a later shortfall is asked for, not shown as paid. record.funding = [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "pay", vout: 0, valueSats: 9000)] @@ -1699,7 +2098,7 @@ final class SparkExitMathTests: XCTestCase { var record = SparkExitFixtures.record(phase: .needsFunding(requiredSats: 9000)) record.funding = [.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 0, valueSats: 5000)] let utxos = [SparkExitFixtures.utxo("f0", value: 5000), SparkExitFixtures.utxo("new", value: 1500, confirmed: false)] - XCTAssertEqual(SparkExitMath.fundingState(record, utxos: utxos), .ask(sats: 2500, receivedSats: 1500)) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: utxos), .ask(sats: 2636, receivedSats: 1500)) } func testEachExitTakesAFundingIndexNoEarlierExitUsed() { From 7c8cf47ca652e0885423ce602c792ff84d376f05 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 16:26:50 +0100 Subject: [PATCH 026/378] fix: spark exit keeps its pre-exit backup The SPARK_EXIT backup is not refreshed while an exit runs: the operators may stop reporting a leaf once it is being exited, so a fresh export could lack the leaf a restored wallet needs. --- Bitkit/Services/BackupService.swift | 8 ++++++++ BitkitTests/SparkExitBackupTests.swift | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 2d014bb4a..5b182aa40 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -131,6 +131,7 @@ class BackupService { private let defaults: UserDefaults private let backupData: ((BackupCategory) async throws -> Data)? + private let isSparkExitActive: () async -> Bool private let uploadBackup: (String, Data) async throws -> Void private let waitForBackupDelay: () async throws -> Void private let waitForTemporaryFailureDelay: (UInt64) async throws -> Void @@ -160,6 +161,7 @@ class BackupService { init( defaults: UserDefaults = .standard, backupData: ((BackupCategory) async throws -> Data)? = nil, + isSparkExitActive: @escaping () async -> Bool = { @MainActor in SparkExitManager.shared.isExiting }, usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(), uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in _ = try await VssBackupClient.shared.putObject(key: key, data: data) @@ -175,6 +177,7 @@ class BackupService { } ) { self.defaults = defaults + self.isSparkExitActive = isSparkExitActive self.backupData = backupData self.usdtRecoveryBackup = usdtRecoveryBackup self.uploadBackup = uploadBackup @@ -1405,7 +1408,12 @@ class BackupService { } } + /// Nothing while an exit runs: the operators may stop reporting a leaf once it is being exited, so a fresh export + /// could drop the very leaf a restored wallet needs. The backup from before the exit stays. private func sparkExitBackupData() async throws -> Data? { + if await isSparkExitActive() { + return nil + } if let backupData { return try await backupData(.sparkExit) } diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift index e451aed3d..344d9eb02 100644 --- a/BitkitTests/SparkExitBackupTests.swift +++ b/BitkitTests/SparkExitBackupTests.swift @@ -31,6 +31,29 @@ final class SparkExitBackupTests: XCTestCase { XCTAssertEqual(restored.network, backup.network) } + /// R4: while an exit runs, a fresh export could lack the leaves it moves, so the backup from before it stays. + func testDoesNotRefreshTheExitDataWhileAnExitRuns() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var uploads: [String] = [] + var exports = 0 + let service = BackupService( + defaults: defaults, + backupData: { _ in + exports += 1 + return Data("{}".utf8) + }, + isSparkExitActive: { true }, + uploadBackup: { key, _ in uploads.append(key) } + ) + + await service.triggerBackup(category: .sparkExit) + + XCTAssertTrue(uploads.isEmpty) + XCTAssertEqual(exports, 0) + } + func testStagesARestoredExitStateUntilSparkConnects() async throws { guard Env.network == .regtest else { throw XCTSkip("Spark's test network is regtest") } let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "state") From 50b186a39b5230b79672eb9c211c00fded066115 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 16:26:51 +0100 Subject: [PATCH 027/378] fix: exit notifications open the exit Tapping a Spark exit notification opens the exit's progress once the wallet is unlocked. --- Bitkit/AppScene.swift | 13 +++++++++++++ Bitkit/BitkitApp.swift | 2 ++ 2 files changed, 15 insertions(+) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5bc352ac3..402dfe866 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -613,6 +613,19 @@ struct AppScene: View { guard scenePhase == .active else { return } await SparkExitManager.shared.runWhileActive() } + .task(id: [ + SparkExitManager.shared.opensProgress, + SparkExitManager.shared.showsResult, + isLockedForDeepLinks, + wallet.walletExists == true, + ]) { + // A tapped exit notification opens the exit once the wallet is unlocked and its record is loaded. + let exit = SparkExitManager.shared + guard exit.opensProgress, exit.showsResult, wallet.walletExists == true, !isLockedForDeepLinks else { return } + exit.opensProgress = false + navigation.reset() + navigation.navigate(.sparkExitProgress) + } .task(id: foregroundContactSession) { shopContactImport?.task.cancel() shopContactImport = nil diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index b627cab19..89ce0bdba 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -164,6 +164,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate { PaykitSubscriptionNotificationTargetStore.save(target) } NotificationCenter.default.post(name: .paykitSubscriptionPaymentDue, object: nil, userInfo: userInfo) + } else if userInfo["bitkit_action"] as? String == SparkExitManager.notificationAction { + Task { @MainActor in SparkExitManager.shared.opensProgress = true } } else { PushNotificationManager.shared.handleNotification(userInfo) } From df489ccd4e59ad2fec1a23f948693f9084233191 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 16:54:58 +0100 Subject: [PATCH 028/378] fix: spark exit shows a low fee rate when the mempool refuses it - a refusal for a nonzero fee under the mempool's minimum (min relay fee not met, N < M with N > 0, or mempool min fee not met) shows fee_low at once, without a rebuild, and its reminder keeps its first date - a zero fee and a lost replacement race stay on the broken-chain path - an unpaid child is never a refusal, a step confirmed since is not stuck, and Done counts what landed before a Redo --- .../SparkExit/SparkExitBroadcaster.swift | 9 +++ .../Services/SparkExit/SparkExitDriver.swift | 9 ++- .../Services/SparkExit/SparkExitFunding.swift | 24 +++++-- .../Services/SparkExit/SparkExitRecord.swift | 49 +++++++++---- BitkitTests/SparkExitSweepTests.swift | 22 ++++++ BitkitTests/SparkExitTests.swift | 69 +++++++++++++++++++ 6 files changed, 160 insertions(+), 22 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index b426def1a..a79400dde 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -314,6 +314,15 @@ enum SparkExitBroadcastClassifier { return text.range(of: #"min relay fee not met,\s*0\s*<"#, options: .regularExpression) != nil } + /// The step paid a fee, but less than the mempool takes now: the network is busier than the exit's rate. A rebuild + /// at the same rate would not help. A zero fee (`isUnpaid`) and a lost replacement race + /// (`insufficient fee, rejecting replacement`) are a broken fee chain instead. + static func isFeeRateTooLow(_ outcome: SparkExitBroadcastOutcome) -> Bool { + guard case let .rejected(text) = outcome else { return false } + return text.range(of: #"min relay fee not met,\s*[1-9]"#, options: .regularExpression) != nil + || text.contains("mempool min fee not met") + } + static func classify(error text: String) -> SparkExitBroadcastOutcome { if knownMessages.contains(where: text.contains) || hasRpcCode(-27, in: text) { return .accepted diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 71f227922..b9bc05356 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -697,8 +697,11 @@ struct SparkExitDriver { let isInputSpent: Bool? = hasConfirmedParents && isInputsMissing ? true : nil let isUnpaid = SparkExitBroadcastClassifier.isUnpaid(outcome) let isFeeChainFailure = hasConfirmedParents && (isUnpaid || isInputsMissing) + // Neither an unpaid child nor a rate under the mempool's is a refusal to rebuild over: the first is a broken fee + // chain, the second needs lower fees, not a build at the same rate. + let isFeeRateRefused = SparkExitBroadcastClassifier.isFeeRateTooLow(outcome) let isRejected = if case .rejected = outcome { - !isFeeChainFailure + !isUnpaid && !isFeeRateRefused } else { false } @@ -711,6 +714,7 @@ struct SparkExitDriver { record.broadcastLog[i].isAccepted = isAccepted record.broadcastLog[i].isInputSpent = isInputSpent record.broadcastLog[i].feeChainFailures = isFeeChainFailure ? (record.broadcastLog[i].feeChainFailures ?? 0) + 1 : nil + record.broadcastLog[i].isFeeRateRefused = isFeeRateRefused ? true : nil } else { record.broadcastLog.append(.init( generation: record.generation, @@ -722,7 +726,8 @@ struct SparkExitDriver { isAccepted: isAccepted, isInputSpent: isInputSpent, feeChainFailures: isFeeChainFailure ? 1 : nil, - acceptedAtHeight: isAccepted ? tip : nil + acceptedAtHeight: isAccepted ? tip : nil, + isFeeRateRefused: isFeeRateRefused ? true : nil )) } } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 85214cfcd..707efe40b 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -296,14 +296,26 @@ enum SparkExitMath { /// What the exit's sweep and cooperative recoveries paid to its savings address, and the sweep's txid, once it /// ended done. Nil before, or when no output to savings can be read. + /// Generations a Redo replaced count too, so a cooperative recovery that landed before it is included. static func received(_ record: SparkExitRecord) -> (sats: UInt64, sweepTxid: String?)? { - guard record.phase == .done, let transactions = record.recovery?.response.transactions, - let destination = try? SparkExitAddress.scriptPubKey(record.destination) - else { return nil } - let paying = transactions.filter { $0.kind == .sweep || $0.kind == .cooperative } - let sats = paying.flatMap { SparkExitTxid.outputs(hex: $0.txHex) ?? [] }.filter { $0.script == destination }.reduce(0) { $0 + $1.value } + guard record.phase == .done, let transactions = record.recovery?.response.transactions else { return nil } + let paid = (record.earlierReceivedSats ?? [:]).merging(paidToDestination(transactions, destination: record.destination)) { $1 } + let sats = paid.values.reduce(0, +) guard sats > 0 else { return nil } - return (sats, paying.first { $0.kind == .sweep }?.txid) + return (sats, transactions.first { $0.kind == .sweep }?.txid) + } + + /// What each sweep or cooperative recovery among `transactions` pays to `destination`, by txid. + static func paidToDestination(_ transactions: [RecoveryTransaction], destination: String) -> [String: UInt64] { + guard let script = try? SparkExitAddress.scriptPubKey(destination) else { return [:] } + var paid: [String: UInt64] = [:] + for tx in transactions where tx.kind == .sweep || tx.kind == .cooperative { + let sats = (SparkExitTxid.outputs(hex: tx.txHex) ?? []).filter { $0.script == script }.reduce(0) { $0 + $1.value } + if sats > 0 { + paid[tx.txid] = sats + } + } + return paid } /// Whether every refund's timelock is behind it: none still waits for its parents or its height. diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index c16f02d5a..a03c7c3fb 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -56,6 +56,8 @@ struct SparkExitRecord: Codable, Equatable { /// The tip when a node first accepted it, kept while it stays accepted, so a step the network holds but does /// not confirm shows as underpriced. var acceptedAtHeight: UInt32? + /// Its last answer was a fee under the mempool's minimum: the network is busier than the exit's rate. + var isFeeRateRefused: Bool? } /// A leaf an older state of the wallet claimed on-chain first, which this exit cannot recover. @@ -153,6 +155,8 @@ struct SparkExitRecord: Codable, Equatable { var exitStateSnapshot: String? /// The stored record could not be read, so the exit is rebuilt from the chain with the funding it kept. var isRebuiltFromChain: Bool? + /// What confirmed sweeps and cooperative recoveries of replaced generations paid to savings, by txid. + var earlierReceivedSats: [String: UInt64]? var leftBehindLeaves: [LeftBehindLeaf]? /// Set once the user opened the result of an exit that failed or lost leaves. var isResultSeen: Bool? @@ -258,6 +262,15 @@ struct SparkExitRecord: Codable, Equatable { if !txids.isEmpty { earlierCpfpTxids = (earlierCpfpTxids ?? []) + txids } + let landed = SparkExitMath.paidToDestination(recovery.response.transactions.filter { + if case .confirmed = $0.status { + return true + } + return false + }, destination: destination) + if !landed.isEmpty { + earlierReceivedSats = (earlierReceivedSats ?? [:]).merging(landed) { $1 } + } } generation += 1 recovery = StoredRecovery(built) @@ -275,33 +288,41 @@ struct SparkExitRecord: Codable, Equatable { retrySince != nil } - /// A step of this generation that keeps being refused, or whose fee chain keeps failing. + /// A step of this generation that keeps being refused, or whose fee chain keeps failing. One the chain shows + /// confirmed since, such as by the operators' copy of it, is not stuck. var stuckStep: BroadcastAttempt? { - broadcastLog.first { - $0.generation == generation && !$0.isAccepted + let confirmed = confirmedTxids + return broadcastLog.first { + $0.generation == generation && !$0.isAccepted && !confirmed.contains($0.txid) && ($0.rejections >= SparkExitDriver.rejectionsToSurface || ($0.feeChainFailures ?? 0) >= SparkExitDriver.feeChainFailuresToRebuild) } } + private var confirmedTxids: Set { + Set((recovery?.response.transactions ?? []).filter { + if case .confirmed = $0.status { + return true + } + return false + }.map(\.txid)) + } + /// Whether rebuilding over a stuck step already failed to move the exit often enough to stop and say so. var isStalled: Bool { (stallRebuilds ?? 0) >= SparkExitDriver.stallRebuildsToSurface } - /// A step the network accepted that has not confirmed for more blocks than a fair fee rate needs: the network is - /// busier than the exit's rate. Rebuilding at the same rate would not help. + /// A step the network is busier than the exit's rate for: refused for a fee under the mempool's minimum, or + /// accepted but unconfirmed for more blocks than a fair rate needs. Rebuilding at the same rate would not help. func underpricedStep(tip: UInt32?) -> BroadcastAttempt? { - guard let tip, let transactions = recovery?.response.transactions else { return nil } - let confirmed = Set(transactions.filter { - if case .confirmed = $0.status { - return true - } - return false - }.map(\.txid)) + guard recovery != nil else { return nil } + let confirmed = confirmedTxids return broadcastLog.first { - guard $0.generation == generation, $0.isAccepted, let height = $0.acceptedAtHeight, !confirmed.contains($0.txid) else { - return false + guard $0.generation == generation, !confirmed.contains($0.txid) else { return false } + if !$0.isAccepted { + return $0.isFeeRateRefused == true } + guard let tip, let height = $0.acceptedAtHeight else { return false } return tip > height && tip - height > SparkExitDriver.unconfirmedBlocksToSurface } } diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 5c69dd865..c0f14786d 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -308,6 +308,28 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertNil(SparkExitMath.received(record)) } + /// A cooperative recovery that landed before a Redo still counts in what reached savings. + func testDoneCountsWhatLandedBeforeARedo() throws { + let script = try SparkExitAddress.scriptPubKey(destination) + func paying(_ sats: String) -> String { + "02000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "ffffffff" + + "01" + sats + String(format: "%02x", script.count) + script.hex + "00000000" + } + func tx(_ txid: String, _ kind: RecoveryTxKind, _ hex: String) -> RecoveryTransaction { + RecoveryTransaction(kind: kind, nodeId: nil, txid: txid, txHex: hex, cpfpTxHex: nil, csvTimelockBlocks: nil, dependsOn: [], + status: .confirmed(blockHeight: 50)) + } + var record = endedRecord(phase: .running) + // 50,000 and 100,000 sats, little-endian. + record.adopt(SparkExitFixtures.recovery([tx("coop", .cooperative, paying("50c3000000000000"))])) + record.adopt(SparkExitFixtures.recovery([tx("sweep", .sweep, paying("a086010000000000"))])) + record.phase = .done + + let received = try XCTUnwrap(SparkExitMath.received(record)) + XCTAssertEqual(received.sats, 150_000) + XCTAssertEqual(received.sweepTxid, "sweep") + } + /// Each exit signs with the key at its own funding index, so a later exit never spends an earlier one's address. func testSignsWithTheKeyAtTheRecordsFundingIndex() async throws { let second = try SparkExitFundingKey.derive(index: 4, mnemonic: Self.mnemonic, passphrase: nil, network: .regtest) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 1730d7abb..2c78d2872 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -569,6 +569,75 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.surfacedRejection, "rejected: min relay fee not met") } + /// The mempool refusing a step's fee rate is no broken chain: the exit says the network is busier than its rate + /// at once and does not rebuild. A zero fee and a lost replacement race stay on the broken-chain path. + func testAFeeRateUnderTheMempoolsShowsAtOnceWithoutRebuilding() async throws { + let lowFee = ["min relay fee not met, 5 < 17", "mempool min fee not met, 120 < 300"] + let brokenChain = ["min relay fee not met, 0 < 17", "insufficient fee, rejecting replacement 1234, less fees than conflicting txs; 200 < 300"] + for text in lowFee { + XCTAssertTrue(SparkExitBroadcastClassifier.isFeeRateTooLow(.rejected(text)), text) + XCTAssertFalse(SparkExitBroadcastClassifier.isUnpaid(.rejected(text)), text) + } + for text in brokenChain { + XCTAssertFalse(SparkExitBroadcastClassifier.isFeeRateTooLow(.rejected(text)), text) + } + XCTAssertTrue(SparkExitBroadcastClassifier.isUnpaid(.rejected(brokenChain[0]))) + + for text in lowFee { + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.outcome = .rejected(text) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 4) + + record = try await driver().tick(record) + XCTAssertEqual(record.underpricedStep(tip: nil)?.txid, "n1", "shown after the first refusal") + + for _ in 0 ..< 3 { + clock = clock.addingTimeInterval(SparkExitDriver.rebuildInterval) + record = try await driver().tick(record) + } + XCTAssertTrue(sdk.recoverRequests.isEmpty, text) + XCTAssertEqual(record.broadcastLog.first?.rejections, 0, text) + XCTAssertNil(record.surfacedRejection, text) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).contains { $0.id == "1-slow-n1" }) + } + + // The lost race rebuilds over the stalled chain. + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + var record = SparkExitFixtures.record(phase: .running) + let recovery = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c")]) + record.adopt(recovery, at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.outcome = .rejected(brokenChain[1]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: recovery, verdict: .valid), count: 3) + sdk.builds = [recovery] + for _ in 0 ..< 3 { + record = try await driver().tick(record) + } + XCTAssertEqual(record.stallRebuilds, 1) + XCTAssertNil(record.underpricedStep(tip: nil)) + } + + /// A refused step the chain shows confirmed since, such as by the operators' copy, is not stuck. + func testAConfirmedStepIsNotStuck() { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900))])) + record.broadcastLog = [.init( + generation: 1, + txid: "n1", + attempts: 3, + rejections: 3, + lastAttemptAt: clock, + lastOutcome: "rejected: bad", + isAccepted: false + )] + + XCTAssertNil(record.stuckStep) + } + func testAStepConfirmingResetsTheRebuildCap() async throws { var record = SparkExitFixtures.record(phase: .running) record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1"), SparkExitFixtures.tx("n2", dependsOn: ["n1"])])) From 43abf3ec382dc4a33fd2390ec3a06de8698092c4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 22:21:11 +0100 Subject: [PATCH 029/378] chore: bump spark sdk to 0.28.0 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +-- Bitkit/Managers/SparkManager.swift | 34 ++++++++++++++----- BitkitTests/SparkLightningSendTests.swift | 7 ++++ BitkitTests/SparkTransferActivityTests.swift | 13 ++++--- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 507f9cb4e..b5e81a489 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1253,7 +1253,7 @@ repositoryURL = "https://github.com/breez/breez-sdk-spark-swift"; requirement = { kind = exactVersion; - version = 0.27.2; + version = 0.28.0; }; }; 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8401f1e44..0ed9cd76c 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/breez/breez-sdk-spark-swift", "state" : { - "revision" : "259f79b8572ed4a1df8b5f6fea64c6f2e534a65d", - "version" : "0.27.2" + "revision" : "09081e9f2862a0359df154aa6813242d36a56275", + "version" : "0.28.0" } }, { diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index cc7a28af4..16c906365 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -168,7 +168,8 @@ final class SparkManager: ChannelFreeLightningRail { amountSats: amountSats, expirySecs: expirySecs, paymentHash: nil, - receiverIdentityPublicKey: nil + receiverIdentityPublicKey: nil, + preimage: nil ) return try await requireSdk().receivePayment(request: ReceivePaymentRequest(paymentMethod: method)).paymentRequest } @@ -182,19 +183,31 @@ final class SparkManager: ChannelFreeLightningRail { sdk = try await requireSdk() prepared = try await prepareSend(paymentRequest: bolt11, amountSats: sats) } catch { - throw ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment", underlying: error) + throw Self.expiredInvoiceRefusal(error) ?? ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment", underlying: error) } guard case let .bolt11Invoice(invoiceDetails, _, _) = prepared.paymentMethod else { throw ChannelFreeRailSendRefused(reason: "Unexpected Spark payment method: \(prepared.paymentMethod)") } - let response = try await sdk.sendPayment(request: SendPaymentRequest( - prepareResponse: prepared, - options: Self.bolt11SendOptions - )) + let response: SendPaymentResponse + do { + response = try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: Self.bolt11SendOptions + )) + } catch { + // The SDK checks the expiry again before sending, so an invoice that expired on the confirm screen left nothing. + throw Self.expiredInvoiceRefusal(error) ?? error + } Logger.info("Sent Spark payment '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") return invoiceDetails.paymentHash } + /// The SDK refuses an expired Lightning invoice before anything leaves the wallet, with the scanner's expiry copy. + nonisolated static func expiredInvoiceRefusal(_ error: Error) -> ChannelFreeRailSendRefused? { + guard case let SdkError.InvalidInput(message) = error, message.localizedCaseInsensitiveContains("invoice has expired") else { return nil } + return ChannelFreeRailSendRefused(reason: t("other__scan__error__expired")) + } + nonisolated func lightningPayments() async -> [ChannelFreeRailPayment] { guard let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments else { return [] } return payments.compactMap { payment in @@ -288,7 +301,7 @@ final class SparkManager: ChannelFreeLightningRail { options: .bitcoinAddress(confirmationSpeed: .medium) )) preparedWithdrawal = nil - if case let .withdraw(txId) = response.payment.details { + if case let .withdraw(txId, _, _) = response.payment.details { sentWithdrawalTxIds.insert(txId) } try await refresh() @@ -437,6 +450,9 @@ final class SparkManager: ChannelFreeLightningRail { private func refresh() async throws { let sdk = try await requireSdk() let info = try await sdk.getInfo(request: GetInfoRequest(ensureSynced: false)) + if let syncError = info.lastSyncError { + Logger.warn("Spark's last sync failed: \(syncError)", context: "SparkManager") + } let unclaimed = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()).deposits let payments = try await sdk.listPayments(request: ListPaymentsRequest()).payments let claimed = payments.filter { $0.method == .deposit && $0.status == .completed } @@ -465,7 +481,7 @@ final class SparkManager: ChannelFreeLightningRail { nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { payments.compactMap { payment in - guard payment.status != .failed, case let .withdraw(txId) = payment.details else { return nil } + guard payment.status != .failed, case let .withdraw(txId, _, _) = payment.details else { return nil } return SparkWithdrawal(txid: txId, amountSats: sats(payment.amount), feeSats: sats(payment.fees)) } } @@ -475,7 +491,7 @@ final class SparkManager: ChannelFreeLightningRail { unclaimed.map(\.txid) + payments.compactMap { payment in guard payment.status != .failed else { return nil } switch payment.details { - case let .deposit(txId, _), let .withdraw(txId): return txId + case let .deposit(txId, _), let .withdraw(txId, _, _): return txId default: return nil } } diff --git a/BitkitTests/SparkLightningSendTests.swift b/BitkitTests/SparkLightningSendTests.swift index b7fcb4811..fa9ade9e6 100644 --- a/BitkitTests/SparkLightningSendTests.swift +++ b/BitkitTests/SparkLightningSendTests.swift @@ -27,6 +27,13 @@ final class SparkLightningSendTests: XCTestCase { XCTAssertNil(completionTimeoutSecs) } + func testExpiredInvoiceRefusalUsesTheScannerExpiryCopy() { + let refusal = SparkManager.expiredInvoiceRefusal(SdkError.InvalidInput("Invoice has expired")) + XCTAssertEqual(refusal?.errorDescription, t("other__scan__error__expired")) + XCTAssertNil(SparkManager.expiredInvoiceRefusal(SdkError.InvalidInput("Invalid amount"))) + XCTAssertNil(SparkManager.expiredInvoiceRefusal(SdkError.Generic("Invoice has expired"))) + } + func testLightningInvoiceWithoutChannelsOrSparkIsRejected() async throws { let app = makeApp(hasChannelFreeRail: false) { _ in false } diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index d37a1bcfb..1acae7bdc 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -10,8 +10,8 @@ final class SparkTransferActivityTests: XCTestCase { ) let payments = [ payment(id: "1", status: .completed, details: .deposit(txId: "claimed-deposit", vout: 0)), - payment(id: "2", status: .completed, details: .withdraw(txId: "withdrawal")), - payment(id: "3", status: .failed, details: .withdraw(txId: "failed-withdrawal")), + payment(id: "2", status: .completed, details: .withdraw(txId: "withdrawal", vout: 0, destinationAddress: "bcrt1qsavings")), + payment(id: "3", status: .failed, details: .withdraw(txId: "failed-withdrawal", vout: 0, destinationAddress: "bcrt1qsavings")), payment(id: "4", status: .completed, details: nil), ] @@ -22,8 +22,13 @@ final class SparkTransferActivityTests: XCTestCase { func testListsWithdrawalsWithWhatSparkKept() { let payments = [ - payment(id: "1", status: .completed, details: .withdraw(txId: "withdrawal"), fees: 1470), - payment(id: "2", status: .failed, details: .withdraw(txId: "failed-withdrawal"), fees: 1470), + payment(id: "1", status: .completed, details: .withdraw(txId: "withdrawal", vout: 0, destinationAddress: "bcrt1qsavings"), fees: 1470), + payment( + id: "2", + status: .failed, + details: .withdraw(txId: "failed-withdrawal", vout: 0, destinationAddress: "bcrt1qsavings"), + fees: 1470 + ), payment(id: "3", status: .completed, details: .deposit(txId: "deposit", vout: 0)), ] From 1a5d3dd8b28af23c64df4f4ceb10b0858123c1c2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 22:40:02 +0100 Subject: [PATCH 030/378] fix: spark exit waits for leaves the operators cannot locate yet --- Bitkit/Services/SparkExit/SparkExitDriver.swift | 4 ++-- BitkitTests/SparkExitTests.swift | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index b9bc05356..db60f0d3f 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -299,11 +299,11 @@ struct SparkExitDriver { } /// A skip of one of the exit's leaves that may still move: the chain could not be read for it, its funds were not - /// found yet, or the watchtower stopped it moments ago. + /// found yet, the operators could not be asked where they are, or the watchtower stopped it moments ago. private func isProvisional(_ skip: SkippedLeaf, prepared: PrepareRecoverFundsResponse, record: SparkExitRecord, tip: UInt32?) -> Bool { guard record.exitLeafIds.contains(skip.leafId) else { return false } switch skip.reason { - case .unverified, .fundsNotFound: return true + case .unverified, .fundsNotFound, .operatorsUnavailable: return true case .notRecoverable: return isStoppedRecently(skip.leafId, prepared: prepared, record: record, tip: tip) case .feeExceedsValue: return false } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 2c78d2872..af560788c 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -59,7 +59,7 @@ enum SparkExitFixtures { fanoutFeeSats: 500, refundFeeSats: 1000, sweepFeeSats: 500, - funding: funding.map { RecoveryFunding(singleUtxoSats: $0, perBranch: []) }, + funding: funding.map { RecoveryFunding(singleUtxoSats: $0, perBranch: [], strategy: .perBranch) }, feeRateSatPerVbyte: 2, destination: "bcrt1qdestination", exitChainState: ExitChainState( @@ -1548,6 +1548,18 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertNil(record.retrySince) } + /// R3 on SDK 0.28: a leaf the SDK could not ask the operators about waits like one whose funds were not found. + func testABuildWaitsForOperatorsUnavailable() async throws { + var record = rebuildingRecord() + sdk.preparedResponse = preparedSkipping(.operatorsUnavailable) + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.retrySince, 1000) + XCTAssertTrue(record.isWaitingForLeaves) + } + func testIsRetryDueDoublesUpToADay() { XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 105)) XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 106)) From f0bcc7e4ca72a5bdcb0420a60b4107cebc2a63ac Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 22:47:18 +0100 Subject: [PATCH 031/378] fix: spark route reads the withdrawal txid on sdk 0.28 --- Bitkit/Managers/SparkManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index f75eee31e..3312b73e0 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -370,7 +370,7 @@ final class SparkManager: ChannelFreeLightningRail { )) Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") try await refresh() - if case let .withdraw(txId) = response.payment.details { + if case let .withdraw(txId, _, _) = response.payment.details { return txId } return response.payment.id From 8eac1b98a22aacfce9320e614309fafdd8c68858 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 22:53:42 +0100 Subject: [PATCH 032/378] feat: spark exit funds each step separately and can pay a higher fee - Exits prepare with per-node CPFP funding on SDK 0.28 and resume with the strategy they started with; one payment of the quote's funding, which the SDK splits per step. Fan-out outputs no longer count as new funding. - Progress offers "Pay a higher fee" when the exit is underpriced or the network's next-block rate passes its own: each higher rate with its cost, paid from savings, then the unconfirmed steps are rebuilt and replaced. - Cooperative leaves the operators moved on-chain but could not co-sign are retried for about a week instead of ending the exit, and the result says plainly what is left and that it is not on Spark. - The pre-start partial-payment hint includes the next input's fee. --- Bitkit/MainNavView.swift | 1 + Bitkit/Managers/SparkExitManager.swift | 119 ++++++- Bitkit/Managers/SparkManager.swift | 6 + .../Localization/ar.lproj/Localizable.strings | 13 +- .../Localization/ca.lproj/Localizable.strings | 13 +- .../Localization/cs.lproj/Localizable.strings | 13 +- .../Localization/de.lproj/Localizable.strings | 13 +- .../Localization/el.lproj/Localizable.strings | 13 +- .../Localization/en.lproj/Localizable.strings | 13 +- .../es-419.lproj/Localizable.strings | 13 +- .../Localization/es.lproj/Localizable.strings | 13 +- .../Localization/fr.lproj/Localizable.strings | 13 +- .../Localization/it.lproj/Localizable.strings | 13 +- .../Localization/nl.lproj/Localizable.strings | 13 +- .../Localization/pl.lproj/Localizable.strings | 13 +- .../pt-BR.lproj/Localizable.strings | 13 +- .../Localization/pt.lproj/Localizable.strings | 13 +- .../Localization/ru.lproj/Localizable.strings | 13 +- .../SparkExit/SparkExitBroadcaster.swift | 17 + .../Services/SparkExit/SparkExitDriver.swift | 116 ++++++- .../Services/SparkExit/SparkExitFunding.swift | 74 ++++- .../Services/SparkExit/SparkExitRecord.swift | 55 +++- Bitkit/ViewModels/NavigationViewModel.swift | 1 + .../Settings/Advanced/SparkExitViews.swift | 210 +++++++++++- BitkitTests/SparkExitFeeBumpTests.swift | 306 ++++++++++++++++++ 25 files changed, 1034 insertions(+), 66 deletions(-) create mode 100644 BitkitTests/SparkExitFeeBumpTests.swift diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 415848e00..3f5ba1efb 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -710,6 +710,7 @@ struct MainNavView: View { case .sparkExitFunding: SparkExitFundingView() case .sparkExitConfirm: SparkExitConfirmView() case .sparkExitProgress: SparkExitProgressView() + case .sparkExitFeeBump: SparkExitFeeBumpView() case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 363d59332..cfc54dde0 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -29,6 +29,14 @@ final class SparkExitManager { private(set) var tipHeight: UInt32? /// Confirmed or not, what reached the funding address and is not a funding input yet. private(set) var fundingSeenSats: UInt64 = 0 + /// How many payments make up `fundingSeenSats` on the pre-start funding screen. + private(set) var fundingSeenCount = 0 + /// The network's rates now, read on every tick: the SDK's recommendation, or the chain host's without the SDK. + private(set) var networkFees: SparkExitNetworkFees? + /// The higher rates a fee bump offers, priced against the running exit. + private(set) var feeBumpOptions: [SparkExitFeeBumpOption] = [] + private(set) var isPricingFeeBump = false + private(set) var feeBumpError: String? /// What the running exit shows about its fee funding while it lacks some. private(set) var fundingState: SparkExitFundingState? private(set) var fundingAddress: String? @@ -48,6 +56,11 @@ final class SparkExitManager { private init() {} + /// The fee rate of the quote on screen. + var quoteFeeRate: UInt64? { + pendingFeeRate + } + var isExiting: Bool { record?.isActive == true } @@ -206,6 +219,7 @@ final class SparkExitManager { ) do { tipHeight = try? await chain.tipHeight() + await refreshNetworkFees(chain: chain) await refreshFundingSeen(chain: chain, record: current) var updated = try await driver.tick(current) // A tick that adopted the funding and came up short would otherwise keep showing that payment as seen. @@ -238,18 +252,20 @@ final class SparkExitManager { } } - /// Ends the exit's transfer once the sweep reaches savings, or once the exit ended, so its amount is never - /// counted both as in transfer and in the on-chain balance. Runs on every tick and launch until it holds. + /// Ends the exit's transfer once the sweep reaches savings, once only leaves the operators moved on-chain are left, + /// or once the exit ended, so its amount is never counted both as in transfer and in the on-chain balance. Runs on + /// every tick and launch until it holds. A sweep of a generation the failed-leaf retry replaced is looked for too. private func settleIfFinished(_ record: SparkExitRecord) async { guard let transferId = record.transferId, let transfer = try? TransferStorage.shared.getById(transferId), !transfer.isSettled else { return } let activity = CoreService.shared.activity - var isFinished = !record.isActive - if let sweep = record.recovery?.response.transactions.first(where: { $0.kind == .sweep }), - await activity.hasOnchainActivityForTxid(txid: sweep.txid) - { - await activity.markOnchainActivityAsTransfer(txId: sweep.txid) + var isFinished = !record.isActive || record.isRetryingFailedLeaves + let sweeps = (record.recovery?.response.transactions ?? []).filter { $0.kind == .sweep }.map(\.txid) + + (record.earlierReceivedSats ?? [:]).keys.sorted() + for txid in sweeps { + guard await activity.hasOnchainActivityForTxid(txid: txid) else { continue } + await activity.markOnchainActivityAsTransfer(txId: txid) isFinished = true } guard isFinished else { return } @@ -289,6 +305,93 @@ final class SparkExitManager { fundingState = SparkExitMath.fundingState(record, utxos: utxos) } + private func refreshNetworkFees(chain: SparkExitChain) async { + if SparkManager.shared.exitRecovering != nil, let fees = try? await SparkManager.shared.exitNetworkFees() { + networkFees = fees + } else if let fees = try? await chain.networkFees() { + networkFees = fees + } + } + + // MARK: Fee bump + + /// Whether the running exit can move to a higher fee: it is underpriced (refused under the mempool's minimum, or + /// accepted and unconfirmed for more than `unconfirmedBlocksToSurface` blocks), or the network's next-block rate + /// is above its own. Not while it rebuilds or waits for funding, and not once only leaves the operators hold are left. + var canPayHigherFee: Bool { + guard let record, record.phase == .running, !record.isRebuilding, record.recovery != nil, !record.isRetryingFailedLeaves else { + return false + } + return record.underpricedStep(tip: tipHeight) != nil || isNetworkAboveExitRate + } + + /// The network's next-block rate is above the exit's. + var isNetworkAboveExitRate: Bool { + guard let record, let networkFees else { return false } + return networkFees.fastest > record.feeRateSatPerVbyte + } + + /// Prices each higher rate against what the exit's funding holds now. + func loadFeeBumpOptions() async { + guard let record, let chain else { return } + isPricingFeeBump = true + feeBumpError = nil + defer { isPricingFeeBump = false } + await refreshNetworkFees(chain: chain) + guard let networkFees else { + feeBumpError = t("lightning__spark_exit__fee_bump_error") + return + } + let walletIndex = walletIndex + let driver = SparkExitDriver( + chain: chain, + recovering: SparkManager.shared.exitRecovering, + makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, + save: { _ in } + ) + var options = SparkExitFeeBumpOption.options(exitRate: record.feeRateSatPerVbyte, fees: networkFees) + for i in options.indices { + do { + options[i].extraSats = try await driver.feeBumpShortfall(record, rate: options[i].rate) + } catch { + Logger.warn("Failed to price a Spark exit fee bump to \(options[i].rate) sat/vB: \(error)", context: "SparkExitManager") + } + } + feeBumpOptions = options.filter { $0.extraSats != nil } + if feeBumpOptions.isEmpty { + feeBumpError = t("lightning__spark_exit__fee_bump_error") + } + } + + /// Notes what savings pays in fees to send the extra funding of `rate`. + func setFeeBumpPaymentFee(rate: UInt64, sats: UInt64) { + guard let i = feeBumpOptions.firstIndex(where: { $0.rate == rate }) else { return } + feeBumpOptions[i].paymentFeeSats = sats + } + + /// Moves the exit to `option`'s rate: pays its extra funding from savings at `savingsFeeRate` first, then rebuilds + /// what has not confirmed at the new rate, which replaces it on the network. + func payHigherFee(_ option: SparkExitFeeBumpOption, savingsFeeRate: UInt32) async throws { + guard let extraSats = option.extraSats, let address = record?.funding.first?.address, canPayHigherFee else { + throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "No fee bump to pay") + } + var txid: String? + if extraSats > 0 { + txid = try await payFunding(address: address, sats: extraSats, satsPerVbyte: savingsFeeRate, feeSats: option.paymentFeeSats ?? 0) + } + // Written between ticks, so a tick in flight never saves over it. + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + guard var record else { return } + record.applyFeeBump(rate: option.rate, extraSats: extraSats, paymentTxid: txid) + try saveRecord(record) + self.record = record + fundingState = SparkExitMath.fundingState(record, utxos: nil) + Logger.info("Spark exit moves to \(option.rate) sat/vB with \(extraSats) sats more funding", context: "SparkExitManager") + Task { await tick() } + } + // MARK: Quote func loadQuote() async { @@ -332,6 +435,7 @@ final class SparkExitManager { while !Task.isCancelled { if let utxos = try? await chain.utxos(address: fundingAddress) { fundingSeenSats = utxos.reduce(0) { $0 + $1.value } + fundingSeenCount = utxos.count } try? await Task.sleep(nanoseconds: 15 * 1_000_000_000) } @@ -360,6 +464,7 @@ final class SparkExitManager { expectedSats: quote.incomingSats, skippedLeafCount: quote.prepared.skipped.count ) + record.fundingStrategy = SparkExitRecord.perNodeStrategy // Saved before the transfer exists, so a failed save never leaves a transfer counted with no exit behind it. try store.save(record) record.transferId = try await TransferService(lightningService: .shared).createTransfer(type: .sparkExit, amountSats: quote.incomingSats) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index c0ee5cb7b..14fe2d66a 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -204,6 +204,12 @@ final class SparkManager: ChannelFreeLightningRail { try await max(1, requireSdk().recommendedFees().fastestFee) } + /// The SDK's recommended rates for an exit's fee bump. + func exitNetworkFees() async throws -> SparkExitNetworkFees { + let fees = try await requireSdk().recommendedFees() + return SparkExitNetworkFees(fastest: max(1, fees.fastestFee), halfHour: max(1, fees.halfHourFee)) + } + nonisolated static var exitNetwork: BreezSdkSpark.Network? { switch Env.network { case .bitcoin: .mainnet diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 3357291f7..140ee3a56 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -356,7 +356,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -383,13 +383,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 9d269c416..b7cccd9e0 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -393,7 +393,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -420,13 +420,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index a3ed507f0..781f79746 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -409,7 +409,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -436,13 +436,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 41db1f0a2..25a1f8599 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -407,7 +407,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -434,13 +434,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 687c02419..7ae18359f 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -380,7 +380,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -407,13 +407,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c98185646..df83d3652 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -402,7 +402,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -429,13 +429,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 4a43cde82..1bc9891aa 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -410,7 +410,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -437,13 +437,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 15f657697..55c9d7279 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -394,7 +394,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -421,13 +421,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 4980c1a03..d9ce5be67 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -410,7 +410,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -437,13 +437,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 4afe1dd31..886ae4ad7 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -403,7 +403,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -430,13 +430,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 3f188c22b..0241ef69e 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -407,7 +407,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -434,13 +434,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 4075c131c..9a97ae8ab 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -409,7 +409,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -436,13 +436,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 235b6546d..0c1aa9776 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -409,7 +409,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -436,13 +436,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index f7c7d8a6b..80bbdc36a 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -407,7 +407,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -434,13 +434,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index f22be78fb..c0aea4b6b 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -404,7 +404,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{count} amounts the Spark operators could not sign for stayed on Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -431,13 +431,22 @@ "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; "lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. It confirms once fees drop."; +"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; "lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; +"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; +"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; +"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; +"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; +"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; +"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; +"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index a79400dde..3fb052cf6 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -52,6 +52,15 @@ protocol SparkExitChain: Sendable { func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome /// The rate for the next block, rounded up, at least 1 sat/vB. func fastestFeeRate() async throws -> UInt64 + /// The rates for the next block and for about half an hour, for a fee bump while the SDK is not connected. + func networkFees() async throws -> SparkExitNetworkFees +} + +extension SparkExitChain { + func networkFees() async throws -> SparkExitNetworkFees { + let fastest = try await fastestFeeRate() + return SparkExitNetworkFees(fastest: fastest, halfHour: fastest) + } } protocol SparkExitHTTP: Sendable { @@ -136,6 +145,14 @@ struct EsploraSparkExitChain: SparkExitChain { return max(1, UInt64(rate.rounded(.up))) } + /// esplora's targets of one and three blocks, mempool's `fastestFee` and `halfHourFee`. + func networkFees() async throws -> SparkExitNetworkFees { + let estimates = try await JSONDecoder().decode([String: Double].self, from: get("/fee-estimates")) + guard let fastest = estimates["1"] else { throw URLError(.cannotParseResponse) } + let rate = { (value: Double) in max(1, UInt64(value.rounded(.up))) } + return SparkExitNetworkFees(fastest: rate(fastest), halfHour: rate(estimates["3"] ?? fastest)) + } + /// `/tx/{txid}/hex` looks in the mempool and the chain alike; `/status` of an unknown transaction may answer /// unconfirmed instead of not found. func isKnown(txid: String) async throws -> Bool { diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index db60f0d3f..ff5415418 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -11,6 +11,10 @@ protocol SparkExitRecovering: Sendable { func importExitState(_ exitState: String) async throws } +enum SparkExitFeeBumpError: Error { + case notConnected +} + /// Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready, /// rebuilds when the chain diverged, and finishes when everything confirmed. Without the SDK (a background launch, /// or operators down at connect) it still broadcasts the stored, already signed transactions whose parents @@ -219,11 +223,8 @@ struct SparkExitDriver { var isRecoverableOnly = false while !record.exitLeafIds.isEmpty { do { - let prepared = try await recovering.prepare(PrepareRecoverFundsRequest( - feeRateSatPerVbyte: record.feeRateSatPerVbyte, - fundingKind: .p2wpkh, - destination: record.destination, - selection: isRecoverableOnly ? .recoverableOnly : .specific(leafIds: record.exitLeafIds) + let prepared = try await recovering.prepare(record.prepareRequest( + isRecoverableOnly ? .recoverableOnly : .specific(leafIds: record.exitLeafIds) )) guard isRecoverableOnly else { return prepared } let kept = Set(prepared.leaves.map(\.leafId)) @@ -232,12 +233,7 @@ struct SparkExitDriver { // earlier exit: the exit's own are prepared again on their own. guard prepared.leaves.contains(where: { !record.exitLeafIds.contains($0.leafId) }) else { return prepared } guard !record.exitLeafIds.isEmpty else { return nil } - return try await recovering.prepare(PrepareRecoverFundsRequest( - feeRateSatPerVbyte: record.feeRateSatPerVbyte, - fundingKind: .p2wpkh, - destination: record.destination, - selection: .specific(leafIds: record.exitLeafIds) - )) + return try await recovering.prepare(record.prepareRequest(.specific(leafIds: record.exitLeafIds))) } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { if let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) { leaveBehind([leafId], reason: message, in: &record) @@ -370,6 +366,9 @@ struct SparkExitDriver { try await rebuildStalled(&record, recovering: recovering) } case .done: + if try await retryFailedLeaves(&record, failed: checked.recovery.failed, recovering: recovering, tip: tip) { + return + } await finish(&record, phase: .done) case .redo: Logger.warn("Spark exit diverged on-chain, rebuilding generation \(record.generation + 1)", context: "SparkExitDriver") @@ -378,6 +377,85 @@ struct SparkExitDriver { } } + /// Cooperative leaves a finished recovery lists as failed: the operators' watchtower moved them on-chain and the + /// operators could not co-sign their recovery. They stay part of the exit and are prepared again as a recovery of + /// their own, at once and then at Glow's doubling gaps (`isRetryDue`), until they land or about a week passed + /// (`releaseBlocks`). True while the exit keeps waiting for them, false once it can end. + private func retryFailedLeaves( + _ record: inout SparkExitRecord, + failed: [CooperativeRecoveryFailure], + recovering: SparkExitRecovering, + tip: UInt32? + ) async throws -> Bool { + let failedIds = failed.map(\.leafId) + guard !failedIds.isEmpty, record.isFailedLeavesReleased != true else { return false } + guard let tip else { return true } + let since = record.failedLeafRetrySince ?? tip + record.failedLeafRetrySince = since + if tip.saturatingSub(since) >= Self.releaseBlocks { + Logger.warn("Spark exit lets go of leaves the operators could not co-sign: \(failedIds)", context: "SparkExitDriver") + record.isFailedLeavesReleased = true + return false + } + if let retriedAt = record.failedLeafRetriedAt, !Self.isRetryDue(since: since, retriedAt: retriedAt, tip: tip) { + return true + } + record.failedLeafRetriedAt = tip + try save(record) + guard !Task.isCancelled else { return true } + do { + let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: failedIds))) + noteFailedLeafValues(prepared, failedIds: failedIds, in: &record) + guard !prepared.leaves.isEmpty else { return true } + let built = try await recovering.recover( + RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs), + signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex) + ) + guard !built.transactions.isEmpty else { return true } + Logger.info("Spark exit recovers \(built.leaves.count) leaves the operators moved on-chain", context: "SparkExitDriver") + record.adopt(built, at: now()) + try save(record) + try await broadcastReady(&record, tip: tip) + } catch { + Logger.warn("Spark exit could not recover the leaves the operators moved on-chain yet: \(error)", context: "SparkExitDriver") + } + return true + } + + /// Keeps the value of each failed leaf from what the SDK reports for it, listed or skipped. + private func noteFailedLeafValues(_ prepared: PrepareRecoverFundsResponse, failedIds: [String], in record: inout SparkExitRecord) { + let reported = Dictionary( + prepared.leaves.map { ($0.leafId, $0.valueSats) } + prepared.skipped.map { ($0.leafId, $0.valueSats) }, + uniquingKeysWith: { first, _ in first } + ) + let known = Dictionary((record.failedLeaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first }) + record.failedLeaves = failedIds.map { SparkExitRecord.FailedLeaf(leafId: $0, valueSats: reported[$0] ?? known[$0] ?? 0) } + } + + // MARK: Fee bump + + /// What the funding address still has to receive for the exit to rebuild at `rate`, 0 when what it holds covers + /// it. Builds the exit's leaves at that rate with the funding it has, the way a rebuild would, and broadcasts + /// nothing; a shortfall is sized like a rebuild's (`neededFunding`), the input fee of the payment to come included. + func feeBumpShortfall(_ record: SparkExitRecord, rate: UInt64) async throws -> UInt64 { + guard let recovering else { throw SparkExitFeeBumpError.notConnected } + let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: record.exitLeafIds), feeRate: rate)) + guard prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 } + do { + _ = try await recovering.recover( + RecoverFundsRequest(prepared: prepared, fundingInputs: record.fundingInputs), + signer: makeSigner(record.fundingIndex) + ) + return 0 + } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { + var atRate = record + atRate.feeRateSatPerVbyte = rate + let utxos = try? await chain.utxos(address: record.funding.first?.address ?? "") + let needed = SparkExitMath.neededFunding(requiredSats: requiredSats, record: atRate, utxos: utxos) + return max(needed.saturatingSub(record.fundedSats), SparkExitMath.fundingDustSats) + } + } + /// A rebuild over a chain that stopped moving: refused steps, missing inputs, unpaid children or a dead CPFP child. /// Only `stallRebuildsToSurface` of them run while no step confirms, so a stale chain index or a step no build can /// fix does not rebuild forever; the exit then says it is not moving. @@ -747,6 +825,17 @@ extension SparkExitRecord { var surfacedRejection: String? { isStalled ? stuckStep?.lastOutcome : nil } + + /// A prepare of `selection` at the exit's fee rate, or at `feeRate`, funded the way the exit was started. + func prepareRequest(_ selection: ExitLeafSelection, feeRate: UInt64? = nil) -> PrepareRecoverFundsRequest { + PrepareRecoverFundsRequest( + feeRateSatPerVbyte: feeRate ?? feeRateSatPerVbyte, + fundingKind: .p2wpkh, + fundingStrategy: cpfpFundingStrategy, + destination: destination, + selection: selection + ) + } } /// When a step's relative timelocks mature, as the SDK's `spendable_at_height` computes it: each input's BIP68 block @@ -797,6 +886,11 @@ enum SparkExitTxid { recovery.transactions.compactMap { $0.cpfpTxHex.flatMap(txid(hex:)) } } + /// The exit's own transactions that pay the funding script: the CPFP children's change and the fan-out's split. + static func chainedTxids(_ recovery: RecoverFundsResponse) -> [String] { + cpfpChildTxids(recovery) + recovery.transactions.filter { $0.kind == .fanOut }.map(\.txid) + } + /// The txids a serialized transaction spends; nil when the hex does not decode. static func inputTxids(hex: String) -> [String]? { let bytes = hex.hexaBytes diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 707efe40b..e86e4a678 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -78,10 +78,17 @@ struct SparkExitQuote: Equatable { _ recovering: SparkExitRecovering, feeRateSatPerVbyte: UInt64, destination: String, - lostLeafIds: Set + lostLeafIds: Set, + fundingStrategy: CpfpFundingStrategy = .perNode ) async throws -> SparkExitQuote { func request(_ selection: ExitLeafSelection) -> PrepareRecoverFundsRequest { - PrepareRecoverFundsRequest(feeRateSatPerVbyte: feeRateSatPerVbyte, fundingKind: .p2wpkh, destination: destination, selection: selection) + PrepareRecoverFundsRequest( + feeRateSatPerVbyte: feeRateSatPerVbyte, + fundingKind: .p2wpkh, + fundingStrategy: fundingStrategy, + destination: destination, + selection: selection + ) } let all = try await recovering.prepare(request(.all)) let lostLeafSats = all.leaves.filter { lostLeafIds.contains($0.leafId) }.map(\.valueSats) @@ -124,11 +131,17 @@ struct SparkExitQuote: Equatable { exitSats.saturatingSub(networkFeeSats) } - /// The on-chain payment that funds the CPFP fees, or 0 when every leaf is recovered cooperatively. + /// The on-chain payment that funds the CPFP fees, or 0 when every leaf is recovered cooperatively. One payment, + /// from savings or another wallet: the SDK splits it into one output per fee-bumped transaction (`fundedSteps`). var fundingSats: UInt64 { prepared.funding?.singleUtxoSats ?? 0 } + /// The transactions the funding pays a fee for, each from its own share. + var fundedSteps: Int { + prepared.funding?.perBranch.count ?? 0 + } + /// What the sweep and the cooperative recoveries pay to savings: `receiveSats` plus the fee funding the exit /// did not spend, which the sweep returns. var incomingSats: UInt64 { @@ -153,6 +166,44 @@ struct SparkExitQuote: Equatable { } } +/// The network's fee rates now, in sat/vB: for the next block, and for about half an hour. +struct SparkExitNetworkFees: Equatable { + let fastest: UInt64 + let halfHour: UInt64 +} + +/// A higher fee rate an exit can move to, and what moving costs: the funding the exit lacks at that rate (`extraSats`, +/// nil while it is priced or when it could not be) and, when savings pays it, that payment's own fee. +struct SparkExitFeeBumpOption: Equatable, Identifiable { + let speed: TransactionSpeed + let rate: UInt64 + var extraSats: UInt64? + var paymentFeeSats: UInt64? + + var id: UInt64 { + rate + } + + /// What leaves savings: the extra funding and its payment's fee. Nothing when the funding already covers the rate. + var totalSats: UInt64? { + guard let extraSats else { return nil } + guard extraSats > 0 else { return 0 } + return paymentFeeSats.map { extraSats + $0 } + } + + /// The rates a fee bump offers, each strictly above the exit's: replacements must pay a higher rate. Fast is the + /// next-block rate, or one above the exit's when the network's estimate is no higher; Normal the half-hour rate, + /// when it lies between the two. + static func options(exitRate: UInt64, fees: SparkExitNetworkFees) -> [SparkExitFeeBumpOption] { + let fast = max(fees.fastest, exitRate + 1) + var options = [SparkExitFeeBumpOption(speed: .fast, rate: fast)] + if fees.halfHour > exitRate, fees.halfHour < fast { + options.append(SparkExitFeeBumpOption(speed: .normal, rate: fees.halfHour)) + } + return options + } +} + /// A local notification that brings the user back to an exit; `id` is stable across runs for the same step. struct SparkExitReminder: Equatable { let id: String @@ -244,7 +295,7 @@ enum SparkExitMath { guard case .running = record.phase, let recovery = record.recovery?.response else { return lostReminder } var reminders = lostReminder // Waiting for a leaf that may still move is the background runs' job, for up to a week. - if record.isWaitingForLeaves { + if record.isWaitingForLeaves || record.isWaitingForOperators { return reminders } let generation = record.generation @@ -346,11 +397,11 @@ enum SparkExitMath { }.count } - /// UTXOs at the funding address that are new fee funding: neither a funding input yet nor CPFP change any - /// generation of the exit chained there. + /// UTXOs at the funding address that are new fee funding: neither a funding input yet nor an output any + /// generation of the exit made there, a CPFP child's change or a fan-out's split of the funding. static func newFunding(_ utxos: [EsploraUtxo], record: SparkExitRecord) -> [EsploraUtxo] { let known = Set(record.funding.compactMap { funding in funding.txid.map { "\($0):\(funding.vout ?? 0)" } }) - let chained = Set((record.recovery.map { SparkExitTxid.cpfpChildTxids($0.response) } ?? []) + (record.earlierCpfpTxids ?? [])) + let chained = Set((record.recovery.map { SparkExitTxid.chainedTxids($0.response) } ?? []) + (record.earlierCpfpTxids ?? [])) return utxos.filter { !known.contains("\($0.txid):\($0.vout)") && !chained.contains($0.txid) } } @@ -419,6 +470,15 @@ enum SparkExitMath { return record.fundingShortfallSats.map { max($0, fundingDustSats) } } + /// The fee funding the pre-start screen counts towards once `payments` reached the funding address: the quote's + /// funding, priced for one payment, plus the input fee of each further one. `toSend` adds the input of the + /// payment still to come while what arrived falls short, as the Progress top-up does. + static func prestartFunding(fundingSats: UInt64, payments: Int, receivedSats: UInt64, feeRate: UInt64) -> (covered: UInt64, toSend: UInt64) { + let inputFee = inputVbytes * feeRate + let covered = fundingSats + inputFee * UInt64(max(0, payments - 1)) + return (covered, receivedSats >= covered || payments == 0 ? covered : covered + inputFee) + } + /// What a partial payment leaves to send, at least the dust limit: nil before any new funding arrives and once /// it covers `requiredSats`. static func partialFundingSats(receivedSats: UInt64, requiredSats: UInt64) -> UInt64? { diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index a03c7c3fb..f1f241d8d 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -73,6 +73,12 @@ struct SparkExitRecord: Codable, Equatable { let reason: String } + /// A cooperative leaf the operators' watchtower moved on-chain that they could not co-sign a recovery for. + struct FailedLeaf: Codable, Equatable { + let leafId: String + var valueSats: UInt64 + } + struct Leftover: Codable, Equatable { let txid: String let vout: UInt32 @@ -128,8 +134,18 @@ struct SparkExitRecord: Codable, Equatable { var claimableAtHeight: UInt32? var leftover: [Leftover] = [] var leftoverSweep: LeftoverSweep? - /// The CPFP children of earlier generations, whose change at the funding address is not new funding. + /// The CPFP children and fan-outs of earlier generations, whose outputs at the funding address are not new funding. var earlierCpfpTxids: [String]? + /// How the funding pays the CPFP children, the one every prepare and rebuild of the exit uses: `perNode` for exits + /// started on SDK 0.28, absent for those started earlier, which keep resuming per branch. + var fundingStrategy: String? + /// Cooperative leaves a finished recovery listed as failed, tried again as their own recovery for about a week. + var failedLeaves: [FailedLeaf]? + /// The tip when the exit first finished with failed leaves, and of their last try. + var failedLeafRetrySince: UInt32? + var failedLeafRetriedAt: UInt32? + /// The failed leaves were let go after about a week: the exit ended without them. + var isFailedLeavesReleased: Bool? var skippedLeafCount: Int = 0 /// Cooperative leaves the last build could not recover. var failedLeafCount: Int = 0 @@ -258,7 +274,7 @@ struct SparkExitRecord: Codable, Equatable { /// Starts a new generation from a fresh build, forgetting what was broadcast for the previous one. mutating func adopt(_ built: RecoverFundsResponse, at date: Date = Date()) { if let recovery { - let txids = SparkExitTxid.cpfpChildTxids(recovery.response) + let txids = SparkExitTxid.chainedTxids(recovery.response) if !txids.isEmpty { earlierCpfpTxids = (earlierCpfpTxids ?? []) + txids } @@ -288,6 +304,41 @@ struct SparkExitRecord: Codable, Equatable { retrySince != nil } + /// The SDK strategy for `fundingStrategy`. + var cpfpFundingStrategy: CpfpFundingStrategy { + fundingStrategy == Self.perNodeStrategy ? .perNode : .perBranch + } + + static let perNodeStrategy = "perNode" + + /// The exit's own steps all landed and it waits for the operators to co-sign leaves they moved on-chain. + var isRetryingFailedLeaves: Bool { + isActive && failedLeafRetrySince != nil + } + + /// Waiting for the operators to co-sign leaves they moved on-chain: nothing of the exit's own is left to send. + var isWaitingForOperators: Bool { + isRetryingFailedLeaves && !(recovery?.response.failed.isEmpty ?? true) + } + + var failedLeavesSats: UInt64 { + (failedLeaves ?? []).reduce(0) { $0 + $1.valueSats } + } + + /// Takes a higher fee rate for the steps still to confirm. With `extraSats` to come at the funding address the + /// exit waits for it as for a top-up; with none it rebuilds at once at the new rate, replacing what is unconfirmed. + mutating func applyFeeBump(rate: UInt64, extraSats: UInt64, paymentTxid: String?) { + feeRateSatPerVbyte = rate + isRedoRebuild = nil + if extraSats > 0 { + phase = .needsFunding(requiredSats: fundedSats + extraSats) + fundingPaymentTxid = paymentTxid + } else { + isRebuilding = true + lastBuildAt = nil + } + } + /// A step of this generation that keeps being refused, or whose fee chain keeps failing. One the chain shows /// confirmed since, such as by the operators' copy of it, is not stuck. var stuckStep: BroadcastAttempt? { diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 21093e4ff..98f48bdbd 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -109,6 +109,7 @@ enum Route: Hashable { case sparkExitFunding case sparkExitConfirm case sparkExitProgress + case sparkExitFeeBump case closeConnection(channel: ChannelDetails) case node case electrumSettings diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 64efe39b5..21f347a4e 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -169,6 +169,16 @@ struct SparkExitFundingView: View { exit.quote?.fundingSats ?? 0 } + /// What the funding address has to hold for the payments seen so far, and what the next one should bring it to. + private var prestartFunding: (covered: UInt64, toSend: UInt64) { + SparkExitMath.prestartFunding( + fundingSats: fundingSats, + payments: exit.fundingSeenCount, + receivedSats: exit.fundingSeenSats, + feeRate: exit.quoteFeeRate ?? 0 + ) + } + private var canFundFromSavings: Bool { guard let savingsFeeSats else { return false } return SparkExitMath.canFundFromSavings( @@ -194,12 +204,19 @@ struct SparkExitFundingView: View { .fixedSize(horizontal: false, vertical: true) .padding(.top, 16) + if let steps = exit.quote?.fundedSteps, steps > 1 { + BodySText(t("lightning__spark_exit__funding_split", variables: ["count": "\(steps)"])) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFundingSplit") + } + if showsAddress, let address = exit.fundingAddress { SparkExitFundingAddress( address: address, requiredSats: fundingSats, receivedSats: exit.fundingSeenSats, - targetSats: fundingSats + targetSats: prestartFunding.toSend ) .padding(.top, 24) } @@ -212,7 +229,7 @@ struct SparkExitFundingView: View { exit.fundFromSavings = false navigation.navigate(.sparkExitConfirm) } - .disabled(exit.fundingSeenSats < fundingSats) + .disabled(exit.fundingSeenSats < prestartFunding.covered) .accessibilityIdentifier("SparkExitFundingContinue") } else { if hasSavingsFeeFailed || (savingsFeeSats != nil && !canFundFromSavings) { @@ -465,11 +482,19 @@ struct SparkExitProgressView: View { ].compactMap { $0 }.joined(separator: " ") } + /// A top-up already paid, such as a higher fee's from savings, or seen at the address: nothing to ask for. + private var isFundingOnItsWay: Bool { + switch fundingState { + case .paid, .seen: true + default: false + } + } + private var title: String { switch record?.phase { case .done: t("lightning__spark_exit__done_title") case .failed: t("lightning__spark_exit__failed_title") - case .needsFunding: t("lightning__spark_exit__needs_funding_title") + case .needsFunding where !isFundingOnItsWay: t("lightning__spark_exit__needs_funding_title") default: t("lightning__spark_exit__progress_title") } } @@ -484,11 +509,12 @@ struct SparkExitProgressView: View { }, (record?.skippedLeafCount ?? 0) > 0 ? t("lightning__spark_exit__partial_text", variables: ["count": "\(record?.skippedLeafCount ?? 0)"]) : nil, - (record?.failedLeafCount ?? 0) > 0 - ? t("lightning__spark_exit__failed_leaves", variables: ["count": "\(record?.failedLeafCount ?? 0)"]) : nil, + record.flatMap { $0.isFailedLeavesReleased == true && $0.failedLeavesSats > 0 ? $0.failedLeavesSats : nil }.map { + t("lightning__spark_exit__failed_leaves", variables: ["amount": currency.primaryAmountText(sats: $0)]) + }, ].compactMap { $0 }.joined(separator: " ") case .failed: failedText - case .needsFunding: + case .needsFunding where !isFundingOnItsWay: t( "lightning__spark_exit__needs_funding_text", variables: ["amount": currency.primaryAmountText(sats: record.flatMap { SparkExitMath.neededSats($0, state: fundingState) } ?? 0)] @@ -536,6 +562,16 @@ struct SparkExitProgressView: View { .accessibilityIdentifier("SparkExitWaitingLeaves") } + if record.isWaitingForOperators { + BodySText(t( + "lightning__spark_exit__waiting_operators", + variables: ["amount": currency.primaryAmountText(sats: record.failedLeavesSats)] + )) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingOperators") + } + if let address = record.funding.first?.address { switch fundingState { case let .ask(sats, receivedSats): @@ -587,6 +623,17 @@ struct SparkExitProgressView: View { .fixedSize(horizontal: false, vertical: true) .padding(.top, 16) .accessibilityIdentifier("SparkExitFeeLow") + } else if exit.canPayHigherFee { + Button { + navigation.navigate(.sparkExitFeeBump) + } label: { + BodySText(t("lightning__spark_exit__fee_rose"), textColor: .yellowAccent) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + .buttonStyle(PlainButtonStyle()) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFeeRose") } if let outsideSats = SparkExitMath.outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) { @@ -611,6 +658,14 @@ struct SparkExitProgressView: View { } } + if exit.canPayHigherFee { + CustomButton(title: t("lightning__spark_exit__fee_bump_button"), variant: .secondary) { + navigation.navigate(.sparkExitFeeBump) + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitFeeBump") + } + if exit.canCancel { CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { Task { @@ -644,6 +699,138 @@ struct SparkExitProgressView: View { } } +/// Moves a running exit to a higher fee rate: each rate with what it costs from savings, then pays the extra fee +/// funding and rebuilds what has not confirmed, which replaces it on the network. +struct SparkExitFeeBumpView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var selectedRate: UInt64? + @State private var savingsFeeRate: UInt32? + @State private var isPaying = false + + private let exit = SparkExitManager.shared + + private var selected: SparkExitFeeBumpOption? { + exit.feeBumpOptions.first { $0.rate == selectedRate } + } + + private func canPay(_ option: SparkExitFeeBumpOption) -> Bool { + guard let total = option.totalSats else { return false } + return total == 0 || UInt64(max(0, wallet.spendableOnchainBalanceSats)) >= total + } + + private var isInsufficient: Bool { + guard let selected, selected.totalSats != nil else { return false } + return !canPay(selected) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("lightning__spark_exit__nav_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__spark_exit__fee_bump_title"), accentColor: .yellowAccent) + + BodyMText( + t("lightning__spark_exit__fee_bump_text", variables: ["rate": "\(exit.record?.feeRateSatPerVbyte ?? 0)"]), + accentFont: Fonts.bold + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .padding(.bottom, 24) + + if exit.isPricingFeeBump { + BodyMText(t("lightning__spark_exit__fee_bump_loading")) + } else if let error = exit.feeBumpError { + BodyMText(error, textColor: .redAccent) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("SparkExitFeeBumpError") + } else { + VStack(spacing: 0) { + ForEach(exit.feeBumpOptions) { option in + FeeItem( + title: option.speed.title, + subtitle: "\(option.speed.range) · \(option.rate) sat/vB", + iconName: option.speed.iconName, + iconColor: option.speed.iconColor, + amount: option.totalSats ?? 0, + isSelected: option.rate == selectedRate, + isDisabled: !canPay(option) + ) { + selectedRate = option.rate + } + .accessibilityIdentifier("SparkExitFeeBumpRate-\(option.rate)") + } + } + .padding(.horizontal, -16) + } + } + .padding(.bottom, 16) + } + + if isInsufficient { + BodySText(t("lightning__spark_exit__fund_insufficient")) + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitFeeBumpInsufficient") + } + + CustomButton( + title: t(selected?.extraSats == 0 ? "lightning__spark_exit__fee_bump_confirm" : "lightning__spark_exit__fund_savings"), + isLoading: isPaying || exit.isPricingFeeBump + ) { + await pay() + } + .disabled(selected.map { !canPay($0) } ?? true) + .accessibilityIdentifier("SparkExitFeeBumpPay") + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await exit.loadFeeBumpOptions() + await priceSavingsPayments() + selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate + } + } + + /// What savings pays in fees to send each option's extra funding, at its fast rate. + private func priceSavingsPayments() async { + guard let address = exit.record?.funding.first?.address, + let feeRates = await feeEstimatesManager.getEstimates(refresh: true) + else { return } + let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) + savingsFeeRate = rate + for option in exit.feeBumpOptions { + guard let extraSats = option.extraSats, extraSats > 0 else { continue } + do { + let fee = try await wallet.calculateTotalFee(address: address, amountSats: extraSats, satsPerVByte: rate) + exit.setFeeBumpPaymentFee(rate: option.rate, sats: fee) + } catch { + // Savings can't send it, usually because it doesn't hold enough: the option stays unpriced and disabled. + Logger.warn("Failed to price a Spark exit fee bump payment: \(error)", context: "SparkExitFeeBumpView") + } + } + } + + private func pay() async { + guard let selected else { return } + isPaying = true + defer { isPaying = false } + do { + try await exit.payHigherFee(selected, savingsFeeRate: savingsFeeRate ?? 1) + navigation.navigateBack() + } catch { + app.toast(error) + } + } +} + /// What keeps the exit from moving or from reaching the user while Bitkit is closed: Background App Refresh off, /// Low Power Mode, or notifications denied. Each note shows only while its setting is in the way. struct SparkExitBackgroundNotes: View { @@ -704,20 +891,25 @@ struct SparkExitSteps: View { return BlockTimeHelpers.getDurationForBlocks(blocks) } + /// The exit's own steps all landed: done, or only leaves the operators moved on-chain are left. + private var isLanded: Bool { + record.phase == .done || record.isRetryingFailedLeaves + } + var body: some View { VStack(alignment: .leading, spacing: 0) { step(t("lightning__spark_exit__step_funding"), isDone: isFunded) step( t("lightning__spark_exit__step_publish"), detail: record.recovery.map { "\(SparkExitMath.confirmedCount($0.response))/\(transactions.count)" }, - isDone: record.phase == .done + isDone: isLanded ) step( t("lightning__spark_exit__step_timelock"), detail: remainingDuration.map { t("lightning__spark_exit__ready_in", variables: ["duration": $0]) }, - isDone: record.phase == .done || SparkExitMath.areTimelocksMatured(transactions) + isDone: isLanded || SparkExitMath.areTimelocksMatured(transactions) ) - step(t("lightning__spark_exit__step_sweep"), isDone: record.phase == .done) + step(t("lightning__spark_exit__step_sweep"), isDone: isLanded) } } diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift new file mode 100644 index 000000000..e97217885 --- /dev/null +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -0,0 +1,306 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +/// SDK 0.28: per-node funding, the fee bump and the retry of cooperative leaves the operators moved on-chain. +final class SparkExitFeeBumpTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + private var saved: [SparkExitRecord] = [] + private let clock = Date(timeIntervalSince1970: 1_800_000_000) + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + saved = [] + } + + private func driver() -> SparkExitDriver { + SparkExitDriver( + chain: chain, + recovering: sdk, + makeSigner: { _ in StubCpfpSigner() }, + save: { [unowned self] in saved.append($0) }, + now: { [unowned self] in clock } + ) + } + + /// A per-node exit, running on generation 1 at 2 sat/vB, funded by one 20,000 sat payment. + private func runningRecord(strategy: String? = SparkExitRecord.perNodeStrategy) -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.fundingStrategy = strategy + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", cpfp: "03c1")]), at: .distantPast) + return record + } + + // MARK: Per-node funding + + func testTheQuotePreparesPerNode() async throws { + _ = try await SparkExitQuote.prepare(sdk, feeRateSatPerVbyte: 2, destination: "bcrt1qdestination", lostLeafIds: []) + + XCTAssertEqual(sdk.prepareRequests.map(\.fundingStrategy), [.perNode]) + } + + /// The funding ask is one figure: one payment of `single_utxo_sats`, which the SDK splits per node. + func testTheQuoteAsksForOnePaymentAndCountsTheStepsItFunds() async throws { + var prepared = SparkExitFixtures.prepared(funding: 12000) + prepared.funding = RecoveryFunding( + singleUtxoSats: 12000, + perBranch: [ + PerBranchFunding(leafId: "leaf-1", fundingSats: 4000), + PerBranchFunding(leafId: "leaf-1", fundingSats: 3000), + PerBranchFunding(leafId: "leaf-1", fundingSats: 3000), + ], + strategy: .perNode + ) + sdk.preparedResponse = prepared + + let quote = try await SparkExitQuote.prepare(sdk, feeRateSatPerVbyte: 2, destination: "bcrt1qdestination", lostLeafIds: []) + + XCTAssertEqual(quote.fundingSats, 12000) + XCTAssertEqual(quote.fundedSteps, 3) + } + + /// Every rebuild resumes with the strategy the exit was prepared with; one started before 0.28 stays per branch. + func testRebuildsKeepTheRecordsStrategy() async throws { + for (strategy, expected) in [(SparkExitRecord.perNodeStrategy, CpfpFundingStrategy.perNode), (nil, .perBranch)] { + sdk = FakeSparkExitRecovering() + var record = runningRecord(strategy: strategy) + record.isRebuilding = true + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + + _ = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.map(\.fundingStrategy), [expected]) + } + } + + /// With a single payment the SDK's fan-out splits it at the funding address: those outputs are the exit's own. + func testFanOutOutputsAreNotNewFunding() { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("fan", kind: .fanOut), + SparkExitFixtures.tx("n1", cpfp: SparkExitFixtures.parentHex, dependsOn: ["fan"]), + ])) + let utxos = [ + SparkExitFixtures.utxo("fan", vout: 0, value: 5000), + SparkExitFixtures.utxo("fan", vout: 1, value: 6000), + SparkExitFixtures.utxo(SparkExitFixtures.parentTxid, value: 900), + SparkExitFixtures.utxo("topup", value: 3000), + ] + + XCTAssertEqual(SparkExitMath.newFunding(utxos, record: record).map(\.txid), ["topup"]) + + // A rebuild keeps the replaced fan-out among the exit's own transactions. + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])) + XCTAssertEqual(SparkExitMath.newFunding(utxos, record: record).map(\.txid), ["topup"]) + } + + /// Case 4: a first payment short of the quote's funding. The hint asks for the rest plus the input fee of the + /// payment still to send (68 vB at 2 sat/vB), as the Progress top-up does. + func testThePrestartHintIncludesTheNextInputsFee() { + let funding = SparkExitMath.prestartFunding(fundingSats: 10000, payments: 1, receivedSats: 6000, feeRate: 2) + + XCTAssertEqual(funding.covered, 10000) + XCTAssertEqual(funding.toSend, 10136) + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 6000, requiredSats: funding.toSend), 4136) + + // Two payments covering the quote are still short of the second input's fee. + let two = SparkExitMath.prestartFunding(fundingSats: 10000, payments: 2, receivedSats: 10000, feeRate: 2) + XCTAssertEqual(two.covered, 10136) + XCTAssertEqual(two.toSend, 10272) + + // One payment covering the quote is enough: nothing more to send. + let one = SparkExitMath.prestartFunding(fundingSats: 10000, payments: 1, receivedSats: 10000, feeRate: 2) + XCTAssertEqual(one.covered, 10000) + XCTAssertEqual(one.toSend, 10000) + XCTAssertNil(SparkExitMath.partialFundingSats(receivedSats: 10000, requiredSats: one.toSend)) + } + + // MARK: Fee bump + + func testFeeBumpOffersOnlyRatesAboveTheExits() { + let options = SparkExitFeeBumpOption.options(exitRate: 5, fees: SparkExitNetworkFees(fastest: 12, halfHour: 8)) + XCTAssertEqual(options.map(\.rate), [12, 8]) + XCTAssertEqual(options.map(\.speed), [.fast, .normal]) + + let calm = SparkExitFeeBumpOption.options(exitRate: 5, fees: SparkExitNetworkFees(fastest: 4, halfHour: 3)) + XCTAssertEqual(calm.map(\.rate), [6], "a step stuck while estimates look low still gets one higher rate") + } + + func testFeeBumpCostIsTheExtraFundingAndItsPayment() { + var option = SparkExitFeeBumpOption(speed: .fast, rate: 12, extraSats: 5000) + XCTAssertNil(option.totalSats, "not priced until the savings payment fee is known") + option.paymentFeeSats = 300 + XCTAssertEqual(option.totalSats, 5300) + XCTAssertEqual(SparkExitFeeBumpOption(speed: .fast, rate: 12, extraSats: 0).totalSats, 0) + } + + /// The funding the exit holds covers the higher rate: nothing more to pay. + func testFeeBumpShortfallIsZeroWhenTheFundingCoversTheRate() async throws { + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1b")])] + + let shortfall = try await driver().feeBumpShortfall(runningRecord(), rate: 10) + + XCTAssertEqual(shortfall, 0) + XCTAssertEqual(sdk.prepareRequests.first?.feeRateSatPerVbyte, 10) + XCTAssertEqual(sdk.prepareRequests.first?.fundingStrategy, .perNode) + XCTAssertEqual(sdk.prepareRequests.first?.selection, .specific(leafIds: ["leaf-1"])) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [.p2wpkh(txid: "f0", vout: 1, valueSats: 20000, pubkey: "02ab")]) + XCTAssertTrue(chain.broadcasts.isEmpty, "pricing broadcasts nothing") + } + + /// Short at the higher rate: what the inputs lack, plus the input fee of the payment to come at the new rate + /// (68 vB at 10 sat/vB). + func testFeeBumpShortfallIsWhatTheInputsLackPlusTheNextInput() async throws { + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 20000)] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 25000, docsUrl: "") + + let shortfall = try await driver().feeBumpShortfall(runningRecord(), rate: 10) + + XCTAssertEqual(shortfall, 5000 + 680) + } + + /// Paid from savings: the exit waits for the payment as for a top-up, then rebuilds at the new rate. The rebuild is + /// neither a Redo nor a stall, so it adds no strike and no stall count. + func testAPaidFeeBumpRebuildsAtTheNewRateOnceTheFundingConfirms() async throws { + var record = runningRecord() + record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump") + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 25680)) + XCTAssertEqual(record.fundingShortfallSats, 5680) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .paid(txid: "bump")) + + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("bump", value: 5680)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1b", cpfp: "03c2")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.feeRateSatPerVbyte, 10) + XCTAssertEqual(sdk.prepareRequests.first?.feeRateSatPerVbyte, 10) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 2) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1b"]) + XCTAssertNil(record.stallRebuilds) + XCTAssertNil(record.isRedoRebuild) + } + + /// The funding already covers the higher rate: the exit rebuilds at once and replaces what has not confirmed. + func testAFeeBumpWithNothingToPayRebuildsAtOnce() async throws { + var record = runningRecord() + record.applyFeeBump(rate: 10, extraSats: 0, paymentTxid: nil) + + XCTAssertEqual(record.phase, .running) + XCTAssertTrue(record.isRebuilding) + + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1b", cpfp: "03c2")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2) + XCTAssertEqual(sdk.prepareRequests.first?.feeRateSatPerVbyte, 10) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1b"]) + } + + // MARK: Leaves the operators moved on-chain + + private let failure = CooperativeRecoveryFailure( + leafId: "coop-1", + outputTxid: "watchtower", + outputVout: 0, + error: .operatorsUnavailable(message: "operators unreachable") + ) + + private func doneWithFailedLeaf() -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)], failed: [failure])) + let done = SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 990))], failed: [failure]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: done, verdict: .done), count: 20) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: []) + prepared.leaves = [RecoverFundsLeaf(leafId: "coop-1", valueSats: 5000, method: .cooperative)] + sdk.preparedResponse = prepared + return record + } + + /// Done with a failed cooperative leaf: the exit keeps it, tries it at once and then at Glow's doubling gaps + /// from 6 blocks, as a recovery of its own, and shows the wait instead of Done. + func testDoneWithAFailedLeafKeepsRetryingIt() async throws { + var record = doneWithFailedLeaf() + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertTrue(record.isWaitingForOperators) + XCTAssertEqual(record.failedLeaves, [.init(leafId: "coop-1", valueSats: 5000)]) + XCTAssertEqual(record.failedLeafRetrySince, 1000) + XCTAssertEqual(sdk.prepareRequests.map(\.selection), [.specific(leafIds: ["coop-1"])]) + XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs, [], "a cooperative recovery needs no funding") + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).isEmpty) + + chain.tip = 1005 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 1, "not before 6 blocks") + + chain.tip = 1006 + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])] + record = try await driver().tick(record) + + XCTAssertEqual(sdk.prepareRequests.count, 2) + XCTAssertEqual(record.generation, 2) + XCTAssertFalse(record.isWaitingForOperators) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["c1"]) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative, status: .confirmed(blockHeight: 1007))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)] + chain.tip = 1008 + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertNil(record.isFailedLeavesReleased) + } + + /// After about a week (1,008 blocks) the exit lets the leaf go and ends, saying what is left. + func testAFailedLeafIsReleasedAfterAWeek() async throws { + var record = doneWithFailedLeaf() + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + record = try await driver().tick(record) + + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.isFailedLeavesReleased, true) + XCTAssertEqual(record.failedLeavesSats, 5000) + XCTAssertFalse(record.isRetryingFailedLeaves) + } + + func testIsRetryingFailedLeavesSurvivesTheRecord() throws { + var record = doneWithFailedLeaf() + record.failedLeaves = [.init(leafId: "coop-1", valueSats: 5000)] + record.failedLeafRetrySince = 1000 + record.failedLeafRetriedAt = 1006 + record.isFailedLeavesReleased = false + record.fundingStrategy = SparkExitRecord.perNodeStrategy + + let decoded = try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)) + + XCTAssertEqual(decoded, record) + XCTAssertEqual(decoded.cpfpFundingStrategy, .perNode) + } + + // MARK: Copy + + /// The released leaves are on-chain under the operators' output, not on Spark. + func testFailedLeavesCopySaysTheyAreNotOnSpark() { + let text = t("lightning__spark_exit__failed_leaves", variables: ["amount": "₿ 5 000"]) + + XCTAssertTrue(text.contains("₿ 5 000")) + XCTAssertTrue(text.contains("not on Spark")) + XCTAssertFalse(text.contains("stayed on Spark")) + XCTAssertTrue(t("lightning__spark_exit__waiting_operators", variables: ["amount": "₿ 5 000"]).contains("up to a week")) + XCTAssertTrue(t("lightning__spark_exit__fee_low", variables: ["rate": "2"]).contains("Pay a higher fee")) + } +} From 7c9dedb7fb7e1babf830ef28098eaee7c8011d0d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 23:19:57 +0100 Subject: [PATCH 033/378] fix: spark exit result leads with what reached savings - Done starts with the amount that reached savings and keeps the sweep's transaction ID after a failed-leaf retry replaced its generation. - Released leaves: "The operators can still release it to you." - Pay a higher fee says the amount next to each speed is what savings pays, and says savings can't cover it when no rate is affordable. --- .../Localization/ar.lproj/Localizable.strings | 4 ++-- .../Localization/ca.lproj/Localizable.strings | 4 ++-- .../Localization/cs.lproj/Localizable.strings | 4 ++-- .../Localization/de.lproj/Localizable.strings | 4 ++-- .../Localization/el.lproj/Localizable.strings | 4 ++-- .../Localization/en.lproj/Localizable.strings | 4 ++-- .../Localization/es-419.lproj/Localizable.strings | 4 ++-- .../Localization/es.lproj/Localizable.strings | 4 ++-- .../Localization/fr.lproj/Localizable.strings | 4 ++-- .../Localization/it.lproj/Localizable.strings | 4 ++-- .../Localization/nl.lproj/Localizable.strings | 4 ++-- .../Localization/pl.lproj/Localizable.strings | 4 ++-- .../Localization/pt-BR.lproj/Localizable.strings | 4 ++-- .../Localization/pt.lproj/Localizable.strings | 4 ++-- .../Localization/ru.lproj/Localizable.strings | 4 ++-- Bitkit/Services/SparkExit/SparkExitFunding.swift | 2 +- Bitkit/Services/SparkExit/SparkExitRecord.swift | 5 +++++ Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 12 ++++++++---- BitkitTests/SparkExitFeeBumpTests.swift | 4 ++++ 19 files changed, 48 insertions(+), 35 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 140ee3a56..275e2e3c4 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -356,7 +356,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -395,7 +395,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index b7cccd9e0..53a9e484f 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -393,7 +393,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -432,7 +432,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 781f79746..11757f675 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -409,7 +409,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -448,7 +448,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 25a1f8599..4caf9b86f 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -407,7 +407,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -446,7 +446,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 7ae18359f..19c99f371 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -380,7 +380,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -419,7 +419,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index df83d3652..c448e16f4 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -402,7 +402,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -441,7 +441,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 1bc9891aa..1c44c7c33 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -410,7 +410,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -449,7 +449,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 55c9d7279..6bdfa78e5 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -394,7 +394,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -433,7 +433,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index d9ce5be67..326a73972 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -410,7 +410,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -449,7 +449,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 886ae4ad7..cae670ab2 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -403,7 +403,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -442,7 +442,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 0241ef69e..750320703 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -407,7 +407,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -446,7 +446,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 9a97ae8ab..22ff4148a 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -409,7 +409,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -448,7 +448,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 0c1aa9776..30668b4ad 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -409,7 +409,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -448,7 +448,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 80bbdc36a..64655528a 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -407,7 +407,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -446,7 +446,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index c0aea4b6b..f065aca11 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -404,7 +404,7 @@ "lightning__spark_exit__done_title" = "Exit\ncomplete"; "lightning__spark_exit__done_text" = "Your funds are in savings."; "lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. They can still be asked to release it."; +"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; @@ -443,7 +443,7 @@ "lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate: Bitkit pays the difference from savings and sends the waiting steps again."; +"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index e86e4a678..4e8548103 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -353,7 +353,7 @@ enum SparkExitMath { let paid = (record.earlierReceivedSats ?? [:]).merging(paidToDestination(transactions, destination: record.destination)) { $1 } let sats = paid.values.reduce(0, +) guard sats > 0 else { return nil } - return (sats, transactions.first { $0.kind == .sweep }?.txid) + return (sats, transactions.first { $0.kind == .sweep }?.txid ?? record.landedSweepTxid) } /// What each sweep or cooperative recovery among `transactions` pays to `destination`, by txid. diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index f1f241d8d..c9f06df74 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -146,6 +146,8 @@ struct SparkExitRecord: Codable, Equatable { var failedLeafRetriedAt: UInt32? /// The failed leaves were let go after about a week: the exit ended without them. var isFailedLeavesReleased: Bool? + /// The confirmed sweep of a generation the failed-leaf retry replaced, which Done still shows. + var landedSweepTxid: String? var skippedLeafCount: Int = 0 /// Cooperative leaves the last build could not recover. var failedLeafCount: Int = 0 @@ -287,6 +289,9 @@ struct SparkExitRecord: Codable, Equatable { if !landed.isEmpty { earlierReceivedSats = (earlierReceivedSats ?? [:]).merging(landed) { $1 } } + if let sweep = recovery.response.transactions.first(where: { $0.kind == .sweep }), case .confirmed = sweep.status { + landedSweepTxid = sweep.txid + } } generation += 1 recovery = StoredRecovery(built) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 21f347a4e..887910058 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -503,10 +503,10 @@ struct SparkExitProgressView: View { switch record?.phase { case .done: [ - t("lightning__spark_exit__done_text"), + // The amount that reached savings says it all; the generic line only stands in when it is unknown. record.flatMap(SparkExitMath.received).map { t("lightning__spark_exit__done_received", variables: ["amount": currency.primaryAmountText(sats: $0.sats)]) - }, + } ?? t("lightning__spark_exit__done_text"), (record?.skippedLeafCount ?? 0) > 0 ? t("lightning__spark_exit__partial_text", variables: ["count": "\(record?.skippedLeafCount ?? 0)"]) : nil, record.flatMap { $0.isFailedLeavesReleased == true && $0.failedLeavesSats > 0 ? $0.failedLeavesSats : nil }.map { @@ -723,9 +723,13 @@ struct SparkExitFeeBumpView: View { return total == 0 || UInt64(max(0, wallet.spendableOnchainBalanceSats)) >= total } + /// Savings can't pay the selected rate, or, with none selected, any rate priced so far. private var isInsufficient: Bool { - guard let selected, selected.totalSats != nil else { return false } - return !canPay(selected) + if let selected { + return selected.totalSats != nil && !canPay(selected) + } + let priced = exit.feeBumpOptions.filter { $0.totalSats != nil } + return !priced.isEmpty && !priced.contains(where: canPay) } var body: some View { diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index e97217885..0facea552 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -250,6 +250,7 @@ final class SparkExitFeeBumpTests: XCTestCase { XCTAssertEqual(sdk.prepareRequests.count, 2) XCTAssertEqual(record.generation, 2) + XCTAssertEqual(record.landedSweepTxid, "s", "Done still names the sweep the retry replaced") XCTAssertFalse(record.isWaitingForOperators) XCTAssertEqual(chain.broadcasts.map(\.txid), ["c1"]) @@ -284,6 +285,9 @@ final class SparkExitFeeBumpTests: XCTestCase { record.failedLeafRetriedAt = 1006 record.isFailedLeavesReleased = false record.fundingStrategy = SparkExitRecord.perNodeStrategy + record.landedSweepTxid = "s" + // Whole milliseconds, as the record stores dates. + record.lastBuildAt = clock let decoded = try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)) From 495fca5af7e0f214023f7b47e9dae381dc7b07b1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 23:46:54 +0100 Subject: [PATCH 034/378] fix: spark exit stays quiet while a higher fee's payment confirms --- .../Services/SparkExit/SparkExitFunding.swift | 2 ++ .../Services/SparkExit/SparkExitRecord.swift | 12 ++++++++++ .../Settings/Advanced/SparkExitViews.swift | 2 +- BitkitTests/SparkExitFeeBumpTests.swift | 22 +++++++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 4e8548103..142eaaa36 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -290,6 +290,8 @@ enum SparkExitMath { lostReminder.append(SparkExitReminder(id: "failed", fireAt: now, isFailed: true)) } if case let .needsFunding(requiredSats) = record.phase { + // A payment Bitkit already sent from savings, such as a higher fee's, needs no reminder to fund. + guard !record.isAwaitingOwnPayment else { return lostReminder } return lostReminder + [SparkExitReminder(id: "funding-\(requiredSats)", fireAt: now, isFunding: true)] } guard case .running = record.phase, let recovery = record.recovery?.response else { return lostReminder } diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index c9f06df74..996f2a76d 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -368,6 +368,18 @@ struct SparkExitRecord: Codable, Equatable { (stallRebuilds ?? 0) >= SparkExitDriver.stallRebuildsToSurface } + /// A savings payment Bitkit made to the funding address, such as a higher fee's, that the exit has not adopted yet: + /// nothing to ask the user for while it confirms. + var isAwaitingOwnPayment: Bool { + guard let paid = fundingPaymentTxid, fundingShortfallSats != nil else { return false } + return !funding.contains { $0.txid == paid } + } + + /// Progress says the exit is underpriced: it runs at its rate, nothing rebuilds it and no higher fee is on its way. + func showsFeeLow(tip: UInt32?) -> Bool { + phase == .running && !isRebuilding && underpricedStep(tip: tip) != nil + } + /// A step the network is busier than the exit's rate for: refused for a fee under the mempool's minimum, or /// accepted but unconfirmed for more blocks than a fair rate needs. Rebuilding at the same rate would not help. func underpricedStep(tip: UInt32?) -> BroadcastAttempt? { diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 887910058..0523a2159 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -618,7 +618,7 @@ struct SparkExitProgressView: View { .accessibilityIdentifier("SparkExitNotMoving") } - if record.isActive, !record.isRebuilding, record.underpricedStep(tip: exit.tipHeight) != nil { + if record.showsFeeLow(tip: exit.tipHeight) { BodySText(t("lightning__spark_exit__fee_low", variables: ["rate": "\(record.feeRateSatPerVbyte)"])) .fixedSize(horizontal: false, vertical: true) .padding(.top, 16) diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index 0facea552..3b7d99f89 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -188,6 +188,28 @@ final class SparkExitFeeBumpTests: XCTestCase { XCTAssertNil(record.isRedoRebuild) } + /// Right after savings paid a higher fee, the step that was underpriced is still in the log: Progress no longer + /// says the exit is underpriced, and no "needs more fees" reminder fires for a payment already on its way. + func testAPaidFeeBumpNeitherShowsFeeLowNorRemindsToFund() { + var record = runningRecord() + record.broadcastLog = [.init( + generation: record.generation, txid: "n1", attempts: 3, rejections: 0, lastAttemptAt: clock, + lastOutcome: "accepted", isAccepted: true, acceptedAtHeight: 990 + )] + XCTAssertTrue(record.showsFeeLow(tip: 1000)) + + record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump") + + XCTAssertTrue(record.isAwaitingOwnPayment) + XCTAssertFalse(record.showsFeeLow(tip: 1000)) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).isEmpty) + + // Adopted, the payment no longer waits; a shortfall left after it reminds as before. + record.funding.append(.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "bump", vout: 0, valueSats: 5000)) + XCTAssertFalse(record.isAwaitingOwnPayment) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.isFunding), [true]) + } + /// The funding already covers the higher rate: the exit rebuilds at once and replaces what has not confirmed. func testAFeeBumpWithNothingToPayRebuildsAtOnce() async throws { var record = runningRecord() From 3d871fbc377e9b66e419cea2ba4f10343d0acdaa Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 23:58:17 +0100 Subject: [PATCH 035/378] fix: spark exit sweeps fee change when network fees drop --- .../Localization/ar.lproj/Localizable.strings | 3 +- .../Localization/ca.lproj/Localizable.strings | 3 +- .../Localization/cs.lproj/Localizable.strings | 3 +- .../Localization/de.lproj/Localizable.strings | 3 +- .../Localization/el.lproj/Localizable.strings | 3 +- .../Localization/en.lproj/Localizable.strings | 3 +- .../es-419.lproj/Localizable.strings | 3 +- .../Localization/es.lproj/Localizable.strings | 3 +- .../Localization/fr.lproj/Localizable.strings | 3 +- .../Localization/it.lproj/Localizable.strings | 3 +- .../Localization/nl.lproj/Localizable.strings | 3 +- .../Localization/pl.lproj/Localizable.strings | 3 +- .../pt-BR.lproj/Localizable.strings | 3 +- .../Localization/pt.lproj/Localizable.strings | 3 +- .../Localization/ru.lproj/Localizable.strings | 3 +- .../SparkExit/SparkExitBroadcaster.swift | 6 +- .../Services/SparkExit/SparkExitDriver.swift | 84 +++++++++++++----- .../Services/SparkExit/SparkExitFunding.swift | 17 ++++ .../Services/SparkExit/SparkExitRecord.swift | 15 ++++ .../Settings/Advanced/SparkExitViews.swift | 13 ++- BitkitTests/SparkExitSweepTests.swift | 85 ++++++++++++++++++- BitkitTests/SparkExitTests.swift | 6 ++ 22 files changed, 228 insertions(+), 43 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 275e2e3c4..5f41a74a9 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -360,7 +360,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -399,6 +399,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 53a9e484f..ade8650ef 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -397,7 +397,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -436,6 +436,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 11757f675..9fc96f79e 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -413,7 +413,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -452,6 +452,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 4caf9b86f..6900a73db 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -411,7 +411,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -450,6 +450,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 19c99f371..5a5bbe4c3 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -384,7 +384,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -423,6 +423,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c448e16f4..de271b378 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -406,7 +406,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -445,6 +445,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 1c44c7c33..3092d4860 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -414,7 +414,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -453,6 +453,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 6bdfa78e5..9e505e38f 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -398,7 +398,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -437,6 +437,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 326a73972..f14c36409 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -414,7 +414,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -453,6 +453,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index cae670ab2..982ada6c6 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -407,7 +407,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -446,6 +446,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 750320703..cd8b2e495 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -411,7 +411,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -450,6 +450,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 22ff4148a..773e8f973 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -413,7 +413,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -452,6 +452,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 30668b4ad..434eddf39 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -413,7 +413,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -452,6 +452,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 64655528a..ca72ee48a 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -411,7 +411,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -450,6 +450,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index f065aca11..5984661e1 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -408,7 +408,7 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}."; +"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; @@ -447,6 +447,7 @@ "lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; +"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index 3fb052cf6..56d952367 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -59,7 +59,7 @@ protocol SparkExitChain: Sendable { extension SparkExitChain { func networkFees() async throws -> SparkExitNetworkFees { let fastest = try await fastestFeeRate() - return SparkExitNetworkFees(fastest: fastest, halfHour: fastest) + return SparkExitNetworkFees(fastest: fastest, halfHour: fastest, economy: fastest) } } @@ -150,7 +150,9 @@ struct EsploraSparkExitChain: SparkExitChain { let estimates = try await JSONDecoder().decode([String: Double].self, from: get("/fee-estimates")) guard let fastest = estimates["1"] else { throw URLError(.cannotParseResponse) } let rate = { (value: Double) in max(1, UInt64(value.rounded(.up))) } - return SparkExitNetworkFees(fastest: rate(fastest), halfHour: rate(estimates["3"] ?? fastest)) + // The slowest tier is the longest target the host estimates (mempool's `economyFee` is its day-long rate). + let slowest = estimates.compactMap { key, value in Int(key).map { ($0, value) } }.max { $0.0 < $1.0 }?.1 ?? fastest + return SparkExitNetworkFees(fastest: rate(fastest), halfHour: rate(estimates["3"] ?? fastest), economy: rate(slowest)) } /// `/tx/{txid}/hex` looks in the mempool and the chain alike; `/status` of an unknown transaction may answer diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index ff5415418..d68fc5e83 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -57,6 +57,10 @@ struct SparkExitDriver { static let releaseBlocks: UInt32 = 1008 /// Blocks an accepted leftover sweep may stay unconfirmed before it is replaced at a higher rate. static let sweepReplaceBlocks: UInt32 = 144 + /// While the fee change is not worth sending, the sweep checks the network's rate again after this many blocks. + static let sweepRecheckBlocks: UInt32 = 144 + /// About four weeks: then the change too small to send stays at the funding address. + static let sweepWaitBlocks: UInt32 = 4032 @discardableResult func tick(_ record: SparkExitRecord) async throws -> SparkExitRecord { @@ -602,9 +606,9 @@ struct SparkExitDriver { } if isReplaceDue, let fastest = try? await chain.fastestFeeRate() { let rate = max(fastest, oldRate + 1) - let utxos = record.leftover.map { + let utxos = SparkExitMath.economicalUtxos(record.leftover.map { EsploraUtxo(txid: $0.txid, vout: $0.vout, value: $0.valueSats, status: EsploraTxStatus(confirmed: true, blockHeight: nil)) - } + }, rate: rate) Logger.info("Spark exit rebuilds its leftover sweep at \(rate) sat/vB", context: "SparkExitDriver") let built = try await SparkExitLeftoverSweep.build( utxos: utxos, @@ -623,27 +627,20 @@ struct SparkExitDriver { ) try save(record) } else if !sweep.isAccepted { - // Too small to send at the new rate, and nothing of it is in the mempool: kept where it is, as dust. - record.leftoverSweep = SparkExitRecord.LeftoverSweep(feeRateSatPerVbyte: rate, replacedTxids: sweep.replacedTxids) + // Too small to send at the new rate, and nothing of it is in the mempool: it waits for fees to drop. + let tip = try? await chain.tipHeight() + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: rate, + replacedTxids: sweep.replacedTxids, + waitingSince: tip ?? 0, + checkedAtHeight: tip ?? 0 + ) try save(record) } } } - if record.leftoverSweep == nil { - guard let address = record.funding.first?.address else { return } - let utxos = try await chain.utxos(address: address) - record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } - let confirmed = utxos.filter(\.status.confirmed) - guard !confirmed.isEmpty, confirmed.count == utxos.count else { return } - let built = try await SparkExitLeftoverSweep.build( - utxos: confirmed, - fundingAddress: address, - destination: record.destination, - feeRateSatPerVbyte: record.feeRateSatPerVbyte, - signer: makeSigner(record.fundingIndex) - ) - record.leftoverSweep = SparkExitRecord.LeftoverSweep(txid: built?.txid, txHex: built?.txHex, valueSats: built?.valueSats ?? 0) - try save(record) + if record.leftoverSweep == nil || record.leftoverSweep?.isWaitingForFees == true { + try await sweepWhenEconomical(&record) } guard let sweep = record.leftoverSweep, let txid = sweep.txid, let hex = sweep.txHex, sweep.rejection == nil, sweep.isConfirmed != true, !Task.isCancelled @@ -675,6 +672,55 @@ struct SparkExitDriver { } } + /// Builds the leftover sweep at the network's economy rate (`sweepFeeRate`) from the UTXOs worth more than their + /// own input fee. While nothing is worth sending it waits, checking the rate again every `sweepRecheckBlocks`, + /// and after `sweepWaitBlocks` keeps the change at the funding address, shown with the path that spends it. + private func sweepWhenEconomical(_ record: inout SparkExitRecord) async throws { + guard let address = record.funding.first?.address else { return } + let waiting = record.leftoverSweep + let tip = try? await chain.tipHeight() + if let waiting, let since = waiting.waitingSince { + guard let tip else { return } + if tip.saturatingSub(since) >= Self.sweepWaitBlocks { + Logger.info("Spark exit keeps its fee change at the funding address after waiting for fees", context: "SparkExitDriver") + record.leftoverSweep?.isDustKept = true + try save(record) + return + } + guard tip.saturatingSub(waiting.checkedAtHeight ?? since) >= Self.sweepRecheckBlocks else { return } + } + let utxos = try await chain.utxos(address: address) + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + let confirmed = utxos.filter(\.status.confirmed) + guard !confirmed.isEmpty, confirmed.count == utxos.count else { return } + let economy = try? await chain.networkFees().economy + let rate = SparkExitMath.sweepFeeRate(economy: economy, exitRate: record.feeRateSatPerVbyte) + let built = try await SparkExitLeftoverSweep.build( + utxos: SparkExitMath.economicalUtxos(confirmed, rate: rate), + fundingAddress: address, + destination: record.destination, + feeRateSatPerVbyte: rate, + signer: makeSigner(record.fundingIndex) + ) + if let built { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + txid: built.txid, + txHex: built.txHex, + valueSats: built.valueSats, + feeRateSatPerVbyte: rate + ) + } else { + let since = waiting?.waitingSince ?? tip ?? 0 + Logger.info("Spark exit waits for lower fees to send its fee change (\(rate) sat/vB)", context: "SparkExitDriver") + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: rate, + waitingSince: since, + checkedAtHeight: tip ?? since + ) + } + try save(record) + } + // MARK: Broadcast /// A unilateral step the SDK could not read is never sent: a rebuild settles it. A cooperative one is, since nodes diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 142eaaa36..77037ba47 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -170,6 +170,8 @@ struct SparkExitQuote: Equatable { struct SparkExitNetworkFees: Equatable { let fastest: UInt64 let halfHour: UInt64 + /// The slowest tier: what the leftover sweep pays, since the change can wait. + var economy: UInt64 = 1 } /// A higher fee rate an exit can move to, and what moving costs: the funding the exit lacks at that rate (`extraSats`, @@ -463,6 +465,21 @@ enum SparkExitMath { /// The least a P2WPKH funding output can hold: nodes refuse a payment below it. static let fundingDustSats: UInt64 = 294 + /// The lowest rate nodes relay. + static let minRelayFeeRate: UInt64 = 1 + + /// The leftover sweep's rate: the network's economy rate, never above the exit's own and never under the relay + /// floor. Per-node funding leaves each step's change of at least 294 at the funding address; at the exit's rate + /// the sweep can cost more per input than the input holds. + static func sweepFeeRate(economy: UInt64?, exitRate: UInt64) -> UInt64 { + max(minRelayFeeRate, min(economy ?? exitRate, exitRate)) + } + + /// The UTXOs worth sending at `rate`: each holds more than the fee of its own input. + static func economicalUtxos(_ utxos: [EsploraUtxo], rate: UInt64) -> [EsploraUtxo] { + utxos.filter { $0.value > inputVbytes * rate } + } + /// What the exit says it still needs: the ask, or, while a payment is awaited or before the funding address was /// read, the shortfall. Never under the dust limit. static func neededSats(_ record: SparkExitRecord, state: SparkExitFundingState?) -> UInt64? { diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 996f2a76d..416396bcb 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -103,6 +103,18 @@ struct SparkExitRecord: Codable, Equatable { var sentAtHeight: UInt32? /// The sweeps this one replaced: any of them confirming also ends the sweep. var replacedTxids: [String]? + /// With no `txid`: the tip when nothing at the address was worth sending at the network's economy rate. The + /// sweep checks again every `sweepRecheckBlocks` and keeps the change after `sweepWaitBlocks`. + var waitingSince: UInt32? + /// The tip of the last such check. + var checkedAtHeight: UInt32? + /// The wait ran out: what is left stays at the funding address, with the path that spends it. + var isDustKept: Bool? + + /// Waiting for fees to drop before sending the fee change. + var isWaitingForFees: Bool { + txid == nil && waitingSince != nil && isDustKept != true + } /// A refusal for its fee is not final: the sweep is built again at the network's current rate. var isFeeRejection: Bool { @@ -194,6 +206,9 @@ struct SparkExitRecord: Codable, Equatable { var isLeftoverSweepPending: Bool { guard !isActive, !leftover.isEmpty else { return false } guard let leftoverSweep else { return true } + if leftoverSweep.isWaitingForFees { + return true + } return leftoverSweep.txid != nil && (leftoverSweep.rejection == nil || leftoverSweep.isFeeRejection) && leftoverSweep.isConfirmed != true } diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 0523a2159..76e66d427 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -367,11 +367,16 @@ struct SparkExitLeftoverStatus: View { .lineLimit(2) .truncationMode(.middle) } + } else if sweep.isWaitingForFees { + BodySText(t("lightning__spark_exit__leftover_waiting", variables: ["amount": currency.primaryAmountText(sats: leftoverSats)])) + .fixedSize(horizontal: false, vertical: true) } else { - BodySText(t( - "lightning__spark_exit__leftover_dust", - variables: ["amount": currency.primaryAmountText(sats: leftoverSats), "address": address] - )) + BodySText(t("lightning__spark_exit__leftover_dust", variables: [ + "amount": currency.primaryAmountText(sats: leftoverSats), + "address": address, + "path": SparkExitFundingKey.path(index: record.fundingIndex, network: Env.bitkitCoreNetwork), + ])) + .fixedSize(horizontal: false, vertical: true) } } else if let sweep = record.leftoverSweep, !sweep.isFeeRejection { // Refused for good: where the coins stay, and the path that spends them with the recovery phrase. diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index c0f14786d..5807030e1 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -232,6 +232,7 @@ final class SparkExitSweepTests: XCTestCase { var record = try await driver().tick(endedRecord()) let first = try XCTUnwrap(record.leftoverSweep) XCTAssertEqual(first.sentAtHeight, 1000) + XCTAssertEqual(first.feeRateSatPerVbyte, 1, "the network's economy rate of 1, under the exit's 2") chain.tip = 1143 record = try await driver().tick(record) @@ -241,7 +242,7 @@ final class SparkExitSweepTests: XCTestCase { record = try await driver().tick(record) let replacement = try XCTUnwrap(record.leftoverSweep) XCTAssertNotEqual(replacement.txid, first.txid) - XCTAssertEqual(replacement.feeRateSatPerVbyte, 3, "the old rate of 2 plus one, over the network's 1") + XCTAssertEqual(replacement.feeRateSatPerVbyte, 2, "the old rate of 1 plus one, over the network's 1") XCTAssertEqual(replacement.sentAtHeight, 1144) try assertValidP2wpkhSpend( .init(txid: XCTUnwrap(replacement.txid), txHex: XCTUnwrap(replacement.txHex), valueSats: replacement.valueSats), @@ -359,10 +360,88 @@ final class SparkExitSweepTests: XCTestCase { chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 400)] record = try await driver().tick(record) - XCTAssertEqual(record.leftoverSweep, SparkExitRecord.LeftoverSweep()) + // Too small to send now: it waits for fees to drop instead of being kept at once. + XCTAssertEqual(record.leftoverSweep?.waitingSince, 1000) + XCTAssertTrue(record.leftoverSweep?.isWaitingForFees == true) + XCTAssertTrue(record.needsDriving) + XCTAssertTrue(chain.broadcasts.isEmpty) + + // A record from before the wait, with nothing sent and no wait, stays kept as dust. + var legacy = endedRecord() + legacy.leftoverSweep = SparkExitRecord.LeftoverSweep() + XCTAssertFalse(legacy.needsDriving) + XCTAssertFalse(legacy.showsResult, "dust is kept, and a new exit may start") + } + + // MARK: Fee change at the economy rate + + func testTheSweepPaysTheEconomyRateNeverAboveTheExitsOrUnderTheRelayFloor() { + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: 1, exitRate: 5), 1) + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: 9, exitRate: 5), 5) + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: 0, exitRate: 5), 1) + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: nil, exitRate: 5), 5) + } + + func testOnlyInputsWorthMoreThanTheirOwnFeeAreSwept() { + let utxos = [ + SparkExitFixtures.utxo("a", value: 294), + SparkExitFixtures.utxo("b", value: 341), + SparkExitFixtures.utxo("c", value: 5000), + ] + // 68 vB at 5 sat/vB is 340. + XCTAssertEqual(SparkExitMath.economicalUtxos(utxos, rate: 5).map(\.txid), ["b", "c"]) + XCTAssertEqual(SparkExitMath.economicalUtxos(utxos, rate: 1).count, 3) + } + + /// Per-node change at 20 sat/vB: every 600-sat output costs more to spend than it holds. The sweep waits, checks + /// the rate again after 144 blocks, and sends the change once the economy rate makes it worth it. + func testChangeWaitsForFeesToDropThenSweepsAtTheEconomyRate() async throws { + var record = endedRecord() + record.feeRateSatPerVbyte = 20 + chain.economyFee = 20 + chain.utxosByAddress[key.address] = (0 ..< 3).map { SparkExitFixtures.utxo(String(repeating: "\($0 + 1)", count: 64), value: 600) } + + record = try await driver().tick(record) + XCTAssertTrue(record.leftoverSweep?.isWaitingForFees == true) + XCTAssertEqual(record.leftoverSweep?.checkedAtHeight, 1000) + + chain.economyFee = 1 + chain.tip = 1143 + record = try await driver().tick(record) + XCTAssertTrue(record.leftoverSweep?.isWaitingForFees == true, "not checked again before 144 blocks") + + chain.tip = 1144 + record = try await driver().tick(record) + let sweep = try XCTUnwrap(record.leftoverSweep) + XCTAssertNotNil(sweep.txid) + XCTAssertEqual(sweep.feeRateSatPerVbyte, 1) + XCTAssertEqual(sweep.isAccepted, true) + try assertValidP2wpkhSpend( + .init(txid: XCTUnwrap(sweep.txid), txHex: XCTUnwrap(sweep.txHex), valueSats: sweep.valueSats), + utxos: chain.utxosByAddress[key.address] ?? [] + ) + } + + /// After about four weeks (4,032 blocks) the change stays at the funding address and the exit is let go. + func testChangeTooSmallForFourWeeksIsKept() async throws { + var record = endedRecord() + record.feeRateSatPerVbyte = 20 + chain.economyFee = 20 + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 600)] + record = try await driver().tick(record) + XCTAssertTrue(record.needsDriving) + XCTAssertTrue(record.showsResult) + + chain.tip = 1000 + SparkExitDriver.sweepWaitBlocks + record = try await driver().tick(record) + + XCTAssertEqual(record.leftoverSweep?.isDustKept, true) + XCTAssertFalse(record.isLeftoverSweepPending) XCTAssertFalse(record.needsDriving) - XCTAssertFalse(record.showsResult, "dust is kept, and a new exit may start") XCTAssertTrue(chain.broadcasts.isEmpty) + XCTAssertTrue(t("lightning__spark_exit__leftover_waiting", variables: ["amount": "₿ 600"]).contains("when network fees drop")) + XCTAssertTrue(t("lightning__spark_exit__leftover_dust", variables: ["amount": "₿ 600", "address": "a", "path": "m/84'"]) + .contains("derivation path m/84'")) } func testFinishingAnExitSweepsTheLeftoverInTheSameTick() async throws { diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index af560788c..e66713b6f 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -161,10 +161,16 @@ final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { } var fastestFee: UInt64 = 5 + /// The network's economy rate; nil reads as the fastest one. + var economyFee: UInt64? func fastestFeeRate() async throws -> UInt64 { fastestFee } + + func networkFees() async throws -> SparkExitNetworkFees { + SparkExitNetworkFees(fastest: fastestFee, halfHour: fastestFee, economy: economyFee ?? fastestFee) + } } final class FakeSparkExitRecovering: SparkExitRecovering, @unchecked Sendable { From f8aad6060bed0b86e245607648712ff4e28977ec Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 00:20:23 +0100 Subject: [PATCH 036/378] fix: spark exit rerun findings --- Bitkit/Managers/SparkExitManager.swift | 3 + .../Services/SparkExit/SparkExitDriver.swift | 1 + .../Services/SparkExit/SparkExitFunding.swift | 34 ++++++--- .../Services/SparkExit/SparkExitRecord.swift | 18 ++++- .../Settings/Advanced/SparkExitViews.swift | 5 +- BitkitTests/SparkExitFeeBumpTests.swift | 71 +++++++++++++++++++ 6 files changed, 119 insertions(+), 13 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index cfc54dde0..7bacb7f5c 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -39,6 +39,8 @@ final class SparkExitManager { private(set) var feeBumpError: String? /// What the running exit shows about its fee funding while it lacks some. private(set) var fundingState: SparkExitFundingState? + /// The full funding the Progress top-up asks for, every extra payment's input fee included. + private(set) var fundingAskTotal: UInt64? private(set) var fundingAddress: String? private(set) var lastError: String? /// Set when the user tapped an exit notification, until the app shows the exit. @@ -303,6 +305,7 @@ final class SparkExitManager { fundingSeenSats = SparkExitMath.newFunding(utxos, record: record).reduce(0) { $0 + $1.value } } fundingState = SparkExitMath.fundingState(record, utxos: utxos) + fundingAskTotal = SparkExitMath.fundingAskTotal(record, utxos: utxos) } private func refreshNetworkFees(chain: SparkExitChain) async { diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index d68fc5e83..f4519bb2d 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -340,6 +340,7 @@ struct SparkExitDriver { } let checked = try await recovering.check(withConfirmations(stored)) record.recovery = StoredRecovery(checked.recovery) + record.noteProgressAfterRebuildFromChain(checked.recovery) let tip = try? await chain.tipHeight() // A step that confirmed since the last rebuild over a stuck step shows the exit moves again. if let count = record.stallConfirmedCount, SparkExitMath.confirmedCount(checked.recovery) > count { diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 77037ba47..545c2645d 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -451,15 +451,33 @@ enum SparkExitMath { } let fresh = utxos.map { newFunding($0, record: record) } ?? [] let seenSats = fresh.reduce(0) { $0 + $1.value } - // The shortfall assumes one payment: each one beyond it, and the one still to send, adds an input fee. - let inputFee = inputFeeSats(record) - let neededNow = shortfall + inputFee * UInt64(max(0, fresh.count - 1)) + let total = fundingTotal(shortfall: shortfall, payments: fresh.count, receivedSats: seenSats, inputFeeSats: inputFeeSats(record)) // Covered by confirmed funding alone, the next tick builds. - if seenSats >= neededNow, let pending = fresh.first(where: { !$0.status.confirmed }) ?? fresh.first { + if seenSats >= total.covered, let pending = fresh.first(where: { !$0.status.confirmed }) ?? fresh.first { return .seen(txid: pending.txid) } - let needed = neededNow + (fresh.isEmpty ? 0 : inputFee) - return .ask(sats: max(needed.saturatingSub(seenSats), fundingDustSats), receivedSats: seenSats) + return .ask(sats: max(total.toSend.saturatingSub(seenSats), fundingDustSats), receivedSats: seenSats) + } + + /// The one rule both funding screens state: the funding is priced for one payment, and every payment beyond it, + /// the one still to send included, adds its own input fee (68 vB at the exit's rate). `covered` is what the + /// payments seen must hold to build; `toSend` the full total to ask for while they hold less, which the + /// partial-payment hint names as its target. What is left to send is never asked under the 294-sat dust limit. + static func fundingTotal(shortfall: UInt64, payments: Int, receivedSats: UInt64, inputFeeSats: UInt64) -> (covered: UInt64, toSend: UInt64) { + let covered = shortfall + inputFeeSats * UInt64(max(0, payments - 1)) + return (covered, receivedSats >= covered || payments == 0 ? covered : covered + inputFeeSats) + } + + /// The total the Progress top-up's partial hint names: `fundingTotal`'s `toSend` for what reached the address. + static func fundingAskTotal(_ record: SparkExitRecord, utxos: [EsploraUtxo]?) -> UInt64? { + guard let shortfall = record.fundingShortfallSats, let utxos else { return nil } + let fresh = newFunding(utxos, record: record) + return fundingTotal( + shortfall: shortfall, + payments: fresh.count, + receivedSats: fresh.reduce(0) { $0 + $1.value }, + inputFeeSats: inputFeeSats(record) + ).toSend } /// The least a P2WPKH funding output can hold: nodes refuse a payment below it. @@ -493,9 +511,7 @@ enum SparkExitMath { /// funding, priced for one payment, plus the input fee of each further one. `toSend` adds the input of the /// payment still to come while what arrived falls short, as the Progress top-up does. static func prestartFunding(fundingSats: UInt64, payments: Int, receivedSats: UInt64, feeRate: UInt64) -> (covered: UInt64, toSend: UInt64) { - let inputFee = inputVbytes * feeRate - let covered = fundingSats + inputFee * UInt64(max(0, payments - 1)) - return (covered, receivedSats >= covered || payments == 0 ? covered : covered + inputFee) + fundingTotal(shortfall: fundingSats, payments: payments, receivedSats: receivedSats, inputFeeSats: inputVbytes * feeRate) } /// What a partial payment leaves to send, at least the dust limit: nil before any new funding arrives and once diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 416396bcb..862adb5e8 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -183,8 +183,11 @@ struct SparkExitRecord: Codable, Equatable { var retriedAt: UInt32? /// The SDK's exit data from just before the first build, imported again before every rebuild. var exitStateSnapshot: String? - /// The stored record could not be read, so the exit is rebuilt from the chain with the funding it kept. + /// The stored record could not be read, so the exit is rebuilt from the chain with the funding it kept. Kept, + /// and shown, until a step of the rebuilt exit confirms beyond those already in a block when it was built. var isRebuiltFromChain: Bool? + /// The steps already confirmed when the rebuild from the chain was adopted. + var rebuiltFromChainConfirmedCount: Int? /// What confirmed sweeps and cooperative recoveries of replaced generations paid to savings, by txid. var earlierReceivedSats: [String: UInt64]? var leftBehindLeaves: [LeftBehindLeaf]? @@ -315,10 +318,21 @@ struct SparkExitRecord: Codable, Equatable { isRebuilding = false lastBuildAt = date failedLeafCount = built.failed.count - isRebuiltFromChain = nil + if isRebuiltFromChain == true { + rebuiltFromChainConfirmedCount = SparkExitMath.confirmedCount(built) + } isRedoRebuild = nil } + /// Ends the rebuilt-from-chain note once the rebuilt exit moved: a step confirmed beyond those already in a block. + mutating func noteProgressAfterRebuildFromChain(_ checked: RecoverFundsResponse) { + guard isRebuiltFromChain == true, let count = rebuiltFromChainConfirmedCount, SparkExitMath.confirmedCount(checked) > count else { + return + } + isRebuiltFromChain = nil + rebuiltFromChainConfirmedCount = nil + } + /// Whether a build that found nothing to build is waiting for a leaf that may still move. var isWaitingForLeaves: Bool { retrySince != nil diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 76e66d427..fcbbdecc5 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -580,12 +580,13 @@ struct SparkExitProgressView: View { if let address = record.funding.first?.address { switch fundingState { case let .ask(sats, receivedSats): - // The ask already counts the input fee of each payment, so it is what is missing. + // The ask counts the input fee of each payment, so it is what is missing; the hint + // names the full total, not the dust-floored ask added to what arrived. SparkExitFundingAddress( address: address, requiredSats: sats, receivedSats: receivedSats, - targetSats: sats + receivedSats, + targetSats: exit.fundingAskTotal ?? sats + receivedSats, showsSeen: false ) .padding(.top, 24) diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index 3b7d99f89..1108b9ca7 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -330,3 +330,74 @@ final class SparkExitFeeBumpTests: XCTestCase { XCTAssertTrue(t("lightning__spark_exit__fee_low", variables: ["rate": "2"]).contains("Pay a higher fee")) } } + +/// Findings of spark-exit's rerun of the Glow hardening (case 4 and R8). +final class SparkExitRerunTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + } + + private func driver() -> SparkExitDriver { + SparkExitDriver(chain: chain, recovering: sdk, makeSigner: { _ in StubCpfpSigner() }, save: { _ in }) + } + + /// Case 4: an ask of 2,283 at 2 sat/vB. The first hint already counts the second payment's input (136), and once + /// two payments are in, the hint and Progress name one full total, with the third payment's input too. + func testThePartialHintAsksForTheFullTotalWithEveryExtraInputsFee() { + let first = SparkExitMath.prestartFunding(fundingSats: 2283, payments: 1, receivedSats: 1283, feeRate: 2) + XCTAssertEqual(first.toSend, 2419) + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 1283, requiredSats: first.toSend), 1136) + + let second = SparkExitMath.prestartFunding(fundingSats: 2283, payments: 2, receivedSats: 2283, feeRate: 2) + XCTAssertEqual(second.covered, 2419, "what two payments must hold to build") + XCTAssertEqual(second.toSend, 2555) + XCTAssertEqual(SparkExitMath.partialFundingSats(receivedSats: 2283, requiredSats: second.toSend), 294, "272, floored at dust") + + // Progress names the same total: "₿ 2 283 of ₿ 2 555 received, send ₿ 294 more." + let record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 2283)) + let utxos = [SparkExitFixtures.utxo("p1", value: 1283), SparkExitFixtures.utxo("p2", value: 1000)] + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: utxos), .ask(sats: 294, receivedSats: 2283)) + XCTAssertEqual(SparkExitMath.fundingAskTotal(record, utxos: utxos), 2555) + + // Paying the first hint's 1,136 builds without a second round: 2,419 covers two payments. + let paid = [SparkExitFixtures.utxo("p1", value: 1283), SparkExitFixtures.utxo("p2", value: 1136)] + XCTAssertNotNil(SparkExitMath.fundingTopUp(paid, requiredSats: 2283, inputFeeSats: 136)) + } + + /// R8: a record rebuilt from the chain keeps saying so after the rebuild is adopted, until a step confirms beyond + /// those already in a block. + func testTheRebuiltFromChainNoteStaysUntilTheRebuildMoves() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.isRebuiltFromChain = true + record.isRebuilding = true + let built = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"]), + ]) + sdk.builds = [built] + + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(record.isRebuiltFromChain, true, "still shown once the rebuild is adopted") + XCTAssertEqual(record.rebuiltFromChainConfirmedCount, 1) + + sdk.checks = [CheckRecoverFundsResponse(recovery: built, verdict: .valid)] + record = try await driver().tick(record) + XCTAssertEqual(record.isRebuiltFromChain, true, "nothing new confirmed") + + let moved = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"], status: .confirmed(blockHeight: 901)), + ]) + sdk.checks = [CheckRecoverFundsResponse(recovery: moved, verdict: .valid)] + record = try await driver().tick(record) + XCTAssertNil(record.isRebuiltFromChain) + XCTAssertNil(record.rebuiltFromChainConfirmedCount) + } +} From 1cfce7a2fa596a6ec13d51e0a7eb3773e15e4b3b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 00:58:19 +0100 Subject: [PATCH 037/378] fix: spark exit sends lone transactions as a package when the host refuses them --- .../SparkExit/SparkExitBroadcaster.swift | 14 +++++- BitkitTests/SparkExitTests.swift | 48 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index 56d952367..5ca1a252f 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -165,11 +165,16 @@ struct EsploraSparkExitChain: SparkExitChain { func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome { var outcomes: [SparkExitBroadcastOutcome] = [] for host in broadcastHosts { - let attempt: SparkExitBroadcastOutcome? = if let child = tx.cpfpTxHex { + var attempt: SparkExitBroadcastOutcome? = if let child = tx.cpfpTxHex { await submitPackage(host: host, hexes: [tx.txHex, child], txid: tx.txid) } else { await submitSingle(host: host.url, hex: tx.txHex) } + // Some hosts (Spark's hosted regtest) refuse every lone transaction with a bare RPC -1, yet give a real answer + // to the same hex sent as a package of one. + if tx.cpfpTxHex == nil, let refused = attempt, SparkExitBroadcastClassifier.isUnplaced(refused) { + attempt = await submitPackage(host: host, hexes: [tx.txHex], txid: tx.txid) ?? refused + } // nil: this host cannot take it (no package endpoint, unreachable). guard let attempt else { continue } if attempt == .accepted { @@ -326,6 +331,13 @@ enum SparkExitBroadcastClassifier { status >= 500 || status == 429 || status == 403 ? .retryLater("HTTP \(status)") : nil } + /// A refusal that names nothing but RPC error -1, with no reason: the host refused how it was sent, not the + /// transaction, so the same hex goes again as a package of one. + static func isUnplaced(_ outcome: SparkExitBroadcastOutcome) -> Bool { + guard case let .rejected(text) = outcome else { return false } + return hasRpcCode(-1, in: text) && !text.contains("message") + } + /// The step paid no fee of its own and its CPFP child did not pay for it (`min relay fee not met, 0 < 17`): the /// child is missing or invalid, such as one spending the change of an earlier child that lost a race. static func isUnpaid(_ outcome: SparkExitBroadcastOutcome) -> Bool { diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index e66713b6f..f52b0afc5 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -2331,6 +2331,54 @@ final class SparkExitBroadcasterTests: XCTestCase { XCTAssertEqual(try String(decoding: XCTUnwrap(http.requests.first?.httpBody), as: UTF8.self), "02s1") } + /// Spark's hosted regtest answers a bare -1 to every lone transaction: the same hex goes again as a package of one, + /// on the same host, versioned path first. + func testALoneTransactionTheHostRefusesWithABareRpcErrorGoesAgainAsAPackage() async throws { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 400, #"{"error":"sendrawtransaction RPC error: {\"code\":-1}"}"#), + ("a.example/api/v1/txs/package", 404, "Not Found"), + ("a.example/api/txs/package", 200, #"{"package_msg":"success","tx-results":{}}"#), + ] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(http.requests.map { $0.url?.path }, ["/api/tx", "/api/v1/txs/package", "/api/txs/package"]) + XCTAssertEqual(try JSONDecoder().decode([String].self, from: XCTUnwrap(http.requests.last?.httpBody)), ["02s1"]) + } + + /// The package of one answers with the transaction's own error, which the classifier places. + func testThePackageOfOnesErrorIsClassified() async { + let http = FakeHTTP() + http.responses = [ + ("a.example/api/tx", 400, #"{"error":"sendrawtransaction RPC error: {\"code\":-1}"}"#), + ("a.example/api/v1/txs/package", 200, + #"{"package_msg":"transaction failed","tx-results":{"w":{"txid":"s1","error":"bad-txns-inputs-missingorspent"}}}"#), + ] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + + XCTAssertEqual(outcome, .inputsMissing("bad-txns-inputs-missingorspent")) + } + + /// A refusal with a reason stays a refusal, and an accepted lone transaction never goes to the package path. + func testOnlyABareRpcErrorTriesThePackagePath() async { + XCTAssertTrue(SparkExitBroadcastClassifier.isUnplaced(.rejected(#"sendrawtransaction RPC error: {"code":-1}"#))) + let withReason = #"sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"# + XCTAssertFalse(SparkExitBroadcastClassifier.isUnplaced(.rejected(withReason))) + XCTAssertFalse(SparkExitBroadcastClassifier.isUnplaced(.rejected(#"{"code":-1,"message":"TX decode failed"}"#))) + + let http = FakeHTTP() + http.responses = [("a.example/api/tx", 200, "s1")] + let chain = EsploraSparkExitChain(hosts: [hostA], http: http) + let outcome = await chain.broadcast(SparkExitFixtures.tx("s1", kind: .sweep)) + XCTAssertEqual(outcome, .accepted) + XCTAssertEqual(http.requests.map { $0.url?.path }, ["/api/tx"]) + } + func testTriesTheNextHostAfterARejection() async { let http = FakeHTTP() http.responses = [ From 3721f4bc5df72d4cac3de5624d8fca1f3fcdc2c3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 15:41:56 +0100 Subject: [PATCH 038/378] fix: spark exit retries a refused fee change sweep --- .../SparkExit/SparkExitBroadcaster.swift | 44 +++++-- .../Services/SparkExit/SparkExitDriver.swift | 57 ++++++-- .../Services/SparkExit/SparkExitFunding.swift | 11 ++ .../Services/SparkExit/SparkExitRecord.swift | 33 ++++- .../Settings/Advanced/SparkExitViews.swift | 57 ++++---- BitkitTests/SparkExitSweepTests.swift | 124 +++++++++++++++++- 6 files changed, 266 insertions(+), 60 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index 5ca1a252f..595d30391 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -41,6 +41,30 @@ enum SparkExitBroadcastOutcome: Equatable { } } +/// Reads esplora's `/fee-estimates` (target in blocks → sat/vB). A host may answer `{}` or skip targets, as the local +/// regtest mempool does: a missing target falls back to the next one estimated, the nearest longer one first, and +/// with none at all to the 1 sat/vB relay floor, so no step fails for want of an estimate. +enum SparkExitFeeEstimates { + static func rate(_ estimates: [String: Double], target: Int) -> UInt64 { + let targets = parsed(estimates) + guard let value = (targets.first { $0.target >= target } ?? targets.last)?.rate else { return SparkExitMath.minRelayFeeRate } + return rounded(value) + } + + /// The slowest tier: the longest target estimated (mempool's `economyFee` is its day-long rate). + static func economy(_ estimates: [String: Double]) -> UInt64 { + parsed(estimates).last.map { rounded($0.rate) } ?? SparkExitMath.minRelayFeeRate + } + + private static func parsed(_ estimates: [String: Double]) -> [(target: Int, rate: Double)] { + estimates.compactMap { key, value in Int(key).map { (target: $0, rate: value) } }.sorted { $0.target < $1.target } + } + + private static func rounded(_ rate: Double) -> UInt64 { + max(SparkExitMath.minRelayFeeRate, UInt64(max(0, rate).rounded(.up))) + } +} + /// The chain the Spark SDK reads, for what an exit does outside the SDK: broadcasting and the funding address. protocol SparkExitChain: Sendable { func tipHeight() async throws -> UInt32 @@ -140,19 +164,21 @@ struct EsploraSparkExitChain: SparkExitChain { /// esplora's `/fee-estimates`, whose target of one block is mempool's `fastestFee`. func fastestFeeRate() async throws -> UInt64 { - let estimates = try await JSONDecoder().decode([String: Double].self, from: get("/fee-estimates")) - guard let rate = estimates["1"] else { throw URLError(.cannotParseResponse) } - return max(1, UInt64(rate.rounded(.up))) + try await SparkExitFeeEstimates.rate(estimates(), target: 1) + } + + private func estimates() async throws -> [String: Double] { + try await JSONDecoder().decode([String: Double].self, from: get("/fee-estimates")) } /// esplora's targets of one and three blocks, mempool's `fastestFee` and `halfHourFee`. func networkFees() async throws -> SparkExitNetworkFees { - let estimates = try await JSONDecoder().decode([String: Double].self, from: get("/fee-estimates")) - guard let fastest = estimates["1"] else { throw URLError(.cannotParseResponse) } - let rate = { (value: Double) in max(1, UInt64(value.rounded(.up))) } - // The slowest tier is the longest target the host estimates (mempool's `economyFee` is its day-long rate). - let slowest = estimates.compactMap { key, value in Int(key).map { ($0, value) } }.max { $0.0 < $1.0 }?.1 ?? fastest - return SparkExitNetworkFees(fastest: rate(fastest), halfHour: rate(estimates["3"] ?? fastest), economy: rate(slowest)) + let estimates = try await estimates() + return SparkExitNetworkFees( + fastest: SparkExitFeeEstimates.rate(estimates, target: 1), + halfHour: SparkExitFeeEstimates.rate(estimates, target: 3), + economy: SparkExitFeeEstimates.economy(estimates) + ) } /// `/tx/{txid}/hex` looks in the mempool and the chain alike; `/status` of an unknown transaction may answer diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index f4519bb2d..371bcfb0f 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -594,19 +594,27 @@ struct SparkExitDriver { return } } - if let sweep = record.leftoverSweep, let oldTxid = sweep.txid, sweep.isConfirmed != true, !Task.isCancelled, - let address = record.funding.first?.address + // A sweep refused for its fee, before this rule or on the last tick, waits for the next block and a rate the + // node takes. + if let sweep = record.leftoverSweep, let rejection = sweep.rejection, sweep.isFeeRejection, !sweep.isAccepted { + await waitAfterFeeRefusal(&record, refusal: rejection) + try save(record) + } + if let sweep = record.leftoverSweep, let oldTxid = sweep.txid, sweep.isConfirmed != true, sweep.rejection == nil, + !Task.isCancelled, let address = record.funding.first?.address { let oldRate = sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte - var isReplaceDue = sweep.isFeeRejection - if !isReplaceDue, sweep.isAccepted, let sentAt = sweep.sentAtHeight, let tip = try? await chain.tipHeight(), + var isReplaceDue = false + if sweep.isAccepted, let sentAt = sweep.sentAtHeight, let tip = try? await chain.tipHeight(), tip.saturatingSub(sentAt) >= Self.sweepReplaceBlocks { // A node that dropped it answers not found: that is unconfirmed too. isReplaceDue = await (try? chain.txStatus(txid: oldTxid))?.confirmed != true } - if isReplaceDue, let fastest = try? await chain.fastestFeeRate() { - let rate = max(fastest, oldRate + 1) + if isReplaceDue { + // A host with no fee estimates still gets a replacement one sat/vB over the old rate. + let fastest = try? await chain.fastestFeeRate() + let rate = max(fastest ?? 0, oldRate + 1) let utxos = SparkExitMath.economicalUtxos(record.leftover.map { EsploraUtxo(txid: $0.txid, vout: $0.vout, value: $0.valueSats, status: EsploraTxStatus(confirmed: true, blockHeight: nil)) }, rate: rate) @@ -668,11 +676,33 @@ struct SparkExitDriver { record.leftoverSweep?.sentAtHeight = try? await chain.tipHeight() } // Once accepted, a later refusal is a node that already has it or a conflict with it confirming. - case let .rejected(reason) where !sweep.isAccepted: record.leftoverSweep?.rejection = reason + case let .rejected(reason) where !sweep.isAccepted: + if SparkExitRecord.LeftoverSweep(rejection: reason).isFeeRejection { + await waitAfterFeeRefusal(&record, refusal: reason) + } else { + record.leftoverSweep?.rejection = reason + } default: break } } + /// A sweep refused for its fee is not final: the leftover waits again, keeping when its wait began, and is built + /// again from the next block at the rate the refusal asks for (`refusedSweepRate`), or the economy rate if higher. + private func waitAfterFeeRefusal(_ record: inout SparkExitRecord, refusal: String) async { + guard let sweep = record.leftoverSweep else { return } + let tip = try? await chain.tipHeight() + let rate = SparkExitMath.refusedSweepRate(refusal, paidRate: sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte) + Logger.info("Spark exit leftover sweep refused for its fee (\(refusal)), tries again at \(rate) sat/vB", context: "SparkExitDriver") + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, + replacedTxids: sweep.replacedTxids, + waitingSince: sweep.waitingSince ?? tip ?? 0, + checkedAtHeight: tip ?? 0, + refusedFeeRate: rate, + retryAtHeight: (tip ?? 0) + 1 + ) + } + /// Builds the leftover sweep at the network's economy rate (`sweepFeeRate`) from the UTXOs worth more than their /// own input fee. While nothing is worth sending it waits, checking the rate again every `sweepRecheckBlocks`, /// and after `sweepWaitBlocks` keeps the change at the funding address, shown with the path that spends it. @@ -688,14 +718,16 @@ struct SparkExitDriver { try save(record) return } - guard tip.saturatingSub(waiting.checkedAtHeight ?? since) >= Self.sweepRecheckBlocks else { return } + let due = waiting.retryAtHeight ?? (waiting.checkedAtHeight ?? since).addingReportingOverflow(Self.sweepRecheckBlocks).partialValue + guard tip >= due else { return } } let utxos = try await chain.utxos(address: address) record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } let confirmed = utxos.filter(\.status.confirmed) guard !confirmed.isEmpty, confirmed.count == utxos.count else { return } let economy = try? await chain.networkFees().economy - let rate = SparkExitMath.sweepFeeRate(economy: economy, exitRate: record.feeRateSatPerVbyte) + // A rate a node refused stays refused: the least it asked for lifts the economy rate, past the exit's if need be. + let rate = max(SparkExitMath.sweepFeeRate(economy: economy, exitRate: record.feeRateSatPerVbyte), waiting?.refusedFeeRate ?? 0) let built = try await SparkExitLeftoverSweep.build( utxos: SparkExitMath.economicalUtxos(confirmed, rate: rate), fundingAddress: address, @@ -704,11 +736,13 @@ struct SparkExitDriver { signer: makeSigner(record.fundingIndex) ) if let built { + // The wait's start carries over, so a sweep refused again still ends at `sweepWaitBlocks`. record.leftoverSweep = SparkExitRecord.LeftoverSweep( txid: built.txid, txHex: built.txHex, valueSats: built.valueSats, - feeRateSatPerVbyte: rate + feeRateSatPerVbyte: rate, + waitingSince: waiting?.waitingSince ) } else { let since = waiting?.waitingSince ?? tip ?? 0 @@ -716,7 +750,8 @@ struct SparkExitDriver { record.leftoverSweep = SparkExitRecord.LeftoverSweep( feeRateSatPerVbyte: rate, waitingSince: since, - checkedAtHeight: tip ?? since + checkedAtHeight: tip ?? since, + refusedFeeRate: waiting?.refusedFeeRate ) } try save(record) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 545c2645d..a4beb858f 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -493,6 +493,17 @@ enum SparkExitMath { max(minRelayFeeRate, min(economy ?? exitRate, exitRate)) } + /// The rate a sweep refused for its fee is built at next: what the node's minimum asks for (`min relay fee not + /// met, 450 < 674` is the fee paid and the least it takes, at `paidRate`), and at least one sat/vB more. + static func refusedSweepRate(_ refusal: String, paidRate: UInt64) -> UInt64 { + let bumped = max(paidRate, minRelayFeeRate - 1) + 1 + guard let match = refusal.firstMatch(of: #/(?:min relay fee|mempool min fee) not met[^0-9]*(\d+)\s*<\s*(\d+)/#), + let paid = UInt64(match.1), let least = UInt64(match.2), paid > 0 + else { return bumped } + let leastRate = (least * max(paidRate, 1) + paid - 1) / paid + return max(leastRate, bumped) + } + /// The UTXOs worth sending at `rate`: each holds more than the fee of its own input. static func economicalUtxos(_ utxos: [EsploraUtxo], rate: UInt64) -> [EsploraUtxo] { utxos.filter { $0.value > inputVbytes * rate } diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 862adb5e8..f4269a73a 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -110,18 +110,49 @@ struct SparkExitRecord: Codable, Equatable { var checkedAtHeight: UInt32? /// The wait ran out: what is left stays at the funding address, with the path that spends it. var isDustKept: Bool? + /// The least rate a node asked for when it refused the sweep for its fee: the next build pays at least this. + var refusedFeeRate: UInt64? + /// After a refusal, the sweep is built again from this block instead of after `sweepRecheckBlocks`. + var retryAtHeight: UInt32? /// Waiting for fees to drop before sending the fee change. var isWaitingForFees: Bool { txid == nil && waitingSince != nil && isDustKept != true } - /// A refusal for its fee is not final: the sweep is built again at the network's current rate. + /// A refusal for its fee is not final: the sweep waits and is built again at a rate the node takes. var isFeeRejection: Bool { rejection.map { $0.range(of: "fee", options: .caseInsensitive) != nil } ?? false } } + /// The one line the result shows about the leftover fee funding, with the total of every UTXO at the address. + enum LeftoverLine: Equatable { + case sending(sats: UInt64) + case swept(sats: UInt64, txid: String) + /// Too small to send now, or refused for its fee: it waits for fees to drop. + case waiting(sats: UInt64) + /// The wait ran out: kept at the funding address, with the path that spends it. + case kept(sats: UInt64) + /// Refused for a reason other than its fee. + case rejected(sats: UInt64) + /// No sweep yet. + case stillAt(sats: UInt64) + } + + var leftoverLine: LeftoverLine? { + guard !leftover.isEmpty else { return nil } + let total = leftover.reduce(0) { $0 + $1.valueSats } + guard let sweep = leftoverSweep else { return .stillAt(sats: total) } + if let rejection = sweep.rejection { + return sweep.isFeeRejection || rejection.isEmpty ? .waiting(sats: total) : .rejected(sats: total) + } + if let txid = sweep.txid { + return sweep.isAccepted ? .swept(sats: sweep.valueSats, txid: txid) : .sending(sats: sweep.valueSats) + } + return sweep.isWaitingForFees ? .waiting(sats: total) : .kept(sats: total) + } + var version: Int = Self.currentVersion let network: String let createdAt: Date diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index fcbbdecc5..69db0901c 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -352,47 +352,40 @@ struct SparkExitLeftoverStatus: View { let record: SparkExitRecord let address: String - private var leftoverSats: UInt64 { - record.leftover.reduce(0) { $0 + $1.valueSats } + private var path: String { + SparkExitFundingKey.path(index: record.fundingIndex, network: Env.bitkitCoreNetwork) } + /// One line with the total of every UTXO at the funding address, never one per UTXO. var body: some View { VStack(alignment: .leading, spacing: 8) { - if let sweep = record.leftoverSweep, sweep.rejection == nil { - if let txid = sweep.txid { - let key = sweep.isAccepted ? "lightning__spark_exit__leftover_swept" : "lightning__spark_exit__leftover_sending" - BodySText(t(key, variables: ["amount": currency.primaryAmountText(sats: sweep.valueSats)])) - if sweep.isAccepted { - BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": txid])) - .lineLimit(2) - .truncationMode(.middle) - } - } else if sweep.isWaitingForFees { - BodySText(t("lightning__spark_exit__leftover_waiting", variables: ["amount": currency.primaryAmountText(sats: leftoverSats)])) - .fixedSize(horizontal: false, vertical: true) - } else { - BodySText(t("lightning__spark_exit__leftover_dust", variables: [ - "amount": currency.primaryAmountText(sats: leftoverSats), - "address": address, - "path": SparkExitFundingKey.path(index: record.fundingIndex, network: Env.bitkitCoreNetwork), - ])) + switch record.leftoverLine { + case let .sending(sats): + BodySText(t("lightning__spark_exit__leftover_sending", variables: ["amount": currency.primaryAmountText(sats: sats)])) + case let .swept(sats, txid): + BodySText(t("lightning__spark_exit__leftover_swept", variables: ["amount": currency.primaryAmountText(sats: sats)])) + BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": txid])) + .lineLimit(2) + .truncationMode(.middle) + case let .waiting(sats): + BodySText(t("lightning__spark_exit__leftover_waiting", variables: ["amount": currency.primaryAmountText(sats: sats)])) .fixedSize(horizontal: false, vertical: true) - } - } else if let sweep = record.leftoverSweep, !sweep.isFeeRejection { + case let .kept(sats): + BodySText(t("lightning__spark_exit__leftover_dust", variables: [ + "amount": currency.primaryAmountText(sats: sats), "address": address, "path": path, + ])) + .fixedSize(horizontal: false, vertical: true) + case let .rejected(sats): // Refused for good: where the coins stay, and the path that spends them with the recovery phrase. BodySText(t("lightning__spark_exit__leftover_rejected", variables: [ - "amount": currency.primaryAmountText(sats: leftoverSats), - "address": address, - "path": SparkExitFundingKey.path(index: record.fundingIndex, network: Env.bitkitCoreNetwork), + "amount": currency.primaryAmountText(sats: sats), "address": address, "path": path, ])) .fixedSize(horizontal: false, vertical: true) - } else { - ForEach(Array(record.leftover.enumerated()), id: \.offset) { _, leftover in - BodySText(t( - "lightning__spark_exit__leftover", - variables: ["amount": currency.primaryAmountText(sats: leftover.valueSats), "address": address] - )) - } + case let .stillAt(sats): + BodySText(t("lightning__spark_exit__leftover", variables: ["amount": currency.primaryAmountText(sats: sats), "address": address])) + .fixedSize(horizontal: false, vertical: true) + case nil: + EmptyView() } } .accessibilityElement(children: .contain) diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 5807030e1..44ea12de0 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -202,26 +202,28 @@ final class SparkExitSweepTests: XCTestCase { } /// R7: a sweep refused for its fee is built again at the network's current rate; any other refusal is final. - func testASweepRefusedForItsFeeIsRebuiltAtTheCurrentRate() async throws { + func testASweepRefusedForItsFeeIsRebuiltAtTheRateTheNodeTakes() async throws { chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)] chain.outcome = .rejected("min relay fee not met, 300 < 330") chain.fastestFee = 5 var record = try await driver().tick(endedRecord()) let refused = try XCTUnwrap(record.leftoverSweep) - XCTAssertTrue(refused.isFeeRejection) - XCTAssertTrue(record.needsDriving, "a fee refusal is not final") + XCTAssertTrue(refused.isWaitingForFees, "a fee refusal is not final") + XCTAssertEqual(refused.refusedFeeRate, 3, "330 over the 300 paid at 2 sat/vB, and at least one more") + XCTAssertTrue(record.needsDriving) chain.outcome = .accepted + chain.tip = 1001 record = try await driver().tick(record) let rebuilt = try XCTUnwrap(record.leftoverSweep) - XCTAssertNotEqual(rebuilt.txid, refused.txid) - XCTAssertEqual(rebuilt.feeRateSatPerVbyte, 5) - XCTAssertEqual(rebuilt.valueSats, 5035 - SparkExitLeftoverSweep.feeSats(inputs: 1, outputScriptLength: 22, feeRateSatPerVbyte: 5)) + XCTAssertNotNil(rebuilt.txid) + XCTAssertEqual(rebuilt.feeRateSatPerVbyte, 3) + XCTAssertEqual(rebuilt.valueSats, 5035 - SparkExitLeftoverSweep.feeSats(inputs: 1, outputScriptLength: 22, feeRateSatPerVbyte: 3)) XCTAssertTrue(rebuilt.isAccepted) XCTAssertNil(rebuilt.rejection) - XCTAssertEqual(rebuilt.sentAtHeight, 1000) + XCTAssertEqual(rebuilt.sentAtHeight, 1001) } /// R7: an accepted sweep still unconfirmed 144 blocks later is replaced (RBF) at a higher rate, at least one @@ -422,6 +424,114 @@ final class SparkExitSweepTests: XCTestCase { ) } + /// Run E (case 6): six 294-sat change outputs, swept at 1 sat/vB, refused "min relay fee not met, 450 < 674". The + /// sweep is not given up: it waits for the next block, then goes again at the rate the refusal asks for. + func testAFeeChangeSweepRefusedForItsFeeIsRetriedAtTheRateTheNodeAsksFor() async throws { + var record = endedRecord() + record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: String(repeating: "\($0)", count: 64), vout: 0, valueSats: 294) } + chain.economyFee = 1 + chain.utxosByAddress[key.address] = (1 ... 6).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 294) } + chain.outcome = .rejected("min relay fee not met, 450 < 674") + + record = try await driver().tick(record) + + let waiting = try XCTUnwrap(record.leftoverSweep) + XCTAssertNil(waiting.txid) + XCTAssertNil(waiting.rejection, "a fee refusal is never final") + XCTAssertTrue(waiting.isWaitingForFees) + XCTAssertEqual(waiting.refusedFeeRate, 2, "674 sats over the 450 vB paid at 1 sat/vB") + XCTAssertEqual(waiting.retryAtHeight, 1001) + XCTAssertTrue(record.needsDriving, "launch and background runs keep trying") + XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764)) + XCTAssertEqual(chain.broadcasts.count, 1) + + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.count, 1, "not again before the next block") + + chain.tip = 1001 + chain.outcome = .accepted + record = try await driver().tick(record) + + let sweep = try XCTUnwrap(record.leftoverSweep) + XCTAssertEqual(sweep.feeRateSatPerVbyte, 2) + XCTAssertEqual(sweep.isAccepted, true) + XCTAssertEqual(sweep.valueSats, 1764 - 900) + XCTAssertEqual(sweep.waitingSince, 1000, "the 4-week bound still counts from the first wait") + try assertValidP2wpkhSpend( + .init(txid: XCTUnwrap(sweep.txid), txHex: XCTUnwrap(sweep.txHex), valueSats: sweep.valueSats), + utxos: chain.utxosByAddress[key.address] ?? [] + ) + } + + /// A record from Run E's build, with the fee refusal stored as final, waits and retries on the next run too. + func testAStoredFeeRefusalWaitsAndRetries() async throws { + var record = endedRecord() + record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: String(repeating: "\($0)", count: 64), vout: 0, valueSats: 294) } + record.leftoverSweep = .init( + txid: "d770cd89", + txHex: "02", + valueSats: 1314, + rejection: "min relay fee not met, 450 < 674", + feeRateSatPerVbyte: 1 + ) + chain.utxosByAddress[key.address] = (1 ... 6).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 294) } + chain.economyFee = 1 + XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764), "one line with the total, never one per output") + + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.refusedFeeRate, 2) + XCTAssertTrue(chain.broadcasts.isEmpty) + + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.feeRateSatPerVbyte, 2) + XCTAssertEqual(record.leftoverSweep?.isAccepted, true) + } + + func testTheRefusedRateFollowsTheNodesMinimum() { + XCTAssertEqual(SparkExitMath.refusedSweepRate("min relay fee not met, 450 < 674", paidRate: 1), 2) + XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 900 < 2700", paidRate: 2), 6) + XCTAssertEqual(SparkExitMath.refusedSweepRate("insufficient fee", paidRate: 3), 4, "no numbers: one sat/vB more") + } + + /// The result names the leftover once, with its total, whatever state it is in. + func testTheLeftoverIsOneLineWithTheTotal() { + var record = endedRecord() + record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: "u\($0)", vout: 0, valueSats: 294) } + XCTAssertEqual(record.leftoverLine, .stillAt(sats: 1764)) + record.leftoverSweep = .init(waitingSince: 1000, checkedAtHeight: 1000) + XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764)) + record.leftoverSweep?.isDustKept = true + XCTAssertEqual(record.leftoverLine, .kept(sats: 1764)) + record.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "non-mandatory-script-verify-flag") + XCTAssertEqual(record.leftoverLine, .rejected(sats: 1764)) + } + + /// The local regtest mempool answers `{}`: every rate falls back, never failing the step. + func testFeeEstimatesFallBackToTheNextTargetThenTheRelayFloor() { + XCTAssertEqual(SparkExitFeeEstimates.rate([:], target: 1), 1) + XCTAssertEqual(SparkExitFeeEstimates.economy([:]), 1) + let sparse = ["2": 7.2, "6": 3.0, "144": 1.4] + XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 1), 8, "the next target up, rounded up") + XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 3), 3) + XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 1008), 2, "none longer: the longest there is") + XCTAssertEqual(SparkExitFeeEstimates.economy(sparse), 2) + XCTAssertEqual(SparkExitFeeEstimates.rate(["1": 0.2], target: 1), 1) + } + + func testAnEmptyEstimateMapStillPricesEveryStep() async throws { + let http = SparkExitBroadcasterTests.FakeHTTP() + http.responses = [("a.example/api/fee-estimates", 200, "{}"), ("a.example/api/fee-estimates", 200, "{}")] + let chain = try EsploraSparkExitChain(hosts: [XCTUnwrap(URL(string: "https://a.example/api"))], http: http) + + let fees = try await chain.networkFees() + let fastest = try await chain.fastestFeeRate() + + XCTAssertEqual(fees, SparkExitNetworkFees(fastest: 1, halfHour: 1, economy: 1)) + XCTAssertEqual(fastest, 1) + XCTAssertEqual(SparkExitFeeBumpOption.options(exitRate: 2, fees: fees).map(\.rate), [3], "a bump still offers a higher rate") + } + /// After about four weeks (4,032 blocks) the change stays at the funding address and the exit is let go. func testChangeTooSmallForFourWeeksIsKept() async throws { var record = endedRecord() From 328b29a960bf2705380a5d47c71fc9787a4a0cde Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:04:29 +0100 Subject: [PATCH 039/378] fix: spark exit retries a fee change sweep dropped from the mempool --- .../Services/SparkExit/SparkExitDriver.swift | 14 +++++- .../Services/SparkExit/SparkExitRecord.swift | 9 +++- .../Settings/Advanced/SparkExitViews.swift | 7 ++- BitkitTests/SparkExitSweepTests.swift | 50 +++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 371bcfb0f..5000cc209 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -675,13 +675,21 @@ struct SparkExitDriver { if record.leftoverSweep?.sentAtHeight == nil { record.leftoverSweep?.sentAtHeight = try? await chain.tipHeight() } - // Once accepted, a later refusal is a node that already has it or a conflict with it confirming. case let .rejected(reason) where !sweep.isAccepted: if SparkExitRecord.LeftoverSweep(rejection: reason).isFeeRejection { await waitAfterFeeRefusal(&record, refusal: reason) } else { record.leftoverSweep?.rejection = reason } + // Once accepted, a later refusal is mostly a node that already has it or a conflict with it confirming. A fee + // refusal of one no host holds any more means it was evicted when the mempool's floor rose: it waits and is + // built again over the same coins at the rate the node asks for, replacing it (RBF). + case let .rejected(reason) where SparkExitRecord.LeftoverSweep(rejection: reason).isFeeRejection: + let isKnown = try? await chain.isKnown(txid: txid) + if isKnown == false { + Logger.warn("Spark exit leftover sweep \(txid) left the mempool unconfirmed", context: "SparkExitDriver") + await waitAfterFeeRefusal(&record, refusal: reason) + } default: break } } @@ -692,10 +700,12 @@ struct SparkExitDriver { guard let sweep = record.leftoverSweep else { return } let tip = try? await chain.tipHeight() let rate = SparkExitMath.refusedSweepRate(refusal, paidRate: sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte) + // One a node accepted may still confirm: any of the replaced sweeps confirming ends the sweep. + let replaced = sweep.isAccepted ? (sweep.replacedTxids ?? []) + [sweep.txid].compactMap { $0 } : sweep.replacedTxids Logger.info("Spark exit leftover sweep refused for its fee (\(refusal)), tries again at \(rate) sat/vB", context: "SparkExitDriver") record.leftoverSweep = SparkExitRecord.LeftoverSweep( feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, - replacedTxids: sweep.replacedTxids, + replacedTxids: replaced, waitingSince: sweep.waitingSince ?? tip ?? 0, checkedAtHeight: tip ?? 0, refusedFeeRate: rate, diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index f4269a73a..03b78cfe5 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -128,7 +128,9 @@ struct SparkExitRecord: Codable, Equatable { /// The one line the result shows about the leftover fee funding, with the total of every UTXO at the address. enum LeftoverLine: Equatable { - case sending(sats: UInt64) + /// Built, or accepted by a node (`txid` set) but not in a block yet. + case sending(sats: UInt64, txid: String?) + /// In a block: only now does the result say it reached savings. case swept(sats: UInt64, txid: String) /// Too small to send now, or refused for its fee: it waits for fees to drop. case waiting(sats: UInt64) @@ -148,7 +150,10 @@ struct SparkExitRecord: Codable, Equatable { return sweep.isFeeRejection || rejection.isEmpty ? .waiting(sats: total) : .rejected(sats: total) } if let txid = sweep.txid { - return sweep.isAccepted ? .swept(sats: sweep.valueSats, txid: txid) : .sending(sats: sweep.valueSats) + if sweep.isConfirmed == true { + return .swept(sats: sweep.valueSats, txid: txid) + } + return .sending(sats: sweep.valueSats, txid: sweep.isAccepted ? txid : nil) } return sweep.isWaitingForFees ? .waiting(sats: total) : .kept(sats: total) } diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 69db0901c..7de6992c1 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -360,8 +360,13 @@ struct SparkExitLeftoverStatus: View { var body: some View { VStack(alignment: .leading, spacing: 8) { switch record.leftoverLine { - case let .sending(sats): + case let .sending(sats, txid): BodySText(t("lightning__spark_exit__leftover_sending", variables: ["amount": currency.primaryAmountText(sats: sats)])) + if let txid { + BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": txid])) + .lineLimit(2) + .truncationMode(.middle) + } case let .swept(sats, txid): BodySText(t("lightning__spark_exit__leftover_swept", variables: ["amount": currency.primaryAmountText(sats: sats)])) BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": txid])) diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 44ea12de0..6afa99e5d 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -488,6 +488,56 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(record.leftoverSweep?.isAccepted, true) } + /// Run F (case 6): the fee-change sweep went out at 2 sat/vB and was accepted, then the relay floor rose to 3 and + /// evicted it. Its rebroadcast is refused "492 < 738": it waits, goes again at 3 from the next block (RBF), and the + /// result never says it reached savings before it confirms. + func testASweepEvictedFromTheMempoolIsRetriedAtTheNewFloor() async throws { + var record = endedRecord() + let utxos = (1 ... 3).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 600) } + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: 0, valueSats: 600) } + chain.utxosByAddress[key.address] = utxos + chain.economyFee = 2 + + record = try await driver().tick(record) + let first = try XCTUnwrap(record.leftoverSweep) + let firstTxid = try XCTUnwrap(first.txid) + XCTAssertEqual(first.feeRateSatPerVbyte, 2) + XCTAssertEqual(first.valueSats, 1800 - 492) + XCTAssertTrue(first.isAccepted) + XCTAssertEqual(record.leftoverLine, .sending(sats: 1308, txid: firstTxid), "accepted is not yet in savings") + + // Still in the mempool: a fee refusal from another node changes nothing. + chain.mempoolTxids = [firstTxid] + chain.outcome = .rejected("min relay fee not met, 492 < 738") + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.txid, firstTxid) + + // Evicted: no host holds it, and the rebroadcast is refused for its fee. + chain.mempoolTxids = [] + record = try await driver().tick(record) + let waiting = try XCTUnwrap(record.leftoverSweep) + XCTAssertTrue(waiting.isWaitingForFees) + XCTAssertEqual(waiting.refusedFeeRate, 3, "738 over the 492 paid at 2 sat/vB") + XCTAssertEqual(waiting.retryAtHeight, 1001) + XCTAssertEqual(waiting.replacedTxids, [firstTxid], "the evicted sweep confirming would still end it") + XCTAssertEqual(record.leftoverLine, .waiting(sats: 1800)) + + chain.tip = 1001 + chain.outcome = .accepted + record = try await driver().tick(record) + let retried = try XCTUnwrap(record.leftoverSweep) + let retriedTxid = try XCTUnwrap(retried.txid) + XCTAssertNotEqual(retriedTxid, firstTxid) + XCTAssertEqual(retried.feeRateSatPerVbyte, 3) + XCTAssertEqual(retried.valueSats, 1800 - 738) + XCTAssertEqual(record.leftoverLine, .sending(sats: 1062, txid: retriedTxid)) + + chain.confirmedTxids = [retriedTxid] + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverLine, .swept(sats: 1062, txid: retriedTxid)) + XCTAssertFalse(record.needsDriving) + } + func testTheRefusedRateFollowsTheNodesMinimum() { XCTAssertEqual(SparkExitMath.refusedSweepRate("min relay fee not met, 450 < 674", paidRate: 1), 2) XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 900 < 2700", paidRate: 2), 6) From 56a77229ad8cc1ca3d63caa8a9a15f93271e861c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 18:06:23 +0100 Subject: [PATCH 040/378] fix: address move to spark review findings --- Bitkit/Managers/MoveToSparkManager.swift | 41 ++++++++++++++----- .../Localization/ar.lproj/Localizable.strings | 1 - .../Localization/ca.lproj/Localizable.strings | 1 - .../Localization/cs.lproj/Localizable.strings | 1 - .../Localization/de.lproj/Localizable.strings | 1 - .../Localization/el.lproj/Localizable.strings | 1 - .../Localization/en.lproj/Localizable.strings | 1 - .../es-419.lproj/Localizable.strings | 1 - .../Localization/es.lproj/Localizable.strings | 1 - .../Localization/fr.lproj/Localizable.strings | 1 - .../Localization/it.lproj/Localizable.strings | 1 - .../Localization/nl.lproj/Localizable.strings | 1 - .../Localization/pl.lproj/Localizable.strings | 1 - .../pt-BR.lproj/Localizable.strings | 1 - .../Localization/pt.lproj/Localizable.strings | 1 - .../Localization/ru.lproj/Localizable.strings | 1 - .../Settings/Advanced/MoveToSparkView.swift | 4 +- BitkitTests/MoveToSparkMathTests.swift | 12 +++++- 18 files changed, 44 insertions(+), 28 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 3f2a52e40..2c6a087b8 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -39,9 +39,14 @@ enum MoveToSparkMath { return reserved.overflow || reserved.partialValue >= limitSats ? 0 : limitSats - reserved.partialValue } - /// The routing fee cap for the payment: the quoted fee and its margin, so it never pays more than shown. + /// The routing fee to show for an estimate: the estimate and its margin, which also covers the estimate's rounding. + static func quotedFeeSats(estimateSats: UInt64) -> UInt64 { + estimateSats + feeMarginSats + } + + /// The routing fee cap for the payment: exactly the quoted fee, so it never pays more than shown. static func feeCapMsat(feeSats: UInt64) -> UInt64 { - (feeSats + feeMarginSats) * 1000 + feeSats * 1000 } /// The smaller amount to offer after one did not route: half of it, or nothing below the minimum. @@ -70,11 +75,11 @@ enum MoveToSparkPayments { private static var paymentHashes = Set() static func insert(_ paymentHash: String) { - paymentHashes.insert(paymentHash) + paymentHashes.insert(paymentHash.lowercased()) } static func contains(_ paymentHash: String?) -> Bool { - paymentHash.map(paymentHashes.contains) ?? false + paymentHash.map { paymentHashes.contains($0.lowercased()) } ?? false } } @@ -126,6 +131,7 @@ final class MoveToSparkManager { /// Quotes moving `targetSats`, or everything the channels can send. func quote(wallet: WalletViewModel, targetSats: UInt64? = nil) async { state = .quoting + await wallet.syncStateAsync() do { state = try await .confirm(makeQuote(channels: wallet.channels, targetSats: targetSats), isMoving: false) } catch { @@ -145,7 +151,7 @@ final class MoveToSparkManager { sats: quote.amountSats, maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats) ) - wallet.syncState() + await wallet.syncStateAsync() let openChannels = (wallet.channels ?? []).filter(\.isChannelReady) state = .done( amountSats: quote.amountSats, @@ -155,7 +161,9 @@ final class MoveToSparkManager { ) } catch { Logger.error("Failed to move '\(quote.amountSats)' sats to Spark: \(error)", context: "MoveToSpark") - state = .tooLarge(amountSats: quote.amountSats, smallerSats: MoveToSparkMath.smallerAmount(quote.amountSats)) + state = Self.isNoRoute(error) + ? .tooLarge(amountSats: quote.amountSats, smallerSats: MoveToSparkMath.smallerAmount(quote.amountSats)) + : .failed(.unknown) } } @@ -174,16 +182,17 @@ final class MoveToSparkManager { var fitting: MoveToSparkQuote? // The first amount leaves room for a guessed fee; once the real fee is known, the amount grows to fit it. for _ in 0 ..< Self.maxQuoteRounds where amountSats >= MoveToSparkMath.minMoveSats { - let feeSats: UInt64 + let estimateSats: UInt64 do { - feeSats = try await lightningService.estimateRoutingFees(bolt11: invoice, amountSats: amountSats, channelsOnly: true) + estimateSats = try await lightningService.estimateRoutingFees(bolt11: invoice, amountSats: amountSats, channelsOnly: true) } catch { if let fitting { return fitting } - throw MoveToSparkError.tooLarge(amountSats: amountSats) + throw Self.isNoRoute(error) ? MoveToSparkError.tooLarge(amountSats: amountSats) : error } - let nextSats = min(wantedSats, MoveToSparkMath.amountAfterFee(limitSats: limitSats, feeSats: feeSats)) + let feeSats = MoveToSparkMath.quotedFeeSats(estimateSats: estimateSats) + let nextSats = min(wantedSats, MoveToSparkMath.amountAfterFee(limitSats: limitSats, feeSats: estimateSats)) if amountSats + feeSats <= limitSats { let quote = MoveToSparkQuote(invoice: invoice, amountSats: amountSats, feeSats: feeSats) fitting = quote @@ -199,6 +208,18 @@ final class MoveToSparkManager { throw MoveToSparkError.tooLarge(amountSats: amountSats) } + /// Whether the payment failed for want of a route that carries the amount, so a smaller one may route. + private static func isNoRoute(_ error: Error) -> Bool { + let appError = error as? AppError + if case .RouteNotFound = (appError?.underlyingError ?? error) as? NodeError { + return true + } + switch appError?.paymentFailureReason { + case .routeNotFound, .retriesExhausted: return true + default: return false + } + } + private static func state(for error: Error) -> MoveToSparkState { switch error as? MoveToSparkError { case let .tooLarge(amountSats): .tooLarge(amountSats: amountSats, smallerSats: MoveToSparkMath.smallerAmount(amountSats)) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 2b25b4605..e19279a13 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -303,7 +303,6 @@ "lightning__move_to_spark__failed_title" = "تعذّر إتمام\nالنقل"; "lightning__move_to_spark__failed_unknown" = "حدث خطأ أثناء تحضير النقل. حاول مرة أخرى."; "lightning__move_to_spark__fee" = "رسوم التوجيه"; -"lightning__move_to_spark__in_flight_text" = "الدفعة في طريقها وستظهر في Spark بمجرد إتمامها. ثم أغلق الاتصال الفارغ لإعادة احتياطيه إلى المدخرات."; "lightning__move_to_spark__intro_text" = "يقوم Blocktank بإغلاق اتصالات Lightning الخاصة به. انقل رصيدك إلى Spark الآن: فهو الخيار الأقل رسومًا، ويبقى ضمن الإنفاق."; "lightning__move_to_spark__intro_title" = "اتصالك على وشك\nالإغلاق"; "lightning__move_to_spark__move_half" = "نقل النصف"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 792e69310..0dcdd4324 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -340,7 +340,6 @@ "lightning__move_to_spark__failed_title" = "No s'ha pogut\nmoure"; "lightning__move_to_spark__failed_unknown" = "Alguna cosa ha anat malament en preparar el moviment. Torna-ho a provar."; "lightning__move_to_spark__fee" = "Tarifa d'enrutament"; -"lightning__move_to_spark__in_flight_text" = "El pagament està en camí i apareixerà a Spark quan s'hagi liquidat. Després tanca la connexió buida per tornar-ne la reserva als Estalvis."; "lightning__move_to_spark__intro_text" = "Blocktank està tancant les seves connexions Lightning. Mou ara el teu saldo a Spark: és l'opció amb menys tarifes i es manté a Despesa."; "lightning__move_to_spark__intro_title" = "La teva connexió\nes tanca"; "lightning__move_to_spark__move_half" = "Moure meitat"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 27b9677e0..394b8e42b 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -356,7 +356,6 @@ "lightning__move_to_spark__failed_title" = "Přesun se\nnezdařil"; "lightning__move_to_spark__failed_unknown" = "Při přípravě přesunu se něco pokazilo. Zkuste to znovu."; "lightning__move_to_spark__fee" = "Poplatek za směrování"; -"lightning__move_to_spark__in_flight_text" = "Platba je na cestě a ve Spark se zobrazí po vypořádání. Poté uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor."; "lightning__move_to_spark__intro_text" = "Blocktank uzavírá svá spojení Lightning. Přesuňte svůj zůstatek do Spark hned: je to možnost s nejnižšími poplatky a zůstane v Útratách."; "lightning__move_to_spark__intro_title" = "Vaše spojení\nse uzavírá"; "lightning__move_to_spark__move_half" = "Přesun půlku"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index bc1567b02..12630a967 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -354,7 +354,6 @@ "lightning__move_to_spark__failed_title" = "Das Verschieben\nschlug fehl"; "lightning__move_to_spark__failed_unknown" = "Beim Vorbereiten der Verschiebung ist etwas schiefgelaufen. Versuchen Sie es erneut."; "lightning__move_to_spark__fee" = "Routing-Gebühr"; -"lightning__move_to_spark__in_flight_text" = "Die Zahlung ist unterwegs und erscheint in Spark, sobald sie abgeschlossen ist. Schließen Sie dann die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben."; "lightning__move_to_spark__intro_text" = "Blocktank schließt seine Lightning-Verbindungen. Verschieben Sie Ihr Guthaben jetzt zu Spark: Das ist die günstigste Option, und es bleibt im Ausgabenkonto."; "lightning__move_to_spark__intro_title" = "Ihre Verbindung wird\ngeschlossen"; "lightning__move_to_spark__move_half" = "Halb senden"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index bbb5cabf6..a9074566e 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -327,7 +327,6 @@ "lightning__move_to_spark__failed_title" = "Η μεταφορά\nαπέτυχε"; "lightning__move_to_spark__failed_unknown" = "Κάτι πήγε στραβά κατά την προετοιμασία της μεταφοράς. Δοκιμάστε ξανά."; "lightning__move_to_spark__fee" = "Τέλος δρομολόγησης"; -"lightning__move_to_spark__in_flight_text" = "Η πληρωμή βρίσκεται σε εξέλιξη και θα εμφανιστεί στο Spark μόλις διακανονιστεί. Έπειτα κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις."; "lightning__move_to_spark__intro_text" = "Το Blocktank κλείνει τις συνδέσεις Lightning του. Μεταφέρετε το υπόλοιπό σας στο Spark τώρα: είναι η επιλογή με τα χαμηλότερα τέλη και παραμένει στις Δαπάνες."; "lightning__move_to_spark__intro_title" = "Η σύνδεσή σας\nκλείνει"; "lightning__move_to_spark__move_half" = "Το μισό"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 290f12f1c..c40d6988c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -349,7 +349,6 @@ "lightning__move_to_spark__failed_title" = "Could not\nmove"; "lightning__move_to_spark__failed_unknown" = "Something went wrong while preparing the move. Try again."; "lightning__move_to_spark__fee" = "Routing fee"; -"lightning__move_to_spark__in_flight_text" = "The payment is on its way and shows in Spark once it settles. Then close the emptied connection to return its reserve to Savings."; "lightning__move_to_spark__intro_text" = "Blocktank is closing its Lightning connections. Move your balance to Spark now: it is the lowest-fee option, and it stays in Spending."; "lightning__move_to_spark__intro_title" = "Your connection\nis closing"; "lightning__move_to_spark__move_half" = "Move Half"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index e60b8669b..aad07d828 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -357,7 +357,6 @@ "lightning__move_to_spark__failed_title" = "No se pudo\nmover"; "lightning__move_to_spark__failed_unknown" = "Algo salió mal al preparar el movimiento. Inténtelo de nuevo."; "lightning__move_to_spark__fee" = "Comisión de enrutamiento"; -"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; "lightning__move_to_spark__intro_text" = "Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto."; "lightning__move_to_spark__intro_title" = "Su conexión\nse cierra"; "lightning__move_to_spark__move_half" = "Mover mitad"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 643c37078..8f47e1b4c 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -341,7 +341,6 @@ "lightning__move_to_spark__failed_title" = "No se pudo\nmover"; "lightning__move_to_spark__failed_unknown" = "Algo salió mal al preparar el movimiento. Inténtelo de nuevo."; "lightning__move_to_spark__fee" = "Comisión de enrutamiento"; -"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; "lightning__move_to_spark__intro_text" = "Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto."; "lightning__move_to_spark__intro_title" = "Su conexión\nse cierra"; "lightning__move_to_spark__move_half" = "Mover mitad"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index f9e31ea35..83c4ea2b7 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -357,7 +357,6 @@ "lightning__move_to_spark__failed_title" = "Le déplacement\na échoué"; "lightning__move_to_spark__failed_unknown" = "Un problème est survenu pendant la préparation du déplacement. Réessayez."; "lightning__move_to_spark__fee" = "Frais de routage"; -"lightning__move_to_spark__in_flight_text" = "Le paiement est en route et apparaîtra sur Spark une fois réglé. Fermez ensuite la connexion vidée pour rendre sa réserve à Épargnes."; "lightning__move_to_spark__intro_text" = "Blocktank ferme ses connexions Lightning. Déplacez votre solde vers Spark maintenant : c'est l'option la moins coûteuse, et il reste dans Dépenses."; "lightning__move_to_spark__intro_title" = "Votre connexion\nse ferme"; "lightning__move_to_spark__move_half" = "Moitié"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index a30b36f4f..31c9f0bef 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -350,7 +350,6 @@ "lightning__move_to_spark__failed_title" = "Impossibile\nspostare"; "lightning__move_to_spark__failed_unknown" = "Qualcosa è andato storto durante la preparazione dello spostamento. Riprova."; "lightning__move_to_spark__fee" = "Commissione di instradamento"; -"lightning__move_to_spark__in_flight_text" = "Il pagamento è in arrivo e compare su Spark una volta completato. Poi chiudi la connessione svuotata per restituire la sua riserva ai Risparmi."; "lightning__move_to_spark__intro_text" = "Blocktank sta chiudendo le sue connessioni Lightning. Sposta ora il tuo saldo su Spark: è l'opzione con le commissioni più basse e resta in Spesa."; "lightning__move_to_spark__intro_title" = "La tua connessione\nsi sta chiudendo"; "lightning__move_to_spark__move_half" = "Sposta Metà"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 0c0501135..96e3be5c3 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -354,7 +354,6 @@ "lightning__move_to_spark__failed_title" = "Verplaatsen\nmislukt"; "lightning__move_to_spark__failed_unknown" = "Er is iets misgegaan bij het voorbereiden van de verplaatsing. Probeer het opnieuw."; "lightning__move_to_spark__fee" = "Routeringsvergoeding"; -"lightning__move_to_spark__in_flight_text" = "De betaling is onderweg en verschijnt in Spark zodra ze is afgerond. Sluit daarna de lege verbinding om de reserve terug te geven aan uw spaargeld."; "lightning__move_to_spark__intro_text" = "Blocktank sluit zijn Lightning-verbindingen. Verplaats uw saldo nu naar Spark: dit is de goedkoopste optie en het blijft op uw bestedingssaldo."; "lightning__move_to_spark__intro_title" = "Uw verbinding wordt\ngesloten"; "lightning__move_to_spark__move_half" = "Stuur helft"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 36d0453b9..aae48cfcd 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -356,7 +356,6 @@ "lightning__move_to_spark__failed_title" = "Nie udało się\nprzenieść"; "lightning__move_to_spark__failed_unknown" = "Coś poszło nie tak podczas przygotowania przeniesienia. Spróbuj ponownie."; "lightning__move_to_spark__fee" = "Opłata za routing"; -"lightning__move_to_spark__in_flight_text" = "Płatność jest w drodze i pojawi się w Spark po rozliczeniu. Następnie zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności."; "lightning__move_to_spark__intro_text" = "Blocktank zamyka swoje połączenia Lightning. Przenieś teraz saldo do Spark: to opcja z najniższymi opłatami, a środki zostają w Wydatkach."; "lightning__move_to_spark__intro_title" = "Twoje połączenie\nsię zamyka"; "lightning__move_to_spark__move_half" = "Połowę"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 4d8cbf80e..c86d058ce 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -356,7 +356,6 @@ "lightning__move_to_spark__failed_title" = "Não foi possível\nmover"; "lightning__move_to_spark__failed_unknown" = "Algo deu errado ao preparar a movimentação. Tente novamente."; "lightning__move_to_spark__fee" = "Taxa de roteamento"; -"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark assim que for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; "lightning__move_to_spark__intro_text" = "O Blocktank está fechando suas conexões Lightning. Mova seu saldo para o Spark agora: é a opção de menor taxa e ele continua em Gastos."; "lightning__move_to_spark__intro_title" = "Sua conexão está\nfechando"; "lightning__move_to_spark__move_half" = "Mover Metade"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 08d2dec28..4dc6b273e 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -354,7 +354,6 @@ "lightning__move_to_spark__failed_title" = "Não foi possível\nmover"; "lightning__move_to_spark__failed_unknown" = "Algo correu mal ao preparar a movimentação. Tente novamente."; "lightning__move_to_spark__fee" = "Taxa de encaminhamento"; -"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark quando for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; "lightning__move_to_spark__intro_text" = "O Blocktank está a fechar as suas conexões Lightning. Mova já o seu saldo para o Spark: é a opção com as taxas mais baixas e fica em Gastos."; "lightning__move_to_spark__intro_title" = "A sua conexão\nvai fechar"; "lightning__move_to_spark__move_half" = "Mover Metade"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 108035862..18e1daacf 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -351,7 +351,6 @@ "lightning__move_to_spark__failed_title" = "Не удалось\nперенести"; "lightning__move_to_spark__failed_unknown" = "Что-то пошло не так при подготовке переноса. Попробуйте ещё раз."; "lightning__move_to_spark__fee" = "Комиссия за маршрутизацию"; -"lightning__move_to_spark__in_flight_text" = "Платёж в пути и появится в Spark после завершения. Затем закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения."; "lightning__move_to_spark__intro_text" = "Blocktank закрывает свои соединения Lightning. Перенесите баланс в Spark сейчас: это вариант с самой низкой комиссией, и средства остаются в Расходах."; "lightning__move_to_spark__intro_title" = "Ваше соединение\nзакрывается"; "lightning__move_to_spark__move_half" = "Половину"; diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift index b043afb4d..10d4f7419 100644 --- a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -101,7 +101,9 @@ struct MoveToSparkView: View { CustomButton(title: t("common__close")) { // Closing returns to Lightning Connections through the channel's detail, as a close from there does. navigation.navigateBack() - navigation.navigate(.connections) + if navigation.path.last != .connections { + navigation.navigate(.connections) + } if let closeChannel { navigation.navigate(.connectionDetail(channelId: closeChannel.channelIdString)) navigation.navigate(.closeConnection(channel: closeChannel)) diff --git a/BitkitTests/MoveToSparkMathTests.swift b/BitkitTests/MoveToSparkMathTests.swift index 0f90725f9..21bc97362 100644 --- a/BitkitTests/MoveToSparkMathTests.swift +++ b/BitkitTests/MoveToSparkMathTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import LDKNode import XCTest final class MoveToSparkMathTests: XCTestCase { @@ -17,8 +18,12 @@ final class MoveToSparkMathTests: XCTestCase { XCTAssertEqual(MoveToSparkMath.amountAfterFee(limitSats: 5, feeSats: 9000), 0) } - func testFeeCapCoversTheQuotedFeeAndItsMargin() { - XCTAssertEqual(MoveToSparkMath.feeCapMsat(feeSats: 30), 32000) + func testFeeCapIsTheQuotedFee() { + let feeSats = MoveToSparkMath.quotedFeeSats(estimateSats: 30) + XCTAssertEqual(feeSats, 32) + XCTAssertEqual(MoveToSparkMath.feeCapMsat(feeSats: feeSats), 32000) + // Amount and quoted fee together fit under the limit. + XCTAssertEqual(MoveToSparkMath.amountAfterFee(limitSats: 1_000_000, feeSats: 30) + feeSats, 1_000_000) } func testSmallerAmountHalvesUntilTheMinimum() { @@ -29,5 +34,8 @@ final class MoveToSparkMathTests: XCTestCase { func testMoveIsOfferedOnlyWithAUsableSpark() { XCTAssertFalse(MoveToSparkMath.canMove(channels: [], isSparkUsable: true)) XCTAssertFalse(MoveToSparkMath.canMove(channels: nil, isSparkUsable: false)) + let channel = ChannelDetails.mock(outboundCapacityMsat: 50_000_000) + XCTAssertFalse(MoveToSparkMath.canMove(channels: [channel], isSparkUsable: false)) + XCTAssertTrue(MoveToSparkMath.canMove(channels: [channel], isSparkUsable: true)) } } From 61a7d7a6f62ddf0626c19e667e24fc3d00c5ff84 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 18:41:33 +0100 Subject: [PATCH 041/378] fix: import libsecp256k1 in the spark exit sweep test --- BitkitTests/SparkExitSweepTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 6afa99e5d..3ab2f6d2e 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -1,7 +1,7 @@ @testable import Bitkit import BreezSdkSpark import CryptoKit -import secp256k1 +import libsecp256k1 import XCTest final class SparkExitSweepTests: XCTestCase { From 106b5939159970c8b0863a4cae9493310d667fa1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 18:46:28 +0100 Subject: [PATCH 042/378] fix: keep move to spark outcome across unlock --- Bitkit/Managers/MoveToSparkManager.swift | 19 +++++++++++++++++++ .../Settings/Advanced/MoveToSparkView.swift | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 2c6a087b8..e33ab632f 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -114,11 +114,16 @@ enum MoveToSparkState: Equatable { /// Blocktank is closing its channels, and this is the cheapest way to keep the balance in Spending. @Observable @MainActor final class MoveToSparkManager { + /// Shared so a move and its outcome outlive the screen, which is rebuilt when the app is unlocked. + static let shared = MoveToSparkManager() + private static let invoiceDescription = "Move to Spark" private static let invoiceExpirySecs: UInt32 = 3600 private static let maxQuoteRounds = 4 private(set) var state: MoveToSparkState = .quoting + /// A move was started and its outcome is still the screen's, so showing the screen again keeps it. + private var keepsOutcome = false private let lightningService: LightningService private let spark: SparkManager @@ -128,8 +133,21 @@ final class MoveToSparkManager { spark = .shared } + /// Opens the screen: quotes a new move, unless a move started earlier is still running or showing its outcome. + func start(wallet: WalletViewModel) async { + guard !keepsOutcome else { return } + await quote(wallet: wallet) + } + + /// Forgets the last move once its screen is left. + func reset() { + keepsOutcome = false + state = .quoting + } + /// Quotes moving `targetSats`, or everything the channels can send. func quote(wallet: WalletViewModel, targetSats: UInt64? = nil) async { + keepsOutcome = false state = .quoting await wallet.syncStateAsync() do { @@ -142,6 +160,7 @@ final class MoveToSparkManager { func move(wallet: WalletViewModel) async { guard case let .confirm(quote, isMoving) = state, !isMoving else { return } + keepsOutcome = true state = .confirm(quote, isMoving: true) do { let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: quote.invoice).paymentHash() diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift index 10d4f7419..e06db93f6 100644 --- a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -6,7 +6,7 @@ struct MoveToSparkView: View { @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var wallet: WalletViewModel - @State private var manager = MoveToSparkManager() + @State private var manager = MoveToSparkManager.shared var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -30,7 +30,13 @@ struct MoveToSparkView: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .task { - await manager.quote(wallet: wallet) + await manager.start(wallet: wallet) + } + .onDisappear { + // Unlocking rebuilds the screen with the route still in the path; only leaving it ends the move. + if !navigation.path.contains(.moveToSpark) { + manager.reset() + } } } From 7edb344c5ece888100dc96cd3bc1a133ef0b6774 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 19:33:54 +0100 Subject: [PATCH 043/378] fix: skip move to spark sheet in e2e builds --- Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift index cf173e027..73f23b041 100644 --- a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift +++ b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift @@ -10,7 +10,8 @@ struct MoveToSparkTimedSheet: TimedSheetItem { @MainActor func shouldShow() async -> Bool { - guard !app.hasSeenMoveToSparkIntro else { return false } + // E2E runs open new channels and do not expect the move offer covering Home; the journey covers the sheet. + guard !Env.isE2E, !app.hasSeenMoveToSparkIntro else { return false } let spark = SparkManager.shared return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(spark)) } From 38d3ba3a4d8897f3f6dbabebe16b1ae76f9ba950 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 19:38:54 +0100 Subject: [PATCH 044/378] Revert "fix: skip move to spark sheet in e2e builds" This reverts commit 7edb344c5ece888100dc96cd3bc1a133ef0b6774. --- Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift index 73f23b041..cf173e027 100644 --- a/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift +++ b/Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift @@ -10,8 +10,7 @@ struct MoveToSparkTimedSheet: TimedSheetItem { @MainActor func shouldShow() async -> Bool { - // E2E runs open new channels and do not expect the move offer covering Home; the journey covers the sheet. - guard !Env.isE2E, !app.hasSeenMoveToSparkIntro else { return false } + guard !app.hasSeenMoveToSparkIntro else { return false } let spark = SparkManager.shared return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(spark)) } From 14f29f9093e54f6f21e62bd368b8719eb88190e8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 19:52:46 +0100 Subject: [PATCH 045/378] fix: harden spark payments, withdrawals and wipe - Record and deliver a settled Spark payment before reading the balance again, so a failed read never drops it. - A sent withdrawal no longer reads as failed when the refresh after it fails; Spark's own failed status still fails it. - The Spark withdrawal fee must be priced before the swipe, is re-quoted at the swipe, and the withdrawal refuses a fee above the one shown. - Serialize Spark refreshes, and let a wipe stop an in-flight connect, clear every per-wallet value and surface its failures. - Map Spark failures to app copy; the SDK text goes to the log only. - Duplicate-payment checks fail closed when Spark can't list its payments; a retried invoice settles from its live attempt with its fee. - Transfer to Spending needs a deposit above the claim fee cap, so it stays claimable. - Check a Spark send against the fee it was quoted, and make the sendable estimate overflow-safe. - Guard the rail event queue with a lock. - Remove the unused deposit-claimed callback and recoverable funds value. --- Bitkit/Managers/SparkManager.swift | 236 ++++++++++++++---- Bitkit/Services/LightningService.swift | 65 +++-- .../Services/PaykitPaymentProofService.swift | 8 +- .../PrivatePaykitService+Invoices.swift | 13 +- .../PrivatePaykitService+Payments.swift | 8 +- .../QuickPayPaymentCoordinator.swift | 3 +- Bitkit/ViewModels/SparkTransferFlow.swift | 6 + Bitkit/ViewModels/TransferViewModel.swift | 48 +++- Bitkit/ViewModels/WalletViewModel.swift | 9 +- .../Views/Transfer/SavingsConfirmView.swift | 42 +++- Bitkit/Views/Transfer/SpendingConfirm.swift | 21 +- BitkitTests/ChannelFreeRailTests.swift | 52 +++- BitkitTests/SparkPaymentSafetyTests.swift | 185 ++++++++++++++ 13 files changed, 594 insertions(+), 102 deletions(-) create mode 100644 BitkitTests/SparkPaymentSafetyTests.swift diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 16c906365..3d8fbeb1e 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -27,12 +27,9 @@ final class SparkManager: ChannelFreeLightningRail { private(set) var balanceSats: UInt64 = 0 /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. private(set) var pendingDepositSats: UInt64 = 0 - private(set) var recoverableFundsSats: UInt64 = 0 /// On-chain deposits into Spark, pending ones first, for the Lightning Connections screens. private(set) var deposits: [SparkDeposit] = [] private(set) var withdrawals: [SparkWithdrawal] = [] - /// Called when Spark claims on-chain deposits, which completes a Savings to Spending transfer. - var onDepositsClaimed: (() -> Void)? /// Called after the Spark balance or readiness was read again, so balances and the receive invoice can follow. var onStateChanged: (() -> Void)? @@ -47,6 +44,11 @@ final class SparkManager: ChannelFreeLightningRail { @ObservationIgnored private var sentWithdrawalTxIds: Set = [] @ObservationIgnored private var listenerId: String? @ObservationIgnored private var connectTask: Task? + /// Bumped by `wipe`, so a connect that started before it disconnects instead of keeping the wiped wallet. + @ObservationIgnored private var connectGeneration = 0 + /// The refresh in flight; callers during it wait for it, and it reads once more for them. + @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored private var refreshAgain = false private init() {} @@ -83,7 +85,8 @@ final class SparkManager: ChannelFreeLightningRail { do { if sdk == nil { if connectTask == nil { - connectTask = Task { try await connect(walletIndex: walletIndex, network: network) } + let generation = connectGeneration + connectTask = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) } } try await connectTask?.value connectTask = nil @@ -99,7 +102,7 @@ final class SparkManager: ChannelFreeLightningRail { } } - private func connect(walletIndex: Int, network: BreezSdkSpark.Network) async throws { + private func connect(walletIndex: Int, network: BreezSdkSpark.Network, generation: Int) async throws { guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { throw CustomServiceError.mnemonicNotFound } @@ -114,6 +117,10 @@ final class SparkManager: ChannelFreeLightningRail { await builder.withRestChainService(url: chainServiceUrl, apiType: .esplora, credentials: nil) } let connected = try await builder.build() + guard generation == connectGeneration else { + try? await connected.disconnect() + throw CancellationError() + } listenerId = await connected.addEventListener(listener: SparkEventListener { [weak self] event in await self?.onSdkEvent(event) }) @@ -139,25 +146,52 @@ final class SparkManager: ChannelFreeLightningRail { return config } - func wipe(walletIndex: Int) async { + /// Disconnects and deletes this wallet's Spark data. A connect still running is waited for first, so it can't + /// keep the wiped wallet; throws when the data could not be deleted, so the wipe is retried. + func wipe(walletIndex: Int) async throws { + connectGeneration += 1 + if let connectTask { + _ = try? await connectTask.value + } + connectTask = nil if LightningService.shared.channelFreeRail === self { LightningService.shared.channelFreeRail = nil } + var disconnectError: Error? if let sdk { if let listenerId { _ = await sdk.removeEventListener(id: listenerId) } - try? await sdk.disconnect() + do { + try await sdk.disconnect() + } catch { + Logger.error("Failed to disconnect Spark before wipe: \(error)", context: "SparkManager") + disconnectError = error + } } sdk = nil listenerId = nil + refreshAgain = false isReady = false balanceSats = 0 pendingDepositSats = 0 - recoverableFundsSats = 0 deposits = [] - try? FileManager.default.removeItem(at: Env.sparkStorage(walletIndex: walletIndex)) + withdrawals = [] + broadcastDeposits = [:] + sendsRecordedWithoutFee = [] + sentWithdrawalTxIds = [] + preparedWithdrawal = nil onStateChanged?() + try Self.removeIfPresent(Env.sparkStorage(walletIndex: walletIndex)) + try Self.removeIfPresent(Env.sparkExitRestore(walletIndex: walletIndex)) + if let disconnectError { + throw disconnectError + } + } + + nonisolated static func removeIfPresent(_ url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) } // MARK: ChannelFreeLightningRail @@ -183,10 +217,22 @@ final class SparkManager: ChannelFreeLightningRail { sdk = try await requireSdk() prepared = try await prepareSend(paymentRequest: bolt11, amountSats: sats) } catch { - throw Self.expiredInvoiceRefusal(error) ?? ChannelFreeRailSendRefused(reason: "Spark could not prepare the payment", underlying: error) + Logger.warn("Spark could not prepare the payment: \(error)", context: "SparkManager") + throw Self.expiredInvoiceRefusal(error) ?? ChannelFreeRailSendRefused( + reason: SparkErrorCopy.message(for: error, fallback: t("wallet__payment_failed_description")), + underlying: error + ) } - guard case let .bolt11Invoice(invoiceDetails, _, _) = prepared.paymentMethod else { - throw ChannelFreeRailSendRefused(reason: "Unexpected Spark payment method: \(prepared.paymentMethod)") + guard case let .bolt11Invoice(invoiceDetails, sparkTransferFeeSats, lightningFeeSats) = prepared.paymentMethod else { + Logger.error("Unexpected Spark payment method: \(prepared.paymentMethod)", context: "SparkManager") + throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) + } + let feeSats = Self.prefersSpark ? sparkTransferFeeSats ?? lightningFeeSats : lightningFeeSats + let amountSats = sats ?? invoiceDetails.amountMsat.map { $0 / 1000 } ?? 0 + let balanceSats = await balanceSats + guard Self.fitsBalance(amountSats: amountSats, feeSats: feeSats, balanceSats: balanceSats) else { + Logger.info("Spark balance can't cover \(amountSats) sats plus its quoted fee of \(feeSats) sats", context: "SparkManager") + throw ChannelFreeRailSendRefused(reason: t("other__pay_insufficient_spending_description")) } let response: SendPaymentResponse do { @@ -202,21 +248,30 @@ final class SparkManager: ChannelFreeLightningRail { return invoiceDetails.paymentHash } + /// Whether the balance covers the amount and the fee `prepareSendPayment` quoted, the real sendable check behind + /// the estimate in `ChannelFreeRailFees`. + nonisolated static func fitsBalance(amountSats: UInt64, feeSats: UInt64, balanceSats: UInt64) -> Bool { + let (total, overflow) = amountSats.addingReportingOverflow(feeSats) + return !overflow && total <= balanceSats + } + /// The SDK refuses an expired Lightning invoice before anything leaves the wallet, with the scanner's expiry copy. nonisolated static func expiredInvoiceRefusal(_ error: Error) -> ChannelFreeRailSendRefused? { guard case let SdkError.InvalidInput(message) = error, message.localizedCaseInsensitiveContains("invoice has expired") else { return nil } return ChannelFreeRailSendRefused(reason: t("other__scan__error__expired")) } - nonisolated func lightningPayments() async -> [ChannelFreeRailPayment] { - guard let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments else { return [] } + nonisolated func lightningPayments() async throws -> [ChannelFreeRailPayment] { + let payments = try await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments return payments.compactMap { payment in guard case let .lightning(_, _, _, htlcDetails, _, _, _, _) = payment.details else { return nil } return ChannelFreeRailPayment( paymentHash: htlcDetails.paymentHash, direction: payment.paymentType == .send ? .outbound : .inbound, status: Self.ldkStatus(payment.status), - preimage: htlcDetails.preimage + preimage: htlcDetails.preimage, + timestamp: payment.timestamp, + feeSats: Self.sats(payment.fees) ) } } @@ -293,9 +348,11 @@ final class SparkManager: ChannelFreeLightningRail { return claim } - /// Moves the whole Spark balance to the on-chain `address`, fees included, for Spending to Savings. - func withdrawAll(to address: String) async throws { + /// Moves the whole Spark balance to the on-chain `address`, fees included, for Spending to Savings. Refuses before + /// sending when the fee is above `maxFeeSats`, the fee the confirm screen showed. + func withdrawAll(to address: String, maxFeeSats: UInt64) async throws { let prepared = try await prepareWithdrawAll(to: address) + try Self.checkWithdrawalFee(Self.withdrawalFeeSats(prepared), shownFeeSats: maxFeeSats) let response = try await requireSdk().sendPayment(request: SendPaymentRequest( prepareResponse: prepared, options: .bitcoinAddress(confirmationSpeed: .medium) @@ -304,16 +361,37 @@ final class SparkManager: ChannelFreeLightningRail { if case let .withdraw(txId, _, _) = response.payment.details { sentWithdrawalTxIds.insert(txId) } - try await refresh() + try await Self.settleWithdrawal(status: response.payment.status) { try await self.refresh() } } - /// The fee `withdrawAll(to:)` pays at its speed, which comes out of the balance it moves. + /// A sent withdrawal has left the balance, so only Spark's own failed status fails it; reading the balance + /// again afterwards is best effort. + static func settleWithdrawal(status: BreezSdkSpark.PaymentStatus, refresh: () async throws -> Void) async throws { + guard status != .failed else { + throw AppError(message: "common__error_body", debugMessage: "Spark reported the withdrawal as failed") + } + do { + try await refresh() + } catch { + Logger.warn("Failed to refresh Spark after its withdrawal: \(error)", context: "SparkManager") + } + } + + /// The fee `withdrawAll(to:maxFeeSats:)` pays at its speed, which comes out of the balance it moves. func withdrawAllFeeSats(to address: String) async throws -> UInt64 { - let prepared = try await prepareWithdrawAll(to: address) + try await Self.withdrawalFeeSats(prepareWithdrawAll(to: address)) + } + + private static func withdrawalFeeSats(_ prepared: PrepareSendPaymentResponse) -> UInt64 { guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { return 0 } return feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat } + nonisolated static func checkWithdrawalFee(_ feeSats: UInt64, shownFeeSats: UInt64) throws { + guard feeSats > shownFeeSats else { return } + throw SparkWithdrawalFeeIncreased(feeSats: feeSats, shownFeeSats: shownFeeSats) + } + /// Whether `txid` is a withdrawal of this wallet's Spark balance to Savings, so its receipt is a transfer. func isWithdrawal(txid: String) -> Bool { Self.isWithdrawal(txid: txid, sent: sentWithdrawalTxIds, withdrawals: withdrawals) @@ -406,7 +484,7 @@ final class SparkManager: ChannelFreeLightningRail { private nonisolated func requireSdk() async throws -> BreezSdk { guard let sdk = await sdk else { - throw AppError(message: "Spark wallet is not connected", debugMessage: nil) + throw AppError(message: "wallet__payment_failed_description", debugMessage: "Spark wallet is not connected") } return sdk } @@ -447,7 +525,24 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// One refresh at a time: a call during one waits for it, and it reads once more so the caller sees fresh state. private func refresh() async throws { + if let refreshTask { + refreshAgain = true + return try await refreshTask.value + } + let task = Task { + defer { refreshTask = nil } + repeat { + refreshAgain = false + try await refreshOnce() + } while refreshAgain + } + refreshTask = task + try await task.value + } + + private func refreshOnce() async throws { let sdk = try await requireSdk() let info = try await sdk.getInfo(request: GetInfoRequest(ensureSynced: false)) if let syncError = info.lastSyncError { @@ -456,13 +551,15 @@ final class SparkManager: ChannelFreeLightningRail { let unclaimed = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()).deposits let payments = try await sdk.listPayments(request: ListPaymentsRequest()).payments let claimed = payments.filter { $0.method == .deposit && $0.status == .completed } + let listedDeposits = await Self.deposits(unclaimed: unclaimed, claimed: claimed) + // A wipe while this read was in flight leaves the state it cleared. + guard self.sdk === sdk else { return } let pending = unclaimed.reduce(0) { $0 + $1.amountSats } let changed = !isReady || balanceSats != info.balanceSats || pendingDepositSats != pending isReady = true balanceSats = info.balanceSats pendingDepositSats = pending - recoverableFundsSats = info.recoverableFundsSats - deposits = await Self.deposits(unclaimed: unclaimed, claimed: claimed) + deposits = listedDeposits withdrawals = Self.withdrawals(payments) for deposit in deposits { broadcastDeposits[deposit.txid] = nil @@ -540,30 +637,43 @@ final class SparkManager: ChannelFreeLightningRail { } private func onSdkEvent(_ event: SdkEvent) async { + switch event { + case .synced, .newDeposits, .claimedDeposits: + await refreshState() + case let .unclaimedDeposits(deposits): + Logger.warn("Found '\(deposits.count)' unclaimed Spark deposits", context: "SparkManager") + case let .paymentSucceeded(payment), let .paymentFailed(payment): + await Self.handleSettledPayment( + payment, + record: { await self.recordActivity($0) }, + emit: { LightningService.shared.emitChannelFreeRailEvent($0) }, + refresh: { try await self.refresh() } + ) + case let .paymentPending(payment): + await recordActivity(payment) + case .unilateralExitStateChanged: + Self.exitStateChanged.send() + default: + break + } + } + + /// Records a settled payment and delivers its node event before reading the balance again, so a failed read + /// never drops the payment the send and receive flows wait for. + static func handleSettledPayment( + _ payment: Payment, + record: (Payment) async -> Void, + emit: (Event) -> Void, + refresh: () async throws -> Void + ) async { + await record(payment) + if let event = nodeEvent(for: payment) { + emit(event) + } do { - switch event { - case .synced, .newDeposits: - try await refresh() - case .claimedDeposits: - try await refresh() - onDepositsClaimed?() - case let .unclaimedDeposits(deposits): - Logger.warn("Found '\(deposits.count)' unclaimed Spark deposits", context: "SparkManager") - case let .paymentSucceeded(payment), let .paymentFailed(payment): - try await refresh() - await recordActivity(payment) - if let event = Self.nodeEvent(for: payment) { - LightningService.shared.emitChannelFreeRailEvent(event) - } - case let .paymentPending(payment): - await recordActivity(payment) - case .unilateralExitStateChanged: - Self.exitStateChanged.send() - default: - break - } + try await refresh() } catch { - Logger.error("Failed to handle Spark event '\(event)': \(error)", context: "SparkManager") + Logger.warn("Failed to refresh Spark after payment '\(payment.id)': \(error)", context: "SparkManager") } } @@ -596,7 +706,7 @@ final class SparkManager: ChannelFreeLightningRail { } } - private static func nodeEvent(for payment: Payment) -> Event? { + static func nodeEvent(for payment: Payment) -> Event? { guard case let .lightning(_, _, _, htlcDetails, _, _, _, _) = payment.details else { return nil } let hash = htlcDetails.paymentHash switch (payment.paymentType, payment.status) { @@ -625,7 +735,11 @@ final class SparkManager: ChannelFreeLightningRail { } private nonisolated static func sats(_ value: U128) -> UInt64 { - UInt64(value.description) ?? 0 + guard let sats = UInt64(value.description) else { + Logger.error("Spark amount '\(value)' does not fit in sats", context: "SparkManager") + return 0 + } + return sats } } @@ -733,6 +847,34 @@ struct SparkWithdrawalQuote: Equatable { } } +/// The withdrawal now costs more than the confirm screen showed, so nothing was sent. +struct SparkWithdrawalFeeIncreased: LocalizedError, Equatable { + let feeSats: UInt64 + let shownFeeSats: UInt64 + + var errorDescription: String? { + t("lightning__spending_confirm__fees_changed_title") + } +} + +/// User copy for a Spark failure; the SDK's own text goes to the log only. +enum SparkErrorCopy { + static func message(for error: Error, fallback: String) -> String { + switch error { + case let refused as ChannelFreeRailSendRefused: + return refused.reason + case let appError as AppError where !appError.isGeneric: + return t(appError.message) + case SdkError.InsufficientFunds: + return t("other__pay_insufficient_spending_description") + case SdkError.NetworkError, SdkError.ChainServiceError: + return t("hardware__send_broadcast_failed_text") + default: + return fallback + } + } +} + struct SparkWithdrawal: Identifiable, Equatable { let txid: String let amountSats: UInt64 diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 97b7f4d40..ff153d49e 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -13,7 +13,8 @@ protocol ChannelFreeLightningRail: AnyObject { func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 - func lightningPayments() async -> [ChannelFreeRailPayment] + /// Throws when the rail could not list its payments, so a duplicate-payment check can tell that from none. + func lightningPayments() async throws -> [ChannelFreeRailPayment] } @MainActor @@ -30,8 +31,9 @@ enum ChannelFreeRailFees { static func sendableSats(balanceSats: UInt64) -> UInt64 { // The fee on the sent amount, rounded up, must fit beside it: amount + ceil(amount * ppm / 1e6) <= balance. - let sendable = balanceSats * 1_000_000 / (1_000_000 + serviceFeePpm) - let fee = (sendable * serviceFeePpm + 999_999) / 1_000_000 + let scaled = balanceSats.multipliedFullWidth(by: 1_000_000) + let sendable = (1_000_000 + serviceFeePpm).dividingFullWidth((scaled.high, scaled.low)).quotient + let fee = sendable / 1_000_000 * serviceFeePpm + (sendable % 1_000_000 * serviceFeePpm + 999_999) / 1_000_000 return sendable + fee <= balanceSats || sendable == 0 ? sendable : sendable - 1 } } @@ -42,8 +44,9 @@ struct ChannelFreeRailSendRefused: LocalizedError { let reason: String var underlying: Error? + /// Always the user copy in `reason`; the underlying error is for logs. var errorDescription: String? { - (underlying as? LocalizedError)?.errorDescription ?? reason + reason } } @@ -53,6 +56,16 @@ struct ChannelFreeRailPayment: Equatable { let direction: PaymentDirection let status: PaymentStatus let preimage: String? + var timestamp: UInt64 = 0 + var feeSats: UInt64 = 0 + + /// The attempt that decides an outbound payment of `paymentHash`: a retried invoice is listed once per attempt, + /// so a pending or settled one wins over failed ones, then the newest. + static func latestOutboundAttempt(_ payments: [ChannelFreeRailPayment], paymentHash: String) -> ChannelFreeRailPayment? { + payments + .filter { $0.direction == .outbound && $0.paymentHash.caseInsensitiveCompare(paymentHash) == .orderedSame } + .max { ($0.status == .failed ? 0 : 1, $0.timestamp) < ($1.status == .failed ? 0 : 1, $1.timestamp) } + } } /// The node lifecycle `WalletViewModel` drives, apart from the rest of `LightningService` so its ordering can be tested. @@ -96,6 +109,8 @@ class LightningService: NodeLifecycleService { @MainActor private var cachedPeers: [PeerDetails]? @MainActor private var cachedChannels: [ChannelDetails]? + /// Guards the event callback and the rail events waiting for it, which the node and the rail set from different threads. + private let eventCallbackLock = NSLock() private var storedEventCallback: ((Event) -> Void)? /// Rail events that arrived before the node's event callback was set, delivered once it is. private var pendingChannelFreeRailEvents: [Event] = [] @@ -322,7 +337,7 @@ class LightningService: NodeLifecycleService { setEventCallback(onEvent) } - listenForEvents(onEvent: storedEventCallback) + listenForEvents(onEvent: eventCallbackLock.withLock { storedEventCallback }) Logger.debug("Starting node...") try await ServiceQueue.background(.ldk) { @@ -376,8 +391,10 @@ class LightningService: NodeLifecycleService { self.node = nil if clearEventCallback { - storedEventCallback = nil - pendingChannelFreeRailEvents = [] + eventCallbackLock.withLock { + storedEventCallback = nil + pendingChannelFreeRailEvents = [] + } } await MainActor.run { @@ -949,12 +966,15 @@ class LightningService: NodeLifecycleService { do { feeSats = try await rail.estimateRoutingFees(bolt11: bolt11, amountSats: sats) } catch { - throw error as? ChannelFreeRailSendRefused ?? ChannelFreeRailSendRefused(reason: "Spark fee estimate failed", underlying: error) + Logger.warn("Spark fee estimate failed: \(error)", context: "LightningService") + throw error as? ChannelFreeRailSendRefused ?? ChannelFreeRailSendRefused( + reason: t("wallet__payment_failed_description"), + underlying: error + ) } guard feeSats * 1000 <= maxFeeMsat else { - throw ChannelFreeRailSendRefused( - reason: "Spark routing fee of \(feeSats) sats exceeds the limit of \(maxFeeMsat / 1000) sats" - ) + Logger.info("Spark routing fee of \(feeSats) sats exceeds the limit of \(maxFeeMsat / 1000) sats", context: "LightningService") + throw ChannelFreeRailSendRefused(reason: t("wallet__max_possible_fee_rate")) } } try beforeSubmission() @@ -1483,23 +1503,28 @@ extension LightningService { /// Delivers a channel-free rail's payment as an LDK event, so flows that wait on node events cover both rails. /// Sets the callback node and rail events are delivered to, and delivers the rail events that arrived before it. func setEventCallback(_ onEvent: @escaping (Event) -> Void) { - storedEventCallback = onEvent - let pending = pendingChannelFreeRailEvents - pendingChannelFreeRailEvents = [] + let pending = eventCallbackLock.withLock { + storedEventCallback = onEvent + defer { pendingChannelFreeRailEvents = [] } + return pendingChannelFreeRailEvents + } pending.forEach(onEvent) } /// Spark runs before the node starts, so its events wait until the node's event callback is set rather than being lost. func emitChannelFreeRailEvent(_ event: Event) { - guard let storedEventCallback else { - pendingChannelFreeRailEvents.append(event) - return + let callback = eventCallbackLock.withLock { + if storedEventCallback == nil { + pendingChannelFreeRailEvents.append(event) + } + return storedEventCallback } - storedEventCallback(event) + callback?(event) } - func channelFreeRailPayments() async -> [ChannelFreeRailPayment] { - await channelFreeRail?.lightningPayments() ?? [] + /// The rail's Lightning payments, none without a rail; throws when the rail could not list them. + func channelFreeRailPayments() async throws -> [ChannelFreeRailPayment] { + try await channelFreeRail?.lightningPayments() ?? [] } func deliverEvent(_ event: Event, onEvent: ((Event) -> Void)?) async -> OnchainActivityCompletion? { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index cb289a597..cf2132358 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -218,10 +218,12 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { } /// Spark payments are not in LDK's list, so a proof whose completion event was missed settles from Spark's own record. + /// Unavailable while Spark can't list its payments. static func railStatus(paymentHash: String) async -> PaykitLightningPaymentProofStatus { - guard let payment = await LightningService.shared.channelFreeRailPayments().first(where: { - $0.paymentHash.caseInsensitiveCompare(paymentHash) == .orderedSame && $0.direction == .outbound - }) else { + guard let payments = try? await LightningService.shared.channelFreeRailPayments() else { + return .unavailable + } + guard let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) else { return .unknown } switch payment.status { diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 7f9316e43..a9c642f77 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -72,8 +72,10 @@ extension PrivatePaykitService { return decodedInvoice.paymentHash.hex } - func hasAttemptedOutboundBolt11Payment(paymentHash: String) async -> Bool { - await attemptedOutboundBolt11PaymentHashes().contains(paymentHash) + /// Whether this wallet already tried to pay `paymentHash`, or nil when Spark could not list its payments. + func hasAttemptedOutboundBolt11Payment(paymentHash: String) async -> Bool? { + guard let attempted = try? await attemptedOutboundBolt11PaymentHashes() else { return nil } + return attempted.contains(paymentHash) } @MainActor @@ -136,13 +138,14 @@ extension PrivatePaykitService { return payment.id } - let railHashes = await LightningService.shared.channelFreeRailPayments() + let railHashes = await ((try? LightningService.shared.channelFreeRailPayments()) ?? []) .filter { $0.direction == .inbound && $0.status == .succeeded } .map(\.paymentHash) return Set(ldkHashes + railHashes) } - func attemptedOutboundBolt11PaymentHashes() async -> Set { + /// Throws when Spark could not list its payments, so a payment it may have attempted isn't read as never tried. + func attemptedOutboundBolt11PaymentHashes() async throws -> Set { let payments = await LightningService.shared.listPayments() ?? [] let ldkHashes = payments.compactMap { payment -> String? in guard payment.direction == .outbound, @@ -152,7 +155,7 @@ extension PrivatePaykitService { return payment.id } - let railHashes = await LightningService.shared.channelFreeRailPayments() + let railHashes = try await LightningService.shared.channelFreeRailPayments() .filter { $0.direction == .outbound && $0.status != .failed } .map(\.paymentHash) return Set(ldkHashes + railHashes) diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 28eacf7bf..d74133681 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -554,9 +554,11 @@ extension PrivatePaykitService { continue } - guard PublicPaykitService.hasLightningRouteHints(bolt11: endpoint.value), - await !hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) - else { + // Unknown while Spark can't list its payments: the invoice may be paid already, so it isn't offered. + guard let hasAttempted = await hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) else { + continue + } + guard PublicPaykitService.hasLightningRouteHints(bolt11: endpoint.value), !hasAttempted else { staleLightningPaymentHashes.insert(paymentHash) continue } diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index 8247418e4..4d99631ab 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -69,7 +69,8 @@ final class QuickPayPaymentCoordinator { try await String(LightningService.shared.send(bolt11: bolt11)) } self.listRows = listRows ?? { - let railRows = await LightningService.shared.channelFreeRailPayments().map(QuickPayReconcileRow.init(railPayment:)) + // A Spark list failure throws, so reconciling waits rather than reading Spark's payments as none. + let railRows = try await LightningService.shared.channelFreeRailPayments().map(QuickPayReconcileRow.init(railPayment:)) guard let ldkRows = await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) else { return railRows.isEmpty ? nil : railRows } diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 62b12e0ae..5ad0e7266 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -34,6 +34,12 @@ struct SparkTransferQuote: Equatable { var totalSats: UInt64 { depositSats.saturatingAdd(networkFeeSats) } + + /// Whether the deposit stays claimable at the most Spark may charge to claim it, at either speed: the mature claim + /// runs at that hour's rate under the same cap, so a deposit at or below `instantMaxSats` could be left unclaimed. + var coversClaimFee: Bool { + depositSats > claimFees.instantMaxSats + } } /// Where a transfer to spending stands once its deposit is broadcast. diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 18f6346d8..47f349cd6 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -48,8 +48,12 @@ class TransferViewModel: ObservableObject { private let swapQuoteTimeout: TimeInterval = 15 /// Prices Spark's withdrawal for the confirm screen; tests replace it. var priceSparkWithdrawal: @Sendable (String) async throws -> UInt64 = { try await SparkManager.shared.withdrawAllFeeSats(to: $0) } - /// How long the confirm screen waits for Spark's withdrawal fee before showing none. - var sparkFeeTimeout: TimeInterval = 15 + var sparkBalanceSats: @MainActor () -> UInt64 = { SparkManager.shared.balanceSats } + /// Sends Spark's balance to the address, refusing above the shown fee; tests replace it. + var withdrawSpark: @MainActor (String, UInt64) async throws -> Void = { try await SparkManager.shared.withdrawAll(to: $0, maxFeeSats: $1) } + /// How long the confirm screen waits for Spark's withdrawal fee before offering to retry; a full-balance quote can + /// take minutes. + var sparkFeeTimeout: TimeInterval = 180 private var sparkFeeTask: Task? /// The Spark balance the fee was priced for, so it is priced once per balance. private var sparkFeeBalanceSats: UInt64? @@ -299,6 +303,29 @@ class TransferViewModel: ObservableObject { } } + /// Whether the swipe may commit: a transfer that withdraws Spark waits until its fee is shown. + func isSparkWithdrawalPriced(withdrawsSpark: Bool) -> Bool { + !withdrawsSpark || sparkWithdrawFeeSats != nil + } + + /// Prices the withdrawal again at swipe time. Throws `SparkWithdrawalFeeIncreased` after showing the higher fee, so + /// the user swipes again for it, and throws when it can't be priced. + func recheckSparkWithdrawFee() async throws { + guard let shownFeeSats = sparkWithdrawFeeSats, let address = sparkWithdrawAddress else { + throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced") + } + let price = priceSparkWithdrawal + guard let feeSats = await Self.withTimeout(seconds: sparkFeeTimeout, { try await price(address) }) else { + throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal re-quote failed") + } + do { + try SparkManager.checkWithdrawalFee(feeSats, shownFeeSats: shownFeeSats) + } catch { + sparkWithdrawFeeSats = feeSats + throw error + } + } + /// The result of `operation`, or nil when it throws or takes longer than `seconds`. private nonisolated static func withTimeout( seconds: TimeInterval, @@ -356,8 +383,8 @@ class TransferViewModel: ObservableObject { do { try await withdrawSparkToSavings() } catch { - Logger.error("Failed to withdraw Spark to savings", context: error.localizedDescription) - sparkWithdrawError = error.localizedDescription + Logger.error("Failed to withdraw Spark to savings: \(error)", context: "TransferViewModel") + sparkWithdrawError = SparkErrorCopy.message(for: error, fallback: t("lightning__savings_spark_failed__text")) } } @@ -365,14 +392,11 @@ class TransferViewModel: ObservableObject { sparkFeeTask?.cancel() sparkFeeTask = nil isPricingSparkWithdrawal = false - let spark = SparkManager.shared - guard spark.balanceSats > 0 else { return } - let address: String = if let sparkWithdrawAddress { - sparkWithdrawAddress - } else { - try await lightningService.newAddress() - } - try await spark.withdrawAll(to: address) + guard sparkBalanceSats() > 0 else { return } + guard let shownFeeSats = sparkWithdrawFeeSats, let address = sparkWithdrawAddress else { + throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced") + } + try await withdrawSpark(address, shownFeeSats) sparkWithdrawAddress = nil sparkFeeBalanceSats = nil sparkWithdrawFeeSats = nil diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 31b9e50c3..6e89273ff 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1193,12 +1193,11 @@ class WalletViewModel: ObservableObject { /// A Spark-to-Spark payment settles inside the send call, so its completion event can fire before `watchSend` /// starts listening; the rail's own payment list still has the outcome. private func finishWatchSendIfRailAlreadySettled(hash: String, eventId: String) async { - let payment = await lightningService.channelFreeRailPayments().first { - $0.direction == .outbound && $0.paymentHash.caseInsensitiveCompare(hash) == .orderedSame - } + let payments = await (try? lightningService.channelFreeRailPayments()) ?? [] + let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: hash) switch payment?.status { case .succeeded: - finishWatchSend(id: eventId, result: .success(SettledLightningPayment(paymentHash: hash, feePaidSats: 0))) + finishWatchSend(id: eventId, result: .success(SettledLightningPayment(paymentHash: hash, feePaidSats: payment?.feeSats ?? 0))) case .failed: finishWatchSend(id: eventId, result: .failure(AppError(paymentFailureReason: nil))) case .pending, nil: @@ -2077,7 +2076,7 @@ class WalletViewModel: ObservableObject { try await nodeLifecycle.wipeStorage(walletIndex: 0) } - await SparkManager.shared.wipe(walletIndex: 0) + try await SparkManager.shared.wipe(walletIndex: 0) probeOutcomes.removeAll() diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index f8705aee7..9fe3930ea 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -79,6 +79,9 @@ struct SavingsConfirmView: View { ) .padding(.top, swapState.quote == nil ? 24 : 16) .accessibilityIdentifier("SavingsSparkFee") + } else if selection.withdrawsSpark { + sparkFeeError + .padding(.top, swapState.quote == nil ? 24 : 16) } if hasMultipleItems { @@ -119,11 +122,14 @@ struct SavingsConfirmView: View { SwipeButton( title: t("lightning__transfer__swipe"), accentColor: .brandAccent, - isLoading: swapState.quote == nil && swapState.isLoading + isLoading: (swapState.quote == nil && swapState.isLoading) || transfer.isPricingSparkWithdrawal ) { // 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. + if selection.withdrawsSpark { + try await recheckSparkFee() + } do { transfer.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark) @@ -139,6 +145,7 @@ struct SavingsConfirmView: View { throw error } } + .disabled(!transfer.isSparkWithdrawalPriced(withdrawsSpark: selection.withdrawsSpark)) } if swapState.quote != nil { @@ -172,6 +179,39 @@ struct SavingsConfirmView: View { } } + /// Spark's withdrawal fee could not be priced; the swipe waits for it, so it can be retried here. + private var sparkFeeError: some View { + HStack(alignment: .center, spacing: 16) { + BodyMText(t("lightning__spending_confirm__error_quote"), textColor: .redAccent) + .frame(maxWidth: .infinity, alignment: .leading) + CustomButton(title: t("common__retry"), size: .small) { + transfer.loadSparkWithdrawFee(balanceSats: sparkSats, savingsAddress: wallet.onchainAddress) + } + .fixedSize() + .accessibilityIdentifier("SavingsSparkFeeRetry") + } + } + + /// Asks for another swipe when Spark's withdrawal fee rose since it was shown. + private func recheckSparkFee() async throws { + do { + try await transfer.recheckSparkWithdrawFee() + } catch let increase as SparkWithdrawalFeeIncreased { + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: increase.feeSats - increase.shownFeeSats)] + ) + ) + throw SpendingFeesIncreasedError() + } catch { + app.toast(error) + throw error + } + } + private func quoteSection(_ quote: SavingsSwapQuote) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top, spacing: 16) { diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 62619b852..33bab6326 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -37,6 +37,10 @@ struct SpendingConfirm: View { if let error = flow.quoteError { quoteError(error) .padding(.bottom, 16) + } else if let quote = flow.quote, !quote.coversClaimFee { + BodyMText(Self.belowClaimFeeMessage(quote), textColor: .redAccent) + .padding(.bottom, 16) + .accessibilityIdentifier("SpendingConfirmBelowMinimum") } SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) @@ -86,7 +90,14 @@ struct SpendingConfirm: View { private var canConfirm: Bool { guard let quote = flow.quote, !isPaying, !flow.isQuoting else { return false } - return quote.networkFeeSats > 0 && quote.toSpendingSats(for: flow.speed) > 0 + return quote.networkFeeSats > 0 && quote.coversClaimFee && quote.toSpendingSats(for: flow.speed) > 0 + } + + private static func belowClaimFeeMessage(_ quote: SparkTransferQuote) -> String { + t( + "lightning__spending_amount__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(quote.claimFees.instantMaxSats.saturatingAdd(1))] + ) } private func quoteError(_ message: String) -> some View { @@ -109,6 +120,14 @@ struct SpendingConfirm: View { guard let fresh = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) else { throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "Re-quote before sending failed") } + guard fresh.coversClaimFee else { + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_min__title"), + description: Self.belowClaimFeeMessage(fresh) + ) + throw SpendingFeesIncreasedError() + } let speed = flow.speed let shownCost = shown.networkFeeSats.saturatingAdd(shown.claimFeeSats(for: speed)) let freshCost = fresh.networkFeeSats.saturatingAdd(fresh.claimFeeSats(for: speed)) diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 81d2865c0..a84a7a7aa 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -45,7 +45,7 @@ final class ChannelFreeRailTests: XCTestCase { XCTAssertEqual(preimage, "cc") } - func testPrivatePaykitSeesRailReceivesAndSends() async { + func testPrivatePaykitSeesRailReceivesAndSends() async throws { rail.payments = [ Bitkit.ChannelFreeRailPayment(paymentHash: "in", direction: .inbound, status: .succeeded, preimage: nil), Bitkit.ChannelFreeRailPayment(paymentHash: "out", direction: .outbound, status: .pending, preimage: nil), @@ -53,7 +53,7 @@ final class ChannelFreeRailTests: XCTestCase { ] let received = await PrivatePaykitService.shared.receivedSettledPaymentHashes() - let attempted = await PrivatePaykitService.shared.attemptedOutboundBolt11PaymentHashes() + let attempted = try await PrivatePaykitService.shared.attemptedOutboundBolt11PaymentHashes() XCTAssertTrue(received.contains("in")) XCTAssertEqual(attempted.intersection(["out", "failed"]), ["out"]) @@ -137,6 +137,46 @@ extension ChannelFreeRailTests { XCTAssertEqual(Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000), 99750) } + func testDuplicatePaymentCheckFailsClosedWhenTheRailCannotListItsPayments() async { + rail.listError = NSError(domain: "spark", code: 1) + + let attempted = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "out") + let proof = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: "out") + + XCTAssertNil(attempted, "An unknown attempt must not read as never paid") + guard case .unavailable = proof else { return XCTFail("Expected an unavailable proof, got \(proof)") } + } + + func testDuplicatePaymentCheckSeesARailAttempt() async { + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "out", direction: .outbound, status: .pending, preimage: nil)] + + let attempted = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "out") + let fresh = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "new") + + XCTAssertEqual(attempted, true) + XCTAssertEqual(fresh, false) + } + + func testRetriedInvoiceSettlesFromItsLiveAttempt() { + let failed = Bitkit.ChannelFreeRailPayment(paymentHash: "aa", direction: .outbound, status: .failed, preimage: nil, timestamp: 20) + let retry = Bitkit.ChannelFreeRailPayment( + paymentHash: "AA", direction: .outbound, status: .succeeded, preimage: "pp", timestamp: 10, feeSats: 3 + ) + let received = Bitkit.ChannelFreeRailPayment(paymentHash: "aa", direction: .inbound, status: .pending, preimage: nil, timestamp: 30) + + let latest = Bitkit.ChannelFreeRailPayment.latestOutboundAttempt([failed, retry, received], paymentHash: "aa") + + XCTAssertEqual(latest, retry) + XCTAssertEqual(Bitkit.ChannelFreeRailPayment.latestOutboundAttempt([failed], paymentHash: "aa"), failed) + } + + func testSendableSatsDoesNotOverflowOnAHugeBalance() { + let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: UInt64.max) + + XCTAssertLessThan(sendable, UInt64.max) + XCTAssertGreaterThan(sendable, UInt64.max / 2) + } + func testSparkReadyTriggersOnceUntilItDropsAgain() { var transition = SparkReadinessTransition() XCTAssertFalse(transition.becameReady(false)) @@ -153,6 +193,7 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var payments: [Bitkit.ChannelFreeRailPayment] = [] var routingFeeSats: UInt64 = 0 var estimateError: Error? + var listError: Error? var sentBolt11: String? func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { @@ -171,7 +212,10 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { return routingFeeSats } - func lightningPayments() async -> [Bitkit.ChannelFreeRailPayment] { - payments + func lightningPayments() async throws -> [Bitkit.ChannelFreeRailPayment] { + if let listError { + throw listError + } + return payments } } diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift new file mode 100644 index 000000000..afd8a08b7 --- /dev/null +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -0,0 +1,185 @@ +@testable import Bitkit +import BreezSdkSpark +import LDKNode +import XCTest + +@MainActor +final class SparkPaymentSafetyTests: XCTestCase { + private struct RefreshFailed: Error {} + + // MARK: - Settled payment events + + func testSettledPaymentIsRecordedAndEmittedEvenWhenTheRefreshFails() async { + var steps: [String] = [] + + await SparkManager.handleSettledPayment( + lightningPayment(type: .send, status: .completed), + record: { _ in steps.append("record") }, + emit: { event in + if case .paymentSuccessful = event { + steps.append("emit") + } + }, + refresh: { + steps.append("refresh") + throw RefreshFailed() + } + ) + + XCTAssertEqual(steps, ["record", "emit", "refresh"]) + } + + func testReceivedPaymentEmitsItsNodeEvent() { + let event = SparkManager.nodeEvent(for: lightningPayment(type: .receive, status: .completed)) + + guard case let .paymentReceived(_, paymentHash, amountMsat, _) = event else { + return XCTFail("Expected a receive, got \(String(describing: event))") + } + XCTAssertEqual(paymentHash, "hash") + XCTAssertEqual(amountMsat, 1_000_000) + } + + // MARK: - Withdrawal + + func testSentWithdrawalSucceedsWhenTheRefreshAfterItFails() async throws { + var refreshed = false + + try await SparkManager.settleWithdrawal(status: .pending) { + refreshed = true + throw RefreshFailed() + } + + XCTAssertTrue(refreshed) + } + + func testWithdrawalSparkReportsFailedFails() async { + do { + try await SparkManager.settleWithdrawal(status: .failed) {} + XCTFail("Expected Spark's failed status to fail the withdrawal") + } catch {} + } + + func testWithdrawalFeeAboveTheShownFeeIsRefused() throws { + XCTAssertNoThrow(try SparkManager.checkWithdrawalFee(2190, shownFeeSats: 2190)) + XCTAssertNoThrow(try SparkManager.checkWithdrawalFee(2000, shownFeeSats: 2190)) + XCTAssertThrowsError(try SparkManager.checkWithdrawalFee(2191, shownFeeSats: 2190)) { error in + XCTAssertEqual(error as? SparkWithdrawalFeeIncreased, SparkWithdrawalFeeIncreased(feeSats: 2191, shownFeeSats: 2190)) + } + } + + func testSwipeWaitsForTheSparkWithdrawalFee() { + let transfer = TransferViewModel() + + XCTAssertFalse(transfer.isSparkWithdrawalPriced(withdrawsSpark: true)) + XCTAssertTrue(transfer.isSparkWithdrawalPriced(withdrawsSpark: false)) + + transfer.sparkWithdrawFeeSats = 2190 + + XCTAssertTrue(transfer.isSparkWithdrawalPriced(withdrawsSpark: true)) + } + + func testRecheckShowsTheHigherFeeAndAsksAgain() async throws { + let transfer = TransferViewModel() + let fee = FeeQuotes([2190, 2500]) + transfer.priceSparkWithdrawal = { _ in await fee.next() } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2190) + + do { + try await transfer.recheckSparkWithdrawFee() + XCTFail("Expected the higher fee to ask for another swipe") + } catch let increase as SparkWithdrawalFeeIncreased { + XCTAssertEqual(increase.shownFeeSats, 2190) + } + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2500) + } + + func testWithdrawalSendsWithTheShownFeeAsItsCap() async throws { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.sparkBalanceSats = { 99901 } + var sent: (String, UInt64)? + transfer.withdrawSpark = { sent = ($0, $1) } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { + try await Task.sleep(nanoseconds: 20_000_000) + } + transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true) + + _ = try await transfer.closeSelectedChannels() + + XCTAssertEqual(sent?.0, "bcrt1q-savings") + XCTAssertEqual(sent?.1, 2190) + } + + // MARK: - Transfer to spending minimum + + func testTransferMustStayClaimableAtTheClaimFeeCap() { + let fees = SparkClaimFees(instantMaxSats: 500, laterSats: 300) + let quote = { (deposit: UInt64) in + SparkTransferQuote(address: "bcrt1q-spark", satsPerVbyte: 2, networkFeeSats: 200, depositSats: deposit, claimFees: fees) + } + + XCTAssertFalse(quote(400).coversClaimFee, "Above the later fee but not the cap a mature claim may cost") + XCTAssertFalse(quote(500).coversClaimFee) + XCTAssertTrue(quote(501).coversClaimFee) + } + + // MARK: - Error copy + + func testSdkErrorsShowAppCopy() { + XCTAssertEqual( + SparkErrorCopy.message(for: SdkError.InsufficientFunds(tokenIdentifier: nil), fallback: "fallback"), + t("other__pay_insufficient_spending_description") + ) + XCTAssertEqual(SparkErrorCopy.message(for: SdkError.SparkError("raw sdk text"), fallback: "fallback"), "fallback") + XCTAssertEqual( + ChannelFreeRailSendRefused(reason: "copy", underlying: SdkError.SparkError("raw sdk text")).errorDescription, + "copy" + ) + } + + func testSendMustFitTheQuotedFee() { + XCTAssertTrue(SparkManager.fitsBalance(amountSats: 9000, feeSats: 572, balanceSats: 9572)) + XCTAssertFalse(SparkManager.fitsBalance(amountSats: 9001, feeSats: 572, balanceSats: 9572)) + XCTAssertFalse(SparkManager.fitsBalance(amountSats: UInt64.max, feeSats: 1, balanceSats: UInt64.max)) + } + + private func lightningPayment(type: PaymentType, status: BreezSdkSpark.PaymentStatus) -> Payment { + Payment( + id: "id", + paymentType: type, + status: status, + amount: 1000, + fees: 2, + timestamp: 1, + method: .lightning, + details: .lightning( + description: nil, + invoice: "lnbcrt1", + destinationPubkey: "pubkey", + htlcDetails: SparkHtlcDetails(paymentHash: "hash", preimage: "preimage", expiryTime: 0, status: .preimageShared), + lnurlPayInfo: nil, + lnurlWithdrawInfo: nil, + lnurlReceiveMetadata: nil, + conversionInfo: nil + ), + conversionDetails: nil + ) + } +} + +private actor FeeQuotes { + private var fees: [UInt64] + + init(_ fees: [UInt64]) { + self.fees = fees + } + + func next() -> UInt64 { + fees.count > 1 ? fees.removeFirst() : fees[0] + } +} From cde15c44a1bb73cd94b961dc43a8b39c12d28cb8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 21:11:36 +0100 Subject: [PATCH 046/378] fix: settle a retried payment only from its own rail A Lightning payment retried on the other rail could be settled by the earlier attempt: a Spark failure ended an LDK retry of the same invoice, and a retained LDK failure overwrote a later Spark success in the activity list. LightningService now records which rail each payment hash's latest attempt went out on. A failure from the other rail is dropped before it reaches the send screen, the send watch no longer reads Spark's list for a payment retried over a channel, and a settled Lightning activity keeps its status against a stale failure. --- Bitkit/Services/CoreService.swift | 23 +++++++++- Bitkit/Services/LightningService.swift | 59 +++++++++++++++++++++++-- Bitkit/ViewModels/WalletViewModel.swift | 13 ++++-- BitkitTests/ChannelFreeRailTests.swift | 51 +++++++++++++++++++++ 4 files changed, 136 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 366a612e1..7677c57e7 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -959,11 +959,20 @@ class ActivityService { /// its Paykit contact and seen state across syncs. func recordChannelFreeRailPayment(_ payment: PaymentDetails) async throws { try await ServiceQueue.background(.core) { - try await self.processLightningPayment(payment) + try await self.processLightningPayment(payment, rail: .channelFree) } } - private func processLightningPayment(_ payment: PaymentDetails) async throws { + /// Whether a Lightning activity keeps its status against an update: a settled payment stays settled, and a failure + /// from the rail that didn't carry the latest attempt is stale. + static func keepsLightningStatus(existing: BitkitCore.PaymentState, update: BitkitCore.PaymentState, isStaleRailFailure: Bool) -> Bool { + if existing == .succeeded, update != .succeeded { + return true + } + return update == .failed && existing != .failed && isStaleRailFailure + } + + private func processLightningPayment(_ payment: PaymentDetails, rail: LightningPaymentRail = .ldk) async throws { guard case let .bolt11(_, preimage, _, description, bolt11) = payment.kind else { return } // Skip pending inbound payments - just means they created an invoice @@ -983,6 +992,16 @@ class ActivityService { case .succeeded: .succeeded } + if let existing = existingLightning, + Self.keepsLightningStatus( + existing: existing.status, + update: state, + isStaleRailFailure: LightningService.shared.isStaleFailure(paymentHash: payment.id, from: rail) + ) + { + return + } + // Skip if existing activity has newer timestamp, unless payment status is changing if let existing = existingLightning, let existingUpdatedAt = existing.updatedAt { let statusChanging = existing.status != state diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index ff153d49e..a5a902879 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -68,6 +68,12 @@ struct ChannelFreeRailPayment: Equatable { } } +/// The rail a Lightning payment attempt went out on. +enum LightningPaymentRail { + case ldk + case channelFree +} + /// The node lifecycle `WalletViewModel` drives, apart from the rest of `LightningService` so its ordering can be tested. protocol NodeLifecycleService: AnyObject { var hasNode: Bool { get } @@ -114,6 +120,9 @@ class LightningService: NodeLifecycleService { private var storedEventCallback: ((Event) -> Void)? /// Rail events that arrived before the node's event callback was set, delivered once it is. private var pendingChannelFreeRailEvents: [Event] = [] + /// The rail each payment hash's latest attempt went out on, so a stale failure from the other rail can't settle a + /// retry of the same invoice. + private var attemptRails: [String: LightningPaymentRail] = [:] weak var channelFreeRail: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? @@ -391,10 +400,7 @@ class LightningService: NodeLifecycleService { self.node = nil if clearEventCallback { - eventCallbackLock.withLock { - storedEventCallback = nil - pendingChannelFreeRailEvents = [] - } + self.clearEventCallback() } await MainActor.run { @@ -978,6 +984,7 @@ class LightningService: NodeLifecycleService { } } try beforeSubmission() + noteAttempt(on: .channelFree, bolt11: bolt11) return try await rail.send(bolt11: bolt11, sats: sats) } guard let node else { @@ -985,6 +992,7 @@ class LightningService: NodeLifecycleService { } Logger.info("Paying bolt11: \(bolt11)") + noteAttempt(on: .ldk, bolt11: bolt11) do { return try await Self.submitPayment(beforeSubmission: beforeSubmission) { @@ -1511,8 +1519,20 @@ extension LightningService { pending.forEach(onEvent) } + /// Drops the event callback and the rail events waiting for it. + func clearEventCallback() { + eventCallbackLock.withLock { + storedEventCallback = nil + pendingChannelFreeRailEvents = [] + } + } + /// Spark runs before the node starts, so its events wait until the node's event callback is set rather than being lost. func emitChannelFreeRailEvent(_ event: Event) { + if isStaleFailure(event, from: .channelFree) { + Logger.info("Ignored a Spark failure for a payment retried over a channel: \(event)", context: "LightningService") + return + } let callback = eventCallbackLock.withLock { if storedEventCallback == nil { pendingChannelFreeRailEvents.append(event) @@ -1522,6 +1542,32 @@ extension LightningService { callback?(event) } + func noteAttempt(on rail: LightningPaymentRail, paymentHash: String) { + eventCallbackLock.withLock { attemptRails[paymentHash.lowercased()] = rail } + } + + private func noteAttempt(on rail: LightningPaymentRail, bolt11: String) { + guard let hash = try? Bolt11Invoice.fromStr(invoiceStr: bolt11).paymentHash() else { return } + noteAttempt(on: rail, paymentHash: String(hash)) + } + + /// The rail the latest attempt to pay `paymentHash` went out on, nil when none went out since launch. + func attemptRail(paymentHash: String) -> LightningPaymentRail? { + eventCallbackLock.withLock { attemptRails[paymentHash.lowercased()] } + } + + /// A failure `rail` reports for a payment whose latest attempt went out on the other rail: it ended an earlier + /// attempt, so it must not settle the retry. + func isStaleFailure(paymentHash: String, from rail: LightningPaymentRail) -> Bool { + guard let current = attemptRail(paymentHash: paymentHash) else { return false } + return current != rail + } + + func isStaleFailure(_ event: Event, from rail: LightningPaymentRail) -> Bool { + guard case let .paymentFailed(paymentId, paymentHash, _) = event, let hash = paymentHash ?? paymentId else { return false } + return isStaleFailure(paymentHash: hash, from: rail) + } + /// The rail's Lightning payments, none without a rail; throws when the rail could not list them. func channelFreeRailPayments() async throws -> [ChannelFreeRailPayment] { try await channelFreeRail?.lightningPayments() ?? [] @@ -1565,6 +1611,11 @@ extension LightningService { await refreshChannelCache() } + if self.isStaleFailure(event, from: .ldk) { + Logger.info("Ignored an LDK failure for a payment retried over Spark: \(event)", context: "LightningService") + continue + } + let onchainActivityCompletion = await deliverEvent(event, onEvent: onEvent) switch event { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 6e89273ff..a868172dd 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -213,6 +213,12 @@ class WalletViewModel: ObservableObject { eventHandlers[id] = handler } + func notifyEventHandlers(_ event: Event) { + for handler in eventHandlers.values { + handler(event) + } + } + func removeOnEvent(id: String) { eventHandlers.removeValue(forKey: id) } @@ -300,10 +306,7 @@ class WalletViewModel: ObservableObject { } try await nodeLifecycle.start(onEvent: { event in Task { @MainActor in - // Notify all event handlers - for handler in self.eventHandlers.values { - handler(event) - } + self.notifyEventHandlers(event) // Handle specific events for targeted UI updates switch event { @@ -1193,6 +1196,8 @@ class WalletViewModel: ObservableObject { /// A Spark-to-Spark payment settles inside the send call, so its completion event can fire before `watchSend` /// starts listening; the rail's own payment list still has the outcome. private func finishWatchSendIfRailAlreadySettled(hash: String, eventId: String) async { + // A retry over a channel settles from LDK; an earlier Spark attempt's outcome doesn't decide it. + guard lightningService.attemptRail(paymentHash: hash) != .ldk else { return } let payments = await (try? lightningService.channelFreeRailPayments()) ?? [] let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: hash) switch payment?.status { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index a84a7a7aa..01c93ab48 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -15,6 +15,7 @@ final class ChannelFreeRailTests: XCTestCase { } override func tearDown() { + Bitkit.LightningService.shared.clearEventCallback() Bitkit.LightningService.shared.channelFreeRail = previousRail rail = nil super.tearDown() @@ -177,6 +178,56 @@ extension ChannelFreeRailTests { XCTAssertGreaterThan(sendable, UInt64.max / 2) } + func testSparkFailureDoesNotEndAChannelRetryOfTheSameInvoice() async throws { + let hash = "a1a1" + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + Bitkit.LightningService.shared.noteAttempt(on: .ldk, paymentHash: hash) + let wallet = WalletViewModel() + var failures = 0 + wallet.addOnEvent(id: "failures") { event in + if case .paymentFailed = event { + failures += 1 + } + } + Bitkit.LightningService.shared.setEventCallback { wallet.notifyEventHandlers($0) } + + let wait = Task { try await wallet.waitForLightningPayment(hash: hash, timeoutSeconds: 5) } + try await Task.sleep(nanoseconds: 200_000_000) + Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: .unexpectedError)) + wallet.notifyEventHandlers(.paymentSuccessful(paymentId: hash, paymentHash: hash, paymentPreimage: "pp", feePaidMsat: 2000)) + let settled = try await wait.value + + XCTAssertEqual(settled.paymentHash, hash) + XCTAssertEqual(failures, 0, "No failure may reach the send screen") + wallet.removeOnEvent(id: "failures") + } + + func testChannelFailureDoesNotEndASparkRetryOfTheSameInvoice() async throws { + let hash = "b2b2" + Bitkit.LightningService.shared.noteAttempt(on: .channelFree, paymentHash: hash) + let wallet = WalletViewModel() + Bitkit.LightningService.shared.setEventCallback { wallet.notifyEventHandlers($0) } + let staleLdkFailure = LDKNode.Event.paymentFailed(paymentId: hash, paymentHash: hash, reason: .retriesExhausted) + + XCTAssertTrue(Bitkit.LightningService.shared.isStaleFailure(staleLdkFailure, from: .ldk), "The node loop drops it") + let wait = Task { try await wallet.waitForLightningPayment(hash: hash, timeoutSeconds: 5) } + try await Task.sleep(nanoseconds: 200_000_000) + Bitkit.LightningService.shared.emitChannelFreeRailEvent( + .paymentSuccessful(paymentId: hash, paymentHash: hash, paymentPreimage: "pp", feePaidMsat: 1000) + ) + let settled = try await wait.value + + XCTAssertEqual(settled.paymentHash, hash) + } + + func testSettledActivityKeepsItsStatusAgainstAStaleFailure() { + XCTAssertTrue(ActivityService.keepsLightningStatus(existing: .succeeded, update: .failed, isStaleRailFailure: false)) + XCTAssertTrue(ActivityService.keepsLightningStatus(existing: .pending, update: .failed, isStaleRailFailure: true)) + XCTAssertFalse(ActivityService.keepsLightningStatus(existing: .pending, update: .failed, isStaleRailFailure: false)) + XCTAssertFalse(ActivityService.keepsLightningStatus(existing: .failed, update: .succeeded, isStaleRailFailure: false)) + XCTAssertFalse(ActivityService.keepsLightningStatus(existing: .pending, update: .succeeded, isStaleRailFailure: true)) + } + func testSparkReadyTriggersOnceUntilItDropsAgain() { var transition = SparkReadinessTransition() XCTAssertFalse(transition.becameReady(false)) From 3853c1264c9620725bba236c4c576d3bcf52e1e9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 22:14:34 +0100 Subject: [PATCH 047/378] fix: guard spark hardware deposits, proofs, fee limits and claims - A hardware-wallet Transfer to Spending below the Spark claim fee ceiling shows the minimum and can't be signed, and the fresh quote is checked again before signing. - Paykit proofs settle from both rails: a settled payment wins, then the rail of the latest attempt, and an LDK failure no longer fails a request whose Spark retry is pending or paid. QuickPay picks the live attempt the same way. - A Spark send checks the caller's fee limit on the quote it sends and runs the submission check right before sending, as the node path does. - Each Spark deposit keeps its own early claim, so a new transfer neither stops an earlier one's claim nor shows its result. --- Bitkit/Managers/SparkManager.swift | 17 ++++- Bitkit/Services/LightningService.swift | 12 +++- .../Services/PaykitPaymentProofService.swift | 40 ++++++++++- Bitkit/Utilities/QuickPaySpendStore.swift | 4 +- Bitkit/ViewModels/SparkTransferFlow.swift | 66 ++++++++++++------- .../Transfer/Hardware/SpendingHwSign.swift | 14 +++- Bitkit/Views/Transfer/SpendingConfirm.swift | 11 +--- BitkitTests/ChannelFreeRailTests.swift | 40 ++++++++++- BitkitTests/SparkPaymentSafetyTests.swift | 18 +++++ BitkitTests/SparkTransferFlowTests.swift | 34 ++++++++++ 10 files changed, 215 insertions(+), 41 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 3d8fbeb1e..632725813 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -210,7 +210,12 @@ final class SparkManager: ChannelFreeLightningRail { /// Errors before `sendPayment` are `ChannelFreeRailSendRefused`, since nothing left the wallet; a failure inside it /// stays uncertain, as the payment may have been dispatched. - nonisolated func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash { + nonisolated func send( + bolt11: String, + sats: UInt64?, + maxFeeSats: UInt64?, + beforeSubmission: @escaping () throws -> Void + ) async throws -> PaymentHash { let prepared: PrepareSendPaymentResponse let sdk: BreezSdk do { @@ -234,6 +239,10 @@ final class SparkManager: ChannelFreeLightningRail { Logger.info("Spark balance can't cover \(amountSats) sats plus its quoted fee of \(feeSats) sats", context: "SparkManager") throw ChannelFreeRailSendRefused(reason: t("other__pay_insufficient_spending_description")) } + // The fee limit is checked on the quote that is sent, not only on the earlier estimate. + try Self.checkSendFee(feeSats, maxFeeSats: maxFeeSats) + // A deadline that passed while the quote was prepared stops the payment here, before anything leaves. + try beforeSubmission() let response: SendPaymentResponse do { response = try await sdk.sendPayment(request: SendPaymentRequest( @@ -248,6 +257,12 @@ final class SparkManager: ChannelFreeLightningRail { return invoiceDetails.paymentHash } + nonisolated static func checkSendFee(_ feeSats: UInt64, maxFeeSats: UInt64?) throws { + guard let maxFeeSats, feeSats > maxFeeSats else { return } + Logger.info("Spark fee of \(feeSats) sats exceeds the limit of \(maxFeeSats) sats", context: "SparkManager") + throw ChannelFreeRailSendRefused(reason: t("wallet__max_possible_fee_rate")) + } + /// Whether the balance covers the amount and the fee `prepareSendPayment` quoted, the real sendable check behind /// the estimate in `ChannelFreeRailFees`. nonisolated static func fitsBalance(amountSats: UInt64, feeSats: UInt64, balanceSats: UInt64) -> Bool { diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index a5a902879..39cd8d1c6 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -11,7 +11,9 @@ protocol ChannelFreeLightningRail: AnyObject { @MainActor var isReady: Bool { get } @MainActor var balanceSats: UInt64 { get } func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String - func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash + /// Refuses before sending when the prepared fee is above `maxFeeSats`, and runs `beforeSubmission` right before the + /// payment goes out, as the node path does. + func send(bolt11: String, sats: UInt64?, maxFeeSats: UInt64?, beforeSubmission: @escaping () throws -> Void) async throws -> PaymentHash func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 /// Throws when the rail could not list its payments, so a duplicate-payment check can tell that from none. func lightningPayments() async throws -> [ChannelFreeRailPayment] @@ -983,9 +985,13 @@ class LightningService: NodeLifecycleService { throw ChannelFreeRailSendRefused(reason: t("wallet__max_possible_fee_rate")) } } - try beforeSubmission() noteAttempt(on: .channelFree, bolt11: bolt11) - return try await rail.send(bolt11: bolt11, sats: sats) + return try await rail.send( + bolt11: bolt11, + sats: sats, + maxFeeSats: params?.maxTotalRoutingFeeMsat.map { $0 / 1000 }, + beforeSubmission: beforeSubmission + ) } guard let node else { throw AppError(serviceError: .nodeNotSetup) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index cf2132358..3101e7265 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -195,15 +195,49 @@ protocol PaykitLightningPaymentProofLookingUp: Sendable { struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + await Self.combined( + ldk: Self.ldkStatus(paymentHash: paymentHash), + rail: Self.railStatus(paymentHash: paymentHash), + latestAttempt: LightningService.shared.attemptRail(paymentHash: paymentHash) + ) + } + + /// One outcome from both rails, since an invoice retried on the other rail is listed by each. A settled payment is + /// settled whichever rail paid it; otherwise the rail that carried the latest attempt decides, and without one known + /// (after a restart) a pending attempt wins over a failed one. + static func combined( + ldk: PaykitLightningPaymentProofStatus, + rail: PaykitLightningPaymentProofStatus, + latestAttempt: LightningPaymentRail? + ) -> PaykitLightningPaymentProofStatus { + for status in [ldk, rail] { + if case .succeeded = status { + return status + } + } + let latest: PaykitLightningPaymentProofStatus? = switch latestAttempt { + case .ldk: ldk + case .channelFree: rail + case nil: nil + } + if let latest, latest != .unknown, latest != .unavailable { + return latest + } + // A rail that can't list its payments may hold a live retry, so it outranks the other rail's failure. + for wanted in [PaykitLightningPaymentProofStatus.pending, .unavailable, .failed] where ldk == wanted || rail == wanted { + return wanted + } + return .unknown + } + + private static func ldkStatus(paymentHash: String) async -> PaykitLightningPaymentProofStatus { guard let payments = await LightningService.shared.listPayments() else { - let railStatus = await Self.railStatus(paymentHash: paymentHash) - guard case .unknown = railStatus else { return railStatus } return .unavailable } guard let payment = payments.first(where: { $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame }), payment.direction == .outbound else { - return await Self.railStatus(paymentHash: paymentHash) + return .unknown } switch payment.status { diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index 154874010..ce7606c29 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -439,7 +439,9 @@ final class QuickPaySpendStore: @unchecked Sendable { if matches.isEmpty { return nil } - if let paymentId = record.paymentId, let exact = matches.first(where: { $0.paymentId == paymentId }) { + // LDK and Spark each list an attempt under the same payment id when an invoice was retried on the other rail, + // so a settled or pending attempt wins over an earlier failed one. + if let paymentId = record.paymentId, let exact = matches.filter({ $0.paymentId == paymentId }).max(by: { $0.status.rank < $1.status.rank }) { return exact } return matches.max { lhs, rhs in diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 5ad0e7266..271b9caa3 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -40,6 +40,14 @@ struct SparkTransferQuote: Equatable { var coversClaimFee: Bool { depositSats > claimFees.instantMaxSats } + + /// The minimum transfer, for a quote that doesn't cover the claim fee. + var belowClaimFeeMessage: String { + t( + "lightning__spending_amount__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(claimFees.instantMaxSats.saturatingAdd(1))] + ) + } } /// Where a transfer to spending stands once its deposit is broadcast. @@ -137,9 +145,11 @@ final class SparkTransferFlow { private(set) var txid: String? var claimState: SparkClaimState = .looking - @ObservationIgnored private var offer: SparkClaimOffer? - @ObservationIgnored private var ceilingSats: UInt64 = 0 - @ObservationIgnored private var watchTask: Task? + /// Each broadcast deposit keeps its own claim: a later transfer neither stops an earlier one's early claim nor takes + /// its results, which only the current transfer's screen shows. + @ObservationIgnored private var offers: [String: SparkClaimOffer] = [:] + @ObservationIgnored private var ceilings: [String: UInt64] = [:] + @ObservationIgnored private var watchTasks: [String: Task] = [:] @ObservationIgnored private var quoteRequestId = 0 @ObservationIgnored private let operations: SparkClaimOperations @ObservationIgnored private let pollAttempts: Int @@ -252,11 +262,11 @@ final class SparkTransferFlow { /// Records the broadcast deposit and starts its claim for the chosen speed. func onBroadcast(txid: String) { self.txid = txid - ceilingSats = quote?.claimFees.instantMaxSats ?? 0 - offer = nil + ceilings[txid] = quote?.claimFees.instantMaxSats ?? 0 + offers[txid] = nil claimState = speed == .instant ? .looking : .later - watchTask?.cancel() - watchTask = nil + watchTasks[txid]?.cancel() + watchTasks[txid] = nil if speed == .instant { watch(txid: txid) } @@ -265,30 +275,40 @@ final class SparkTransferFlow { /// Accepts the early claim at the repriced fee. func payRepriced() { guard case let .repriced(feeSats) = claimState, let txid else { return } - ceilingSats = feeSats + ceilings[txid] = feeSats claimState = .claiming watch(txid: txid) } /// Leaves the deposit to the claim after 3 confirmations. func waitForConfirmations() { - watchTask?.cancel() - watchTask = nil + if let txid { + watchTasks[txid]?.cancel() + watchTasks[txid] = nil + } claimState = .later } - /// Waits for the running claim to settle; for tests. + /// Waits for the current transfer's claim to settle; for tests. func waitForClaim() async { - await watchTask?.value + guard let txid else { return } + await watchTasks[txid]?.value } private func watch(txid: String) { - watchTask?.cancel() - watchTask = Task { [weak self] in + watchTasks[txid]?.cancel() + watchTasks[txid] = Task { [weak self] in await self?.pollDeposit(txid: txid) + self?.watchTasks[txid] = nil } } + /// Shows a claim's progress only while its deposit is the transfer on screen. + private func setClaimState(_ state: SparkClaimState, for txid: String) { + guard !Task.isCancelled, self.txid == txid else { return } + claimState = state + } + private func pollDeposit(txid: String) async { for _ in 0 ..< pollAttempts { if Task.isCancelled { @@ -296,22 +316,24 @@ final class SparkTransferFlow { } do { let found = try await operations.offer(txid) - if found == nil, offer != nil { + guard !Task.isCancelled else { return } + if found == nil, offers[txid] != nil { // Listed before and no longer unclaimed: the early claim went through. - claimState = .adding + setClaimState(.adding, for: txid) return } if let found { - offer = found - let next = SparkClaimDecision.onOffer(found.kind, speed: .instant, ceilingSats: ceilingSats) + offers[txid] = found + let next = SparkClaimDecision.onOffer(found.kind, speed: .instant, ceilingSats: ceilings[txid] ?? 0) guard next == .claiming else { - claimState = next + setClaimState(next, for: txid) return } - claimState = .claiming + setClaimState(.claiming, for: txid) let claim = try await operations.claimEarly(found) + guard !Task.isCancelled else { return } if let state = SparkClaimDecision.onClaim(claim) { - claimState = state + setClaimState(state, for: txid) return } } @@ -323,6 +345,6 @@ final class SparkTransferFlow { } guard !Task.isCancelled else { return } Logger.warn("Spark did not list deposit \(txid) in time", context: "SparkTransferFlow") - claimState = .notSeen + setClaimState(.notSeen, for: txid) } } diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index 3075b8db4..b21410338 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -45,6 +45,10 @@ struct SpendingHwSign: View { if let error = flow.quoteError { BodyMText(error, textColor: .redAccent) .padding(.bottom, 16) + } else if let quote = flow.quote, !quote.coversClaimFee, !hwSend.hasPendingBroadcast { + BodyMText(quote.belowClaimFeeMessage, textColor: .redAccent) + .padding(.bottom, 16) + .accessibilityIdentifier("HardwareTransferBelowMinimum") } SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) @@ -78,7 +82,7 @@ struct SpendingHwSign: View { CustomButton( title: hwSend.hasPendingBroadcast ? t("common__retry") : vendor.sendSignButtonTitle, - isDisabled: isBusy || flow.quote == nil, + isDisabled: isBusy || flow.quote == nil || !(hwSend.hasPendingBroadcast || flow.quote?.coversClaimFee == true), isLoading: isBusy ) { startSigning() @@ -188,6 +192,14 @@ struct SpendingHwSign: View { } guard let fresh = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager) else { return nil } + guard fresh.coversClaimFee else { + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_min__title"), + description: fresh.belowClaimFeeMessage + ) + return nil + } let speed = flow.speed let shownCost = shown.networkFeeSats.saturatingAdd(shown.claimFeeSats(for: speed)) let freshCost = fresh.networkFeeSats.saturatingAdd(fresh.claimFeeSats(for: speed)) diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 33bab6326..4edaab18d 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -38,7 +38,7 @@ struct SpendingConfirm: View { quoteError(error) .padding(.bottom, 16) } else if let quote = flow.quote, !quote.coversClaimFee { - BodyMText(Self.belowClaimFeeMessage(quote), textColor: .redAccent) + BodyMText(quote.belowClaimFeeMessage, textColor: .redAccent) .padding(.bottom, 16) .accessibilityIdentifier("SpendingConfirmBelowMinimum") } @@ -93,13 +93,6 @@ struct SpendingConfirm: View { return quote.networkFeeSats > 0 && quote.coversClaimFee && quote.toSpendingSats(for: flow.speed) > 0 } - private static func belowClaimFeeMessage(_ quote: SparkTransferQuote) -> String { - t( - "lightning__spending_amount__error_min__description", - variables: ["amount": CurrencyFormatter.formatSats(quote.claimFees.instantMaxSats.saturatingAdd(1))] - ) - } - private func quoteError(_ message: String) -> some View { HStack(alignment: .center, spacing: 16) { BodyMText(message, textColor: .redAccent) @@ -124,7 +117,7 @@ struct SpendingConfirm: View { app.toast( type: .warning, title: t("lightning__spending_amount__error_min__title"), - description: Self.belowClaimFeeMessage(fresh) + description: fresh.belowClaimFeeMessage ) throw SpendingFeesIncreasedError() } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 01c93ab48..172d267ab 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -97,6 +97,7 @@ final class ChannelFreeRailTests: XCTestCase { ) _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100, params: roomy) XCTAssertEqual(rail.sentBolt11, "lnbcrt1rail") + XCTAssertEqual(rail.sentFeeLimitSats, 10, "The rail checks its sent quote against the limit too") } } @@ -228,6 +229,40 @@ extension ChannelFreeRailTests { XCTAssertFalse(ActivityService.keepsLightningStatus(existing: .pending, update: .succeeded, isStaleRailFailure: true)) } + func testRailRunsTheSubmissionCheckBeforeSending() async { + struct Expired: Error {} + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 100) { throw Expired() } + XCTFail("Expected the submission check to stop the payment") + } catch { + XCTAssertTrue(error is Expired) + XCTAssertNil(rail.sentBolt11) + } + } + + func testProofSettlesFromTheRailOfTheLatestAttempt() { + typealias Lookup = PaykitLightningPaymentProofLookup + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .pending, latestAttempt: .channelFree), .pending) + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .succeeded(preimage: "pp"), latestAttempt: .channelFree), .succeeded(preimage: "pp")) + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .succeeded(preimage: "pp"), latestAttempt: nil), .succeeded(preimage: "pp")) + XCTAssertEqual(Lookup.combined(ldk: .pending, rail: .failed, latestAttempt: .ldk), .pending) + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .pending, latestAttempt: nil), .pending, "A retry may still be in flight") + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .unavailable, latestAttempt: nil), .unavailable, "Spark could hold a retry") + XCTAssertEqual(Lookup.combined(ldk: .failed, rail: .unknown, latestAttempt: nil), .failed) + XCTAssertEqual(Lookup.combined(ldk: .unknown, rail: .unknown, latestAttempt: nil), .unknown) + } + + func testQuickPayPrefersTheLiveAttemptAcrossRails() { + let record = QuickPayLedgerRecord(amountCents: 100, dayKey: "2026-11-06", invoicePaymentHash: "cc", paymentId: "cc") + let ldkFailed = QuickPayReconcileRow(paymentId: "cc", invoicePaymentHash: "cc", isOutboundBolt11: true, status: .failed) + let sparkPaid = QuickPayReconcileRow(railPayment: Bitkit.ChannelFreeRailPayment( + paymentHash: "cc", direction: .outbound, status: .succeeded, preimage: nil + )) + + XCTAssertEqual(QuickPaySpendStore.ledgerMatch(record: record, rows: [ldkFailed, sparkPaid])?.status, .succeeded) + } + func testSparkReadyTriggersOnceUntilItDropsAgain() { var transition = SparkReadinessTransition() XCTAssertFalse(transition.becameReady(false)) @@ -246,12 +281,15 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var estimateError: Error? var listError: Error? var sentBolt11: String? + var sentFeeLimitSats: UInt64? func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { "lnbcrt1fake" } - func send(bolt11: String, sats: UInt64?) async throws -> PaymentHash { + func send(bolt11: String, sats: UInt64?, maxFeeSats: UInt64?, beforeSubmission: @escaping () throws -> Void) async throws -> PaymentHash { + try beforeSubmission() + sentFeeLimitSats = maxFeeSats sentBolt11 = bolt11 return "ee" } diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index afd8a08b7..339207e29 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -142,6 +142,24 @@ final class SparkPaymentSafetyTests: XCTestCase { ) } + func testSentQuoteMustStayWithinTheFeeLimit() { + XCTAssertNoThrow(try SparkManager.checkSendFee(10, maxFeeSats: 10)) + XCTAssertNoThrow(try SparkManager.checkSendFee(500, maxFeeSats: nil)) + XCTAssertThrowsError(try SparkManager.checkSendFee(11, maxFeeSats: 10)) { error in + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused) + } + } + + func testHardwareTransferBelowTheClaimFeeNamesTheMinimum() { + let quote = SparkTransferQuote( + address: "bcrt1q-spark", satsPerVbyte: 20, networkFeeSats: 300, depositSats: 1000, + claimFees: SparkClaimFees(instantMaxSats: 2178, laterSats: 1980) + ) + + XCTAssertFalse(quote.coversClaimFee) + XCTAssertTrue(quote.belowClaimFeeMessage.contains(CurrencyFormatter.formatSats(2179))) + } + func testSendMustFitTheQuotedFee() { XCTAssertTrue(SparkManager.fitsBalance(amountSats: 9000, feeSats: 572, balanceSats: 9572)) XCTAssertFalse(SparkManager.fitsBalance(amountSats: 9001, feeSats: 572, balanceSats: 9572)) diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 82dc21bd2..e974844c7 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -108,6 +108,40 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .unavailable) } + @MainActor + func testANewTransferKeepsTheEarlierClaimAndItsResultOffScreen() async throws { + var claimed: [String] = [] + var release: CheckedContinuation? + let operations = SparkClaimOperations( + offer: { txid in SparkClaimOffer(txid: txid, vout: 0, kind: .choice( + earlyFeeSats: 900, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3 + )) }, + claimEarly: { offer in + await withCheckedContinuation { release = $0 } + claimed.append(offer.txid) + return .claimed + }, + refresh: {}, + sleep: { _ in } + ) + let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) + flow.onBroadcast(txid: "first") + for _ in 0 ..< 100 where release == nil { + try await Task.sleep(nanoseconds: 10_000_000) + } + + flow.beginIfBroadcast() + flow.speed = .later + flow.onBroadcast(txid: "second") + release?.resume() + for _ in 0 ..< 100 where claimed.isEmpty { + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertEqual(claimed, ["first"], "The earlier deposit's early claim went on") + XCTAssertEqual(flow.claimState, .later, "Its result stays off the new transfer's screen") + } + /// A flow whose poll answers with `offers` in turn (nil once they run out) and whose claims answer with `claims`. @MainActor private func pollingFlow(offers: [SparkClaimOffer.Kind?], claims: [SparkEarlyClaim] = []) -> SparkTransferFlow { From 597a8c1fd3a62571dff76a90c4810a07e384023d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 22:28:57 +0100 Subject: [PATCH 048/378] fix: show a listed spark deposit as waiting, not unseen A deposit Spark listed whose early claim kept waiting through the polling window now shows Instant as unavailable, as on Android, instead of saying Spark hasn't seen the transfer. --- Bitkit/ViewModels/SparkTransferFlow.swift | 6 ++++++ BitkitTests/SparkTransferFlowTests.swift | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 271b9caa3..73f428c35 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -344,6 +344,12 @@ final class SparkTransferFlow { await operations.sleep(Self.pollInterval) } guard !Task.isCancelled else { return } + // A deposit Spark listed whose early claim never came is waiting for its confirmations, not unseen. + guard offers[txid] == nil else { + Logger.info("No early claim for Spark deposit \(txid) within the polling window", context: "SparkTransferFlow") + setClaimState(.unavailable, for: txid) + return + } Logger.warn("Spark did not list deposit \(txid) in time", context: "SparkTransferFlow") setClaimState(.notSeen, for: txid) } diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index e974844c7..c0923f10b 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -71,6 +71,18 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .notSeen) } + @MainActor + func testListedDepositWhoseEarlyClaimKeepsWaitingIsNotUnseen() async { + let flow = pollingFlow( + offers: [choice(earlyFee: 900), choice(earlyFee: 900), choice(earlyFee: 900)], + claims: [.waiting, .waiting, .waiting], + pollAttempts: 3 + ) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .unavailable) + } + @MainActor func testDepositListedThenGoneWasAdded() async { let flow = pollingFlow(offers: [choice(earlyFee: 900), nil], claims: [.waiting]) @@ -144,7 +156,7 @@ final class SparkTransferFlowTests: XCTestCase { /// A flow whose poll answers with `offers` in turn (nil once they run out) and whose claims answer with `claims`. @MainActor - private func pollingFlow(offers: [SparkClaimOffer.Kind?], claims: [SparkEarlyClaim] = []) -> SparkTransferFlow { + private func pollingFlow(offers: [SparkClaimOffer.Kind?], claims: [SparkEarlyClaim] = [], pollAttempts: Int? = nil) -> SparkTransferFlow { var offers = offers var claims = claims let operations = SparkClaimOperations( @@ -156,7 +168,7 @@ final class SparkTransferFlowTests: XCTestCase { refresh: {}, sleep: { _ in } ) - return SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: offers.count + 1) + return SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: pollAttempts ?? offers.count + 1) } func testQuoteTiles() { From 4b2feb53b0e7bb3d3143353db24e5a63ad273020 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:02:23 +0100 Subject: [PATCH 049/378] fix: spark exit independent review findings - Cancel is off once funding was paid or seen, and reads the funding address first: funding there is swept back to savings, an unreadable address keeps the exit. - No height is written from a failed tip read, so the fee-change wait can't end at once. - A higher fee's top-up keeps the live generation checked, sent and reminded; a savings payment no host knows, or not adopted in a day, is forgotten so the ask and reminder return. - A failed tick reloads the record from disk; the exit data is imported before fee-bump pricing and the failed-leaf retry; fired reminders stay remembered; an unfunded exit reminds. - The leftover sweep's open states are bounded, salvage keeps the chained txids and replaces the record in one write, and the hex reader no longer traps on malformed counts. - Exit journey and changelog fragment. --- Bitkit/Managers/SparkExitManager.swift | 103 ++++++- .../Localization/ar.lproj/Localizable.strings | 3 + .../Localization/ca.lproj/Localizable.strings | 3 + .../Localization/cs.lproj/Localizable.strings | 3 + .../Localization/de.lproj/Localizable.strings | 3 + .../Localization/el.lproj/Localizable.strings | 3 + .../Localization/en.lproj/Localizable.strings | 3 + .../es-419.lproj/Localizable.strings | 3 + .../Localization/es.lproj/Localizable.strings | 3 + .../Localization/fr.lproj/Localizable.strings | 3 + .../Localization/it.lproj/Localizable.strings | 3 + .../Localization/nl.lproj/Localizable.strings | 3 + .../Localization/pl.lproj/Localizable.strings | 3 + .../pt-BR.lproj/Localizable.strings | 3 + .../Localization/pt.lproj/Localizable.strings | 3 + .../Localization/ru.lproj/Localizable.strings | 3 + .../Services/SparkExit/SparkExitDriver.swift | 186 ++++++++++-- .../Services/SparkExit/SparkExitFunding.swift | 24 +- .../Services/SparkExit/SparkExitRecord.swift | 40 ++- .../Settings/Advanced/SparkExitViews.swift | 11 +- BitkitTests/SparkExitFeeBumpTests.swift | 6 +- BitkitTests/SparkExitReviewTests.swift | 269 ++++++++++++++++++ BitkitTests/SparkExitTests.swift | 11 +- changelog.d/next/1217.added.md | 1 + journeys/README.md | 1 + journeys/transfer/spark-emergency-exit.xml | 31 ++ 26 files changed, 682 insertions(+), 46 deletions(-) create mode 100644 BitkitTests/SparkExitReviewTests.swift create mode 100644 changelog.d/next/1217.added.md create mode 100644 journeys/transfer/spark-emergency-exit.xml diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 7bacb7f5c..b430eb354 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -85,13 +85,18 @@ final class SparkExitManager { SparkExitLostLeafStore(walletIndex: walletIndex) } - /// Saves the record, and keeps the leaves it lost to an older state for every later exit of the wallet. + /// Saves the record, and keeps the leaves it lost to an older state for every later exit of the wallet. The lost-leaf + /// list is best effort: it never fails the save the driver relies on. private var saveRecord: (SparkExitRecord) throws -> Void { let store = store let lostLeafStore = lostLeafStore return { try store.save($0) - try lostLeafStore.add(lostIn: $0) + do { + try lostLeafStore.add(lostIn: $0) + } catch { + Logger.warn("Failed to keep the Spark exit's lost leaves: \(error)", context: "SparkExitManager") + } } } @@ -140,7 +145,8 @@ final class SparkExitManager { } catch { guard let salvaged = SparkExitRecordCoder.salvage(data) else { throw error } Logger.warn("Rebuilding the Spark exit from the chain, its record could not be read: \(error)", context: "SparkExitManager") - try store.setAside() + // The original is copied aside first and the salvaged record then replaces it in one write. + try store.keepCopyAside() try store.save(salvaged) record = salvaged } @@ -161,13 +167,35 @@ final class SparkExitManager { /// Stops an exit that has not built anything and whose funding address received nothing, so leaving it /// costs nothing: Spark comes back as it was. var canCancel: Bool { - guard let record, record.recovery == nil, record.fundedSats == 0, fundingSeenSats == 0 else { return false } + guard let record else { return false } + return Self.canCancel(record, fundingSeenSats: fundingSeenSats) + } + + /// Nothing built, nothing paid from savings and nothing seen at the funding address. + nonisolated static func canCancel(_ record: SparkExitRecord, fundingSeenSats: UInt64) -> Bool { + guard record.recovery == nil, record.fundedSats == 0, fundingSeenSats == 0, record.fundingPaymentTxid == nil else { return false } if case .awaitingFunding = record.phase { return true } return false } + enum CancelOutcome: Equatable { + /// Nothing reached the funding address: the exit is forgotten and Spark comes back. + case forget + /// Funding reached it after all: the exit ends and sends that funding back to savings. + case sweepFunding([SparkExitRecord.Leftover]) + /// The address could not be read: nothing changes. + case keep + } + + /// What cancelling does, given what the funding address holds now (nil when it could not be read). + nonisolated static func cancelOutcome(utxos: [EsploraUtxo]?) -> CancelOutcome { + guard let utxos else { return .keep } + guard !utxos.isEmpty else { return .forget } + return .sweepFunding(utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) }) + } + /// Notes that the user opened the result of an exit that failed or lost leaves, so Emergency exit starts a new /// one next time. func markResultSeen() { @@ -181,8 +209,42 @@ final class SparkExitManager { } } - func cancel() async { - guard canCancel, let record else { return } + /// Reads the funding address first: a payment that arrived since the last tick is never orphaned. With funding there + /// the record stays, ends as cancelled and its leftover sweep returns the funding to savings. + @discardableResult + func cancel() async -> CancelOutcome { + guard canCancel, var record, let address = record.funding.first?.address else { return .keep } + let utxos = try? await chain?.utxos(address: address) + let outcome = Self.cancelOutcome(utxos: utxos) + switch outcome { + case .keep: + Logger.warn("Spark exit not cancelled: its funding address could not be read", context: "SparkExitManager") + return outcome + case let .sweepFunding(leftover): + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + record.phase = .failed(reason: SparkExitRecord.cancelledReason) + record.isCancelled = true + record.isResultSeen = true + record.leftover = leftover + do { + try saveRecord(record) + } catch { + Logger.error("Failed to save the cancelled Spark exit: \(error)", context: "SparkExitManager") + return .keep + } + self.record = record + if let transferId = record.transferId { + try? await TransferService(lightningService: .shared).markSettled(id: transferId) + } + await reattachSpark() + Logger.info("Cancelled the Spark exit after funding arrived; sweeping it back to savings", context: "SparkExitManager") + Task { await tick() } + return outcome + case .forget: + break + } try? store.delete() self.record = nil Self.clearReminders() @@ -191,6 +253,7 @@ final class SparkExitManager { } await reattachSpark() Logger.info("Cancelled the Spark exit before it was funded", context: "SparkExitManager") + return .forget } /// Ticks every minute while the app is active and an exit runs. Each step of an exit needs Bitkit to publish it. @@ -239,7 +302,10 @@ final class SparkExitManager { } catch { lastError = error.localizedDescription Logger.error("Spark exit tick failed: \(error)", context: "SparkExitManager") - await scheduleReminders(current) + // The driver saved along the way: the next tick starts from what is on disk, never from the copy it began with. + let stored = (try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore)) ?? current + record = stored + await scheduleReminders(stored) } } @@ -387,6 +453,11 @@ final class SparkExitManager { try? await Task.sleep(nanoseconds: 100_000_000) } guard var record else { return } + // The exit may have ended while savings paid: the extra stays at the funding address for the leftover sweep. + guard record.phase == .running, !record.isRebuilding else { + Logger.warn("Spark exit ended while its higher fee was paid; the payment goes back with the leftover", context: "SparkExitManager") + return + } record.applyFeeBump(rate: option.rate, extraSats: extraSats, paymentTxid: txid) try saveRecord(record) self.record = record @@ -731,7 +802,23 @@ final class SparkExitManager { for request in requests { try? await center.add(request) } - UserDefaults.standard.set(kept, forKey: Self.remindersKey) + UserDefaults.standard.set(Self.keepingFired(kept, remembered: remembered, now: now), forKey: Self.remindersKey) + } + + /// How long a fired reminder stays remembered after it left the plan, so a plan that drops an id for one run (a + /// tip that could not be read, a phase that flickered) never posts it again. + nonisolated static let firedReminderMemory: TimeInterval = 30 * 24 * 60 * 60 + + /// `kept` plus every reminder that already fired within `firedReminderMemory`, whether or not it is planned now. + nonisolated static func keepingFired(_ kept: [String: TimeInterval], remembered: [String: TimeInterval], now: Date) -> [String: TimeInterval] { + var result = kept + for (id, firedAt) in remembered where result[id] == nil { + let age = now.timeIntervalSince1970 - firedAt + if age >= 0, age < firedReminderMemory { + result[id] = firedAt + } + } + return result } private nonisolated static func rememberedReminders() -> [String: TimeInterval] { diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index f2fe65493..b5dee9538 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -400,6 +400,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index c90e71f92..ddfaff3c3 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -437,6 +437,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 6a9650039..51c9721b9 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -453,6 +453,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index d6b54ae48..d4307cbaa 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -451,6 +451,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index d31f31512..71fe72f9e 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -424,6 +424,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 31e570899..a33ec8f2a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -446,6 +446,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index e754aa6c7..2dd3667b1 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -454,6 +454,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 17e5824f0..162d1f2a5 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -438,6 +438,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index ba470409c..debf10196 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -454,6 +454,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 3754e510a..d2d42520f 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -447,6 +447,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 89c9d0955..a4697f718 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -451,6 +451,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 83f20c717..7caa31af2 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -453,6 +453,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 066087179..2e7d47bb3 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -453,6 +453,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index ff8a1e1a0..b45019fd8 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -451,6 +451,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index ec3546a9b..97680694e 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -448,6 +448,9 @@ "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; +"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; +"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; +"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 5000cc209..1d5c828e7 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -61,6 +61,10 @@ struct SparkExitDriver { static let sweepRecheckBlocks: UInt32 = 144 /// About four weeks: then the change too small to send stays at the funding address. static let sweepWaitBlocks: UInt32 = 4032 + /// A savings payment Bitkit sent that no host knows for this many blocks, or that the exit did not adopt within + /// `ownPaymentTimeoutBlocks`, is forgotten: the exit asks for the funding, and reminds, again. + static let ownPaymentMissingBlocks: UInt32 = 1 + static let ownPaymentTimeoutBlocks: UInt32 = 144 @discardableResult func tick(_ record: SparkExitRecord) async throws -> SparkExitRecord { @@ -72,12 +76,26 @@ struct SparkExitDriver { try save(record) return record case .awaitingFunding, .needsFunding: - guard let recovering, !Task.isCancelled else { return record } - // Nothing to fund when every leaf is recovered cooperatively. - if let shortfall = record.fundingShortfallSats { - guard try await adoptFunding(&record, requiredSats: shortfall) else { return record } + guard !Task.isCancelled else { return record } + let before = record + await noteOwnPayment(&record) + // A higher fee's top-up leaves the current generation valid: it keeps being checked and sent meanwhile. + if record.isDrivingWhileFunding { + if let recovering { + try await driveWhileFunding(&record, recovering: recovering) + } else { + try await broadcastWithoutSdk(&record) + } + } + // Still waiting for funding (a generation that finished meanwhile is back to running). + if record.phase != .running { + guard let recovering else { return try saveIfChanged(record, from: before) } + // Nothing to fund when every leaf is recovered cooperatively. + if let shortfall = record.fundingShortfallSats { + guard try await adoptFunding(&record, requiredSats: shortfall) else { return try saveIfChanged(record, from: before) } + } + try await build(&record, recovering: recovering) } - try await build(&record, recovering: recovering) case .running where record.isRebuilding: // The diverged generation is never broadcast again; only a rebuild moves on from here. guard let recovering, !Task.isCancelled, isRebuildDue(record) else { return record } @@ -102,8 +120,56 @@ struct SparkExitDriver { return record } + private func saveIfChanged(_ record: SparkExitRecord, from before: SparkExitRecord) throws -> SparkExitRecord { + if record != before { + try save(record) + } + return record + } + // MARK: Funding + /// Forgets a savings payment the exit waits for once no host knows it for `ownPaymentMissingBlocks` (dropped or + /// replaced), or once it is older than `ownPaymentTimeoutBlocks` without being adopted, so the ask and the reminder + /// come back. Nothing changes without a tip. + private func noteOwnPayment(_ record: inout SparkExitRecord) async { + guard record.isAwaitingOwnPayment, let paid = record.fundingPaymentTxid, let tip = try? await chain.tipHeight() else { return } + let seenAt = record.fundingPaymentSeenAtHeight ?? tip + record.fundingPaymentSeenAtHeight = seenAt + switch try? await chain.isKnown(txid: paid) { + case true?: record.fundingPaymentMissingSince = nil + case false?: record.fundingPaymentMissingSince = record.fundingPaymentMissingSince ?? tip + case nil: break + } + let isMissing = record.fundingPaymentMissingSince.map { tip.saturatingSub($0) >= Self.ownPaymentMissingBlocks } ?? false + let isTooOld = tip.saturatingSub(seenAt) >= Self.ownPaymentTimeoutBlocks + guard isMissing || isTooOld else { return } + Logger.warn("Spark exit forgets its funding payment \(paid) (missing: \(isMissing), too old: \(isTooOld))", context: "SparkExitDriver") + record.fundingPaymentTxid = nil + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + } + + /// While a higher fee's top-up is awaited: the current generation is checked and its ready steps sent. A + /// generation that finished in the meantime runs on to Done; one that diverged waits for the top-up's rebuild. + private func driveWhileFunding(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + guard let stored = record.recovery?.response else { return } + let checked = try await recovering.check(withConfirmations(stored)) + record.recovery = StoredRecovery(checked.recovery) + record.noteProgressAfterRebuildFromChain(checked.recovery) + switch checked.verdict { + case .valid: + let tip = try? await chain.tipHeight() + try await broadcastReady(&record, tip: tip) + case .done: + // Nothing is left to pay more for: back to running, whose next check finishes it (failed leaves included). + record.phase = .running + record.fundingPaymentTxid = nil + case .redo: + break + } + } + /// Adds every confirmed new UTXO at the funding address as a funding input once together they cover /// `requiredSats`. private func adoptFunding(_ record: inout SparkExitRecord, requiredSats: UInt64) async throws -> Bool { @@ -408,6 +474,7 @@ struct SparkExitDriver { record.failedLeafRetriedAt = tip try save(record) guard !Task.isCancelled else { return true } + await useExitStateSnapshot(&record, recovering: recovering) do { let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: failedIds))) noteFailedLeafValues(prepared, failedIds: failedIds, in: &record) @@ -444,6 +511,8 @@ struct SparkExitDriver { /// nothing; a shortfall is sized like a rebuild's (`neededFunding`), the input fee of the payment to come included. func feeBumpShortfall(_ record: SparkExitRecord, rate: UInt64) async throws -> UInt64 { guard let recovering else { throw SparkExitFeeBumpError.notConnected } + var record = record + await useExitStateSnapshot(&record, recovering: recovering) let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: record.exitLeafIds), feeRate: rate)) guard prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 } do { @@ -635,14 +704,13 @@ struct SparkExitDriver { replacedTxids: sweep.isAccepted ? (sweep.replacedTxids ?? []) + [oldTxid] : sweep.replacedTxids ) try save(record) - } else if !sweep.isAccepted { + } else if !sweep.isAccepted, let tip = try? await chain.tipHeight() { // Too small to send at the new rate, and nothing of it is in the mempool: it waits for fees to drop. - let tip = try? await chain.tipHeight() record.leftoverSweep = SparkExitRecord.LeftoverSweep( feeRateSatPerVbyte: rate, replacedTxids: sweep.replacedTxids, - waitingSince: tip ?? 0, - checkedAtHeight: tip ?? 0 + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip ) try save(record) } @@ -658,6 +726,22 @@ struct SparkExitDriver { record.leftoverSweep?.isConfirmed = true return } + // Built but never accepted for `sweepRecheckBlocks`: its coins may have changed, so it is built again from what + // the address holds now. + if !sweep.isAccepted, let builtAt = sweep.builtAtHeight, let tip = try? await chain.tipHeight(), + tip.saturatingSub(builtAt) >= Self.sweepRecheckBlocks + { + Logger.warn("Spark exit leftover sweep \(txid) never accepted, building it again", context: "SparkExitDriver") + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, + replacedTxids: sweep.replacedTxids, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip, + refusedFeeRate: sweep.refusedFeeRate, + retryAtHeight: tip + 1 + ) + return + } let outcome = await chain.broadcast(RecoveryTransaction( kind: .sweep, nodeId: nil, @@ -678,6 +762,17 @@ struct SparkExitDriver { case let .rejected(reason) where !sweep.isAccepted: if SparkExitRecord.LeftoverSweep(rejection: reason).isFeeRejection { await waitAfterFeeRefusal(&record, refusal: reason) + } else if await isAnyKnown(sweep.replacedTxids ?? []) { + // A replacement refused for another reason while the sweep it replaces may still confirm: that one is + // followed (the replaced txids end the sweep once in a block), and a new build is tried later. + if let tip = try? await chain.tipHeight() { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, + replacedTxids: sweep.replacedTxids, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip + ) + } } else { record.leftoverSweep?.rejection = reason } @@ -690,15 +785,42 @@ struct SparkExitDriver { Logger.warn("Spark exit leftover sweep \(txid) left the mempool unconfirmed", context: "SparkExitDriver") await waitAfterFeeRefusal(&record, refusal: reason) } + case .inputsMissing where !sweep.isAccepted: + // The coins it spends are gone or changed: built again from what the address holds, from the next block. + if let tip = try? await chain.tipHeight() { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, + replacedTxids: sweep.replacedTxids, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip, + refusedFeeRate: sweep.refusedFeeRate, + retryAtHeight: tip + 1 + ) + } default: break } } + private func isAnyKnown(_ txids: [String]) async -> Bool { + for txid in txids { + let isKnown = try? await chain.isKnown(txid: txid) + if isKnown == true { + return true + } + } + return false + } + /// A sweep refused for its fee is not final: the leftover waits again, keeping when its wait began, and is built /// again from the next block at the rate the refusal asks for (`refusedSweepRate`), or the economy rate if higher. private func waitAfterFeeRefusal(_ record: inout SparkExitRecord, refusal: String) async { - guard let sweep = record.leftoverSweep else { return } - let tip = try? await chain.tipHeight() + // Without a tip no height is written: the refusal is kept, and the next tick turns it into the wait. + guard let sweep = record.leftoverSweep, let tip = try? await chain.tipHeight() else { + if record.leftoverSweep?.isAccepted == false { + record.leftoverSweep?.rejection = refusal + } + return + } let rate = SparkExitMath.refusedSweepRate(refusal, paidRate: sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte) // One a node accepted may still confirm: any of the replaced sweeps confirming ends the sweep. let replaced = sweep.isAccepted ? (sweep.replacedTxids ?? []) + [sweep.txid].compactMap { $0 } : sweep.replacedTxids @@ -706,10 +828,10 @@ struct SparkExitDriver { record.leftoverSweep = SparkExitRecord.LeftoverSweep( feeRateSatPerVbyte: sweep.feeRateSatPerVbyte, replacedTxids: replaced, - waitingSince: sweep.waitingSince ?? tip ?? 0, - checkedAtHeight: tip ?? 0, + waitingSince: sweep.waitingSince ?? tip, + checkedAtHeight: tip, refusedFeeRate: rate, - retryAtHeight: (tip ?? 0) + 1 + retryAtHeight: tip + 1 ) } @@ -719,9 +841,9 @@ struct SparkExitDriver { private func sweepWhenEconomical(_ record: inout SparkExitRecord) async throws { guard let address = record.funding.first?.address else { return } let waiting = record.leftoverSweep - let tip = try? await chain.tipHeight() + // Every state below records a height: without the tip nothing changes this tick. + guard let tip = try? await chain.tipHeight() else { return } if let waiting, let since = waiting.waitingSince { - guard let tip else { return } if tip.saturatingSub(since) >= Self.sweepWaitBlocks { Logger.info("Spark exit keeps its fee change at the funding address after waiting for fees", context: "SparkExitDriver") record.leftoverSweep?.isDustKept = true @@ -734,7 +856,22 @@ struct SparkExitDriver { let utxos = try await chain.utxos(address: address) record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } let confirmed = utxos.filter(\.status.confirmed) - guard !confirmed.isEmpty, confirmed.count == utxos.count else { return } + guard !confirmed.isEmpty else { return } + // An unconfirmed output (a stuck payment, say) holds the sweep back for one recheck at most; then the confirmed + // ones go and the rest waits for the next sweep. + if confirmed.count < utxos.count { + let since = waiting?.waitingSince ?? tip + if tip.saturatingSub(since) < Self.sweepRecheckBlocks { + record.leftoverSweep = SparkExitRecord.LeftoverSweep( + waitingSince: since, + checkedAtHeight: tip, + refusedFeeRate: waiting?.refusedFeeRate, + retryAtHeight: tip + 1 + ) + try save(record) + return + } + } let economy = try? await chain.networkFees().economy // A rate a node refused stays refused: the least it asked for lifts the economy rate, past the exit's if need be. let rate = max(SparkExitMath.sweepFeeRate(economy: economy, exitRate: record.feeRateSatPerVbyte), waiting?.refusedFeeRate ?? 0) @@ -752,15 +889,16 @@ struct SparkExitDriver { txHex: built.txHex, valueSats: built.valueSats, feeRateSatPerVbyte: rate, - waitingSince: waiting?.waitingSince + waitingSince: waiting?.waitingSince, + builtAtHeight: tip ) } else { - let since = waiting?.waitingSince ?? tip ?? 0 + let since = waiting?.waitingSince ?? tip Logger.info("Spark exit waits for lower fees to send its fee change (\(rate) sat/vB)", context: "SparkExitDriver") record.leftoverSweep = SparkExitRecord.LeftoverSweep( feeRateSatPerVbyte: rate, waitingSince: since, - checkedAtHeight: tip ?? since, + checkedAtHeight: tip, refusedFeeRate: waiting?.refusedFeeRate ) } @@ -1032,7 +1170,7 @@ enum SparkExitTxid { var position = 0 mutating func take(_ count: Int) -> [UInt8]? { - guard count >= 0, position + count <= bytes.count else { return nil } + guard count >= 0, count <= bytes.count - position else { return nil } defer { position += count } return Array(bytes[position ..< position + count]) } @@ -1047,8 +1185,10 @@ enum SparkExitTxid { } guard width > 0 else { return (Int(first), [first]) } guard let rest = take(width) else { return nil } - let value = rest.enumerated().reduce(0) { $0 | Int($1.element) << (8 * $1.offset) } - return (value, [first] + rest) + let value = rest.enumerated().reduce(UInt64(0)) { $0 | UInt64($1.element) << (8 * $1.offset) } + // A count no transaction can hold reads as malformed rather than trapping. + guard value <= UInt64(bytes.count) else { return nil } + return (Int(value), [first] + rest) } mutating func inputs() -> (version: Int32, inputs: [(txid: String, sequence: UInt32)])? { diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index a4beb858f..ee8b7d4ad 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -288,16 +288,28 @@ enum SparkExitMath { let lost = record.lostLeaves ?? [] var lostReminder = lost.isEmpty ? [] : [SparkExitReminder(id: "lost-\(lost.count)", fireAt: now, isLostLeaves: true)] // An exit stopped because every leaf was lost is told by the lost reminder alone. - if case .failed = record.phase, !record.isEveryLeafLost { + if case .failed = record.phase, !record.isEveryLeafLost, record.isCancelled != true { lostReminder.append(SparkExitReminder(id: "failed", fireAt: now, isFailed: true)) } - if case let .needsFunding(requiredSats) = record.phase { - // A payment Bitkit already sent from savings, such as a higher fee's, needs no reminder to fund. - guard !record.isAwaitingOwnPayment else { return lostReminder } - return lostReminder + [SparkExitReminder(id: "funding-\(requiredSats)", fireAt: now, isFunding: true)] + // A payment Bitkit already sent from savings, such as a higher fee's, needs no reminder to fund until the exit + // forgets it (gone from every host, or not adopted in time). + if case let .awaitingFunding(requiredSats) = record.phase { + guard record.fundingShortfallSats != nil, !record.isAwaitingOwnPayment else { return lostReminder } + // Started but never funded: Spark stays detached until the user funds or cancels. + let fireAt = max(now, record.createdAt.addingTimeInterval(reminderSeconds(network: network))) + return lostReminder + [SparkExitReminder(id: "await-funding-\(requiredSats)", fireAt: fireAt, isFunding: true)] } - guard case .running = record.phase, let recovery = record.recovery?.response else { return lostReminder } var reminders = lostReminder + if case let .needsFunding(requiredSats) = record.phase { + if !record.isAwaitingOwnPayment { + reminders.append(SparkExitReminder(id: "funding-\(requiredSats)", fireAt: now, isFunding: true)) + } + // The live generation keeps going while the top-up comes, and so do its step reminders. + guard record.isDrivingWhileFunding else { return reminders } + } else { + guard case .running = record.phase else { return lostReminder } + } + guard let recovery = record.recovery?.response else { return reminders } // Waiting for a leaf that may still move is the background runs' job, for up to a week. if record.isWaitingForLeaves || record.isWaitingForOperators { return reminders diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 03b78cfe5..479cd4d1c 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -114,6 +114,9 @@ struct SparkExitRecord: Codable, Equatable { var refusedFeeRate: UInt64? /// After a refusal, the sweep is built again from this block instead of after `sweepRecheckBlocks`. var retryAtHeight: UInt32? + /// The tip when this sweep was built, so one no node accepts is built again from fresh UTXOs after + /// `sweepRecheckBlocks`. + var builtAtHeight: UInt32? /// Waiting for fees to drop before sending the fee change. var isWaitingForFees: Bool { @@ -177,6 +180,13 @@ struct SparkExitRecord: Codable, Equatable { var transferId: String? /// The fee funding payment Bitkit broadcast from savings, so the exit shows it as paid instead of asking again. var fundingPaymentTxid: String? + /// The tip when the exit first looked for that payment, and the tip since which no host knows it. A payment no + /// host knows for a block, or not adopted within `SparkExitDriver.ownPaymentTimeoutBlocks`, is forgotten, so the + /// exit asks and reminds again. + var fundingPaymentSeenAtHeight: UInt32? + var fundingPaymentMissingSince: UInt32? + /// The user cancelled after funding reached the address: the exit ends and the funding goes back to savings. + var isCancelled: Bool? /// What reaches savings: the exited value less the fees paid out of it. let expectedSats: UInt64 var claimableAtHeight: UInt32? @@ -252,6 +262,7 @@ struct SparkExitRecord: Codable, Equatable { } static let lostReason = "Claimed by an older state" + static let cancelledReason = "Cancelled" var isFailed: Bool { if case .failed = phase { @@ -400,6 +411,8 @@ struct SparkExitRecord: Codable, Equatable { mutating func applyFeeBump(rate: UInt64, extraSats: UInt64, paymentTxid: String?) { feeRateSatPerVbyte = rate isRedoRebuild = nil + fundingPaymentSeenAtHeight = nil + fundingPaymentMissingSince = nil if extraSats > 0 { phase = .needsFunding(requiredSats: fundedSats + extraSats) fundingPaymentTxid = paymentTxid @@ -440,6 +453,15 @@ struct SparkExitRecord: Codable, Equatable { return !funding.contains { $0.txid == paid } } + /// A top-up is awaited for a generation that is still valid (a higher fee, not a diverged rebuild): its steps keep + /// being checked, sent and reminded. + var isDrivingWhileFunding: Bool { + if case .needsFunding = phase { + return recovery != nil && !isRebuilding + } + return false + } + /// Progress says the exit is underpriced: it runs at its rate, nothing rebuilds it and no higher fee is on its way. func showsFeeLow(tip: UInt32?) -> Bool { phase == .running && !isRebuilding && underpricedStep(tip: tip) != nil @@ -496,6 +518,13 @@ struct SparkExitStore { } } + /// Keeps a copy of the record beside it for support, leaving the record in place: a salvaged record then replaces it + /// in one atomic write, so a kill in between never leaves the exit without a record. + func keepCopyAside() throws { + let aside = url.deletingPathExtension().appendingPathExtension("unreadable-\(Int(Date().timeIntervalSince1970)).json") + try FileManager.default.copyItem(at: url, to: aside) + } + /// Moves an unreadable record out of the way, keeping it for support. func setAside() throws { let aside = url.deletingPathExtension().appendingPathExtension("unreadable-\(Int(Date().timeIntervalSince1970)).json") @@ -608,6 +637,9 @@ enum SparkExitRecordCoder { record.lostLeaves = kept.lostLeaves record.leftBehindLeaves = kept.leftBehindLeaves record.exitStateSnapshot = kept.exitStateSnapshot + // Change and fan-out outputs of earlier generations at the funding address stay the exit's own, not new funding. + let chained = (kept.earlierCpfpTxids ?? []) + (kept.recovery.map { SparkExitTxid.chainedTxids($0.response) } ?? []) + record.earlierCpfpTxids = chained.isEmpty ? nil : chained record.isRebuiltFromChain = true return record } @@ -626,10 +658,12 @@ enum SparkExitRecordCoder { let lostLeaves: [SparkExitRecord.LostLeaf]? let leftBehindLeaves: [SparkExitRecord.LeftBehindLeaf]? let exitStateSnapshot: String? + let earlierCpfpTxids: [String]? + let recovery: StoredRecovery? private enum CodingKeys: String, CodingKey { case network, createdAt, phase, feeRateSatPerVbyte, destination, leafIds, leafTotalSats, funding, transferId, expectedSats - case lostLeaves, leftBehindLeaves, exitStateSnapshot + case lostLeaves, leftBehindLeaves, exitStateSnapshot, earlierCpfpTxids, recovery } init(from decoder: Decoder) throws { @@ -647,6 +681,8 @@ enum SparkExitRecordCoder { lostLeaves = try? c.decodeIfPresent([SparkExitRecord.LostLeaf].self, forKey: .lostLeaves) leftBehindLeaves = try? c.decodeIfPresent([SparkExitRecord.LeftBehindLeaf].self, forKey: .leftBehindLeaves) exitStateSnapshot = try? c.decodeIfPresent(String.self, forKey: .exitStateSnapshot) + earlierCpfpTxids = try? c.decodeIfPresent([String].self, forKey: .earlierCpfpTxids) + recovery = try? c.decodeIfPresent(StoredRecovery.self, forKey: .recovery) } } } @@ -659,7 +695,7 @@ enum SparkExitRecordMigration { // MARK: - RecoverFundsResponse in spark-sdk's serde shape -/// `RecoverFundsResponse` as spark-sdk 0.27.2 serializes it with serde: snake_case fields and externally tagged +/// `RecoverFundsResponse` as spark-sdk serializes it with serde (unchanged from 0.27.2 to 0.28.0): snake_case fields and externally tagged /// enums (`"Ready"`, `{"Confirmed":{"block_height":120}}`). A stored recovery is then readable by the Breez CLI's /// `check-recover-funds` and shared with Android as one format. The SDK's Swift types are not `Codable`. struct StoredRecovery: Codable, Equatable { diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 7de6992c1..6869059de 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -457,6 +457,7 @@ struct SparkExitConfirmView: View { /// The running exit's steps and countdown, and its result once it ends. struct SparkExitProgressView: View { + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var currency: CurrencyViewModel @@ -478,6 +479,9 @@ struct SparkExitProgressView: View { /// Lost leaves are gone, so the text claims only what Spark still holds. private var failedText: String { + if record?.isCancelled == true { + return t("lightning__spark_exit__cancelled_text") + } let staysSats = record.flatMap { SparkExitMath.staysOnSparkSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: $0) } return [ t("lightning__spark_exit__failed_text"), @@ -496,6 +500,7 @@ struct SparkExitProgressView: View { private var title: String { switch record?.phase { case .done: t("lightning__spark_exit__done_title") + case .failed where record?.isCancelled == true: t("lightning__spark_exit__cancelled_title") case .failed: t("lightning__spark_exit__failed_title") case .needsFunding where !isFundingOnItsWay: t("lightning__spark_exit__needs_funding_title") default: t("lightning__spark_exit__progress_title") @@ -672,8 +677,10 @@ struct SparkExitProgressView: View { if exit.canCancel { CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { - Task { - await exit.cancel() + // A funding address that cannot be read keeps the exit: a payment there is never orphaned. + if await exit.cancel() == .keep { + app.toast(type: .error, title: t("lightning__spark_exit__cancel_failed"), description: nil) + } else { navigation.reset() } } diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index 1108b9ca7..318da1b89 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -183,7 +183,7 @@ final class SparkExitFeeBumpTests: XCTestCase { XCTAssertEqual(record.feeRateSatPerVbyte, 10) XCTAssertEqual(sdk.prepareRequests.first?.feeRateSatPerVbyte, 10) XCTAssertEqual(sdk.recoverRequests.first?.fundingInputs.count, 2) - XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1b"]) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n1b"], "the live generation's ready step keeps going until the rebuild") XCTAssertNil(record.stallRebuilds) XCTAssertNil(record.isRedoRebuild) } @@ -202,12 +202,12 @@ final class SparkExitFeeBumpTests: XCTestCase { XCTAssertTrue(record.isAwaitingOwnPayment) XCTAssertFalse(record.showsFeeLow(tip: 1000)) - XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).isEmpty) + XCTAssertFalse(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).contains { $0.isFunding }) // Adopted, the payment no longer waits; a shortfall left after it reminds as before. record.funding.append(.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "bump", vout: 0, valueSats: 5000)) XCTAssertFalse(record.isAwaitingOwnPayment) - XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).map(\.isFunding), [true]) + XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).filter(\.isFunding).count, 1) } /// The funding already covers the higher rate: the exit rebuilds at once and replaces what has not confirmed. diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift new file mode 100644 index 000000000..feaf5525f --- /dev/null +++ b/BitkitTests/SparkExitReviewTests.swift @@ -0,0 +1,269 @@ +@testable import Bitkit +import BreezSdkSpark +import XCTest + +/// Findings of the independent review of the exit: cancel, heights without a tip, funding waits, reminders, sweep +/// bounds and salvage. +final class SparkExitReviewTests: XCTestCase { + private var chain: FakeSparkExitChain! + private var sdk: FakeSparkExitRecovering! + private let clock = Date(timeIntervalSince1970: 1_800_000_000) + + override func setUp() { + super.setUp() + chain = FakeSparkExitChain() + sdk = FakeSparkExitRecovering() + } + + private func driver(withSdk: Bool = true) -> SparkExitDriver { + SparkExitDriver(chain: chain, recovering: withSdk ? sdk : nil, makeSigner: { _ in StubCpfpSigner() }, save: { _ in }) + } + + // MARK: H1 cancel + + func testCancelIsOffAfterAPaymentFromSavings() { + var record = SparkExitFixtures.record() + XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0)) + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 1000), "a payment was seen") + record.fundingPaymentTxid = "paid" + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0), "paid from savings, not seen yet") + } + + /// Cancel reads the address first: funding that arrived since the last tick goes back to savings, and an address + /// that cannot be read keeps the exit. + func testCancelNeverOrphansFundingAtTheAddress() { + XCTAssertEqual(SparkExitManager.cancelOutcome(utxos: []), .forget) + XCTAssertEqual(SparkExitManager.cancelOutcome(utxos: nil), .keep) + XCTAssertEqual( + SparkExitManager.cancelOutcome(utxos: [SparkExitFixtures.utxo("late", value: 5000, confirmed: false)]), + .sweepFunding([.init(txid: "late", vout: 0, valueSats: 5000)]) + ) + } + + /// A cancelled exit with funding sweeps it like any ended exit's leftover, and posts no "stopped" notification. + func testACancelledExitSweepsItsFundingAndPostsNoFailure() { + var record = SparkExitFixtures.record() + record.phase = .failed(reason: SparkExitRecord.cancelledReason) + record.isCancelled = true + record.isResultSeen = true + record.leftover = [.init(txid: "late", vout: 0, valueSats: 5000)] + + XCTAssertTrue(record.isLeftoverSweepPending) + XCTAssertTrue(record.showsResult) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).isEmpty) + } + + // MARK: H2 no height without a tip + + /// A tip read that fails while the sweep would wait writes no height, so the next tick does not read a 0 as four + /// weeks gone and keep the change. + func testASweepWithoutATipWritesNoHeightAndKeepsNothing() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.feeRateSatPerVbyte = 20 + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 300)] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c1", value: 300)] + chain.isTipUnreadable = true + + record = try await driver().tick(record) + XCTAssertNil(record.leftoverSweep, "nothing changes without the tip") + + chain.isTipUnreadable = false + chain.economyFee = 20 + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.waitingSince, 1000) + + chain.isTipUnreadable = true + chain.tip = 900_000 + record = try await driver().tick(record) + XCTAssertNotEqual(record.leftoverSweep?.isDustKept, true) + XCTAssertTrue(record.isLeftoverSweepPending) + } + + // MARK: H3 driving while a top-up is awaited + + private func bumpedRecord() -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990)), + SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"]), + ]), at: .distantPast) + record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump") + return record + } + + func testTheLiveGenerationKeepsGoingWhileAHigherFeesTopUpIsAwaited() async throws { + var record = bumpedRecord() + chain.mempoolTxids = ["bump"] + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 25680)) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "the ready step still goes out") + XCTAssertTrue(sdk.recoverRequests.isEmpty, "no rebuild before the top-up confirms") + let reminders = SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest) + XCTAssertFalse(reminders.contains { $0.isFunding }, "the payment is on its way") + XCTAssertTrue(reminders.contains { $0.id.hasPrefix("1-") }, "the live steps keep their reminders") + } + + /// A payment no host knows for a block is forgotten: the ask and the funding reminder come back. + func testADroppedSavingsPaymentBringsTheAskBack() async throws { + var record = bumpedRecord() + sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 3) + + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentMissingSince, 1000) + XCTAssertEqual(record.fundingPaymentTxid, "bump", "one read is not enough") + + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertFalse(record.isAwaitingOwnPayment) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1001, now: clock, network: .regtest).contains { $0.isFunding }) + } + + /// One still unadopted after a day is forgotten too, known or not. + func testASavingsPaymentNotAdoptedInADayIsForgotten() async throws { + var record = bumpedRecord() + chain.mempoolTxids = ["bump"] + sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 2) + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentSeenAtHeight, 1000) + + chain.tip = 1000 + SparkExitDriver.ownPaymentTimeoutBlocks + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + } + + /// A short rebuild after a Redo stays parked: its generation diverged and is never sent again. + func testADivergedRebuildsTopUpSendsNothing() async throws { + var record = bumpedRecord() + record.isRebuilding = true + chain.mempoolTxids = ["bump"] + + record = try await driver().tick(record) + + XCTAssertTrue(chain.broadcasts.isEmpty) + XCTAssertTrue(sdk.checkedRecoveries.isEmpty) + } + + // MARK: M2 snapshot before pricing and the failed-leaf retry + + func testFeeBumpPricingImportsTheExitDataSnapshotFirst() async throws { + var record = bumpedRecord() + record.phase = .running + record.exitStateSnapshot = "snapshot-1" + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2b")])] + + _ = try await driver().feeBumpShortfall(record, rate: 12) + + XCTAssertEqual(sdk.imports, ["snapshot-1"]) + } + + // MARK: M4 a started exit never funded + + func testAnExitNeverFundedReminds() { + let record = SparkExitFixtures.record() + let reminders = SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest) + XCTAssertEqual(reminders.map(\.id), ["await-funding-20000"]) + XCTAssertEqual(reminders.first?.isFunding, true) + XCTAssertEqual(reminders.first?.fireAt, record.createdAt.addingTimeInterval(SparkExitMath.reminderSeconds(network: .regtest))) + + var paid = record + paid.fundingPaymentTxid = "paid" + XCTAssertTrue(SparkExitMath.reminders(paid, tip: 1000, now: record.createdAt, network: .regtest).isEmpty) + } + + // MARK: M3 fired reminders + + func testAFiredReminderStaysRememberedWhenItLeavesThePlanForARun() { + let now = clock + let remembered = ["spark-exit-1-slow-n1": now.addingTimeInterval(-60).timeIntervalSince1970] + let kept = SparkExitManager.keepingFired([:], remembered: remembered, now: now) + XCTAssertEqual(kept, remembered) + + let old = ["spark-exit-1-slow-n1": now.addingTimeInterval(-SparkExitManager.firedReminderMemory - 1).timeIntervalSince1970] + XCTAssertTrue(SparkExitManager.keepingFired([:], remembered: old, now: now).isEmpty, "forgotten after 30 days") + let ahead = ["spark-exit-x": now.addingTimeInterval(600).timeIntervalSince1970] + XCTAssertTrue(SparkExitManager.keepingFired([:], remembered: ahead, now: now).isEmpty, "one not fired yet follows the plan") + } + + // MARK: M6 sweep bounds + + /// One unconfirmed output holds the sweep for one recheck at most; then the confirmed ones go. + func testAnUnconfirmedOutputHoldsTheSweepBackForOneRecheckAtMost() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)] + chain.economyFee = 1 + chain.utxosByAddress["bcrt1qfunding"] = [ + SparkExitFixtures.utxo("c1", value: 5000), + SparkExitFixtures.utxo("stuck", value: 400, confirmed: false), + ] + var signed = 0 + let driver = SparkExitDriver(chain: chain, recovering: nil, makeSigner: { _ in + signed += 1 + return StubCpfpSigner() + }, save: { _ in }) + + record = try await driver.tick(record) + XCTAssertEqual(record.leftoverSweep?.waitingSince, 1000) + XCTAssertEqual(signed, 0) + + chain.tip = 1000 + SparkExitDriver.sweepRecheckBlocks + _ = try? await driver.tick(record) + XCTAssertEqual(signed, 1, "the confirmed output is swept") + } + + /// A built sweep a node answers with missing inputs is built again from the address from the next block. + func testABuiltSweepWithMissingInputsIsBuiltAgain() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)] + record.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4700, feeRateSatPerVbyte: 2, builtAtHeight: 1000) + chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") + + record = try await driver(withSdk: false).tick(record) + + XCTAssertNil(record.leftoverSweep?.txid) + XCTAssertEqual(record.leftoverSweep?.retryAtHeight, 1001) + XCTAssertTrue(record.isLeftoverSweepPending) + } + + /// A replacement refused for another reason while the sweep it replaced is still held is not final. + func testARefusedReplacementKeepsFollowingTheSweepItReplaced() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)] + record.leftoverSweep = .init(txid: "s2", txHex: "02", valueSats: 4700, feeRateSatPerVbyte: 3, replacedTxids: ["s1"], builtAtHeight: 1000) + chain.mempoolTxids = ["s1"] + chain.outcome = .rejected("non-mandatory-script-verify-flag") + + record = try await driver(withSdk: false).tick(record) + + XCTAssertNil(record.leftoverSweep?.rejection) + XCTAssertEqual(record.leftoverSweep?.replacedTxids, ["s1"]) + XCTAssertTrue(record.isLeftoverSweepPending) + } + + // MARK: M7 salvage + + func testASalvagedRecordKeepsTheChainedTxidsAndReplacesTheOriginalInOneWrite() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + let lost = SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")) + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.earlierCpfpTxids = ["child-1"] + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: SparkExitRecordCoder.encode(record)) as? [String: Any]) + json["version"] = 99 + try JSONSerialization.data(withJSONObject: json).write(to: store.url) + + let loaded = try XCTUnwrap(SparkExitManager.loadRecord(store: store, lostLeafStore: lost)) + + XCTAssertEqual(loaded.earlierCpfpTxids, ["child-1"]) + XCTAssertEqual(try store.load()?.isRebuiltFromChain, true, "the salvaged record is in place") + let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) + XCTAssertTrue(files.contains { $0.contains("unreadable") }, "the original is kept aside") + } +} diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index f52b0afc5..88c4beecd 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -127,8 +127,14 @@ final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { private(set) var broadcasts: [RecoveryTransaction] = [] var onBroadcast: (() -> Void)? + /// The tip read fails, as on a host timeout. + var isTipUnreadable = false + func tipHeight() async throws -> UInt32 { - tip + if isTipUnreadable { + throw URLError(.timedOut) + } + return tip } func utxos(address: String) async throws -> [EsploraUtxo] { @@ -201,9 +207,10 @@ final class FakeSparkExitRecovering: SparkExitRecovering, @unchecked Sendable { return builds.removeFirst() } + /// Answers the queued checks in order, then `Valid` with the recovery unchanged. func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse { checkedRecoveries.append(recovery) - return checks.removeFirst() + return checks.isEmpty ? CheckRecoverFundsResponse(recovery: recovery, verdict: .valid) : checks.removeFirst() } var exitState = "exit-state-1" diff --git a/changelog.d/next/1217.added.md b/changelog.d/next/1217.added.md new file mode 100644 index 000000000..ebd705d08 --- /dev/null +++ b/changelog.d/next/1217.added.md @@ -0,0 +1 @@ +Add Emergency exit, which moves your Spark balance to savings on-chain without the Spark operators. diff --git a/journeys/README.md b/journeys/README.md index 53b04b44c..61051b250 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -216,6 +216,7 @@ journey PR, which is what made this file conflict on every merge. | Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../Docs/USDT-QA.md) | | On-chain funds and blocks on regtest | `../bitkit-android/lsp` deposit and mine, borrowed from the sibling Android checkout until #694 lands an iOS copy — [Backend preconditions](#backend-preconditions) | | Spending through Spark (transfer to spending, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key; transfers fund it from regtest Savings. Spark's claim of a deposit (Instant or after 3 confirmations) depends on the provider's quote, so the setting-up states are covered by previews and unit tests — [transfer](transfer/spark-transfer-to-spending.xml) | +| Spark emergency exit up to the swipe | a Debug build with Spark connected on its regtest and a Spark balance from Lightspark's regtest faucet; the states after the swipe take days of blocks, so they are covered by unit tests — [transfer](transfer/spark-emergency-exit.xml) | | Lightning channels | a channel opened through Manual Setup to the node of the LSP the `E2E_BUILD` app targets, as an external LN peer — [Backend preconditions](#backend-preconditions) | | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker`, reached through Trezor Bridge on the host; the simulator has no Bluetooth LE and iOS cannot do WebUSB, so BLE pairing and USB are not covered — [hardware-wallet](hardware-wallet/README.md) | | The OS notification-permission dialog | the one-shot `UNUserNotificationCenter` alert, reset with `xcrun simctl uninstall to.bitkit` and a rebuild | diff --git a/journeys/transfer/spark-emergency-exit.xml b/journeys/transfer/spark-emergency-exit.xml new file mode 100644 index 000000000..a5b390195 --- /dev/null +++ b/journeys/transfer/spark-emergency-exit.xml @@ -0,0 +1,31 @@ + + + Verifies the Spark emergency exit up to the swipe. With a Spark balance, the Spark connection screen shows + "Emergency exit" (testTag "SparkEmergencyExit") under "Transfer to Savings". It opens the explainer, then the + quote, which asks the SDK's prepareRecoverFunds for every leaf and lists "Amount to exit", "Network fees", + "You receive about" and, when the exit needs CPFP fee funding, "Fee funding needed". Continue leads to the + funding screen, whose "Fund from another wallet" shows the funding address as a QR code. The journey stops before + the swipe: starting an exit takes the Spark balance out of Spending for days. The states after the swipe are + covered by SparkExitDriverTests, SparkExitBackgroundRunTests (a background run from a cold start), + SparkExitFeeBumpTests and SparkExitSweepTests. iOS shows the notes on Background App Refresh, Low Power Mode and + notifications in place of Android's battery notes. + + Precondition: dev build with Spark connected on its regtest (no key needed) and a Spark balance, funded at the + deposit address the Spark connection screen shows (testTag "SparkDepositAddress") from Lightspark's regtest + faucet. Start on the wallet home screen. + + + Tap the menu icon (testTag "HeaderMenu") + Tap Settings (testTag "DrawerSettings") + Tap the Advanced tab (testTag "Tab-advanced") + Tap Lightning Connections (testTag "Channels") + Tap the Spark connection (testTag "SparkConnection") + Scroll down and verify "Transfer to Savings" (testTag "SparkTransferToSavings") is enabled and "Emergency exit" (testTag "SparkEmergencyExit") shows under it + Tap "Emergency exit" and verify the "Leave Spark on-chain" explainer says the exit takes up to 4 weeks, runs in the background and that Bitkit notifies you if it needs you + Tap "Continue" (testTag "SparkExitIntroContinue") and verify the "Exit quote" screen shows "Getting a quote…" and then "Amount to exit", "Network fees" and "You receive about" + Verify no reason shows in red (testTag "SparkExitQuoteBlocked") and "Continue" (testTag "SparkExitQuoteContinue") is enabled + Tap "Continue" and verify the "Fund the fees" screen names the fee funding amount, with "Pay from savings" (testTag "SparkExitFundSavings") and "Fund from another wallet" (testTag "SparkExitFundExternal") + Tap "Fund from another wallet" and verify a QR code and a bcrt1 address (testTag "SparkExitFundingAddress") show, and "Continue" (testTag "SparkExitFundingContinue") is disabled + Go back to the Spark connection screen and verify "Transfer to Savings" is still enabled: nothing started + + From a538a47720eee303de8dfdd74c7a66218db2c920 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:14:13 +0100 Subject: [PATCH 050/378] fix: pay paykit endpoints and allowances through spark - A contact's amountless Paykit Lightning invoice, public or private, goes to Spark unless the ready channels' inbound is at least 1,000,000 sats, so a small channel no longer caps what contacts can pay. Plain Receive keeps its rule. - Allowance auto-pay no longer waits for the node: with the node running and a usable channel, or no channels, every covered request pays; otherwise the ones Spark can cover pay through Spark and the rest wait. --- Bitkit/Services/LightningService.swift | 43 ++++++++++++--- Bitkit/Services/PaykitAllowanceManager.swift | 52 +++++++++++++------ .../PrivatePaykitService+Invoices.swift | 2 +- Bitkit/ViewModels/WalletViewModel.swift | 11 ++-- BitkitTests/ChannelFreeRailTests.swift | 28 ++++++++++ .../PaykitAllowanceExecutorTests.swift | 24 ++++----- BitkitTests/PublicPaykitServiceTests.swift | 7 ++- BitkitTests/ReceiveInvoiceRefreshTests.swift | 14 ++++- 8 files changed, 138 insertions(+), 43 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 39cd8d1c6..20615fcd5 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -819,12 +819,26 @@ class LightningService: NodeLifecycleService { } } - func receive(amountSats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { - try await receiveMsats(amountMsats: amountSats.map { $0 * 1000 }, description: description, expirySecs: expirySecs) + /// `isPaykitEndpoint` marks the amountless invoice published as a Paykit Lightning endpoint, which contacts pay any + /// amount: it goes to the channel-free rail unless the channels' inbound covers `paykitEndpointNodeInboundSats`. + func receive(amountSats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600, isPaykitEndpoint: Bool = false) async throws -> String { + try await receiveMsats( + amountMsats: amountSats.map { $0 * 1000 }, + description: description, + expirySecs: expirySecs, + isPaykitEndpoint: isPaykitEndpoint + ) } - func receiveMsats(amountMsats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { - if let rail = channelFreeRail, await shouldReceiveWithRail(rail, amountSats: amountMsats.map { $0 / 1000 }) { + func receiveMsats( + amountMsats: UInt64? = nil, + description: String, + expirySecs: UInt32 = 3600, + isPaykitEndpoint: Bool = false + ) async throws -> String { + if let rail = channelFreeRail, + await shouldReceiveWithRail(rail, amountSats: amountMsats.map { $0 / 1000 }, isPaykitEndpoint: isPaykitEndpoint) + { let amountSats = amountMsats.map { $0 / 1000 }.flatMap { $0 > 0 ? $0 : nil } return try await rail.receive(amountSats: amountSats, description: description, expirySecs: expirySecs) } @@ -856,12 +870,25 @@ class LightningService: NodeLifecycleService { /// Receives on the channel-free rail when no ready channel has the inbound capacity for the amount. @MainActor - private func shouldReceiveWithRail(_ rail: ChannelFreeLightningRail, amountSats: UInt64?) -> Bool { + private func shouldReceiveWithRail(_ rail: ChannelFreeLightningRail, amountSats: UInt64?, isPaykitEndpoint: Bool) -> Bool { guard rail.isReady else { return false } let readyChannels = (channels ?? []).filter(\.isChannelReady) - guard !readyChannels.isEmpty else { return true } - let inboundSats = readyChannels.map(\.inboundCapacityMsat).reduce(0, +) / 1000 - guard let amountSats, amountSats > 0 else { return inboundSats == 0 } + let inboundSats: UInt64? = readyChannels.isEmpty ? nil : readyChannels.map(\.inboundCapacityMsat).reduce(0, +) / 1000 + return Self.receivesOnRail(amountSats: amountSats, readyInboundSats: inboundSats, isPaykitEndpoint: isPaykitEndpoint) + } + + /// Below this channel inbound, a contact's amountless Paykit invoice goes to the channel-free rail, so a small + /// channel can't cap what contacts can pay. + static let paykitEndpointNodeInboundSats: UInt64 = 1_000_000 + + /// Whether a ready channel-free rail takes an invoice, given the ready channels' total inbound (nil without any). + /// An amount the channels can't receive goes to the rail; an amountless invoice does only when no channel can + /// receive at all, except a Paykit endpoint, which does below `paykitEndpointNodeInboundSats`. + static func receivesOnRail(amountSats: UInt64?, readyInboundSats: UInt64?, isPaykitEndpoint: Bool) -> Bool { + guard let inboundSats = readyInboundSats else { return true } + guard let amountSats, amountSats > 0 else { + return isPaykitEndpoint ? inboundSats < paykitEndpointNodeInboundSats : inboundSats == 0 + } return amountSats > inboundSats } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 17b330783..162a59b9d 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -69,7 +69,7 @@ final class PaykitAllowanceManager { @ObservationIgnored private let sdk: any PaykitAllowanceSdkHandling @ObservationIgnored private let executor: PaykitAllowanceExecutor @ObservationIgnored private let now: () -> Date - @ObservationIgnored private let canPayNow: @MainActor () -> Bool + @ObservationIgnored private let payableNow: @MainActor ([PaykitPaymentRequest]) -> [PaykitPaymentRequest] @ObservationIgnored private var identity: String? /// Changes on every activation and deactivation, so work started under an earlier one drops its results. @ObservationIgnored private var activation = 0 @@ -101,21 +101,36 @@ final class PaykitAllowanceManager { sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared, executor: PaykitAllowanceExecutor = .shared, now: @escaping () -> Date = { Date() }, - canPayNow: @escaping @MainActor () -> Bool = PaykitAllowanceManager.lightningReady + payableNow: @escaping @MainActor ([PaykitPaymentRequest]) -> [PaykitPaymentRequest] = PaykitAllowanceManager.payableNow ) { self.sdk = sdk self.executor = executor self.now = now - self.canPayNow = canPayNow + self.payableNow = payableNow + } + + /// The covered requests that can be paid right now. + static func payableNow(_ covered: [PaykitPaymentRequest]) -> [PaykitPaymentRequest] { + let rail = LightningService.shared.channelFreeRail + return payable(covered, nodeCanPay: nodeCanPay(), sparkSendableSats: rail?.isReady == true ? rail?.sendableSats : nil) } /// A node that just started lists its channels before they reconnect, and a payment sent then finds no route. - static func lightningReady() -> Bool { + static func nodeCanPay() -> Bool { guard LightningService.shared.status?.isRunning == true, let channels = LightningService.shared.channels else { return false } - // Spark pays without channels, so a channel still reconnecting does not hold it back. - return channels.isEmpty || channels.contains(where: \.isUsable) || LightningService.shared.channelFreeRail?.isReady == true + return channels.isEmpty || channels.contains(where: \.isUsable) + } + + /// Everything covered pays while the node can; otherwise Spark pays what it can cover, without the node, and the + /// rest waits for the next refresh. + static func payable(_ covered: [PaykitPaymentRequest], nodeCanPay: Bool, sparkSendableSats: UInt64?) -> [PaykitPaymentRequest] { + if nodeCanPay { + return covered + } + guard let sparkSendableSats else { return [] } + return covered.filter { $0.allowanceAmountSats <= sparkSendableSats } } var entries: [PaykitAllowanceEntry] { @@ -461,21 +476,26 @@ final class PaykitAllowanceManager { let signature = allowancesSignature let covered = requests.filter(isAwaitingAutomaticPayment) guard !covered.isEmpty else { return false } - guard canPayNow() else { - // A request over the limits needs the user anyway, so it is handed back before waiting for a channel. - var handedBack = false - for request in covered where await executor.exceedsLimits(request, allowances: allowances, identity: identity) { - manualRequestIds[request.id] = signature - handedBack = true - } - Logger.info("Holding covered requests until the node has a usable channel", context: "PaykitAllowance") + let payable = payableNow(covered) + let payableIds = Set(payable.map(\.id)) + let waiting = covered.filter { !payableIds.contains($0.id) } + // A request over the limits needs the user anyway, so it is handed back before waiting for a channel. + var handedBack = false + for request in waiting where await executor.exceedsLimits(request, allowances: allowances, identity: identity) { + manualRequestIds[request.id] = signature + handedBack = true + } + if !waiting.isEmpty { + Logger.info("Holding '\(waiting.count)' covered requests until the node or Spark can pay them", context: "PaykitAllowance") + } + guard !payable.isEmpty else { return handedBack } isProcessingRequests = true defer { isProcessingRequests = false } var handledAny = false - for request in covered { + for request in payable { guard activation == passActivation else { break } let result = await executor.autoPay(request, allowances: allowances, identity: identity) Logger.info("Automatic payment pass for a covered request ended as \(result)", context: "PaykitAllowance") @@ -491,7 +511,7 @@ final class PaykitAllowanceManager { if handledAny { await refresh() } - return handledAny + return handledAny || handedBack } /// Whether the allowance flow owns the request, so no sheet may open for it. A covered request belongs to the flow diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index a9c642f77..40e70c32e 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -114,7 +114,7 @@ extension PrivatePaykitService { @MainActor func createVariableInvoice(_ wallet: WalletViewModel) async throws -> String { - try await wallet.createInvoice(amountSats: nil, note: "") + try await wallet.createInvoice(amountSats: nil, note: "", isPaykitEndpoint: true) } func settledPrivateInvoicePaymentHashes() async -> [String] { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index a868172dd..bab51d849 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -657,9 +657,14 @@ class WalletViewModel: ObservableObject { } } - func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil, isPaykitEndpoint: Bool = false) async throws -> String { let finalExpirySecs = expirySecs ?? 60 * 60 * 24 - let invoice = try await lightningService.receive(amountSats: amountSats, description: note, expirySecs: finalExpirySecs) + let invoice = try await lightningService.receive( + amountSats: amountSats, + description: note, + expirySecs: finalExpirySecs, + isPaykitEndpoint: isPaykitEndpoint + ) return invoice.lowercased() } @@ -1687,7 +1692,7 @@ class WalletViewModel: ObservableObject { } private func refreshPublicPaykitBolt11(canCommitInvoice: () async throws -> Bool) async throws -> Bool { - let invoice = try await createInvoice(amountSats: nil, note: "") + let invoice = try await createInvoice(amountSats: nil, note: "", isPaykitEndpoint: true) let decodedInvoice = try await decode(invoice: invoice) guard try await canCommitInvoice() else { return false } guard case let .lightning(lightningInvoice) = decodedInvoice else { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 172d267ab..dccb87a6f 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -263,6 +263,34 @@ extension ChannelFreeRailTests { XCTAssertEqual(QuickPaySpendStore.ledgerMatch(record: record, rows: [ldkFailed, sparkPaid])?.status, .succeeded) } + func testAmountlessPaykitEndpointGoesToSparkBelowAMillionInbound() { + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 50000, isPaykitEndpoint: true)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 999_999, isPaykitEndpoint: true)) + XCTAssertFalse(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 1_000_000, isPaykitEndpoint: true)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: nil, isPaykitEndpoint: true)) + } + + func testPlainReceiveKeepsTheChannelWhileItHasInbound() { + XCTAssertFalse(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 50000, isPaykitEndpoint: false)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: 0, isPaykitEndpoint: false)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: nil, readyInboundSats: nil, isPaykitEndpoint: false)) + XCTAssertFalse(Bitkit.LightningService.receivesOnRail(amountSats: 40000, readyInboundSats: 50000, isPaykitEndpoint: false)) + XCTAssertTrue(Bitkit.LightningService.receivesOnRail(amountSats: 60000, readyInboundSats: 50000, isPaykitEndpoint: true)) + } + + func testAllowanceAutoPayGoesOverSparkWhenTheNodeCantPay() throws { + let small = try PaykitAllowanceFixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440011", amount: "0.00001") + let large = try PaykitAllowanceFixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440012", amount: "0.001") + + let nodeCanPay = PaykitAllowanceManager.payable([small, large], nodeCanPay: true, sparkSendableSats: nil) + let sparkOnly = PaykitAllowanceManager.payable([small, large], nodeCanPay: false, sparkSendableSats: 5000) + let neither = PaykitAllowanceManager.payable([small, large], nodeCanPay: false, sparkSendableSats: nil) + + XCTAssertEqual(nodeCanPay.map(\.id), [small.id, large.id]) + XCTAssertEqual(sparkOnly.map(\.id), [small.id], "Spark pays what it covers; the rest waits") + XCTAssertTrue(neither.isEmpty) + } + func testSparkReadyTriggersOnceUntilItDropsAgain() { var transition = SparkReadinessTransition() XCTAssertFalse(transition.becameReady(false)) diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 2d6a4fcaf..1f30747a9 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -138,7 +138,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let request = try Fixtures.paymentRequest() @@ -166,7 +166,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) await harness.sdk.setBeforeAcceptance { await MainActor.run { manager.pause(identity: Fixtures.identityKey) } } @@ -186,7 +186,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { ready } + payableNow: { ready ? $0 : [] } ) await manager.activate(identity: Fixtures.identityKey) let request = try Fixtures.paymentRequest() @@ -214,7 +214,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let covered = try Fixtures.paymentRequest() @@ -240,7 +240,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { false } + payableNow: { _ in [] } ) await manager.activate(identity: Fixtures.identityKey) let aboveLimit = try Fixtures.paymentRequest(amount: "0.0001") @@ -295,7 +295,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let request = try Fixtures.paymentRequest() @@ -512,7 +512,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let pauseWithoutPayerAllowance = manager.automaticPaymentPause @@ -538,7 +538,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) let pauseShown = manager.automaticPaymentPause @@ -565,7 +565,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) await harness.sdk.setAccountingError(AllowanceMockError.unsupported) @@ -591,7 +591,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, - canPayNow: { true } + payableNow: { $0 } ) await manager.activate(identity: Fixtures.identityKey) @@ -2530,7 +2530,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { allowedPaymentEndpointIdentifiers: nil ) await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: uncapped, proposedByMe: false)]) - let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true }) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, payableNow: { $0 }) await manager.activate(identity: Fixtures.identityKey) XCTAssertEqual(manager.entries, []) @@ -2542,7 +2542,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { func testManagerLeavesARequestWithUnknownCreationTimeManual() async throws { let harness = AllowanceHarness() try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) - let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true }) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, payableNow: { $0 }) await manager.activate(identity: Fixtures.identityKey) let request = try Fixtures.paymentRequest(createdAt: "not-a-timestamp") XCTAssertNil(request.createdAt) diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 738141ff1..60213958d 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -591,7 +591,12 @@ private final class EndpointPublicationWallet: WalletViewModel { true } - override func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + override func createInvoice( + amountSats: UInt64? = nil, + note: String, + expirySecs: UInt32? = nil, + isPaykitEndpoint: Bool = false + ) async throws -> String { Self.invoice } } diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 89e4ed25e..ab3712056 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -1754,7 +1754,12 @@ private final class GatedReceiveWallet: WalletViewModel { return "bcrt1qreceive" } - override func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + override func createInvoice( + amountSats: UInt64? = nil, + note: String, + expirySecs: UInt32? = nil, + isPaykitEndpoint: Bool = false + ) async throws -> String { if let invoiceCreation { return try await invoiceCreation() } @@ -1844,7 +1849,12 @@ private final class TaggedReceiveWallet: WalletViewModel { true } - override func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + override func createInvoice( + amountSats: UInt64? = nil, + note: String, + expirySecs: UInt32? = nil, + isPaykitEndpoint: Bool = false + ) async throws -> String { invoiceCount += 1 return invoiceCount == 1 ? "ln-first" : "ln-second" } From 28170b98e0237ad896bae51b9c924c0d2f36a0f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:26:42 +0100 Subject: [PATCH 051/378] fix: spark exit bot review findings - Cancel takes the driver's lock, reloads the record and revalidates it before reading the funding address. - The background connect timeout returns without joining the connect, and a failed background pass hands back the record on disk. - A confirmed partial sweep keeps following the outputs it left out, and sweep states keep their replaced txids. - A first build of only failed cooperative leaves enters the bounded failed-leaf retry. - Fee-bump pricing drops a leaf the SDK refuses at the higher rate. - Reminder notification ids are scoped per exit; salvage keeps the funding strategy and earlier received amounts. --- Bitkit/Managers/SparkExitManager.swift | 22 +++- Bitkit/Managers/SparkManager.swift | 46 ++++++-- .../SparkExit/SparkExitBackgroundRun.swift | 13 ++- .../Services/SparkExit/SparkExitDriver.swift | 64 +++++++++-- .../Services/SparkExit/SparkExitRecord.swift | 16 ++- .../Settings/Advanced/SparkExitViews.swift | 8 +- BitkitTests/SparkExitReviewTests.swift | 108 ++++++++++++++++++ BitkitTests/SparkExitTests.swift | 18 ++- 8 files changed, 266 insertions(+), 29 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index b430eb354..c78bef9b0 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -211,8 +211,18 @@ final class SparkExitManager { /// Reads the funding address first: a payment that arrived since the last tick is never orphaned. With funding there /// the record stays, ends as cancelled and its leftover sweep returns the funding to savings. + /// Holds the same lock as the driver's ticks and the background run for the whole cancel, and decides on the record + /// as stored now: one a tick funded, built or published meanwhile is never cancelled. @discardableResult func cancel() async -> CancelOutcome { + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + isTicking = true + defer { isTicking = false } + if let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) { + record = stored + } guard canCancel, var record, let address = record.funding.first?.address else { return .keep } let utxos = try? await chain?.utxos(address: address) let outcome = Self.cancelOutcome(utxos: utxos) @@ -221,9 +231,6 @@ final class SparkExitManager { Logger.warn("Spark exit not cancelled: its funding address could not be read", context: "SparkExitManager") return outcome case let .sweepFunding(leftover): - while isTicking { - try? await Task.sleep(nanoseconds: 100_000_000) - } record.phase = .failed(reason: SparkExitRecord.cancelledReason) record.isCancelled = true record.isResultSeen = true @@ -240,6 +247,7 @@ final class SparkExitManager { } await reattachSpark() Logger.info("Cancelled the Spark exit after funding arrived; sweeping it back to savings", context: "SparkExitManager") + // After the lock is released by this function's return. Task { await tick() } return outcome case .forget: @@ -772,7 +780,7 @@ final class SparkExitManager { var kept: [String: TimeInterval] = [:] var requests: [UNNotificationRequest] = [] for reminder in planned { - let id = "\(Self.notificationPrefix)-\(reminder.id)" + let id = Self.notificationId(reminder, record: record) // A reminder that fired is not posted again. One still ahead keeps its earliest date, so runs that do not // publish its step never push it out. let rememberedAt = remembered[id].map(Date.init(timeIntervalSince1970:)) @@ -821,6 +829,12 @@ final class SparkExitManager { return result } + /// A reminder's notification id, scoped to its exit (its creation time), so a later exit's `failed`, `lost-1` or + /// `1-rebuild` is never taken for one an earlier exit already fired. + nonisolated static func notificationId(_ reminder: SparkExitReminder, record: SparkExitRecord) -> String { + "\(notificationPrefix)-\(Int64(record.createdAt.timeIntervalSince1970 * 1000))-\(reminder.id)" + } + private nonisolated static func rememberedReminders() -> [String: TimeInterval] { UserDefaults.standard.dictionary(forKey: remindersKey) as? [String: TimeInterval] ?? [:] } diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index fe79811dd..b836e6073 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -111,6 +111,26 @@ final class SparkManager: ChannelFreeLightningRail { /// Connects the Spark SDK alone for a background exit run, never the node: no refresh, no activity sync and no /// Lightning rail. Returns nil when it does not connect within `timeout`; the connect keeps going, and a later /// `start` picks it up. + /// `operation`'s result, or nil once `timeout` passes first. Returns at the timeout without waiting for `operation`, + /// which a task group would do; the operation carries on, unstructured, and its late result is dropped. + nonisolated static func firstOf(timeout: TimeInterval, _ operation: @escaping @Sendable () async -> T) async -> T? { + await withCheckedContinuation { continuation in + let resumed = SparkFirstResume() + Task { + let value = await operation() + if resumed.claim() { + continuation.resume(returning: value) + } + } + Task { + try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + if resumed.claim() { + continuation.resume(returning: nil) + } + } + } + } + func connectForExit(walletIndex: Int, timeout: TimeInterval) async -> SparkExitRecovering? { guard let network = Self.sparkNetwork else { return nil } if sdk == nil { @@ -120,17 +140,8 @@ final class SparkManager: ChannelFreeLightningRail { connectTask = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) } } let connecting = connectTask - // nil while still connecting at the timeout. - let didConnect: Bool? = await withTaskGroup(of: Bool?.self) { group in - group.addTask { await (try? connecting?.value) != nil } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) - return nil - } - let first = await group.next() ?? nil - group.cancelAll() - return first - } + // nil while still connecting at the timeout; the connect itself keeps running for `start`. + let didConnect: Bool? = await Self.firstOf(timeout: timeout) { await (try? connecting?.value) != nil } // A finished connect is forgotten, so a failed one is tried again; one still running is kept for `start`. if didConnect != nil { connectTask = nil @@ -1117,3 +1128,16 @@ private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked await handler(event) } } + +/// Lets the first of two racing tasks resume a continuation, once. +private final class SparkFirstResume: @unchecked Sendable { + private let lock = NSLock() + private var isClaimed = false + + func claim() -> Bool { + lock.withLock { + defer { isClaimed = true } + return !isClaimed + } + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift index 885b21dc0..b24864d57 100644 --- a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift +++ b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift @@ -60,8 +60,14 @@ struct SparkExitBackgroundRun { var recovering = connected if recovering == nil { let keylessStarted = now() - if let ticked = try? await makeDriver(chain, nil).tick(record) { - record = ticked + do { + record = try await makeDriver(chain, nil).tick(record) + } catch { + Logger.warn("Spark exit keyless pass failed: \(error)", context: "SparkExitBackgroundRun") + // The pass saved along the way: go on from the record on disk, never from the copy it started with. + if let stored = try? load() { + record = stored + } } timings.add(.keyless, now().timeIntervalSince(keylessStarted)) @@ -79,6 +85,9 @@ struct SparkExitBackgroundRun { record = try await makeDriver(chain, TimedSparkExitRecovering(base: recovering, timings: timings)).tick(record) } catch { Logger.error("Spark exit background tick failed: \(error)", context: "SparkExitBackgroundRun") + if let stored = try? load() { + record = stored + } } } outcome.record = record diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 1d5c828e7..ad233a5ec 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -253,9 +253,10 @@ struct SparkExitDriver { return } guard !built.transactions.isEmpty else { - // Cooperative leaves the operators could not co-sign now are tried again on the next tick. + // Only cooperative leaves the operators could not co-sign: kept as the exit's recovery, so the next check + // puts them through the bounded failed-leaf retry (about a week), with the operators-waiting line. if !built.failed.isEmpty { - record.failedLeafCount = built.failed.count + record.adopt(built, at: now()) return } await finish(&record, phase: .failed(reason: "Nothing to recover")) @@ -404,6 +405,15 @@ struct SparkExitDriver { record.phase = .awaitingFunding(requiredSats: 0) return } + // Nothing to check on-chain: only cooperative leaves the operators have not co-signed yet. + if stored.transactions.isEmpty, !stored.failed.isEmpty { + let tip = try? await chain.tipHeight() + if try await retryFailedLeaves(&record, failed: stored.failed, recovering: recovering, tip: tip) { + return + } + await finish(&record, phase: .done) + return + } let checked = try await recovering.check(withConfirmations(stored)) record.recovery = StoredRecovery(checked.recovery) record.noteProgressAfterRebuildFromChain(checked.recovery) @@ -511,16 +521,24 @@ struct SparkExitDriver { /// nothing; a shortfall is sized like a rebuild's (`neededFunding`), the input fee of the payment to come included. func feeBumpShortfall(_ record: SparkExitRecord, rate: UInt64) async throws -> UInt64 { guard let recovering else { throw SparkExitFeeBumpError.notConnected } + // A copy at the new rate: a leaf the SDK refuses at that rate is left behind on the copy only, as a rebuild would. var record = record + record.feeRateSatPerVbyte = rate await useExitStateSnapshot(&record, recovering: recovering) - let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: record.exitLeafIds), feeRate: rate)) - guard prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 } + guard let prepared = try await prepare(&record, recovering: recovering), prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 } do { _ = try await recovering.recover( RecoverFundsRequest(prepared: prepared, fundingInputs: record.fundingInputs), signer: makeSigner(record.fundingIndex) ) return 0 + } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { + // The build refused a leaf the prepare kept: priced again without it. + guard let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) else { + throw SdkError.SparkError(message) + } + leaveBehind([leafId], reason: message, in: &record) + return try await feeBumpShortfall(record, rate: rate) } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { var atRate = record atRate.feeRateSatPerVbyte = rate @@ -660,6 +678,7 @@ struct SparkExitDriver { for txid in record.leftoverSweep?.replacedTxids ?? [] where record.leftoverSweep?.isConfirmed != true { if let status = try? await chain.txStatus(txid: txid), status.confirmed { record.leftoverSweep?.isConfirmed = true + await followRemainingOutputs(&record) return } } @@ -724,6 +743,7 @@ struct SparkExitDriver { else { return } if sweep.isAccepted, let status = try? await chain.txStatus(txid: txid), status.confirmed { record.leftoverSweep?.isConfirmed = true + await followRemainingOutputs(&record) return } // Built but never accepted for `sweepRecheckBlocks`: its coins may have changed, so it is built again from what @@ -801,6 +821,23 @@ struct SparkExitDriver { } } + /// After a sweep confirms, reads the funding address again: outputs the sweep left out (too small for their fee, + /// or unconfirmed when it was built) start a new wait of their own, so they are swept once worth it or kept with + /// their path after `sweepWaitBlocks`. What the confirmed sweeps sent is kept for the result. + private func followRemainingOutputs(_ record: inout SparkExitRecord) async { + guard let sweep = record.leftoverSweep, let txid = sweep.txid, let address = record.funding.first?.address, + let tip = try? await chain.tipHeight(), let listed = try? await chain.utxos(address: address) + else { return } + // A host behind the block can still list what the sweep spent. + let spent = Set((sweep.txHex.flatMap(SparkExitTxid.inputOutpoints(hex:)) ?? []).map { "\($0.txid):\($0.vout)" }) + let utxos = listed.filter { !spent.contains("\($0.txid):\($0.vout)") } + guard !utxos.isEmpty else { return } + Logger.info("Spark exit sweep \(txid) left \(utxos.count) outputs at the funding address; following them", context: "SparkExitDriver") + record.sweptLeftover = (record.sweptLeftover ?? []) + [.init(txid: txid, valueSats: sweep.valueSats)] + record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: tip, checkedAtHeight: tip) + } + private func isAnyKnown(_ txids: [String]) async -> Bool { for txid in txids { let isKnown = try? await chain.isKnown(txid: txid) @@ -863,6 +900,7 @@ struct SparkExitDriver { let since = waiting?.waitingSince ?? tip if tip.saturatingSub(since) < Self.sweepRecheckBlocks { record.leftoverSweep = SparkExitRecord.LeftoverSweep( + replacedTxids: waiting?.replacedTxids, waitingSince: since, checkedAtHeight: tip, refusedFeeRate: waiting?.refusedFeeRate, @@ -889,6 +927,7 @@ struct SparkExitDriver { txHex: built.txHex, valueSats: built.valueSats, feeRateSatPerVbyte: rate, + replacedTxids: waiting?.replacedTxids, waitingSince: waiting?.waitingSince, builtAtHeight: tip ) @@ -897,6 +936,7 @@ struct SparkExitDriver { Logger.info("Spark exit waits for lower fees to send its fee change (\(rate) sat/vB)", context: "SparkExitDriver") record.leftoverSweep = SparkExitRecord.LeftoverSweep( feeRateSatPerVbyte: rate, + replacedTxids: waiting?.replacedTxids, waitingSince: since, checkedAtHeight: tip, refusedFeeRate: waiting?.refusedFeeRate @@ -1121,6 +1161,14 @@ enum SparkExitTxid { cpfpChildTxids(recovery) + recovery.transactions.filter { $0.kind == .fanOut }.map(\.txid) } + /// The outpoints a serialized transaction spends; nil when the hex does not decode. + static func inputOutpoints(hex: String) -> [(txid: String, vout: UInt32)]? { + let bytes = hex.hexaBytes + guard hex.count == bytes.count * 2 else { return nil } + var reader = Reader(bytes: bytes) + return reader.inputs()?.inputs.map { ($0.txid, $0.vout) } + } + /// The txids a serialized transaction spends; nil when the hex does not decode. static func inputTxids(hex: String) -> [String]? { let bytes = hex.hexaBytes @@ -1191,19 +1239,19 @@ enum SparkExitTxid { return (Int(value), [first] + rest) } - mutating func inputs() -> (version: Int32, inputs: [(txid: String, sequence: UInt32)])? { + mutating func inputs() -> (version: Int32, inputs: [(txid: String, vout: UInt32, sequence: UInt32)])? { guard let versionBytes = take(4) else { return nil } let version = Int32(bitPattern: littleEndian(versionBytes)) if bytes.count > 5, bytes[4] == 0, bytes[5] == 1 { position += 2 } guard let count = varInt() else { return nil } - var inputs: [(txid: String, sequence: UInt32)] = [] + var inputs: [(txid: String, vout: UInt32, sequence: UInt32)] = [] for _ in 0 ..< count.value { - guard let txid = take(32), take(4) != nil, let script = varInt(), take(script.value) != nil, let sequence = take(4) else { + guard let txid = take(32), let vout = take(4), let script = varInt(), take(script.value) != nil, let sequence = take(4) else { return nil } - inputs.append((Data(txid.reversed()).hex, littleEndian(sequence))) + inputs.append((Data(txid.reversed()).hex, littleEndian(vout), littleEndian(sequence))) } return (version, inputs) } diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 479cd4d1c..d465fb8da 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -79,6 +79,12 @@ struct SparkExitRecord: Codable, Equatable { var valueSats: UInt64 } + /// A leftover sweep that confirmed: what it sent to savings. + struct SweptLeftover: Codable, Equatable { + let txid: String + let valueSats: UInt64 + } + struct Leftover: Codable, Equatable { let txid: String let vout: UInt32 @@ -187,6 +193,8 @@ struct SparkExitRecord: Codable, Equatable { var fundingPaymentMissingSince: UInt32? /// The user cancelled after funding reached the address: the exit ends and the funding goes back to savings. var isCancelled: Bool? + /// Leftover sweeps that confirmed before outputs they left out were followed again: what each sent to savings. + var sweptLeftover: [SweptLeftover]? /// What reaches savings: the exited value less the fees paid out of it. let expectedSats: UInt64 var claimableAtHeight: UInt32? @@ -637,6 +645,8 @@ enum SparkExitRecordCoder { record.lostLeaves = kept.lostLeaves record.leftBehindLeaves = kept.leftBehindLeaves record.exitStateSnapshot = kept.exitStateSnapshot + record.fundingStrategy = kept.fundingStrategy + record.earlierReceivedSats = kept.earlierReceivedSats // Change and fan-out outputs of earlier generations at the funding address stay the exit's own, not new funding. let chained = (kept.earlierCpfpTxids ?? []) + (kept.recovery.map { SparkExitTxid.chainedTxids($0.response) } ?? []) record.earlierCpfpTxids = chained.isEmpty ? nil : chained @@ -660,10 +670,12 @@ enum SparkExitRecordCoder { let exitStateSnapshot: String? let earlierCpfpTxids: [String]? let recovery: StoredRecovery? + let fundingStrategy: String? + let earlierReceivedSats: [String: UInt64]? private enum CodingKeys: String, CodingKey { case network, createdAt, phase, feeRateSatPerVbyte, destination, leafIds, leafTotalSats, funding, transferId, expectedSats - case lostLeaves, leftBehindLeaves, exitStateSnapshot, earlierCpfpTxids, recovery + case lostLeaves, leftBehindLeaves, exitStateSnapshot, earlierCpfpTxids, recovery, fundingStrategy, earlierReceivedSats } init(from decoder: Decoder) throws { @@ -683,6 +695,8 @@ enum SparkExitRecordCoder { exitStateSnapshot = try? c.decodeIfPresent(String.self, forKey: .exitStateSnapshot) earlierCpfpTxids = try? c.decodeIfPresent([String].self, forKey: .earlierCpfpTxids) recovery = try? c.decodeIfPresent(StoredRecovery.self, forKey: .recovery) + fundingStrategy = try? c.decodeIfPresent(String.self, forKey: .fundingStrategy) + earlierReceivedSats = try? c.decodeIfPresent([String: UInt64].self, forKey: .earlierReceivedSats) } } } diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 6869059de..d23f7e4cc 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -356,9 +356,15 @@ struct SparkExitLeftoverStatus: View { SparkExitFundingKey.path(index: record.fundingIndex, network: Env.bitkitCoreNetwork) } - /// One line with the total of every UTXO at the funding address, never one per UTXO. + /// One line with the total of every UTXO at the funding address, never one per UTXO, after what earlier sweeps sent. var body: some View { VStack(alignment: .leading, spacing: 8) { + ForEach(record.sweptLeftover ?? [], id: \.txid) { swept in + BodySText(t("lightning__spark_exit__leftover_swept", variables: ["amount": currency.primaryAmountText(sats: swept.valueSats)])) + BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": swept.txid])) + .lineLimit(2) + .truncationMode(.middle) + } switch record.leftoverLine { case let .sending(sats, txid): BodySText(t("lightning__spark_exit__leftover_sending", variables: ["amount": currency.primaryAmountText(sats: sats)])) diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index feaf5525f..7deef7472 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -266,4 +266,112 @@ final class SparkExitReviewTests: XCTestCase { let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) XCTAssertTrue(files.contains { $0.contains("unreadable") }, "the original is kept aside") } + + // MARK: Bot review threads + + /// The background connect's timeout returns on time, without waiting for the connect it gives up on. + func testTheConnectTimeoutReturnsWithoutWaitingForTheConnect() async { + let started = Date() + let result: Bool? = await SparkManager.firstOf(timeout: 0.02) { + try? await Task.sleep(nanoseconds: 600_000_000) + return true + } + XCTAssertNil(result) + XCTAssertLessThan(Date().timeIntervalSince(started), 0.3) + let quick: Bool? = await SparkManager.firstOf(timeout: 5) { true } + XCTAssertEqual(quick, true) + } + + /// A tick that built or published meanwhile leaves nothing cancel can act on: the reloaded record is revalidated. + func testCancelRevalidatesARecordATickBuiltMeanwhile() { + var record = SparkExitFixtures.record() + XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0)) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0)) + } + + /// A sweep that confirms while it left outputs out (too small, or unconfirmed when built) keeps following them. + func testAConfirmedPartialSweepKeepsFollowingWhatItLeftOut() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000), .init(txid: "c2", vout: 0, valueSats: 300)] + record.leftoverSweep = .init(txid: "s1", txHex: "02", valueSats: 4700, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) + chain.confirmedTxids = ["s1"] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c2", value: 300)] + + record = try await driver(withSdk: false).tick(record) + + XCTAssertEqual(record.sweptLeftover, [.init(txid: "s1", valueSats: 4700)]) + XCTAssertEqual(record.leftover, [.init(txid: "c2", vout: 0, valueSats: 300)]) + XCTAssertEqual(record.leftoverSweep?.isWaitingForFees, true) + XCTAssertTrue(record.isLeftoverSweepPending) + XCTAssertEqual(record.leftoverLine, .waiting(sats: 300)) + } + + /// A first build holding only cooperative leaves the operators could not co-sign enters the bounded retry. + func testAFirstBuildOfOnlyFailedCooperativeLeavesWaitsForTheOperators() async throws { + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "w", outputVout: 0, error: .operatorsUnavailable(message: "down")) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: []) + prepared.leaves = [RecoverFundsLeaf(leafId: "coop-1", valueSats: 5000, method: .cooperative)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure]), SparkExitFixtures.recovery([], failed: [failure])] + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 0)) + + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.recovery?.response.failed.count, 1) + + record = try await driver().tick(record) + XCTAssertEqual(record.failedLeafRetrySince, 1000) + XCTAssertTrue(record.isWaitingForOperators) + XCTAssertTrue(sdk.checkedRecoveries.isEmpty, "nothing to check on-chain") + + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.isFailedLeavesReleased, true) + } + + /// Pricing a higher fee leaves behind a leaf the SDK refuses at that rate, on a copy only, and prices the rest. + func testFeeBumpPricingDropsALeafTheSdkRefusesAtTheHigherRate() async throws { + var record = bumpedRecord() + record.phase = .running + record.exitStateSnapshot = nil + record = SparkExitRecord( + network: record.network, createdAt: record.createdAt, phase: .running, feeRateSatPerVbyte: 2, + destination: record.destination, leafIds: ["leaf-1", "small"], funding: record.funding, + transferId: record.transferId, expectedSats: record.expectedSats + ) + sdk.prepareErrors = [SdkError.SparkError("Leaf small cannot be exited: its refund cannot pay its own fee")] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2b")])] + + let shortfall = try await driver().feeBumpShortfall(record, rate: 12) + + XCTAssertEqual(shortfall, 0) + XCTAssertEqual(sdk.prepareRequests.last?.selection, .specific(leafIds: ["leaf-1"])) + XCTAssertNil(record.leftBehindLeaves, "the live exit keeps its leaves") + } + + /// Reminder ids are scoped per exit, so a later exit's `failed` is never taken for an earlier exit's. + func testReminderIdsAreScopedPerExit() { + let reminder = SparkExitReminder(id: "failed", fireAt: clock, isFailed: true) + let first = SparkExitFixtures.record() + let second = SparkExitRecord( + network: "regtest", createdAt: first.createdAt.addingTimeInterval(86400), phase: .done, feeRateSatPerVbyte: 2, + destination: "bcrt1qdestination", leafIds: ["leaf-2"], funding: first.funding, transferId: nil, expectedSats: 0 + ) + XCTAssertNotEqual(SparkExitManager.notificationId(reminder, record: first), SparkExitManager.notificationId(reminder, record: second)) + XCTAssertTrue(SparkExitManager.notificationId(reminder, record: first).hasPrefix("spark-exit-")) + } + + /// Salvage keeps the funding strategy and what earlier generations paid to savings. + func testSalvageKeepsTheFundingStrategyAndEarlierReceived() throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.fundingStrategy = SparkExitRecord.perNodeStrategy + record.earlierReceivedSats = ["sweep-1": 40000] + let salvaged = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(record))) + XCTAssertEqual(salvaged.fundingStrategy, SparkExitRecord.perNodeStrategy) + XCTAssertEqual(salvaged.earlierReceivedSats, ["sweep-1": 40000]) + } } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 88c4beecd..3ac8cd582 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -901,9 +901,10 @@ final class SparkExitDriverTests: XCTestCase { let record = try await driver().tick(SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 0))) - XCTAssertEqual(record.phase, .awaitingFunding(requiredSats: 0)) + // Kept as the exit's recovery, so the next check puts the leaf through the bounded failed-leaf retry. + XCTAssertEqual(record.phase, .running) XCTAssertEqual(record.failedLeafCount, 1) - XCTAssertNil(record.recovery) + XCTAssertEqual(record.recovery?.response.failed.map(\.leafId), ["leaf-1"]) } func testRebuildWithNothingLeftEndsTheExit() async throws { @@ -1759,6 +1760,19 @@ final class SparkExitBackgroundRunTests: XCTestCase { return recovery } + /// A tick that saved progress and then threw (a Redo saves the rebuild flag, then the rebuild's prepare fails): the + /// run hands back the record on disk, never the copy it started with. + func testAFailedTickHandsBackTheRecordItSaved() async throws { + let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c", status: .waitingForDependencies)]) + sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .redo(reason: .onChainStateDiverged))] + sdk.prepareErrors = [SdkError.Generic("operators unreachable")] + + let outcome = await backgroundRun().run() + + XCTAssertEqual(outcome.record?.isRebuilding, true) + XCTAssertEqual(try store.load()?.isRebuilding, true) + } + func testColdStartSendsStoredStepsThenChecksWithTheSdkOnly() async throws { let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c")]) sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .valid)] From db348780048f35a2ad4a07e079021b17805d2598 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:27:36 +0100 Subject: [PATCH 052/378] feat: route every contact payment through the paykit router Moves to bitkit-core 0.8.0-spark.3 and its `paykitRoutePayment`, and removes the Pubky Swaps code: the build flags, provider quotes, commit and claim, and their journey and changelog fragment. - On the amount screen, every bitcoin contact payment asks the router how to pay from the selected source: Spending offers all of the contact's bitcoin endpoints and a Spark leg to its on-chain ones, Savings only the on-chain endpoints. Direct pays the endpoint it picks, a Spark leg opens Confirm with Route: Spark and its fee, and none stays on the amount screen with the app's copy, its reason going to the log only. - An on-chain-only contact opens with Spending as a source while Spark can carry the payment, whether or not Savings covers it. - A Spark withdrawal to a contact is journaled before it is sent, settles from Spark's payment list on its event or on the next start, and is recorded as a sent on-chain activity for the contact, confirmed once Spark completes it. A second one waits while the first is open. --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Constants/Env.swift | 13 -- Bitkit/Managers/SparkManager.swift | 140 +++++++++++++-- .../Localization/ar.lproj/Localizable.strings | 2 + .../Localization/ca.lproj/Localizable.strings | 2 + .../Localization/cs.lproj/Localizable.strings | 2 + .../Localization/de.lproj/Localizable.strings | 2 + .../Localization/el.lproj/Localizable.strings | 2 + .../Localization/en.lproj/Localizable.strings | 2 + .../es-419.lproj/Localizable.strings | 2 + .../Localization/es.lproj/Localizable.strings | 2 + .../Localization/fr.lproj/Localizable.strings | 2 + .../Localization/it.lproj/Localizable.strings | 2 + .../Localization/nl.lproj/Localizable.strings | 2 + .../Localization/pl.lproj/Localizable.strings | 2 + .../pt-BR.lproj/Localizable.strings | 2 + .../Localization/pt.lproj/Localizable.strings | 2 + .../Localization/ru.lproj/Localizable.strings | 2 + Bitkit/Services/CoreService.swift | 18 ++ Bitkit/Services/PaykitRouteService.swift | 147 +++++++++++++++ Bitkit/Services/PubkySwapsRouteService.swift | 128 ------------- Bitkit/ViewModels/AppViewModel.swift | 25 +-- .../Views/Wallets/Send/SendAmountView.swift | 134 ++++++++++---- .../Wallets/Send/SendConfirmationView.swift | 47 +---- Bitkit/Views/Wallets/Send/SendSheet.swift | 4 +- BitkitTests/PaykitRouteTests.swift | 170 ++++++++++++++++++ changelog.d/next/1170.added.md | 1 - changelog.d/next/1191.added.md | 2 +- journeys/paykit-spark-route.xml | 3 +- journeys/paykit-swap-route.xml | 29 --- 31 files changed, 618 insertions(+), 279 deletions(-) create mode 100644 Bitkit/Services/PaykitRouteService.swift delete mode 100644 Bitkit/Services/PubkySwapsRouteService.swift create mode 100644 BitkitTests/PaykitRouteTests.swift delete mode 100644 changelog.d/next/1170.added.md delete mode 100644 journeys/paykit-swap-route.xml diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 650c0646f..16018693b 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1278,7 +1278,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = "0.8.0-swaps.3"; + version = "0.8.0-spark.3"; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d02afd943..ea2c5255b 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" : "f701e77dd004f3e89c2f45dc21edf7e03963194a", - "version" : "0.8.0-swaps.3" + "revision" : "ac704166d07935e393f3bcf363959b83f0ed3445", + "version" : "0.8.0-spark.3" } }, { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 9fb57c5e1..475fc8da1 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -237,19 +237,6 @@ enum Env { return electrumServerUrl(for: network) } - /// Whether a Paykit payment may route through a Pubky Swaps provider when no balance reaches the contact's - /// endpoints. Regtest debug builds with `PUBKY_SWAPS_ROUTES=true` only. - static var isPubkySwapsRouteEnabled: Bool { - isDebug && network == .regtest && boolConfigValue("PUBKY_SWAPS_ROUTES") - } - - /// Swap providers to ask besides the user's contacts, from `PUBKY_SWAPS_PROVIDERS` (comma-separated pubkys). - static var pubkySwapsProviders: [String] { - (configValue("PUBKY_SWAPS_PROVIDERS") ?? "").split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - } - static var trezorBridgeEnabled: Bool { (isDebug || isE2E) && boolConfigValue("TREZOR_BRIDGE") } diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index cdfc5f405..a12951822 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -51,6 +51,8 @@ final class SparkManager: ChannelFreeLightningRail { /// The refresh in flight; callers during it wait for it, and it reads once more for them. @ObservationIgnored private var refreshTask: Task? @ObservationIgnored private var refreshAgain = false + /// A contact withdrawal is being sent, so its journal entry isn't settled from a list that can't show it yet. + @ObservationIgnored private var isWithdrawingToContact = false private init() {} @@ -98,6 +100,7 @@ final class SparkManager: ChannelFreeLightningRail { LightningService.shared.channelFreeRail = self try await refresh() try await syncActivities() + await settleContactWithdrawal(allowClear: false) } catch { connectTask = nil Logger.error("Failed to start Spark wallet: \(error)", context: "SparkManager") @@ -183,6 +186,8 @@ final class SparkManager: ChannelFreeLightningRail { sendsRecordedWithoutFee = [] sentWithdrawalTxIds = [] preparedWithdrawal = nil + Self.pendingContactWithdrawal = nil + Self.contactWithdrawalTxIds = [] onStateChanged?() try Self.removeIfPresent(Env.sparkStorage(walletIndex: walletIndex)) try Self.removeIfPresent(Env.sparkExitRestore(walletIndex: walletIndex)) @@ -435,7 +440,8 @@ final class SparkManager: ChannelFreeLightningRail { } /// A Paykit route that withdraws exactly `amountSats` to the contact's on-chain `endpoint` at medium speed, with - /// Spark's fee on top, or nil while Spark cannot send it. bitkit-core weighs it against the Pubky Swaps providers. + /// Spark's fee on top, or nil while Spark cannot send it. bitkit-core's Paykit router weighs it against the contact's + /// other endpoints. func withdrawalRouteLeg(endpoint: PaykitEndpoint, amountSats: UInt64) async throws -> ServiceRouteLeg? { guard isReady, balanceSats >= amountSats else { return nil } let prepared = try await prepareSend(paymentRequest: endpoint.value, amountSats: amountSats) @@ -454,19 +460,110 @@ final class SparkManager: ChannelFreeLightningRail { ) } - /// Withdraws exactly `amountSats` to the on-chain `address` at medium speed and returns the withdrawal's txid. - func withdraw(to address: String, amountSats: UInt64) async throws -> String { + /// Withdraws exactly `amountSats` to a contact's on-chain `address` at medium speed and returns its txid. The + /// withdrawal is written to a journal before it is sent, so one interrupted mid-send settles from Spark's payment + /// list; a second one is refused while it is open. It is recorded as a sent on-chain activity for the contact. + func withdraw(to address: String, amountSats: UInt64, contact: String?) async throws -> String { + guard Self.pendingContactWithdrawal == nil else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "A Spark withdrawal to a contact is still settling") + } let prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) - let response = try await requireSdk().sendPayment(request: SendPaymentRequest( - prepareResponse: prepared, - options: .bitcoinAddress(confirmationSpeed: .medium) - )) + let pending = SparkContactWithdrawal( + address: address, + amountSats: amountSats, + contact: contact, + startedAt: UInt64(Date().timeIntervalSince1970) + ) + Self.pendingContactWithdrawal = pending + isWithdrawingToContact = true + defer { isWithdrawingToContact = false } + let response: SendPaymentResponse + do { + response = try await requireSdk().sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium) + )) + } catch { + Logger.warn("Spark withdrawal to a contact failed or its outcome is unknown: \(error)", context: "SparkManager") + Task { + _ = try? await requireSdk().syncWallet(request: SyncWalletRequest()) + await settleContactWithdrawal(allowClear: true) + } + throw error + } Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") - try await refresh() - if case let .withdraw(txId, _, _) = response.payment.details { - return txId + guard case let .withdraw(txId, _, _) = response.payment.details else { + await refreshState() + return response.payment.id + } + await recordContactWithdrawal(response.payment, txId: txId, pending: pending) + await refreshState() + return txId + } + + /// Settles the open contact withdrawal from Spark's payments: a withdrawal of its amount sent since it started is + /// recorded; with `allowClear`, a synced list without one closes it as never sent. + func settleContactWithdrawal(allowClear: Bool) async { + guard let pending = Self.pendingContactWithdrawal, !isWithdrawingToContact, + let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments + else { return } + if let payment = Self.matchingWithdrawal(pending, in: payments), case let .withdraw(txId, _, _) = payment.details { + await recordContactWithdrawal(payment, txId: txId, pending: pending) + } else if allowClear { + Logger.info("Closed a Spark contact withdrawal Spark never listed", context: "SparkManager") + Self.pendingContactWithdrawal = nil + } + } + + nonisolated static func matchingWithdrawal(_ pending: SparkContactWithdrawal, in payments: [Payment]) -> Payment? { + payments.first { payment in + guard payment.paymentType == .send, case let .withdraw(_, _, destination) = payment.details else { return false } + return destination == pending.address && sats(payment.amount) == pending.amountSats && + payment.timestamp + contactWithdrawalClockSkewSecs >= pending.startedAt } - return response.payment.id + } + + private nonisolated static let contactWithdrawalClockSkewSecs: UInt64 = 120 + + private func recordContactWithdrawal(_ payment: Payment, txId: String, pending: SparkContactWithdrawal) async { + Self.pendingContactWithdrawal = nil + guard payment.status != .failed else { return } + Self.contactWithdrawalTxIds.insert(txId) + _ = await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: txId, + address: pending.address, + amount: pending.amountSats, + fee: Self.sats(payment.fees), + feeRate: 0, + contact: pending.contact, + feeIsExact: true + ) + if payment.status == .completed { + await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: txId) + } + } + + private nonisolated static let pendingContactWithdrawalKey = "sparkPendingContactWithdrawal" + private nonisolated static let contactWithdrawalTxIdsKey = "sparkContactWithdrawalTxIds" + + nonisolated static var pendingContactWithdrawal: SparkContactWithdrawal? { + get { + UserDefaults.standard.data(forKey: pendingContactWithdrawalKey) + .flatMap { try? JSONDecoder().decode(SparkContactWithdrawal.self, from: $0) } + } + set { + if let newValue, let data = try? JSONEncoder().encode(newValue) { + UserDefaults.standard.set(data, forKey: pendingContactWithdrawalKey) + } else { + UserDefaults.standard.removeObject(forKey: pendingContactWithdrawalKey) + } + } + } + + /// Withdrawals paid to contacts, which stay sent payments rather than transfers to Savings. + nonisolated static var contactWithdrawalTxIds: Set { + get { Set(UserDefaults.standard.stringArray(forKey: contactWithdrawalTxIdsKey) ?? []) } + set { UserDefaults.standard.set(Array(newValue), forKey: contactWithdrawalTxIdsKey) } } /// What a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. @@ -623,9 +720,15 @@ final class SparkManager: ChannelFreeLightningRail { for payment in payments where sendsRecordedWithoutFee.contains(payment.id) && Self.sats(payment.fees) > 0 { await recordActivity(payment) } - for txId in Self.transferTxIds(unclaimed: unclaimed, payments: payments) { + let contactWithdrawals = Self.contactWithdrawalTxIds + for txId in Self.transferTxIds(unclaimed: unclaimed, payments: payments) where !contactWithdrawals.contains(txId) { await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txId) } + for payment in payments where payment.status == .completed { + if case let .withdraw(txId, _, _) = payment.details, contactWithdrawals.contains(txId) { + await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: txId) + } + } } nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { @@ -690,7 +793,10 @@ final class SparkManager: ChannelFreeLightningRail { private func onSdkEvent(_ event: SdkEvent) async { switch event { - case .synced, .newDeposits, .claimedDeposits: + case .synced: + await refreshState() + await settleContactWithdrawal(allowClear: true) + case .newDeposits, .claimedDeposits: await refreshState() case let .unclaimedDeposits(deposits): Logger.warn("Found '\(deposits.count)' unclaimed Spark deposits", context: "SparkManager") @@ -927,6 +1033,14 @@ enum SparkErrorCopy { } } +/// A Spark withdrawal to a contact's on-chain address, journaled before it is sent. +struct SparkContactWithdrawal: Codable, Equatable { + let address: String + let amountSats: UInt64 + let contact: String? + let startedAt: UInt64 +} + struct SparkWithdrawal: Identifiable, Equatable { let txid: String let amountSats: UInt64 diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index be9c7323a..7963f87a5 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1070,6 +1070,8 @@ "wallet__send_sync_long_description" = "يبدو أنك غير متصل. يرجى التحقق من اتصالك. سيحاول Bitkit إعادة الاتصال كل بضع ثوانٍ."; "wallet__send_to" = "إلى"; "wallet__send_swap_route" = "المسار"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "جهة الاتصال"; "wallet__recipient_invoice" = "لصق فاتورة"; "wallet__recipient_manual" = "إدخال يدوي"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 891fb3ba3..c4a74f891 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1189,6 +1189,8 @@ "wallet__send_sync_long_description" = "Sembla que estàs desconnectat. Comprova la connexió. Bitkit intentarà tornar a connectar-se cada pocs segons."; "wallet__send_to" = "A"; "wallet__send_swap_route" = "Ruta"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contacte"; "wallet__recipient_invoice" = "Enganxa factura"; "wallet__recipient_manual" = "Introdueix manualment"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 5fe0df601..22d3b357c 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1296,6 +1296,8 @@ "wallet__send_sync_long_description" = "Zdá se, že jste odpojeni. Zkontrolujte prosím připojení. Bitkit se bude každých několik sekund pokoušet znovu připojit."; "wallet__send_to" = "Komu"; "wallet__send_swap_route" = "Trasa"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Vložit fakturu"; "wallet__recipient_manual" = "Zadat ručně"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 5bed6d7d6..924505d38 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1294,6 +1294,8 @@ "wallet__send_sync_long_description" = "Anscheinend bist du nicht verbunden. Bitte überprüfe deine Verbindung. Bitkit versucht alle paar Sekunden, sich erneut zu verbinden."; "wallet__send_to" = "An"; "wallet__send_swap_route" = "Route"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Rechnung einfügen"; "wallet__recipient_manual" = "Manuell eingeben"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 8fea8d9ac..dbb52478e 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1088,6 +1088,8 @@ "wallet__send_sync_long_description" = "Φαίνεται ότι είστε αποσυνδεδεμένοι. Ελέγξτε τη σύνδεσή σας. Το Bitkit θα προσπαθεί να επανασυνδεθεί κάθε λίγα δευτερόλεπτα."; "wallet__send_to" = "Προς"; "wallet__send_swap_route" = "Διαδρομή"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Επαφή"; "wallet__recipient_invoice" = "Επικόλληση Τιμολογίου"; "wallet__recipient_manual" = "Χειροκίνητη Εισαγωγή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 4781ee722..1797d581d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1309,6 +1309,8 @@ "wallet__send_sync_long_description" = "It appears you’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds."; "wallet__send_to" = "To"; "wallet__send_swap_route" = "Route"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Paste Invoice"; "wallet__recipient_manual" = "Enter Manually"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 44c2b3cc5..19f2a53df 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1299,6 +1299,8 @@ "wallet__send_sync_long_description" = "Parece que está desconectado. Revise su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Ruta"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contacto"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index b0d12e27d..8de63a51d 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1207,6 +1207,8 @@ "wallet__send_sync_long_description" = "Parece que está desconectado. Compruebe su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Ruta"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contactar"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index d28316896..293fad89a 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1307,6 +1307,8 @@ "wallet__send_sync_long_description" = "Il semble que vous soyez déconnecté. Veuillez vérifier votre connexion. Bitkit tentera de se reconnecter toutes les quelques secondes."; "wallet__send_to" = "Pour"; "wallet__send_swap_route" = "Itinéraire"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Coller la facture"; "wallet__recipient_manual" = "Entrer manuellement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 7e25bc3cf..707f161c0 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1272,6 +1272,8 @@ "wallet__send_sync_long_description" = "Sembra che tu sia disconnesso. Controlla la connessione. Bitkit proverà a riconnettersi ogni pochi secondi."; "wallet__send_to" = "A"; "wallet__send_swap_route" = "Percorso"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contatto"; "wallet__recipient_invoice" = "Incolla fattura"; "wallet__recipient_manual" = "Inserisci Manualmente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 7cd3454ae..3e74cc3a6 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1302,6 +1302,8 @@ "wallet__send_sync_long_description" = "Het lijkt erop dat u niet verbonden bent. Controleer uw verbinding. Bitkit probeert om de paar seconden opnieuw verbinding te maken."; "wallet__send_to" = "Naar"; "wallet__send_swap_route" = "Route"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Factuur Plakken"; "wallet__recipient_manual" = "Handmatig Invoeren"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 521f10f87..1cd27fb4b 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1306,6 +1306,8 @@ "wallet__send_sync_long_description" = "Wygląda na to, że nie masz połączenia. Sprawdź połączenie. Bitkit będzie próbował połączyć się ponownie co kilka sekund."; "wallet__send_to" = "Do"; "wallet__send_swap_route" = "Trasa"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Wklej fakturę"; "wallet__recipient_manual" = "Wprowadź ręcznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 35c0ab87e..6f9138493 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1307,6 +1307,8 @@ "wallet__send_sync_long_description" = "Parece que você está desconectado. Verifique sua conexão. A Bitkit tentará se reconectar a cada poucos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Rota"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index bc0adeff1..e01173050 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1061,6 +1061,8 @@ "wallet__send_sync_long_description" = "Parece que está desligado. Verifique a sua ligação. A Bitkit vai tentar voltar a ligar a cada poucos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Rota"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 002cecaba..e64980d16 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1291,6 +1291,8 @@ "wallet__send_sync_long_description" = "Похоже, вы отключены. Проверьте подключение. Bitkit будет пытаться переподключиться каждые несколько секунд."; "wallet__send_to" = "Кому"; "wallet__send_swap_route" = "Маршрут"; +"wallet__send_paykit_no_route__title" = "Can't pay this contact"; +"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; "wallet__recipient_contact" = "Контакт"; "wallet__recipient_invoice" = "Вставить Инвойс"; "wallet__recipient_manual" = "Ввести Вручную"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 7677c57e7..06ef7729b 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1486,6 +1486,24 @@ class ActivityService { } } + /// Marks a sent on-chain activity the node doesn't watch, such as a Spark withdrawal to a contact, as confirmed once + /// its sender reports it complete. + func markOnchainActivityAsConfirmed(txId: String) async { + do { + try await ServiceQueue.background(.core) { + guard let existing = try BitkitCore.getActivityByTxId(walletId: WalletScope.default, txId: txId), + !existing.confirmed + else { return } + var updated = existing + updated.confirmed = true + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.activitiesChangedSubject.send() + } + } catch { + Logger.error("Failed to mark activity as confirmed for \(txId): \(error)", context: "ActivityService") + } + } + /// Marks the on-chain activity for `txId` as a transfer between Savings and Spending that has no channel, /// such as a Spark deposit or withdrawal. No-op until the activity exists or once it is marked. func markOnchainActivityAsTransfer(txId: String) async { diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift new file mode 100644 index 000000000..56f5b5b3b --- /dev/null +++ b/Bitkit/Services/PaykitRouteService.swift @@ -0,0 +1,147 @@ +import BitkitCore +import Foundation + +/// A contact payment bitkit-core's Paykit router picks the way for: the contact's bitcoin endpoints, whether a Spark leg +/// may reach its on-chain ones, and the leg once the router picked one. +struct PaykitRouteUi: Equatable { + let endpoints: [PaykitEndpoint] + let offersSparkLeg: Bool + var serviceLeg: ServiceRouteLeg? + + var deliveredSat: UInt64? { + serviceLeg?.deliveredSat + } + + var totalFeeSat: UInt64? { + serviceLeg.map { $0.debitSat.saturatingSub($0.deliveredSat) } + } + + var onchainEndpoints: [PaykitEndpoint] { + endpoints.filter(\.isOnchain) + } +} + +extension PaykitEndpoint { + var isOnchain: Bool { + PublicPaykitService.MethodId(rawValue: methodId)?.onchainNetwork != nil + } + + var isLnurl: Bool { + methodId == PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue + } +} + +/// Routes every bitcoin contact payment through bitkit-core's Paykit router: Direct pays one of the contact's +/// endpoints, Service pays through a service the wallet holds a balance on (a Spark withdrawal to the contact's on-chain +/// address), None pays nothing. +enum PaykitRouteService { + /// The source the payment is paid from, as picked on the amount screen. + enum Source { + case spending + case savings + } + + /// Spark can carry a leg while it is ready and holds a sendable balance. + @MainActor + static var canRouteThroughSpark: Bool { + SparkManager.shared.isReady && SparkManager.shared.sendableSats > 0 + } + + /// The route for a contact payment, or nil without bitcoin endpoints. A Spark leg is offered only for a payment the + /// user starts (an incoming request's on-chain proof needs the node's send-attempt store) to a contact with an + /// on-chain endpoint, while Spark can route and the payment can't go directly over Lightning. + static func routeUi( + for context: ContactPaymentContext, + canRouteThroughSpark: Bool, + canPayDirectly: Bool + ) -> PaykitRouteUi? { + let endpoints = context.endpoints + .filter { $0.methodId != .usdtArbitrum } + .map { PaykitEndpoint(methodId: $0.methodId.rawValue, value: $0.value) } + guard !endpoints.isEmpty else { return nil } + let offersSparkLeg = canRouteThroughSpark && !canPayDirectly && context.incomingPaymentRequest == nil && + endpoints.contains(where: \.isOnchain) + return PaykitRouteUi(endpoints: endpoints, offersSparkLeg: offersSparkLeg) + } + + /// What the router is asked from `source`: Spending offers every endpoint, Savings only the on-chain ones. + static func request( + route: PaykitRouteUi, + amountSats: UInt64, + source: Source, + lightningSpendableSats: UInt64, + onchainSpendableSats: UInt64, + serviceLegs: [ServiceRouteLeg] + ) -> PaykitRouteRequest { + switch source { + case .spending: + PaykitRouteRequest( + amountSat: amountSats, + contactEndpoints: route.endpoints, + lightningSpendableSat: lightningSpendableSats, + onchainSpendableSat: 0, + serviceLegs: serviceLegs + ) + case .savings: + PaykitRouteRequest( + amountSat: amountSats, + contactEndpoints: route.onchainEndpoints, + lightningSpendableSat: 0, + onchainSpendableSat: onchainSpendableSats, + serviceLegs: [] + ) + } + } + + /// Asks the router how to pay `amountSats` from `source`, quoting a Spark leg to each on-chain endpoint first when + /// the route offers one. + static func route( + _ route: PaykitRouteUi, + amountSats: UInt64, + source: Source, + lightningSpendableSats: UInt64, + onchainSpendableSats: UInt64 + ) async throws -> PaykitRoute { + let serviceLegs = source == .spending && route.offersSparkLeg + ? await sparkLegs(to: route.onchainEndpoints, amountSats: amountSats) + : [] + return try paykitRoutePayment( + request: request( + route: route, + amountSats: amountSats, + source: source, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats, + serviceLegs: serviceLegs + ), + network: Env.bitkitCoreNetwork + ) + } + + private static func sparkLegs(to endpoints: [PaykitEndpoint], amountSats: UInt64) async -> [ServiceRouteLeg] { + var legs: [ServiceRouteLeg] = [] + for endpoint in endpoints { + do { + if let leg = try await SparkManager.shared.withdrawalRouteLeg(endpoint: endpoint, amountSats: amountSats) { + legs.append(leg) + } + } catch { + Logger.warn("No Spark route to \(endpoint.methodId): \(error)", context: "PaykitRoute") + } + } + return legs + } + + /// Whether a None route is a short balance rather than a contact Bitkit can't pay. + static func isBalanceShort(amountSats: UInt64, lightningSpendableSats: UInt64, onchainSpendableSats: UInt64) -> Bool { + amountSats > lightningSpendableSats.saturatingAdd(onchainSpendableSats) + } + + /// Runs a service leg the router picked and returns the txid that pays the contact. + static func pay(_ leg: ServiceRouteLeg, contact: String?) async throws -> String { + guard leg.service == SparkManager.routeService else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "Unsupported route service \(leg.service)") + } + return try await SparkManager.shared.withdraw(to: leg.endpoint.value, amountSats: leg.deliveredSat, contact: contact) + } +} diff --git a/Bitkit/Services/PubkySwapsRouteService.swift b/Bitkit/Services/PubkySwapsRouteService.swift deleted file mode 100644 index 2e13d9375..000000000 --- a/Bitkit/Services/PubkySwapsRouteService.swift +++ /dev/null @@ -1,128 +0,0 @@ -import BitkitCore -import Foundation - -/// A Paykit payment routed through a Pubky Swaps provider or a service the wallet holds a balance on: the endpoint, -/// the route once quoted (a provider's quote or a service leg), and the provider's name. -struct PaykitSwapRoute: Equatable { - let endpoint: PaykitEndpoint - var quote: PubkySwapRouteQuote? - var providerName: String? - var serviceLeg: ServiceRouteLeg? - - var deliveredSat: UInt64? { - quote?.deliveredSat ?? serviceLeg?.deliveredSat - } - - var totalFeeSat: UInt64? { - quote?.totalFeeSat ?? serviceLeg.map { $0.debitSat - $0.deliveredSat } - } -} - -/// Routes a Paykit payment through a Pubky Swaps provider when no balance the wallet holds reaches an endpoint the -/// contact published. bitkit-core picks the provider and checks its quote, contract and hold invoice; the wallet pays -/// the hold invoice from spending and bitkit-core claims the provider's lockup straight to the contact's address. -enum PubkySwapsRouteService { - private static let claimFeeRateSatVb: UInt64 = 2 - private static let pollInterval: Duration = .seconds(2) - private static let claimTimeout: TimeInterval = 10 * 60 - - static var isEnabled: Bool { - Env.isPubkySwapsRouteEnabled - } - - /// A contact whose only endpoints are on-chain, paid when savings cannot cover it but spending can. - static func route(for context: ContactPaymentContext, address: String, onchainBalanceSats: UInt64, - lightningBalanceSats: UInt64) -> PaykitSwapRoute? - { - guard isEnabled, context.incomingPaymentRequest == nil, !address.isEmpty, - !context.endpoints.isEmpty, context.endpoints.allSatisfy({ $0.methodId.onchainNetwork != nil }), - onchainBalanceSats <= UInt64(Env.dustLimit), lightningBalanceSats > 0, - let methodId = context.endpoints.first?.methodId - else { return nil } - return PaykitSwapRoute(endpoint: PaykitEndpoint(methodId: methodId.rawValue, value: address)) - } - - static func quote( - amountSats: UInt64, - endpoint: PaykitEndpoint, - lightningSpendableSats: UInt64, - onchainSpendableSats: UInt64, - contacts: [String] - ) async throws -> PaykitRoute { - // Providers come from the user's own graph: every contact is asked for its swap offer. - let candidates = contacts + Env.pubkySwapsProviders - let serviceLegs = await serviceLegs(endpoint: endpoint, amountSats: amountSats) - Logger.debug("Routing Paykit payment via \(candidates.map(PubkyPublicKeyFormat.redacted))", context: "PubkySwapsRoute") - let (mnemonic, passphrase) = try secrets() - return try await pubkySwapsRoutePaykitPayment( - request: PaykitRouteRequest( - amountSat: amountSats, - contactEndpoints: [endpoint], - lightningSpendableSat: lightningSpendableSats, - onchainSpendableSat: onchainSpendableSats, - providers: candidates, - feeRateSatVb: claimFeeRateSatVb, - serviceLegs: serviceLegs - ), - network: Env.bitkitCoreNetwork, - mnemonic: mnemonic, - bip39Passphrase: passphrase - ) - } - - /// Legs through the services the wallet holds a balance on, which compete with the providers on fee. - private static func serviceLegs(endpoint: PaykitEndpoint, amountSats: UInt64) async -> [ServiceRouteLeg] { - do { - return try await SparkManager.shared.withdrawalRouteLeg(endpoint: endpoint, amountSats: amountSats).map { [$0] } ?? [] - } catch { - Logger.warn("No Spark route to \(endpoint.methodId): \(error)", context: "PubkySwapsRoute") - return [] - } - } - - /// Runs a service leg the router picked and returns the txid that pays the contact. - static func pay(_ leg: ServiceRouteLeg) async throws -> String { - guard leg.service == SparkManager.routeService else { - throw AppError(message: "Unsupported route service", debugMessage: leg.service) - } - return try await SparkManager.shared.withdraw(to: leg.endpoint.value, amountSats: leg.deliveredSat) - } - - static func commit(quoteId: String) async throws -> PubkySwapRoutePayment { - let (mnemonic, passphrase) = try secrets() - return try await pubkySwapsCommitRoute( - quoteId: quoteId, - electrumUrl: Env.electrumServerUrl, - mnemonic: mnemonic, - bip39Passphrase: passphrase - ) - } - - /// Advance the swap until bitkit-core claims the lockup to the contact, returning the claim txid. - static func awaitClaim(swapId: String) async throws -> String { - let deadline = Date().addingTimeInterval(claimTimeout) - while true { - let (mnemonic, passphrase) = try secrets() - let status = try await pubkySwapsAdvance(swapId: swapId, mnemonic: mnemonic, bip39Passphrase: passphrase) - switch status.state { - case let .claimed(txid): - return txid - case let .failed(reason): - throw AppError(message: reason, debugMessage: nil) - default: - Logger.debug("Pubky Swaps route \(swapId): \(status.state)", context: "PubkySwapsRoute") - } - guard Date() < deadline else { - throw AppError(message: "Swap \(swapId) not claimed yet", debugMessage: "\(status.state)") - } - try await Task.sleep(for: pollInterval) - } - } - - private static func secrets() throws -> (String, String?) { - guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { - throw AppError(message: "Mnemonic not found", debugMessage: nil) - } - return (mnemonic, try? Keychain.loadString(key: .bip39Passphrase(index: 0))) - } -} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1ce0f7c11..e76d3d805 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -167,7 +167,7 @@ class AppViewModel: ObservableObject { @Published var contactPaymentContext: ContactPaymentContext? private(set) var didRejectScannedPayment = false @Published var paykitUsesUsdt = false - @Published var paykitSwapRoute: PaykitSwapRoute? + @Published var paykitRoute: PaykitRouteUi? @Published var paykitReviewedAmount: PaykitAmount? @Published var paykitPaymentTerms: PaykitRequestPricing.Payment? @@ -1038,15 +1038,20 @@ extension AppViewModel { // Fallback to on-chain if address is available guard !invoice.address.isEmpty, validateIncomingOnchainPaymentRequestAmount(requestedAmount ?? invoice.amountSatoshis) else { return } - if let context = contactPaymentContext, let route = PubkySwapsRouteService.route( - for: context, - address: invoice.address, - onchainBalanceSats: max(paymentState.spendableOnchainBalanceSats, alternativeOnchainBalanceSats), - lightningBalanceSats: paymentState.totalLightningBalanceSats - ) { + // An on-chain contact payment opens with Spending as a source while Spark can carry it there, whether or + // not Savings covers it; Spending is the default when Savings holds no more than dust. + if let context = contactPaymentContext, scope != .onchainPayments, + let route = PaykitRouteService.routeUi( + for: context, + canRouteThroughSpark: PaykitRouteService.canRouteThroughSpark, + canPayDirectly: false + ), route.offersSparkLeg + { handleScannedOnchainInvoice(invoice) - paykitSwapRoute = route - selectedWalletToPayFrom = .lightning + paykitRoute = route + if max(paymentState.spendableOnchainBalanceSats, alternativeOnchainBalanceSats) <= UInt64(Env.dustLimit) { + selectedWalletToPayFrom = .lightning + } return } @@ -1395,7 +1400,7 @@ extension AppViewModel { sendStateOwnerID = ownerID sendStateGeneration = UUID() paykitUsesUsdt = false - paykitSwapRoute = nil + paykitRoute = nil paykitReviewedAmount = nil paykitPaymentTerms = nil scannedLightningInvoice = nil diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index f5dfeabeb..6bf5f33cf 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -1,3 +1,4 @@ +import BitkitCore import SwiftUI enum SendFundingSource: Equatable { @@ -10,7 +11,6 @@ enum SendFundingSource: Equatable { struct SendAmountView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var app: AppViewModel - @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel @@ -44,11 +44,9 @@ struct SendAmountView: View { { return [.usdt] } - if app.paykitSwapRoute != nil { - return [.spending] - } var sources: [SendFundingSource] = [] - if app.scannedLightningInvoice != nil || app.lnurlPayData != nil { + // A Spark leg can carry an on-chain contact payment from Spending. + if app.scannedLightningInvoice != nil || app.lnurlPayData != nil || app.paykitRoute?.offersSparkLeg == true { sources.append(.spending) } if app.scannedOnchainInvoice != nil { @@ -393,6 +391,20 @@ struct SendAmountView: View { return } + if !app.paykitUsesUsdt, let context = app.contactPaymentContext, + let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false, canPayDirectly: true) + { + switch await routePaykitPayment(route, request: context.incomingPaymentRequest) { + case .leg: + navigationPath.append(.confirm) + return + case .refused: + return + case .direct: + break + } + } + // Lightning payment if app.selectedWalletToPayFrom == .lightning { if UInt64(wallet.maxSendLightningSats) < amountSats { @@ -404,12 +416,6 @@ struct SendAmountView: View { return } - if let route = app.paykitSwapRoute { - guard await quoteSwapRoute(route) else { return } - navigationPath.append(.confirm) - return - } - navigationPath.append(app.lnurlPayData != nil && app.scannedLightningInvoice == nil ? .lnurlPayConfirm : .confirm) return } @@ -458,41 +464,91 @@ struct SendAmountView: View { } } - private func quoteSwapRoute(_ route: PaykitSwapRoute) async -> Bool { + private enum PaykitRouteOutcome { + case direct + case leg + case refused + } + + /// Asks bitkit-core's router how to pay the contact from the selected source. Direct pays the endpoint it names, a + /// service leg opens Confirm with its route and fee, and none stays here with the app's copy. + private func routePaykitPayment(_ route: PaykitRouteUi, request: PaykitPaymentRequest?) async -> PaykitRouteOutcome { + let source: PaykitRouteService.Source = app.selectedWalletToPayFrom == .lightning ? .spending : .savings + let lightningSpendable = source == .spending ? UInt64(max(0, wallet.maxSendLightningSats)) : 0 + let onchainSpendable = source == .savings ? maxSendableAmount ?? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0 + var unrouted = route + unrouted.serviceLeg = nil do { - let paykitRoute = try await PubkySwapsRouteService.quote( + let picked = try await PaykitRouteService.route( + route, amountSats: amountSats, - endpoint: route.endpoint, - lightningSpendableSats: UInt64(wallet.maxSendLightningSats), - onchainSpendableSats: UInt64(wallet.spendableOnchainBalanceSats), - contacts: contactsManager.contacts.map(\.publicKey) + source: source, + lightningSpendableSats: lightningSpendable, + onchainSpendableSats: onchainSpendable ) - switch paykitRoute { - case let .swap(quote): - var quoted = route - quoted.quote = quote - quoted.serviceLeg = nil - quoted.providerName = contactsManager.contacts - .first { PubkyPublicKeyFormat.matches($0.publicKey, quote.providerPubky) }?.displayName - app.paykitSwapRoute = quoted - return true - case let .none(reason): - app.toast(type: .error, title: t("slashtags__error_pay_title"), description: reason) + switch picked { + case let .direct(endpoint): + app.paykitRoute = unrouted + guard await payDirectly(endpoint) else { return .refused } + if let request { + app.paykitPaymentTerms = try request.payment(using: app.paykitPaymentMethod) + } + return .direct case let .service(leg): - var quoted = route - quoted.quote = nil - quoted.serviceLeg = leg - app.paykitSwapRoute = quoted - return true - case .direct: - app.paykitSwapRoute = nil - app.toast(type: .error, title: t("slashtags__error_pay_title"), description: t("common__error_body")) + var routed = route + routed.serviceLeg = leg + app.paykitRoute = routed + return .leg + case let .none(reason): + Logger.warn("Found no Paykit route: '\(reason)'", context: "SendAmountView") + app.paykitRoute = unrouted + showNoRouteToast(isBalanceShort: PaykitRouteService.isBalanceShort( + amountSats: amountSats, + lightningSpendableSats: lightningSpendable, + onchainSpendableSats: onchainSpendable + )) + return .refused } } catch { - Logger.error("Failed to route Paykit payment through Pubky Swaps: \(error)", context: "SendAmountView") - app.toast(type: .error, title: t("slashtags__error_pay_title"), description: error.localizedDescription) + Logger.error("Failed to route Paykit payment: \(error)", context: "SendAmountView") + showNoRouteToast(isBalanceShort: false) + return .refused + } + } + + /// Switches the send to the endpoint the router picked, when the scan opened another one. + private func payDirectly(_ endpoint: PaykitEndpoint) async -> Bool { + if endpoint.isOnchain { + app.selectedWalletToPayFrom = .onchain + if app.scannedOnchainInvoice?.address != endpoint.value { + app.scannedOnchainInvoice = OnChainInvoice(address: endpoint.value, amountSatoshis: 0, label: nil, message: nil, params: nil) + } + return true + } + app.selectedWalletToPayFrom = .lightning + if endpoint.isLnurl { + return app.lnurlPayData != nil || app.scannedLightningInvoice != nil } - return false + if app.scannedLightningInvoice?.bolt11.caseInsensitiveCompare(endpoint.value) == .orderedSame { + return true + } + guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value) else { + Logger.warn("Failed to decode the Lightning endpoint the router picked", context: "SendAmountView") + showNoRouteToast(isBalanceShort: false) + return false + } + app.scannedLightningInvoice = invoice + app.lnurlPayData = nil + return true + } + + private func showNoRouteToast(isBalanceShort: Bool) { + app.toast( + type: .error, + title: t(isBalanceShort ? "wallet__send_amount_exceeded__title" : "wallet__send_paykit_no_route__title"), + description: t(isBalanceShort ? "wallet__send_amount_exceeded__description" : "wallet__send_paykit_no_route__description"), + accessibilityIdentifier: isBalanceShort ? "PaykitRouteBalanceToast" : "PaykitRouteNoneToast" + ) } private func updateInputCap() { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 96e907980..d393c31a7 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -3,8 +3,6 @@ import LDKNode import SwiftUI struct SendConfirmationView: View { - private static let providerKeyLength = 12 - @Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager @EnvironmentObject var app: AppViewModel @EnvironmentObject var activityList: ActivityListViewModel @@ -347,7 +345,7 @@ struct SendConfirmationView: View { PaykitAmountText(amount: preparingRequest.amount, size: .display) .accessibilityIdentifier("ReviewAmount") } - } else if let deliveredSat = app.paykitSwapRoute?.deliveredSat { + } else if let deliveredSat = app.paykitRoute?.deliveredSat { MoneyStack( sats: Int(wallet.sendAmountSats ?? deliveredSat), showSymbol: true, @@ -375,8 +373,8 @@ struct SendConfirmationView: View { .padding(.bottom, oneOffPaymentRequest == nil ? 44 : 24) if showDetails { - if let route = app.paykitSwapRoute, let totalFeeSat = route.totalFeeSat { - swapRouteView(route, totalFeeSat: totalFeeSat) + if let route = app.paykitRoute, let totalFeeSat = route.totalFeeSat { + paykitRouteView(route, totalFeeSat: totalFeeSat) } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { onchainView(invoice) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { @@ -728,7 +726,7 @@ struct SendConfirmationView: View { } } - func swapRouteView(_ route: PaykitSwapRoute, totalFeeSat: UInt64) -> some View { + func paykitRouteView(_ route: PaykitRouteUi, totalFeeSat: UInt64) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { SendSectionView(t("wallet__send_from")) { @@ -764,7 +762,7 @@ struct SendConfirmationView: View { } SendSectionView(t("wallet__send_swap_route")) { - BodySSBText(routeName(route)) + BodySSBText(Self.routeName(route)) .lineLimit(2) .accessibilityIdentifier("ReviewSwapRoute") } @@ -784,11 +782,8 @@ struct SendConfirmationView: View { } } - private func routeName(_ route: PaykitSwapRoute) -> String { - if let quote = route.quote { - return "Pubky Swaps · \(route.providerName ?? quote.providerPubky.ellipsis(maxLength: Self.providerKeyLength))" - } - return "Spark" + private static func routeName(_ route: PaykitRouteUi) -> String { + route.serviceLeg?.service == SparkManager.routeService ? "Spark" : route.serviceLeg?.service ?? "" } func lightningView(_ invoice: LightningInvoice) -> some View { @@ -1047,10 +1042,8 @@ struct SendConfirmationView: View { if hwSend.isActive { navigationPath.append(.hardwareSign) - } else if let leg = app.paykitSwapRoute?.serviceLeg { + } else if let leg = app.paykitRoute?.serviceLeg { await performServiceRoutedPayment(leg) - } else if let quote = app.paykitSwapRoute?.quote { - await performSwapRoutedPayment(quote) } else { try await performPayment(isAutomatic: isAutomatic) } @@ -1374,7 +1367,7 @@ struct SendConfirmationView: View { isSubmittingPayment = true defer { isSubmittingPayment = false } do { - let txid = try await PubkySwapsRouteService.pay(leg) + let txid = try await PaykitRouteService.pay(leg, contact: app.contactPaymentContext?.publicKey) Logger.info("Paid Paykit contact through \(leg.service) in \(txid)", context: "SendConfirmation") navigationPath.append(.success(paymentId: txid)) } catch { @@ -1388,28 +1381,6 @@ struct SendConfirmationView: View { } } - private func performSwapRoutedPayment(_ quote: PubkySwapRouteQuote) async { - isSubmittingPayment = true - defer { isSubmittingPayment = false } - do { - let payment = try await PubkySwapsRouteService.commit(quoteId: quote.quoteId) - Logger.info("Committed Pubky Swaps route \(payment.swapId) to \(payment.destinationAddress)", context: "SendConfirmation") - // The hold invoice settles only after the claim reveals the preimage, so its payment stays pending until then. - let paymentHash = try await LightningService.shared.send(bolt11: payment.invoice) - let claimTxid = try await PubkySwapsRouteService.awaitClaim(swapId: payment.swapId) - Logger.info("Claimed Pubky Swaps route \(payment.swapId) in \(claimTxid)", context: "SendConfirmation") - navigationPath.append(.success(paymentId: String(paymentHash))) - } catch { - Logger.error("Pubky Swaps route payment failed: \(error)", context: "SendConfirmation") - navigationPath.append(.failure(SendFailureContext( - error: error, - retryRoute: .confirm, - routingCacheResetAttempted: routingCacheResetAttempted, - paymentRequest: nil - ))) - } - } - private func performPayment(isAutomatic: Bool) async throws { let paymentActivity = PaykitPaymentActivity.shared.begin() defer { PaykitPaymentActivity.shared.end(paymentActivity) } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index a4443c486..7eacef40f 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -872,8 +872,8 @@ struct SendSheet: View { } } - // Validate onchain payment balance (for pure onchain invoices); a swap route pays it from spending - if app.paykitSwapRoute == nil, let onchainInvoice = app.scannedOnchainInvoice { + // Validate onchain payment balance (for pure onchain invoices); a Spark route can pay it from spending + if app.paykitRoute?.offersSparkLeg != true, let onchainInvoice = app.scannedOnchainInvoice { let onchainBalance = max( LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0, hwWalletManager.maximumFundingBalanceSats diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift new file mode 100644 index 000000000..dc7c450c1 --- /dev/null +++ b/BitkitTests/PaykitRouteTests.swift @@ -0,0 +1,170 @@ +@testable import Bitkit +import BitkitCore +import BreezSdkSpark +import XCTest + +final class PaykitRouteTests: XCTestCase { + private let onchain = PaykitEndpoint(methodId: "btc-regtest-p2wpkh", value: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x") + private let lightning = PaykitEndpoint( + methodId: "btc-lightning-lnurl", + value: "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns" + ) + + private func sparkLeg(amountSats: UInt64, feeSats: UInt64) -> ServiceRouteLeg { + ServiceRouteLeg( + service: SparkManager.routeService, + endpoint: onchain, + deliveredSat: amountSats, + debitSat: amountSats + feeSats, + atomicity: "trusted", + validUntilUnix: UInt64(Date().timeIntervalSince1970) + 600 + ) + } + + // MARK: - Request + + func testSpendingOffersEveryEndpointAndSavingsOnlyTheOnchainOnes() { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + let leg = sparkLeg(amountSats: 20000, feeSats: 300) + + let spending = PaykitRouteService.request( + route: route, amountSats: 20000, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 90000, + serviceLegs: [leg] + ) + let savings = PaykitRouteService.request( + route: route, amountSats: 20000, source: .savings, lightningSpendableSats: 50000, onchainSpendableSats: 90000, + serviceLegs: [leg] + ) + + XCTAssertEqual(spending.contactEndpoints, [lightning, onchain]) + XCTAssertEqual(spending.lightningSpendableSat, 50000) + XCTAssertEqual(spending.onchainSpendableSat, 0) + XCTAssertEqual(spending.serviceLegs, [leg]) + XCTAssertEqual(savings.contactEndpoints, [onchain]) + XCTAssertEqual(savings.lightningSpendableSat, 0) + XCTAssertEqual(savings.onchainSpendableSat, 90000) + XCTAssertTrue(savings.serviceLegs.isEmpty) + } + + // MARK: - Router outcomes + + func testRouterPaysTheLnurlEndpointItPicksFromSpending() throws { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: false) + + let picked = try paykitRoutePayment( + request: PaykitRouteService.request( + route: route, amountSats: 20, source: .spending, lightningSpendableSats: 5000, onchainSpendableSats: 0, + serviceLegs: [] + ), + network: .regtest + ) + + XCTAssertEqual(picked, .direct(endpoint: lightning)) + } + + func testRouterPaysTheOnchainEndpointItPicksFromSavings() throws { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: false) + + let picked = try paykitRoutePayment( + request: PaykitRouteService.request( + route: route, amountSats: 20000, source: .savings, lightningSpendableSats: 0, onchainSpendableSats: 90000, + serviceLegs: [] + ), + network: .regtest + ) + + XCTAssertEqual(picked, .direct(endpoint: onchain)) + } + + func testRouterTakesTheSparkLegToAnOnchainOnlyContactFromSpending() throws { + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + let leg = sparkLeg(amountSats: 20000, feeSats: 300) + + let picked = try paykitRoutePayment( + request: PaykitRouteService.request( + route: route, amountSats: 20000, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 0, + serviceLegs: [leg] + ), + network: .regtest + ) + + XCTAssertEqual(picked, .service(leg: leg)) + var routed = route + routed.serviceLeg = leg + XCTAssertEqual(routed.totalFeeSat, 300) + XCTAssertEqual(routed.deliveredSat, 20000) + } + + func testRouterFindsNoneWithoutABalanceThatReachesTheContact() throws { + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: false) + + let picked = try paykitRoutePayment( + request: PaykitRouteService.request( + route: route, amountSats: 20000, source: .savings, lightningSpendableSats: 0, onchainSpendableSats: 5000, + serviceLegs: [] + ), + network: .regtest + ) + + guard case .none = picked else { return XCTFail("Expected no route, got \(picked)") } + XCTAssertTrue(PaykitRouteService.isBalanceShort(amountSats: 20000, lightningSpendableSats: 0, onchainSpendableSats: 5000)) + XCTAssertFalse(PaykitRouteService.isBalanceShort(amountSats: 20000, lightningSpendableSats: 0, onchainSpendableSats: 90000)) + } + + // MARK: - Spark leg offer + + func testSparkLegIsOfferedOnlyForAnOnchainContactPaymentTheUserStarts() { + let onchainOnly = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] + ) + + XCTAssertEqual( + PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: true, canPayDirectly: false)?.offersSparkLeg, + true + ) + XCTAssertEqual( + PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: false, canPayDirectly: false)?.offersSparkLeg, + false + ) + XCTAssertEqual( + PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: true, canPayDirectly: true)?.offersSparkLeg, + false + ) + let usdtOnly = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: [ + Self.endpoint(.usdtArbitrum, "0x0000000000000000000000000000000000000001"), + ]) + XCTAssertNil(PaykitRouteService.routeUi(for: usdtOnly, canRouteThroughSpark: true, canPayDirectly: false)) + } + + // MARK: - Contact withdrawal journal + + func testJournaledWithdrawalSettlesFromTheMatchingSparkPayment() { + let pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000) + let other = Self.withdrawal(id: "other", to: "bcrt1qother", amountSats: 20000, timestamp: 1001) + let earlier = Self.withdrawal(id: "earlier", to: onchain.value, amountSats: 20000, timestamp: 100) + let sent = Self.withdrawal(id: "sent", to: onchain.value, amountSats: 20000, timestamp: 1001) + + XCTAssertEqual(SparkManager.matchingWithdrawal(pending, in: [other, earlier, sent])?.id, "sent") + XCTAssertNil(SparkManager.matchingWithdrawal(pending, in: [other, earlier])) + } + + private static func endpoint(_ methodId: PublicPaykitService.MethodId, _ value: String) -> PublicPaykitService.Endpoint { + PublicPaykitService.Endpoint(methodId: methodId, value: value, min: nil, max: nil, rawPayload: value) + } + + private static func withdrawal(id: String, to address: String, amountSats: UInt64, timestamp: UInt64) -> Payment { + Payment( + id: id, + paymentType: .send, + status: .completed, + amount: U128(amountSats), + fees: 300, + timestamp: timestamp, + method: .withdraw, + details: .withdraw(txId: "tx-\(id)", vout: 0, destinationAddress: address), + conversionDetails: nil + ) + } +} diff --git a/changelog.d/next/1170.added.md b/changelog.d/next/1170.added.md deleted file mode 100644 index d7dfa8025..000000000 --- a/changelog.d/next/1170.added.md +++ /dev/null @@ -1 +0,0 @@ -Bitkit can pay a contact who only accepts on-chain bitcoin from your spending balance, through a Pubky Swaps provider among your contacts. diff --git a/changelog.d/next/1191.added.md b/changelog.d/next/1191.added.md index 9dd64f6e8..c908dc605 100644 --- a/changelog.d/next/1191.added.md +++ b/changelog.d/next/1191.added.md @@ -1 +1 @@ -Paykit contacts who only accept on-chain bitcoin can be paid from Spark when that costs less than a swap. +Paykit contacts who only accept on-chain bitcoin can be paid from Spark. diff --git a/journeys/paykit-spark-route.xml b/journeys/paykit-spark-route.xml index 9a0cd6d97..2ed0eaa9a 100644 --- a/journeys/paykit-spark-route.xml +++ b/journeys/paykit-spark-route.xml @@ -5,8 +5,7 @@ and that the contact receives the exact amount on chain. Preconditions: a Debug build with SWIFT_ACTIVE_COMPILATION_CONDITIONS including E2E_BUILD, E2E_BACKEND=local, - E2E_NETWORK=regtest and E2E_HOMESERVER_PUBKY set to the local marketplace homeserver, launched with the - -PUBKY_SWAPS_ROUTES=true argument; bitkit-docker's default stack with the marketplace and payment-requests + E2E_NETWORK=regtest and E2E_HOMESERVER_PUBKY set to the local marketplace homeserver; bitkit-docker's default stack with the marketplace and payment-requests profiles. The simulator reaches the host's loopback directly, so it needs no port forwarding. The wallet has no channels and an empty savings balance, a Spark balance of at least 25 000 sats from Lightspark's regtest faucet sent to the deposit address on the Spark screen, a Pubky profile on the local homeserver, and the diff --git a/journeys/paykit-swap-route.xml b/journeys/paykit-swap-route.xml deleted file mode 100644 index 3499e9490..000000000 --- a/journeys/paykit-swap-route.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - Proves that paying a Paykit contact whose only endpoint is on-chain, from a wallet that holds only a spending - balance, routes through a Pubky Swaps provider found among the user's own contacts, without the user picking a - swap, and that the contact receives the exact amount on chain. - - Preconditions: a Debug build with SWIFT_ACTIVE_COMPILATION_CONDITIONS including E2E_BUILD, E2E_BACKEND=local, - E2E_NETWORK=regtest and E2E_HOMESERVER_PUBKY set to the local marketplace homeserver, launched with the - -PUBKY_SWAPS_ROUTES=true argument; bitkit-docker's default stack with the marketplace, payment-requests and - lnurl-pay profiles; the provider running against bitkit-docker's LND and funded. The simulator reaches the host's - loopback directly, so it needs no port forwarding. - The wallet has a spending balance from an LNURL channel with bitkit-docker's LND and an empty savings balance, - a Pubky profile on the local homeserver, and both the provider's and the fixture-issuer's pubkys saved as contacts. - The contact is bitkit-docker's fixture-issuer, which publishes only a btc-regtest-p2wpkh endpoint. - - - Verify the savings balance (tag "ActivitySavings") shows 0 and the spending balance (tag "ActivitySpending") is above 25 000 - Tap Send (tag "Send"), then Contact (tag "RecipientContact") - Verify the contact list shows both the fixture-issuer and the provider, and tap the fixture-issuer (tag "SendContact-" followed by its pubky) - Verify the amount screen (tag "SendAmount") offers SPENDING as the source - Enter 20000 and tap Continue (tag "ContinueAmount") - Verify the Confirm screen (tag "SendConfirm") is visible, then tap Show Details (tag "SendConfirmToggleDetails") - Verify FROM is SPENDING, TO is the contact, and ROUTE (tag "ReviewSwapRoute") reads "Pubky Swaps" followed by the provider's shortened pubky - Swipe to pay (tag "GRAB") - Once the provider logs "HTLC funded; awaiting client claim", mine one block on bitkit-docker's bitcoind - Verify the Bitcoin Sent screen (tag "SendSuccess") is visible - Verify the provider logs "client claimed; hold invoice settled", and bitcoind shows the claim transaction paying exactly 0.0002 BTC to the fixture-issuer's address - - From 3331a5aba0c51c189beb8344ae1a64a573af1680 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:30:56 +0100 Subject: [PATCH 053/378] fix: spark exit background sweeps use the economy rate --- .../Services/SparkExit/SparkExitBackgroundRun.swift | 7 ++++++- BitkitTests/SparkExitReviewTests.swift | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift index b24864d57..7ea2038c4 100644 --- a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift +++ b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift @@ -146,7 +146,7 @@ private struct TimedSparkExitRecovering: SparkExitRecovering { } } -private struct TimedSparkExitChain: SparkExitChain { +struct TimedSparkExitChain: SparkExitChain { let base: SparkExitChain let timings: SparkExitTimings @@ -173,4 +173,9 @@ private struct TimedSparkExitChain: SparkExitChain { func fastestFeeRate() async throws -> UInt64 { try await base.fastestFeeRate() } + + /// Forwarded, or the protocol's default (every tier at the fastest rate) would price background sweeps. + func networkFees() async throws -> SparkExitNetworkFees { + try await base.networkFees() + } } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 7deef7472..958173241 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -374,4 +374,16 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(salvaged.fundingStrategy, SparkExitRecord.perNodeStrategy) XCTAssertEqual(salvaged.earlierReceivedSats, ["sweep-1": 40000]) } + + /// Background runs wrap the chain to time it: the wrapper forwards the economy tier, so their sweeps pay it. + func testTheTimedBackgroundChainForwardsTheNetworkFees() async throws { + chain.fastestFee = 9 + chain.economyFee = 1 + let timed = TimedSparkExitChain(base: chain, timings: SparkExitTimings()) + + let fees = try await timed.networkFees() + + XCTAssertEqual(fees.economy, 1) + XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: fees.economy, exitRate: 5), 1) + } } From 525004a4803d1694295e21ba3f70375ea45a3913 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 01:01:24 +0100 Subject: [PATCH 054/378] chore: translate the paykit no-route copy --- Bitkit/Resources/Localization/ar.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/ca.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/cs.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/de.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/el.lproj/Localizable.strings | 4 ++-- .../Resources/Localization/es-419.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/es.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/fr.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/it.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/nl.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/pl.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/pt.lproj/Localizable.strings | 4 ++-- Bitkit/Resources/Localization/ru.lproj/Localizable.strings | 4 ++-- 14 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 7963f87a5..881cb4ba2 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1070,8 +1070,8 @@ "wallet__send_sync_long_description" = "يبدو أنك غير متصل. يرجى التحقق من اتصالك. سيحاول Bitkit إعادة الاتصال كل بضع ثوانٍ."; "wallet__send_to" = "إلى"; "wallet__send_swap_route" = "المسار"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "تعذّر الدفع لجهة الاتصال هذه"; +"wallet__send_paykit_no_route__description" = "ليس لدى جهة الاتصال هذه طريقة دفع يمكن لـ Bitkit استخدامها حاليًا."; "wallet__recipient_contact" = "جهة الاتصال"; "wallet__recipient_invoice" = "لصق فاتورة"; "wallet__recipient_manual" = "إدخال يدوي"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index c4a74f891..2c597c3c3 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1189,8 +1189,8 @@ "wallet__send_sync_long_description" = "Sembla que estàs desconnectat. Comprova la connexió. Bitkit intentarà tornar a connectar-se cada pocs segons."; "wallet__send_to" = "A"; "wallet__send_swap_route" = "Ruta"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "No es pot pagar aquest contacte"; +"wallet__send_paykit_no_route__description" = "Aquest contacte no té cap mètode de pagament que Bitkit pugui fer servir ara mateix."; "wallet__recipient_contact" = "Contacte"; "wallet__recipient_invoice" = "Enganxa factura"; "wallet__recipient_manual" = "Introdueix manualment"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 22d3b357c..a32e18008 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1296,8 +1296,8 @@ "wallet__send_sync_long_description" = "Zdá se, že jste odpojeni. Zkontrolujte prosím připojení. Bitkit se bude každých několik sekund pokoušet znovu připojit."; "wallet__send_to" = "Komu"; "wallet__send_swap_route" = "Trasa"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Tomuto kontaktu nelze zaplatit"; +"wallet__send_paykit_no_route__description" = "Tento kontakt teď nemá žádný platební způsob, který by Bitkit mohl použít."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Vložit fakturu"; "wallet__recipient_manual" = "Zadat ručně"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 924505d38..1635854a1 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1294,8 +1294,8 @@ "wallet__send_sync_long_description" = "Anscheinend bist du nicht verbunden. Bitte überprüfe deine Verbindung. Bitkit versucht alle paar Sekunden, sich erneut zu verbinden."; "wallet__send_to" = "An"; "wallet__send_swap_route" = "Route"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Zahlung an diesen Kontakt nicht möglich"; +"wallet__send_paykit_no_route__description" = "Dieser Kontakt hat derzeit keine Zahlungsmethode, die Bitkit nutzen kann."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Rechnung einfügen"; "wallet__recipient_manual" = "Manuell eingeben"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index dbb52478e..d99967136 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1088,8 +1088,8 @@ "wallet__send_sync_long_description" = "Φαίνεται ότι είστε αποσυνδεδεμένοι. Ελέγξτε τη σύνδεσή σας. Το Bitkit θα προσπαθεί να επανασυνδεθεί κάθε λίγα δευτερόλεπτα."; "wallet__send_to" = "Προς"; "wallet__send_swap_route" = "Διαδρομή"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Δεν είναι δυνατή η πληρωμή αυτής της επαφής"; +"wallet__send_paykit_no_route__description" = "Αυτή η επαφή δεν έχει αυτή τη στιγμή κάποια μέθοδο πληρωμής που μπορεί να χρησιμοποιήσει το Bitkit."; "wallet__recipient_contact" = "Επαφή"; "wallet__recipient_invoice" = "Επικόλληση Τιμολογίου"; "wallet__recipient_manual" = "Χειροκίνητη Εισαγωγή"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 19f2a53df..6be5c777f 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1299,8 +1299,8 @@ "wallet__send_sync_long_description" = "Parece que está desconectado. Revise su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Ruta"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "No se puede pagar a este contacto"; +"wallet__send_paykit_no_route__description" = "Este contacto no tiene ningún método de pago que Bitkit pueda usar en este momento."; "wallet__recipient_contact" = "Contacto"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 8de63a51d..c8fc4f9ad 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1207,8 +1207,8 @@ "wallet__send_sync_long_description" = "Parece que está desconectado. Compruebe su conexión. Bitkit intentará reconectarse cada pocos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Ruta"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "No se puede pagar a este contacto"; +"wallet__send_paykit_no_route__description" = "Este contacto no tiene ningún método de pago que Bitkit pueda usar ahora mismo."; "wallet__recipient_contact" = "Contactar"; "wallet__recipient_invoice" = "Pegar factura"; "wallet__recipient_manual" = "Introducir manualmente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 293fad89a..8a9c01ea0 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1307,8 +1307,8 @@ "wallet__send_sync_long_description" = "Il semble que vous soyez déconnecté. Veuillez vérifier votre connexion. Bitkit tentera de se reconnecter toutes les quelques secondes."; "wallet__send_to" = "Pour"; "wallet__send_swap_route" = "Itinéraire"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Impossible de payer ce contact"; +"wallet__send_paykit_no_route__description" = "Ce contact n’a actuellement aucun moyen de paiement que Bitkit peut utiliser."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Coller la facture"; "wallet__recipient_manual" = "Entrer manuellement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 707f161c0..caea01e21 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1272,8 +1272,8 @@ "wallet__send_sync_long_description" = "Sembra che tu sia disconnesso. Controlla la connessione. Bitkit proverà a riconnettersi ogni pochi secondi."; "wallet__send_to" = "A"; "wallet__send_swap_route" = "Percorso"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Impossibile pagare questo contatto"; +"wallet__send_paykit_no_route__description" = "Al momento questo contatto non ha un metodo di pagamento che Bitkit possa usare."; "wallet__recipient_contact" = "Contatto"; "wallet__recipient_invoice" = "Incolla fattura"; "wallet__recipient_manual" = "Inserisci Manualmente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 3e74cc3a6..27ba87e02 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1302,8 +1302,8 @@ "wallet__send_sync_long_description" = "Het lijkt erop dat u niet verbonden bent. Controleer uw verbinding. Bitkit probeert om de paar seconden opnieuw verbinding te maken."; "wallet__send_to" = "Naar"; "wallet__send_swap_route" = "Route"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Kan dit contact niet betalen"; +"wallet__send_paykit_no_route__description" = "Dit contact heeft op dit moment geen betaalmethode die Bitkit kan gebruiken."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Factuur Plakken"; "wallet__recipient_manual" = "Handmatig Invoeren"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 1cd27fb4b..5bd824ee0 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1306,8 +1306,8 @@ "wallet__send_sync_long_description" = "Wygląda na to, że nie masz połączenia. Sprawdź połączenie. Bitkit będzie próbował połączyć się ponownie co kilka sekund."; "wallet__send_to" = "Do"; "wallet__send_swap_route" = "Trasa"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Nie można zapłacić temu kontaktowi"; +"wallet__send_paykit_no_route__description" = "Ten kontakt nie ma obecnie żadnej metody płatności, której Bitkit może użyć."; "wallet__recipient_contact" = "Kontakt"; "wallet__recipient_invoice" = "Wklej fakturę"; "wallet__recipient_manual" = "Wprowadź ręcznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 6f9138493..37917d35b 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1307,8 +1307,8 @@ "wallet__send_sync_long_description" = "Parece que você está desconectado. Verifique sua conexão. A Bitkit tentará se reconectar a cada poucos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Rota"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Não é possível pagar este contato"; +"wallet__send_paykit_no_route__description" = "Este contato não tem nenhum método de pagamento que o Bitkit possa usar agora."; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index e01173050..0e4484eb9 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1061,8 +1061,8 @@ "wallet__send_sync_long_description" = "Parece que está desligado. Verifique a sua ligação. A Bitkit vai tentar voltar a ligar a cada poucos segundos."; "wallet__send_to" = "Para"; "wallet__send_swap_route" = "Rota"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Não é possível pagar a este contacto"; +"wallet__send_paykit_no_route__description" = "Este contacto não tem neste momento nenhum método de pagamento que a Bitkit possa usar."; "wallet__recipient_contact" = "Contato"; "wallet__recipient_invoice" = "Colar Invoice"; "wallet__recipient_manual" = "Inserir Manualmente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index e64980d16..b4d6b8d7e 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1291,8 +1291,8 @@ "wallet__send_sync_long_description" = "Похоже, вы отключены. Проверьте подключение. Bitkit будет пытаться переподключиться каждые несколько секунд."; "wallet__send_to" = "Кому"; "wallet__send_swap_route" = "Маршрут"; -"wallet__send_paykit_no_route__title" = "Can't pay this contact"; -"wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_no_route__title" = "Не удаётся оплатить этому контакту"; +"wallet__send_paykit_no_route__description" = "У этого контакта сейчас нет способа оплаты, который может использовать Bitkit."; "wallet__recipient_contact" = "Контакт"; "wallet__recipient_invoice" = "Вставить Инвойс"; "wallet__recipient_manual" = "Ввести Вручную"; From 771b250ffee996dd672447a9464d6d9f457c7136 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 01:10:19 +0100 Subject: [PATCH 055/378] feat: pay paykit requests through the router and spark - A payment request's Confirm, which opens with fixed terms and no amount screen, asks the router too: Direct switches the send to the endpoint it picks. - An on-chain request can be paid from Spending through a Spark withdrawal. Its on-chain proof is started before Spark sends and completed with the withdrawal's txid, the request's payment check runs right before the send, and the withdrawal journal completes or drops the proof when the app stopped mid-send. --- Bitkit/Managers/SparkManager.swift | 38 +++++- .../Services/PaykitPaymentProofService.swift | 36 +++++ Bitkit/Services/PaykitRouteService.swift | 124 +++++++++++++++++- .../Views/Wallets/Send/SendAmountView.swift | 89 +------------ .../Wallets/Send/SendConfirmationView.swift | 70 +++++++++- .../PaykitPaymentProofServiceTests.swift | 45 +++++++ BitkitTests/PaykitRouteTests.swift | 2 +- 7 files changed, 310 insertions(+), 94 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index a12951822..20a0f63f8 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -463,16 +463,26 @@ final class SparkManager: ChannelFreeLightningRail { /// Withdraws exactly `amountSats` to a contact's on-chain `address` at medium speed and returns its txid. The /// withdrawal is written to a journal before it is sent, so one interrupted mid-send settles from Spark's payment /// list; a second one is refused while it is open. It is recorded as a sent on-chain activity for the contact. - func withdraw(to address: String, amountSats: UInt64, contact: String?) async throws -> String { + /// A withdrawal that pays a Paykit request (`request`) completes the request's on-chain proof with its txid, also when + /// it settles from the journal. `beforeSend` runs right before Spark sends, after the quote. + func withdraw( + to address: String, + amountSats: UInt64, + contact: String?, + request: SparkContactWithdrawal.PaykitRequest? = nil, + beforeSend: () async throws -> Void = {} + ) async throws -> String { guard Self.pendingContactWithdrawal == nil else { throw AppError(message: "wallet__payment_failed_description", debugMessage: "A Spark withdrawal to a contact is still settling") } let prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) + try await beforeSend() let pending = SparkContactWithdrawal( address: address, amountSats: amountSats, contact: contact, - startedAt: UInt64(Date().timeIntervalSince1970) + startedAt: UInt64(Date().timeIntervalSince1970), + request: request ) Self.pendingContactWithdrawal = pending isWithdrawingToContact = true @@ -512,6 +522,9 @@ final class SparkManager: ChannelFreeLightningRail { } else if allowClear { Logger.info("Closed a Spark contact withdrawal Spark never listed", context: "SparkManager") Self.pendingContactWithdrawal = nil + if let request = pending.request { + await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) + } } } @@ -527,7 +540,19 @@ final class SparkManager: ChannelFreeLightningRail { private func recordContactWithdrawal(_ payment: Payment, txId: String, pending: SparkContactWithdrawal) async { Self.pendingContactWithdrawal = nil - guard payment.status != .failed else { return } + guard payment.status != .failed else { + if let request = pending.request { + await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) + } + return + } + if let request = pending.request { + _ = await PaykitPaymentProofService.shared.completeSparkOnchainPayment( + requestId: request.id, + identity: request.identity, + txid: txId + ) + } Self.contactWithdrawalTxIds.insert(txId) _ = await CoreService.shared.activity.createSentOnchainActivityFromSendResult( txid: txId, @@ -1035,10 +1060,17 @@ enum SparkErrorCopy { /// A Spark withdrawal to a contact's on-chain address, journaled before it is sent. struct SparkContactWithdrawal: Codable, Equatable { + /// The Paykit request a withdrawal pays, and the identity its proof belongs to. + struct PaykitRequest: Codable, Equatable { + let id: PaykitPaymentRequest.ID + let identity: String + } + let address: String let amountSats: UInt64 let contact: String? let startedAt: UInt64 + var request: PaykitRequest? } struct SparkWithdrawal: Identifiable, Equatable { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 3101e7265..0e7c92211 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1329,6 +1329,42 @@ actor PaykitPaymentProofService { return true } + /// Completes the on-chain proof of a request Spark paid by withdrawing to the payee's address. Spark's withdrawal + /// isn't one of the node's send attempts, so its txid is the proof as soon as Spark returns it. + func completeSparkOnchainPayment(requestId: PaykitPaymentRequest.ID, identity: String, txid: String) async -> Bool { + guard Self.isHex(txid, byteCount: 32) else { return false } + do { + let completed: PendingPaykitPaymentProof? = try await mutationLock.withLock { + var proofs = try await loadProofs() + guard let index = proofs.lastIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && + $0.paymentStarted && $0.onchainWalletId == nil && + ($0.paymentIdentifier == nil || $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame) + }) else { return nil } + proofs[index].paymentIdentifier = txid.lowercased() + proofs[index].proofData = txid.lowercased() + proofs[index].onchainAcceptanceVerified = true + proofs[index].onchainLocalFollowupComplete = true + try await persist(proofs) + return proofs[index] + } + guard let completed else { return false } + submitInBackground(completed, priority: .interactive) + return true + } catch { + logWarning("Failed to retain a Spark on-chain proof for retry: \(error)") + return false + } + } + + /// Drops the started on-chain proof of a request whose Spark withdrawal never went out. + func failSparkOnchainPayment(requestId: PaykitPaymentRequest.ID, identity: String) async { + await removeProofs { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && + $0.paymentStarted && $0.onchainWalletId == nil && $0.paymentIdentifier == nil && $0.proofData == nil + } + } + @discardableResult func failOnchainPayment(_ request: PaykitPaymentRequest, paymentIdentity: String? = nil) async -> Bool { let liveIdentity = paymentIdentity == nil ? try? await currentIdentity() : nil diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 56f5b5b3b..7f32e7bb1 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -47,9 +47,9 @@ enum PaykitRouteService { SparkManager.shared.isReady && SparkManager.shared.sendableSats > 0 } - /// The route for a contact payment, or nil without bitcoin endpoints. A Spark leg is offered only for a payment the - /// user starts (an incoming request's on-chain proof needs the node's send-attempt store) to a contact with an - /// on-chain endpoint, while Spark can route and the payment can't go directly over Lightning. + /// The route for a contact payment, or nil without bitcoin endpoints. A Spark leg is offered to a contact with an + /// on-chain endpoint while Spark can route and the payment can't go directly over Lightning; a request it pays gets + /// its on-chain proof from the withdrawal's txid. static func routeUi( for context: ContactPaymentContext, canRouteThroughSpark: Bool, @@ -59,8 +59,7 @@ enum PaykitRouteService { .filter { $0.methodId != .usdtArbitrum } .map { PaykitEndpoint(methodId: $0.methodId.rawValue, value: $0.value) } guard !endpoints.isEmpty else { return nil } - let offersSparkLeg = canRouteThroughSpark && !canPayDirectly && context.incomingPaymentRequest == nil && - endpoints.contains(where: \.isOnchain) + let offersSparkLeg = canRouteThroughSpark && !canPayDirectly && endpoints.contains(where: \.isOnchain) return PaykitRouteUi(endpoints: endpoints, offersSparkLeg: offersSparkLeg) } @@ -138,10 +137,121 @@ enum PaykitRouteService { } /// Runs a service leg the router picked and returns the txid that pays the contact. - static func pay(_ leg: ServiceRouteLeg, contact: String?) async throws -> String { + static func pay( + _ leg: ServiceRouteLeg, + contact: String?, + request: SparkContactWithdrawal.PaykitRequest? = nil, + beforeSend: () async throws -> Void = {} + ) async throws -> String { guard leg.service == SparkManager.routeService else { throw AppError(message: "wallet__payment_failed_description", debugMessage: "Unsupported route service \(leg.service)") } - return try await SparkManager.shared.withdraw(to: leg.endpoint.value, amountSats: leg.deliveredSat, contact: contact) + return try await SparkManager.shared.withdraw( + to: leg.endpoint.value, + amountSats: leg.deliveredSat, + contact: contact, + request: request, + beforeSend: beforeSend + ) + } +} + +/// What asking the router did to a contact payment. +enum PaykitRouteOutcome: Equatable { + /// The send pays the endpoint the router picked. + case direct + /// The send pays through the service leg in `AppViewModel.paykitRoute`. + case leg + /// No route pays the contact; the app's copy says why. + case refused +} + +@MainActor +extension AppViewModel { + /// Asks bitkit-core's router how to pay the contact from `source`. Direct switches the send to the endpoint it + /// names, a service leg is kept in `paykitRoute` for Confirm, and none shows the app's copy, its reason going to the + /// log only. + func routeContactPayment( + _ route: PaykitRouteUi, + amountSats: UInt64, + source: PaykitRouteService.Source, + lightningSpendableSats: UInt64, + onchainSpendableSats: UInt64, + request: PaykitPaymentRequest? + ) async -> PaykitRouteOutcome { + var unrouted = route + unrouted.serviceLeg = nil + do { + let picked = try await PaykitRouteService.route( + route, + amountSats: amountSats, + source: source, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats + ) + switch picked { + case let .direct(endpoint): + paykitRoute = unrouted + guard await payDirectly(endpoint) else { return .refused } + if let request { + paykitPaymentTerms = try request.payment(using: paykitPaymentMethod) + } + return .direct + case let .service(leg): + var routed = route + routed.serviceLeg = leg + paykitRoute = routed + selectedWalletToPayFrom = .lightning + return .leg + case let .none(reason): + Logger.warn("Found no Paykit route: '\(reason)'", context: "PaykitRoute") + paykitRoute = unrouted + showNoPaykitRouteToast(isBalanceShort: PaykitRouteService.isBalanceShort( + amountSats: amountSats, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats + )) + return .refused + } + } catch { + Logger.error("Failed to route Paykit payment: \(error)", context: "PaykitRoute") + showNoPaykitRouteToast(isBalanceShort: false) + return .refused + } + } + + /// Switches the send to the endpoint the router picked, when the scan opened another one. + private func payDirectly(_ endpoint: PaykitEndpoint) async -> Bool { + if endpoint.isOnchain { + selectedWalletToPayFrom = .onchain + if scannedOnchainInvoice?.address != endpoint.value { + scannedOnchainInvoice = OnChainInvoice(address: endpoint.value, amountSatoshis: 0, label: nil, message: nil, params: nil) + } + return true + } + selectedWalletToPayFrom = .lightning + if endpoint.isLnurl { + return lnurlPayData != nil || scannedLightningInvoice != nil + } + if scannedLightningInvoice?.bolt11.caseInsensitiveCompare(endpoint.value) == .orderedSame { + return true + } + guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value) else { + Logger.warn("Failed to decode the Lightning endpoint the router picked", context: "PaykitRoute") + showNoPaykitRouteToast(isBalanceShort: false) + return false + } + scannedLightningInvoice = invoice + lnurlPayData = nil + return true + } + + func showNoPaykitRouteToast(isBalanceShort: Bool) { + toast( + type: .error, + title: t(isBalanceShort ? "wallet__send_amount_exceeded__title" : "wallet__send_paykit_no_route__title"), + description: t(isBalanceShort ? "wallet__send_amount_exceeded__description" : "wallet__send_paykit_no_route__description"), + accessibilityIdentifier: isBalanceShort ? "PaykitRouteBalanceToast" : "PaykitRouteNoneToast" + ) } } diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 6bf5f33cf..9577b8d39 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -464,90 +464,15 @@ struct SendAmountView: View { } } - private enum PaykitRouteOutcome { - case direct - case leg - case refused - } - - /// Asks bitkit-core's router how to pay the contact from the selected source. Direct pays the endpoint it names, a - /// service leg opens Confirm with its route and fee, and none stays here with the app's copy. private func routePaykitPayment(_ route: PaykitRouteUi, request: PaykitPaymentRequest?) async -> PaykitRouteOutcome { let source: PaykitRouteService.Source = app.selectedWalletToPayFrom == .lightning ? .spending : .savings - let lightningSpendable = source == .spending ? UInt64(max(0, wallet.maxSendLightningSats)) : 0 - let onchainSpendable = source == .savings ? maxSendableAmount ?? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0 - var unrouted = route - unrouted.serviceLeg = nil - do { - let picked = try await PaykitRouteService.route( - route, - amountSats: amountSats, - source: source, - lightningSpendableSats: lightningSpendable, - onchainSpendableSats: onchainSpendable - ) - switch picked { - case let .direct(endpoint): - app.paykitRoute = unrouted - guard await payDirectly(endpoint) else { return .refused } - if let request { - app.paykitPaymentTerms = try request.payment(using: app.paykitPaymentMethod) - } - return .direct - case let .service(leg): - var routed = route - routed.serviceLeg = leg - app.paykitRoute = routed - return .leg - case let .none(reason): - Logger.warn("Found no Paykit route: '\(reason)'", context: "SendAmountView") - app.paykitRoute = unrouted - showNoRouteToast(isBalanceShort: PaykitRouteService.isBalanceShort( - amountSats: amountSats, - lightningSpendableSats: lightningSpendable, - onchainSpendableSats: onchainSpendable - )) - return .refused - } - } catch { - Logger.error("Failed to route Paykit payment: \(error)", context: "SendAmountView") - showNoRouteToast(isBalanceShort: false) - return .refused - } - } - - /// Switches the send to the endpoint the router picked, when the scan opened another one. - private func payDirectly(_ endpoint: PaykitEndpoint) async -> Bool { - if endpoint.isOnchain { - app.selectedWalletToPayFrom = .onchain - if app.scannedOnchainInvoice?.address != endpoint.value { - app.scannedOnchainInvoice = OnChainInvoice(address: endpoint.value, amountSatoshis: 0, label: nil, message: nil, params: nil) - } - return true - } - app.selectedWalletToPayFrom = .lightning - if endpoint.isLnurl { - return app.lnurlPayData != nil || app.scannedLightningInvoice != nil - } - if app.scannedLightningInvoice?.bolt11.caseInsensitiveCompare(endpoint.value) == .orderedSame { - return true - } - guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value) else { - Logger.warn("Failed to decode the Lightning endpoint the router picked", context: "SendAmountView") - showNoRouteToast(isBalanceShort: false) - return false - } - app.scannedLightningInvoice = invoice - app.lnurlPayData = nil - return true - } - - private func showNoRouteToast(isBalanceShort: Bool) { - app.toast( - type: .error, - title: t(isBalanceShort ? "wallet__send_amount_exceeded__title" : "wallet__send_paykit_no_route__title"), - description: t(isBalanceShort ? "wallet__send_amount_exceeded__description" : "wallet__send_paykit_no_route__description"), - accessibilityIdentifier: isBalanceShort ? "PaykitRouteBalanceToast" : "PaykitRouteNoneToast" + return await app.routeContactPayment( + route, + amountSats: amountSats, + source: source, + lightningSpendableSats: source == .spending ? UInt64(max(0, wallet.maxSendLightningSats)) : 0, + onchainSpendableSats: source == .savings ? maxSendableAmount ?? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0, + request: request ) } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index d393c31a7..6773bfc25 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -28,6 +28,7 @@ struct SendConfirmationView: View { var isSetupPending = false @State private var showDetails = false + @State private var routedIncomingRequestId: PaykitPaymentRequest.ID? @State private var hasShownAmountReview = false @State private var showingBiometricError = false @State private var biometricErrorMessage = "" @@ -464,6 +465,7 @@ struct SendConfirmationView: View { } guard preparingRequest == nil, !isRequestSetupPending, !returnToAmountReviewIfNeeded() else { return } ensureSendAmountFromScannedInvoicesIfNeeded() + await routeIncomingRequestIfNeeded() if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay { showManualConfirmation(reason: "not automatic when opened") } @@ -1363,11 +1365,77 @@ struct SendConfirmationView: View { return note } + /// A request's Confirm opens with fixed terms and no amount screen, so the router is asked here: Direct switches the + /// send to the endpoint it picks, and a Spark leg pays an on-chain request from Spending. + private func routeIncomingRequestIfNeeded() async { + guard let context = app.contactPaymentContext, let request = context.incomingPaymentRequest, + !app.paykitUsesUsdt, !hwSend.isActive, routedIncomingRequestId != request.id, + let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false, canPayDirectly: true) + else { return } + let amount = app.paykitPaymentTerms?.amount ?? request.amount + guard amount.asset == .btc, amount.atomic > 0 else { return } + routedIncomingRequestId = request.id + let source: PaykitRouteService.Source = app.selectedWalletToPayFrom == .lightning ? .spending : .savings + _ = await app.routeContactPayment( + route, + amountSats: amount.atomic, + source: source, + lightningSpendableSats: source == .spending ? UInt64(max(0, wallet.maxSendLightningSats)) : 0, + onchainSpendableSats: source == .savings ? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0, + request: request + ) + } + + /// Withdraws from Spark to the contact's on-chain address. A request it pays gets an on-chain proof with the + /// withdrawal's txid; the proof is started before the send, and Spark's journal settles it if the app stops mid-send. private func performServiceRoutedPayment(_ leg: ServiceRouteLeg) async { + let paymentActivity = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(paymentActivity) } isSubmittingPayment = true defer { isSubmittingPayment = false } + let context = app.contactPaymentContext + let request = context?.incomingPaymentRequest + var proofRequest: SparkContactWithdrawal.PaykitRequest? do { - let txid = try await PaykitRouteService.pay(leg, contact: app.contactPaymentContext?.publicKey) + if let request { + guard let privateContext = context?.privatePaymentContext else { throw PaykitPaymentRequestError.requestUnavailable } + try await PaykitPaymentProofService.shared.prepare( + request: request, + paymentAppId: privateContext.paymentAppId(for: leg.endpoint.methodId), + paymentEndpointIdentifier: leg.endpoint.methodId, + kind: .onchain, + paymentTerms: app.paykitPaymentTerms + ) + let identity = try await PaykitPaymentProofService.shared.onchainPaymentIdentity(requestId: request.id) + do { + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( + request, + address: leg.endpoint.value, + paymentIdentity: identity + ) + } catch { + await PaykitPaymentProofService.shared.cancelPreparation(request) + throw error + } + proofRequest = SparkContactWithdrawal.PaykitRequest(id: request.id, identity: identity) + } + let txid: String + do { + txid = try await PaykitRouteService.pay(leg, contact: context?.publicKey, request: proofRequest) { + if let request { + try await paykitPaymentRequestManager.ensurePaymentAllowed(request) + } + } + } catch { + // Refused before Spark's send: nothing left, so the started proof goes. After it, the journal settles it. + let refusedBeforeSend = SparkManager.pendingContactWithdrawal == nil + if let proofRequest, refusedBeforeSend { + await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: proofRequest.id, identity: proofRequest.identity) + } + await context?.resolvePrivatePaymentListConsumption(refusedBeforeSend ? .definitePreBroadcastFailure : .uncertain) + throw error + } + await context?.resolvePrivatePaymentListConsumption(.succeeded) Logger.info("Paid Paykit contact through \(leg.service) in \(txid)", context: "SendConfirmation") navigationPath.append(.success(paymentId: txid)) } catch { diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index c9db89075..0a8b934f9 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1879,6 +1879,51 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remainingProofs.isEmpty) } + func testSparkWithdrawalSubmitsItsTransactionIdAsTheOnchainProof() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let proofRemoved = expectation(description: "On-chain proof removed after submission") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofRemoved.fulfill() + } + } + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "cd", count: 32) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + let completed = await service.completeSparkOnchainPayment(requestId: request.id, identity: identity, txid: txid) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) + + XCTAssertTrue(completed, "Spark's withdrawal is no node send attempt, yet its txid completes the proof") + let lastSubmission = await sdk.lastSubmission() + let submission = try XCTUnwrap(lastSubmission) + XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) + XCTAssertEqual( + try proofValues(submission.proof.exportText()), + ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + } + + func testSparkWithdrawalThatNeverWentOutDropsItsStartedProof() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.failSparkOnchainPayment(requestId: request.id, identity: identity) + + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + } + func testOnchainCompletionDoesNotWaitForProofDelivery() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index dc7c450c1..1fd62e71f 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -113,7 +113,7 @@ final class PaykitRouteTests: XCTestCase { // MARK: - Spark leg offer - func testSparkLegIsOfferedOnlyForAnOnchainContactPaymentTheUserStarts() { + func testSparkLegIsOfferedForAnOnchainContactLightningCantPay() { let onchainOnly = ContactPaymentContext( publicKey: "contact", privatePaymentContext: nil, From d4df811fbfea9168604f95b8dc27a67f26884311 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 01:13:04 +0100 Subject: [PATCH 056/378] fix: spark exit keeps an unresolved funding payment - A savings funding broadcast with an unknown outcome is kept as paid (pending), so cancel is off and the exit stays until the payment lands or the own-payment expiry forgets it. - Cancel also stays off while an unresolved savings send targets the exit's funding address, since a retry from Send pays that address. --- Bitkit/Managers/SparkExitManager.swift | 43 +++++++++++++++---- .../Services/OnchainSendAttemptService.swift | 7 +++ BitkitTests/SparkExitReviewTests.swift | 37 ++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index c78bef9b0..9d19fd55e 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -2,6 +2,7 @@ import BackgroundTasks import BitkitCore import BreezSdkSpark import Foundation +import LDKNode import Observation import UserNotifications @@ -168,12 +169,20 @@ final class SparkExitManager { /// costs nothing: Spark comes back as it was. var canCancel: Bool { guard let record else { return false } - return Self.canCancel(record, fundingSeenSats: fundingSeenSats) + return Self.canCancel(record, fundingSeenSats: fundingSeenSats, unresolvedSendAddress: unresolvedSendAddress) } - /// Nothing built, nothing paid from savings and nothing seen at the funding address. - nonisolated static func canCancel(_ record: SparkExitRecord, fundingSeenSats: UInt64) -> Bool { + /// The address of an unresolved savings send, read on every tick and before a cancel. + private(set) var unresolvedSendAddress: String? + + /// Nothing built, nothing paid from savings and nothing seen at the funding address, and no unresolved savings send + /// to that address: such a send can still be retried from Send, always to the same address, which a forgotten exit + /// would no longer watch. + nonisolated static func canCancel(_ record: SparkExitRecord, fundingSeenSats: UInt64, unresolvedSendAddress: String? = nil) -> Bool { guard record.recovery == nil, record.fundedSats == 0, fundingSeenSats == 0, record.fundingPaymentTxid == nil else { return false } + if let unresolvedSendAddress, record.funding.contains(where: { $0.address == unresolvedSendAddress }) { + return false + } if case .awaitingFunding = record.phase { return true } @@ -223,6 +232,7 @@ final class SparkExitManager { if let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) { record = stored } + unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress() guard canCancel, var record, let address = record.funding.first?.address else { return .keep } let utxos = try? await chain?.utxos(address: address) let outcome = Self.cancelOutcome(utxos: utxos) @@ -292,6 +302,7 @@ final class SparkExitManager { ) do { tipHeight = try? await chain.tipHeight() + unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress() await refreshNetworkFees(chain: chain) await refreshFundingSeen(chain: chain, record: current) var updated = try await driver.tick(current) @@ -615,17 +626,31 @@ final class SparkExitManager { createdAt: UInt64(Date().timeIntervalSince1970) ) ) - guard case let .accepted(txid) = result else { + // An unknown outcome may still have gone out: it is kept as paid (pending), so the exit, and its address, stay + // until the payment lands or the own-payment expiry forgets it. + guard let txid = Self.fundingTxid(result) else { throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark exit funding outcome: \(result)") } - _ = try? await attempts.resumeAcceptedOrdinarySend( - walletId: OnchainSendAttemptService.walletId(index: lightning.currentWalletIndex), - observedTxid: txid - ) - Logger.info("Funded the Spark exit with \(sats) sats in \(txid)", context: "SparkExitManager") + if case .accepted = result { + _ = try? await attempts.resumeAcceptedOrdinarySend( + walletId: OnchainSendAttemptService.walletId(index: lightning.currentWalletIndex), + observedTxid: txid + ) + Logger.info("Funded the Spark exit with \(sats) sats in \(txid)", context: "SparkExitManager") + } else { + Logger.warn("Spark exit funding \(txid) has an unknown outcome; kept as pending", context: "SparkExitManager") + } return txid } + /// The funding payment's txid to keep as paid: accepted, or unknown (it may still have gone out). A refused one is none. + nonisolated static func fundingTxid(_ result: OnchainSendResult) -> String? { + switch result { + case let .accepted(txid), let .unknown(txid): txid + case .rejected: nil + } + } + // MARK: Keys private nonisolated static func fundingKey(walletIndex: Int, index: UInt32) throws -> SparkExitFundingKey.Derived { diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index f222a4d85..36e8f5d61 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -934,6 +934,13 @@ actor OnchainSendAttemptService { catch { throw OnchainSendAttemptError.unresolved } } + /// The address of the send still unresolved (pending, unknown or refused, so retryable or blocking a new send), if any. + /// A retry always pays this same address with the same inputs. + func unresolvedSendAddress() -> String? { + guard let attempt = try? currentAttempt(), attempt.status.blocksNewSend else { return nil } + return attempt.address + } + private func currentAttempt() throws -> OnchainSendAttempt? { let attempts = try store.load() guard attempts.count <= 1 else { throw OnchainSendAttemptError.unresolved } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 958173241..0132a8c90 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -386,4 +386,41 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(fees.economy, 1) XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: fees.economy, exitRate: 5), 1) } + + // MARK: Unknown funding broadcast (review thread r4498875767) + + /// A savings broadcast with an unknown outcome is kept as paid (pending): it may still have gone out. + func testAnUnknownFundingBroadcastIsKeptAsPaid() { + XCTAssertEqual(SparkExitManager.fundingTxid(.unknown(txid: "unk")), "unk") + XCTAssertEqual(SparkExitManager.fundingTxid(.accepted(txid: "ok")), "ok") + XCTAssertNil(SparkExitManager.fundingTxid(.rejected(txid: "no", reason: "insufficient funds"))) + } + + /// With that payment pending, or any unresolved savings send to the exit's address, cancel is off: the record stays. + func testAPendingFundingSendKeepsTheExit() { + var record = SparkExitFixtures.record() + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0, unresolvedSendAddress: "bcrt1qfunding")) + XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0, unresolvedSendAddress: "bcrt1qelsewhere")) + record.fundingPaymentTxid = "unk" + XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0)) + XCTAssertTrue(record.isAwaitingOwnPayment) + } + + /// The unknown payment confirming late is adopted like any funding, and the exit builds. + func testALateConfirmationOfAnUnknownFundingSendIsAdopted() async throws { + var record = SparkExitFixtures.record() + record.fundingPaymentTxid = "unk" + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + + record = try await driver().tick(record) + XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .paid(txid: "unk")) + XCTAssertNil(record.recovery) + + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("unk", value: 20000)] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.funding.first?.txid, "unk") + XCTAssertNotNil(record.recovery) + } } From 0a47cc8d38dce7163ae0ac3b53c483701cb79f78 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 01:48:05 +0100 Subject: [PATCH 057/378] fix: charge the spark leg its broadcast fee and keep it a send - A Spark leg to a contact's on-chain address now counts the L1 broadcast fee Spark's prepare reports, so Confirm shows the whole fee, and the withdrawal refuses a fee above the one Confirm showed. - A withdrawal to a contact is never marked as a transfer to Savings, so its activity stays a send to the contact. --- Bitkit/Managers/SparkManager.swift | 22 ++++++++++++++++++++-- Bitkit/Services/PaykitRouteService.swift | 1 + BitkitTests/PaykitRouteTests.swift | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 20a0f63f8..f6f2b4c31 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -448,7 +448,7 @@ final class SparkManager: ChannelFreeLightningRail { guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { throw AppError(message: "Unexpected Spark payment method", debugMessage: "\(prepared.paymentMethod)") } - let debitSats = amountSats + feeQuote.speedMedium.userFeeSat + let debitSats = Self.withdrawalDebitSats(amountSats: amountSats, fee: feeQuote.speedMedium) guard !feeQuote.isEstimate, balanceSats >= debitSats else { return nil } return ServiceRouteLeg( service: Self.routeService, @@ -460,6 +460,11 @@ final class SparkManager: ChannelFreeLightningRail { ) } + /// What a withdrawal of `amountSats` takes from the balance: the amount, Spark's fee and the L1 broadcast fee. + nonisolated static func withdrawalDebitSats(amountSats: UInt64, fee: SendOnchainSpeedFeeQuote) -> UInt64 { + amountSats.saturatingAdd(fee.userFeeSat).saturatingAdd(fee.l1BroadcastFeeSat) + } + /// Withdraws exactly `amountSats` to a contact's on-chain `address` at medium speed and returns its txid. The /// withdrawal is written to a journal before it is sent, so one interrupted mid-send settles from Spark's payment /// list; a second one is refused while it is open. It is recorded as a sent on-chain activity for the contact. @@ -470,12 +475,20 @@ final class SparkManager: ChannelFreeLightningRail { amountSats: UInt64, contact: String?, request: SparkContactWithdrawal.PaykitRequest? = nil, + maxFeeSats: UInt64? = nil, beforeSend: () async throws -> Void = {} ) async throws -> String { guard Self.pendingContactWithdrawal == nil else { throw AppError(message: "wallet__payment_failed_description", debugMessage: "A Spark withdrawal to a contact is still settling") } let prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) + if let maxFeeSats, case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod { + // The fee Confirm showed caps the one sent, broadcast fee included. + try Self.checkWithdrawalFee( + Self.withdrawalDebitSats(amountSats: amountSats, fee: feeQuote.speedMedium) - amountSats, + shownFeeSats: maxFeeSats + ) + } try await beforeSend() let pending = SparkContactWithdrawal( address: address, @@ -746,7 +759,7 @@ final class SparkManager: ChannelFreeLightningRail { await recordActivity(payment) } let contactWithdrawals = Self.contactWithdrawalTxIds - for txId in Self.transferTxIds(unclaimed: unclaimed, payments: payments) where !contactWithdrawals.contains(txId) { + for txId in Self.savingsTransferTxIds(unclaimed: unclaimed, payments: payments, contactWithdrawals: contactWithdrawals) { await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txId) } for payment in payments where payment.status == .completed { @@ -763,6 +776,11 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// The transfers between Savings and Spark, without the withdrawals that paid contacts, which stay sends to them. + nonisolated static func savingsTransferTxIds(unclaimed: [DepositInfo], payments: [Payment], contactWithdrawals: Set) -> [String] { + transferTxIds(unclaimed: unclaimed, payments: payments).filter { !contactWithdrawals.contains($0) } + } + /// On-chain transactions that moved funds between Savings and Spark, so their activities show as transfers. nonisolated static func transferTxIds(unclaimed: [DepositInfo], payments: [Payment]) -> [String] { unclaimed.map(\.txid) + payments.compactMap { payment in diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 7f32e7bb1..e8124b591 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -151,6 +151,7 @@ enum PaykitRouteService { amountSats: leg.deliveredSat, contact: contact, request: request, + maxFeeSats: leg.debitSat.saturatingSub(leg.deliveredSat), beforeSend: beforeSend ) } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 1fd62e71f..bf413c075 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -150,6 +150,22 @@ final class PaykitRouteTests: XCTestCase { XCTAssertNil(SparkManager.matchingWithdrawal(pending, in: [other, earlier])) } + func testSparkLegDebitsTheL1BroadcastFeeToo() { + let fee = SendOnchainSpeedFeeQuote(userFeeSat: 750, l1BroadcastFeeSat: 1440) + + XCTAssertEqual(SparkManager.withdrawalDebitSats(amountSats: 2000, fee: fee), 4190, "Confirm and the fee check use 2,190, not 750") + XCTAssertThrowsError(try SparkManager.checkWithdrawalFee(2190, shownFeeSats: 750)) + } + + func testContactWithdrawalStaysASendNotATransfer() { + let toContact = Self.withdrawal(id: "contact", to: onchain.value, amountSats: 20000, timestamp: 1) + let toSavings = Self.withdrawal(id: "savings", to: "bcrt1qsavings", amountSats: 5000, timestamp: 1) + + let transfers = SparkManager.savingsTransferTxIds(unclaimed: [], payments: [toContact, toSavings], contactWithdrawals: ["tx-contact"]) + + XCTAssertEqual(transfers, ["tx-savings"]) + } + private static func endpoint(_ methodId: PublicPaykitService.MethodId, _ value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint(methodId: methodId, value: value, min: nil, max: nil, rawPayload: value) } From 6f0cdee463cf28efbcc87dc280fadf61910ba6a1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 02:02:48 +0100 Subject: [PATCH 058/378] fix: spark exit findings from the android review - A higher fee that would leave a leaf on Spark is not offered. - A keyless pass that sees every step confirmed ends the exit. - A wallet wipe closes the exit's writes and waits for a running tick, so a deleted exit can't come back. --- Bitkit/Managers/SparkExitManager.swift | 53 +++++++++++++-- Bitkit/Managers/SparkManager.swift | 2 +- .../Localization/ar.lproj/Localizable.strings | 1 + .../Localization/ca.lproj/Localizable.strings | 1 + .../Localization/cs.lproj/Localizable.strings | 1 + .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/el.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../es-419.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/nl.lproj/Localizable.strings | 1 + .../Localization/pl.lproj/Localizable.strings | 1 + .../pt-BR.lproj/Localizable.strings | 1 + .../Localization/pt.lproj/Localizable.strings | 1 + .../Localization/ru.lproj/Localizable.strings | 1 + .../Services/SparkExit/SparkExitDriver.swift | 34 +++++++--- BitkitTests/SparkExitReviewTests.swift | 64 ++++++++++++++++--- 19 files changed, 145 insertions(+), 23 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 9d19fd55e..fc8e50801 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -89,9 +89,20 @@ final class SparkExitManager { /// Saves the record, and keeps the leaves it lost to an older state for every later exit of the wallet. The lost-leaf /// list is best effort: it never fails the save the driver relies on. private var saveRecord: (SparkExitRecord) throws -> Void { - let store = store - let lostLeafStore = lostLeafStore - return { + Self.gatedSave(store: store, lostLeafStore: lostLeafStore, gate: writeGate) + } + + /// Writes from ticks and background runs, closed for good once the wallet is wiped: a tick still running then can + /// never write the deleted exit back. + @ObservationIgnored private var writeGate = SparkExitWriteGate() + + nonisolated static func gatedSave( + store: SparkExitStore, + lostLeafStore: SparkExitLostLeafStore, + gate: SparkExitWriteGate + ) -> (SparkExitRecord) throws -> Void { + { + guard gate.isOpen else { throw SparkExitWriteGate.Closed() } try store.save($0) do { try lostLeafStore.add(lostIn: $0) @@ -155,8 +166,14 @@ final class SparkExitManager { return record } - /// Forgets the exit of a wallet being wiped. - func wipe() { + /// Forgets the exit of a wallet being wiped: closes the writes of any tick still running, waits for it to stop, then + /// deletes the record, so the exit can't come back. + func wipe() async { + writeGate.close() + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + writeGate = SparkExitWriteGate() try? store.delete() try? lostLeafStore.delete() record = nil @@ -438,16 +455,24 @@ final class SparkExitManager { save: { _ in } ) var options = SparkExitFeeBumpOption.options(exitRate: record.feeRateSatPerVbyte, fees: networkFees) + var leavesBehind = false for i in options.indices { do { options[i].extraSats = try await driver.feeBumpShortfall(record, rate: options[i].rate) + } catch let SparkExitFeeBumpError.leavesBehind(sats) { + leavesBehind = true + Logger.info( + "Spark exit fee bump to \(options[i].rate) sat/vB would leave \(sats) sats on Spark; not offered", + context: "SparkExitManager" + ) } catch { Logger.warn("Failed to price a Spark exit fee bump to \(options[i].rate) sat/vB: \(error)", context: "SparkExitManager") } } feeBumpOptions = options.filter { $0.extraSats != nil } if feeBumpOptions.isEmpty { - feeBumpError = t("lightning__spark_exit__fee_bump_error") + let key = leavesBehind ? "lightning__spark_exit__fee_bump_leaves_behind" : "lightning__spark_exit__fee_bump_error" + feeBumpError = t(key) } } @@ -890,3 +915,19 @@ private final class SparkExitTaskCompletion: @unchecked Sendable { task?.setTaskCompleted(success: success) } } + +/// Lets the exit's writes through until a wipe closes it. +final class SparkExitWriteGate: @unchecked Sendable { + struct Closed: Error {} + + private let lock = NSLock() + private var closed = false + + var isOpen: Bool { + lock.withLock { !closed } + } + + func close() { + lock.withLock { closed = true } + } +} diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index b836e6073..9158d0988 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -356,7 +356,7 @@ final class SparkManager: ChannelFreeLightningRail { listenerId = nil refreshAgain = false isReady = false - SparkExitManager.shared.wipe() + await SparkExitManager.shared.wipe() balanceSats = 0 pendingDepositSats = 0 recoverableFundsSats = 0 diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index b5dee9538..12e0eafc5 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -403,6 +403,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index ddfaff3c3..dd2a2943d 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -440,6 +440,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 51c9721b9..abfd46a59 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -456,6 +456,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index d4307cbaa..5c475fe3a 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -454,6 +454,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 71fe72f9e..3a68f30e1 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -427,6 +427,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index a33ec8f2a..0e54b13f5 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -449,6 +449,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 2dd3667b1..e4c984723 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -457,6 +457,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 162d1f2a5..696a65733 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -441,6 +441,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index debf10196..51e707787 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -457,6 +457,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index d2d42520f..ed8e7fecb 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -450,6 +450,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index a4697f718..4f403226b 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -454,6 +454,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 7caa31af2..fc7716322 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -456,6 +456,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 2e7d47bb3..af295be2d 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -456,6 +456,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index b45019fd8..f9121f8bb 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -454,6 +454,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 97680694e..b1eae54fc 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -451,6 +451,7 @@ "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index ad233a5ec..f5865185f 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -11,8 +11,10 @@ protocol SparkExitRecovering: Sendable { func importExitState(_ exitState: String) async throws } -enum SparkExitFeeBumpError: Error { +enum SparkExitFeeBumpError: Error, Equatable { case notConnected + /// At this rate a leaf could no longer pay its own exit and would stay on Spark: the rate is not offered. + case leavesBehind(sats: UInt64) } /// Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready, @@ -108,6 +110,13 @@ struct SparkExitDriver { try await check(&record, recovering: recovering) } else { try await broadcastWithoutSdk(&record) + // Every step in a block, as the chain host shows: the exit ends without waiting for the SDK. Leaves the + // operators could not co-sign need the SDK's retry, so that exit waits for it. + if let recovery = record.recovery?.response, !recovery.transactions.isEmpty, recovery.failed.isEmpty, + recovery.transactions.allSatisfy(\.isConfirmed) + { + await finish(&record, phase: .done) + } } } // Without the tip the countdown keeps its last target rather than jump to another measure. @@ -525,7 +534,18 @@ struct SparkExitDriver { var record = record record.feeRateSatPerVbyte = rate await useExitStateSnapshot(&record, recovering: recovering) - guard let prepared = try await prepare(&record, recovering: recovering), prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 } + let leftBehindBefore = record.leftBehindSats + let leavesBefore = Set(record.exitLeafIds) + guard let prepared = try await prepare(&record, recovering: recovering) else { + throw SparkExitFeeBumpError.leavesBehind(sats: record.leftBehindSats.saturatingSub(leftBehindBefore)) + } + // A rate at which a leaf can't pay its own exit would leave it on Spark: never offered. + if Set(record.exitLeafIds) != leavesBefore { + let values = Dictionary((record.recovery?.response.leaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { a, _ in a }) + let dropped = leavesBefore.subtracting(record.exitLeafIds).reduce(UInt64(0)) { $0 + (values[$1] ?? 0) } + throw SparkExitFeeBumpError.leavesBehind(sats: dropped) + } + guard prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 } do { _ = try await recovering.recover( RecoverFundsRequest(prepared: prepared, fundingInputs: record.fundingInputs), @@ -533,12 +553,10 @@ struct SparkExitDriver { ) return 0 } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) { - // The build refused a leaf the prepare kept: priced again without it. - guard let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) else { - throw SdkError.SparkError(message) - } - leaveBehind([leafId], reason: message, in: &record) - return try await feeBumpShortfall(record, rate: rate) + // The build refused a leaf at this rate: it would stay on Spark, so the rate is not offered. + let leafId = SparkExitMath.unexitableLeafId(message) + let value = (record.recovery?.response.leaves ?? []).first { $0.leafId == leafId }?.valueSats ?? 0 + throw SparkExitFeeBumpError.leavesBehind(sats: value) } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { var atRate = record atRate.feeRateSatPerVbyte = rate diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 0132a8c90..1b69fdb84 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -332,11 +332,9 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(record.isFailedLeavesReleased, true) } - /// Pricing a higher fee leaves behind a leaf the SDK refuses at that rate, on a copy only, and prices the rest. - func testFeeBumpPricingDropsALeafTheSdkRefusesAtTheHigherRate() async throws { + /// A rate at which a leaf can no longer pay its own exit would leave it on Spark: it is not offered (Android review). + func testFeeBumpPricingRefusesARateThatLeavesALeafBehind() async throws { var record = bumpedRecord() - record.phase = .running - record.exitStateSnapshot = nil record = SparkExitRecord( network: record.network, createdAt: record.createdAt, phase: .running, feeRateSatPerVbyte: 2, destination: record.destination, leafIds: ["leaf-1", "small"], funding: record.funding, @@ -345,11 +343,61 @@ final class SparkExitReviewTests: XCTestCase { sdk.prepareErrors = [SdkError.SparkError("Leaf small cannot be exited: its refund cannot pay its own fee")] sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2b")])] - let shortfall = try await driver().feeBumpShortfall(record, rate: 12) + do { + _ = try await driver().feeBumpShortfall(record, rate: 12) + XCTFail("a rate that leaves a leaf behind is not priced") + } catch let SparkExitFeeBumpError.leavesBehind(sats) { + XCTAssertEqual(sats, 0, "the small leaf's value is unknown before the first build") + } + XCTAssertTrue(sdk.recoverRequests.isEmpty) + + // A build that refuses a leaf the prepare kept is not offered either. + sdk = FakeSparkExitRecovering() + sdk.recoverError = SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee") + do { + _ = try await driver().feeBumpShortfall(bumpedRecord(), rate: 12) + XCTFail("expected leavesBehind") + } catch let SparkExitFeeBumpError.leavesBehind(sats) { + XCTAssertEqual(sats, 100_000) + } + } + + /// The keyless pass finding every step in a block ends the exit without waiting for the SDK (Android review). + func testAKeylessPassThatSeesEveryStepConfirmedEndsTheExit() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990)), + SparkExitFixtures.tx("s", kind: .sweep, dependsOn: ["n1"]), + ])) + chain.confirmedTxids = ["s"] + chain.utxosByAddress["bcrt1qfunding"] = [] + + record = try await driver(withSdk: false).tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertTrue(chain.broadcasts.isEmpty) + } + + /// A wipe closes the exit's writes: a tick still running can't write the deleted record back (Android review). + func testAClosedWriteGateDropsTheSaveOfATickStillRunning() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + let gate = SparkExitWriteGate() + let save = SparkExitManager.gatedSave( + store: store, + lostLeafStore: SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")), + gate: gate + ) + + try save(SparkExitFixtures.record()) + XCTAssertNotNil(try store.load()) + try store.delete() - XCTAssertEqual(shortfall, 0) - XCTAssertEqual(sdk.prepareRequests.last?.selection, .specific(leafIds: ["leaf-1"])) - XCTAssertNil(record.leftBehindLeaves, "the live exit keeps its leaves") + gate.close() + XCTAssertThrowsError(try save(SparkExitFixtures.record())) + XCTAssertNil(try store.load()) } /// Reminder ids are scoped per exit, so a later exit's `failed` is never taken for an earlier exit's. From 46baea6e340da78ce48c774531527556f6c95227 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 02:32:59 +0100 Subject: [PATCH 059/378] fix: spark exit home balances and start racing cancel The running exit's own transfer counts what it still brings to savings: expected, less confirmed sweep and cooperative payouts, less unpaid first funding. Spending keeps Spark's balance beyond the exit's leaves. A start re-checks its exit under the tick lock after the optimization wait, and pays and saves the funding under it. --- Bitkit/Managers/BalanceManager.swift | 18 ++++- Bitkit/Managers/SparkExitManager.swift | 54 +++++++++---- .../Services/SparkExit/SparkExitFunding.swift | 17 ++++ Bitkit/ViewModels/WalletViewModel.swift | 19 ++++- .../Settings/Advanced/SparkExitViews.swift | 3 + BitkitTests/SparkExitSweepTests.swift | 81 +++++++++++++++++++ 6 files changed, 169 insertions(+), 23 deletions(-) diff --git a/Bitkit/Managers/BalanceManager.swift b/Bitkit/Managers/BalanceManager.swift index 8fa29f60e..f53e0e3f9 100644 --- a/Bitkit/Managers/BalanceManager.swift +++ b/Bitkit/Managers/BalanceManager.swift @@ -26,7 +26,9 @@ class BalanceManager { /// Derives the complete balance state including pending transfers /// - Returns: BalanceState with all balance calculations /// - Throws: Error if balance retrieval fails - func deriveBalanceState() async throws -> BalanceState { + /// - Parameter exitInTransfer: the running Spark exit's transfer and what it still brings to savings, counted in + /// place of that transfer's fixed amount. + func deriveBalanceState(exitInTransfer: (transferId: String, sats: UInt64)? = nil) async throws -> BalanceState { let balanceDetails = await MainActor.run { lightningService.balances } guard let balanceDetails else { throw AppError(message: "Balance details unavailable", debugMessage: "LightningService.balances is nil") @@ -43,7 +45,8 @@ class BalanceManager { let (lightningToSubtract, pendingCloseAmount) = await getCloseTransferAmounts( transfers: activeTransfers, - balanceDetails: balanceDetails + balanceDetails: balanceDetails, + exitInTransfer: exitInTransfer ) // Detect orphan closed channels: channels with lightning balance that are no longer @@ -138,7 +141,8 @@ class BalanceManager { /// - Returns: lightningToSubtract (always subtract from display), pendingAmount (add to total only if on-chain hasn't arrived) private func getCloseTransferAmounts( transfers: [Transfer], - balanceDetails: BalanceDetails + balanceDetails: BalanceDetails, + exitInTransfer: (transferId: String, sats: UInt64)? = nil ) async -> (lightningToSubtract: UInt64, pendingAmount: UInt64) { var lightningToSubtract: UInt64 = 0 var pendingAmount: UInt64 = 0 @@ -146,7 +150,7 @@ class BalanceManager { for transfer in transfers.filter({ $0.type.isToSavings() }) { // A Spark exit has no channel: it is in transfer until its sweep confirms and the exit settles it. if transfer.type == .sparkExit { - pendingAmount += transfer.amountSats + pendingAmount += Self.sparkExitInTransferSats(transfer, exitInTransfer: exitInTransfer) continue } guard let channelId = transfer.channelId else { continue } @@ -170,6 +174,12 @@ class BalanceManager { return (lightningToSubtract, pendingAmount) } + /// The running exit's own transfer counts what it still brings to savings; any other exit transfer its amount. + static func sparkExitInTransferSats(_ transfer: Transfer, exitInTransfer: (transferId: String, sats: UInt64)?) -> UInt64 { + guard let exitInTransfer, exitInTransfer.transferId == transfer.id else { return transfer.amountSats } + return exitInTransfer.sats + } + /// Detects "orphan" closed channels - channels that have lightning balance in LDK /// but are no longer in the channels list and don't have a Transfer record yet. private func getOrphanClosedChannelBalance( diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index fc8e50801..22361c2a5 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -192,6 +192,12 @@ final class SparkExitManager { /// The address of an unresolved savings send, read on every tick and before a cancel. private(set) var unresolvedSendAddress: String? + /// What the exit still brings to savings, for its own transfer's in-transfer amount; nil without one. + var inTransfer: (transferId: String, sats: UInt64)? { + guard let record, let transferId = record.transferId else { return nil } + return (transferId, SparkExitMath.inTransferSats(record, fundingSeenSats: fundingSeenSats)) + } + /// Nothing built, nothing paid from savings and nothing seen at the funding address, and no unresolved savings send /// to that address: such a send can still be retried from Send, always to the same address, which a forgotten exit /// would no longer watch. @@ -594,33 +600,46 @@ final class SparkExitManager { SparkManager.shared.detachForExit() // Reconnecting cuts an optimization short, which would leave its counter-swap unclaimed for a while. await SparkManager.shared.waitForOptimization() + // The rest runs under the ticks' lock, on this exit as stored: one cancelled during the wait above is never + // reconnected for or paid to. + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + isTicking = true + defer { isTicking = false } + let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) + guard var started = Self.startedExit(stored: stored, createdAt: record.createdAt) else { + Logger.info("Spark exit was cancelled before it started; nothing paid", context: "SparkExitManager") + throw SparkExitStartCancelled() + } await SparkManager.shared.reconnectForExit() Logger.info("Started Spark exit of \(quote.exitSats) sats over \(quote.leafIds.count) leaves", context: "SparkExitManager") if fundFromSavings, quote.fundingSats > 0 { let txid = try await payFunding(address: key.address, sats: quote.fundingSats, satsPerVbyte: savingsFeeRate, feeSats: savingsFeeSats) - await notePaidFunding(txid: txid) + // Saved before the lock is released, so a cancel after the payment finds it and refuses. + started.fundingPaymentTxid = txid + do { + try store.save(started) + } catch { + Logger.error("Failed to save the Spark exit funding payment: \(error)", context: "SparkExitManager") + } + self.record = started + fundingState = SparkExitMath.fundingState(started, utxos: nil) } Task { await tick() } } - /// Keeps the savings payment in the record, so the exit waits for it instead of asking for the funding again. - /// Written between ticks, so a tick in flight never saves over it. - private func notePaidFunding(txid: String) async { - while isTicking { - try? await Task.sleep(nanoseconds: 100_000_000) - } - guard var record else { return } - record.fundingPaymentTxid = txid - do { - try store.save(record) - } catch { - Logger.error("Failed to save the Spark exit funding payment: \(error)", context: "SparkExitManager") - } - self.record = record - fundingState = SparkExitMath.fundingState(record, utxos: nil) + /// The exit a start began, still stored and active (not cancelled meanwhile); nil otherwise. The store keeps + /// dates to the millisecond. + nonisolated static func startedExit(stored: SparkExitRecord?, createdAt: Date) -> SparkExitRecord? { + guard let stored, stored.isActive else { return nil } + let millis = { (date: Date) in (date.timeIntervalSince1970 * 1000).rounded() } + return millis(stored.createdAt) == millis(createdAt) ? stored : nil } + /// Keeps the savings payment in the record, so the exit waits for it instead of asking for the funding again. + /// Written between ticks, so a tick in flight never saves over it. /// The funding key index of the next exit: one no exit of this wallet used, from the counter the last start /// left and the stored record, so leftover of an earlier exit never mixes into a new one. private func nextFundingIndex() -> UInt32 { @@ -916,6 +935,9 @@ private final class SparkExitTaskCompletion: @unchecked Sendable { } } +/// A start whose exit was cancelled while it waited: nothing was paid and Spark was not taken off Lightning. +struct SparkExitStartCancelled: Error {} + /// Lets the exit's writes through until a wipe closes it. final class SparkExitWriteGate: @unchecked Sendable { struct Closed: Error {} diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index ee8b7d4ad..da00ef9ae 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -576,6 +576,23 @@ enum SparkExitMath { return String(match.1) } + /// What the exit still brings to savings: what it expects, less what already reached savings, less the first fee + /// funding nobody paid yet (the expected amount counts it as coming back). Only confirmed steps count as reached: + /// the sweep is in the plan from its first build. + static func inTransferSats(_ record: SparkExitRecord, fundingSeenSats: UInt64) -> UInt64 { + let confirmed = (record.recovery?.response.transactions ?? []).filter { + guard case .confirmed = $0.status else { return false } + return true + } + let current = paidToDestination(confirmed, destination: record.destination) + let received = (record.earlierReceivedSats ?? [:]).merging(current) { $1 }.values.reduce(0, +) + var unpaid: UInt64 = 0 + if case let .awaitingFunding(requiredSats) = record.phase, record.fundingPaymentTxid == nil { + unpaid = requiredSats.saturatingSub(max(record.fundedSats, fundingSeenSats)) + } + return record.expectedSats.saturatingSub(received).saturatingSub(unpaid) + } + /// What the Spark balance holds beyond the exit's leaves: value that reached Spark after the quote, such as a /// counter-swap claimed late, which this exit leaves on Spark. Nil when nothing is beyond them. static func outsideExitSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index bdbd5488c..2720366db 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1402,18 +1402,31 @@ class WalletViewModel: ObservableObject { } } + /// Spark's part of Spending: its balance, or while an exit runs only what it holds beyond the exit's leaves. + nonisolated static func spendingSparkSats(isExiting: Bool, balanceSats: UInt64, outsideExitSats: UInt64?) -> UInt64 { + isExiting ? (outsideExitSats ?? 0) : balanceSats + } + func updateBalanceState() async { // Ensure we have fresh data from LDK before computing balance await lightningService.refreshCache() do { try? await transferService.syncTransferStates() - let state = try await balanceManager.deriveBalanceState() + let state = try await balanceManager.deriveBalanceState(exitInTransfer: SparkExitManager.shared.inTransfer) let spark = SparkManager.shared balanceInTransferToSavings = Int(state.balanceInTransferToSavings) balanceInTransferToSpending = Int(state.balanceInTransferToSpending + spark.pendingDepositSats) - // During an emergency exit the Spark balance is on its way to savings and counted there instead. - let sparkBalanceSats = spark.isExiting ? 0 : spark.balanceSats + // During an emergency exit the exit's leaves are on their way to savings and counted there instead; what + // Spark holds beyond them stays in Spending. + let outsideExitSats = SparkExitManager.shared.record.flatMap { + SparkExitMath.outsideExitSats(sparkBalanceSats: spark.balanceSats, record: $0) + } + let sparkBalanceSats = Self.spendingSparkSats( + isExiting: spark.isExiting, + balanceSats: spark.balanceSats, + outsideExitSats: outsideExitSats + ) // Update display values with adjusted balances totalOnchainSats = Int(state.totalOnchainSats) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index d23f7e4cc..cdb1ab9c6 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -439,6 +439,9 @@ struct SparkExitConfirmView: View { do { try await exit.start() navigation.navigate(.sparkExitProgress) + } catch let error as SparkExitStartCancelled { + // Cancelled while the start waited: nothing was paid, nothing to say. Rethrown so the swipe resets. + throw error } catch { app.toast(error) // The exit started but paying its fee funding failed: the progress screen shows where to fund it. diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 3ab2f6d2e..87d1694f9 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -700,6 +700,87 @@ final class SparkExitSweepTests: XCTestCase { private func le64(_ value: UInt64) -> [UInt8] { withUnsafeBytes(of: value.littleEndian) { [UInt8]($0) } } + + // MARK: Home balances + + /// The exit's own transfer counts what is still to come: a planned sweep counts only once it confirmed. + func testInTransferCountsOnlyWhatConfirmedToSavings() throws { + let script = try SparkExitAddress.scriptPubKey(destination) + // Pays 50,000 sats to savings, little-endian. + let hex = "02000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "ffffffff" + + "01" + "50c3000000000000" + String(format: "%02x", script.count) + script.hex + "00000000" + func sweep(_ status: ExitTransactionStatus) -> RecoveryTransaction { + RecoveryTransaction(kind: .sweep, nodeId: nil, txid: "sweep", txHex: hex, cpfpTxHex: nil, csvTimelockBlocks: nil, + dependsOn: [], status: status) + } + var record = endedRecord(phase: .running) + record.recovery = StoredRecovery(SparkExitFixtures.recovery([sweep(.waitingForTimelock(spendableAtHeight: 900))])) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000) + + record.recovery = StoredRecovery(SparkExitFixtures.recovery([sweep(.confirmed(blockHeight: 50))])) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 40000) + record.earlierReceivedSats = ["coop": 30000] + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 10000) + } + + /// The first fee funding nobody paid yet is not in transfer; once paid, or once the exit pays it, it is. + func testInTransferLeavesOutUnpaidFirstFunding() { + var record = endedRecord(phase: .awaitingFunding(requiredSats: 20000)) + record.funding = [.init(index: 0, address: key.address, pubkey: key.pubkey)] + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 70000) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 5000), 75000) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 20000), 90000) + + record.fundingPaymentTxid = "paid" + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000) + record.fundingPaymentTxid = nil + record.phase = .running + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000) + } + + /// Only the running exit's own transfer swaps its amount; any other exit transfer keeps it. + func testOnlyTheExitsOwnTransferCountsItsInTransferAmount() { + let own = Bitkit.Transfer(id: "own", type: .sparkExit, amountSats: 90000, createdAt: 0) + let other = Bitkit.Transfer(id: "other", type: .sparkExit, amountSats: 12000, createdAt: 0) + + XCTAssertEqual(BalanceManager.sparkExitInTransferSats(own, exitInTransfer: ("own", 40000)), 40000) + XCTAssertEqual(BalanceManager.sparkExitInTransferSats(other, exitInTransfer: ("own", 40000)), 12000) + XCTAssertEqual(BalanceManager.sparkExitInTransferSats(own, exitInTransfer: nil), 90000) + } + + /// While an exit runs, Spending keeps what Spark holds beyond the exit's leaves. + func testSpendingKeepsSparkValueOutsideTheExit() { + XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: false, balanceSats: 50000, outsideExitSats: nil), 50000) + XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 50000, outsideExitSats: 32768), 32768) + XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 50000, outsideExitSats: nil), 0) + } + + /// A start goes on only with its own exit still stored and active: one cancelled while it waited stops it. + func testStartGoesOnOnlyWithItsExitStillRunning() throws { + let createdAt = Date(timeIntervalSince1970: 1_700_000_000.123_456) + let record = SparkExitRecord( + network: "regtest", + createdAt: createdAt, + phase: .awaitingFunding(requiredSats: 20000), + feeRateSatPerVbyte: 2, + destination: destination, + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: key.address, pubkey: key.pubkey)], + expectedSats: 90000 + ) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-start-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + let store = SparkExitStore(url: url) + try store.save(record) + let stored = try XCTUnwrap(store.load()) + + XCTAssertNotNil(SparkExitManager.startedExit(stored: stored, createdAt: createdAt)) + XCTAssertNil(SparkExitManager.startedExit(stored: nil, createdAt: createdAt)) + XCTAssertNil(SparkExitManager.startedExit(stored: stored, createdAt: createdAt.addingTimeInterval(1))) + var cancelled = stored + cancelled.phase = .done + XCTAssertNil(SparkExitManager.startedExit(stored: cancelled, createdAt: createdAt)) + } } private extension Data { From e1b3052f5bb6202719edd163792a830885af73b3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 02:58:56 +0100 Subject: [PATCH 060/378] fix: spark exit ended payments, top-up coins and sweep activity An exit that ended before its savings payment arrived keeps being driven until the payment lands and is swept with the leftover, or the own-payment expiry forgets it. A funding top-up takes the fewest confirmed coins, largest first, and never one worth no more than its input fee. The sweep's activity is marked as a transfer also after the transfer settled, the landed sweep included. --- Bitkit/Managers/SparkExitManager.swift | 12 ++- .../Services/SparkExit/SparkExitDriver.swift | 67 +++++++++++++-- .../Services/SparkExit/SparkExitFunding.swift | 27 ++++++- .../Services/SparkExit/SparkExitRecord.swift | 12 ++- BitkitTests/SparkExitSweepTests.swift | 81 +++++++++++++++++++ BitkitTests/SparkExitTests.swift | 29 +++++-- 6 files changed, 204 insertions(+), 24 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 22361c2a5..09c93c675 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -366,19 +366,17 @@ final class SparkExitManager { /// or once the exit ended, so its amount is never counted both as in transfer and in the on-chain balance. Runs on /// every tick and launch until it holds. A sweep of a generation the failed-leaf retry replaced is looked for too. private func settleIfFinished(_ record: SparkExitRecord) async { - guard let transferId = record.transferId, - let transfer = try? TransferStorage.shared.getById(transferId), !transfer.isSettled - else { return } + guard let transferId = record.transferId, let transfer = try? TransferStorage.shared.getById(transferId) else { return } let activity = CoreService.shared.activity var isFinished = !record.isActive || record.isRetryingFailedLeaves - let sweeps = (record.recovery?.response.transactions ?? []).filter { $0.kind == .sweep }.map(\.txid) - + (record.earlierReceivedSats ?? [:]).keys.sorted() - for txid in sweeps { + // The sweep's activity is marked as a transfer even after the transfer settled: it may reach the activity + // list only later. + for txid in SparkExitMath.sweepTxids(record) { guard await activity.hasOnchainActivityForTxid(txid: txid) else { continue } await activity.markOnchainActivityAsTransfer(txId: txid) isFinished = true } - guard isFinished else { return } + guard isFinished, !transfer.isSettled else { return } try? await TransferService(lightningService: .shared).markSettled(id: transferId) } diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index f5865185f..3fa1780ca 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -73,10 +73,15 @@ struct SparkExitDriver { var record = record switch record.phase { case .done, .failed: - guard record.isLeftoverSweepPending, !Task.isCancelled else { return record } - try await sweepLeftover(&record) - try save(record) - return record + guard record.needsDriving, !Task.isCancelled else { return record } + let before = record + await followEndedPayment(&record) + if record.isLeftoverSweepPending { + try await sweepLeftover(&record) + try save(record) + return record + } + return try saveIfChanged(record, from: before) case .awaitingFunding, .needsFunding: guard !Task.isCancelled else { return record } let before = record @@ -142,7 +147,9 @@ struct SparkExitDriver { /// replaced), or once it is older than `ownPaymentTimeoutBlocks` without being adopted, so the ask and the reminder /// come back. Nothing changes without a tip. private func noteOwnPayment(_ record: inout SparkExitRecord) async { - guard record.isAwaitingOwnPayment, let paid = record.fundingPaymentTxid, let tip = try? await chain.tipHeight() else { return } + guard record.isAwaitingOwnPayment || record.isEndedPaymentPending, let paid = record.fundingPaymentTxid, + let tip = try? await chain.tipHeight() + else { return } let seenAt = record.fundingPaymentSeenAtHeight ?? tip record.fundingPaymentSeenAtHeight = seenAt switch try? await chain.isKnown(txid: paid) { @@ -159,6 +166,54 @@ struct SparkExitDriver { record.fundingPaymentMissingSince = nil } + /// An ended exit's savings payment that had not arrived: once at the funding address it joins the leftover and is + /// swept with it, after any sweep in flight settles (a confirmed one is kept in `sweptLeftover`). One no host + /// sees is forgotten by the own-payment expiry. + private func followEndedPayment(_ record: inout SparkExitRecord) async { + guard !record.isActive, let paid = record.fundingPaymentTxid else { return } + guard record.isEndedPaymentPending else { + // Already in the leftover (or adopted as funding): nothing more to follow. + record.fundingPaymentTxid = nil + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + return + } + guard let address = record.funding.first?.address, let tip = try? await chain.tipHeight(), + let listed = try? await chain.utxos(address: address) + else { return } + guard listed.contains(where: { $0.txid == paid }) else { + await noteOwnPayment(&record) + return + } + let sweep = record.leftoverSweep + var isSweepConfirmed = sweep?.isConfirmed == true + if let txid = sweep?.txid, !isSweepConfirmed, sweep?.rejection == nil { + // In flight: its coins are settled first, so the payment is never mixed into a sweep that may replace it. + guard let status = try? await chain.txStatus(txid: txid), status.confirmed else { return } + isSweepConfirmed = true + } else if sweep?.txid == nil, await isAnyKnown(sweep?.replacedTxids ?? []) { + return + } + if isSweepConfirmed, let sweep, let txid = sweep.txid { + if !(record.sweptLeftover ?? []).contains(where: { $0.txid == txid }) { + record.sweptLeftover = (record.sweptLeftover ?? []) + [.init(txid: txid, valueSats: sweep.valueSats)] + } + // A host behind the block can still list what the sweep spent. + let spent = Set((sweep.txHex.flatMap(SparkExitTxid.inputOutpoints(hex:)) ?? []).map { "\($0.txid):\($0.vout)" }) + record.leftover = listed.filter { !spent.contains("\($0.txid):\($0.vout)") } + .map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + } else { + let known = Set(record.leftover.map { "\($0.txid):\($0.vout)" }) + record.leftover += listed.filter { $0.txid == paid && !known.contains("\($0.txid):\($0.vout)") } + .map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) } + } + Logger.info("Spark exit funding payment \(paid) arrived after the exit ended; sweeping it back", context: "SparkExitDriver") + record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: tip, checkedAtHeight: tip, retryAtHeight: tip) + record.fundingPaymentTxid = nil + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + } + /// While a higher fee's top-up is awaited: the current generation is checked and its ready steps sent. A /// generation that finished in the meantime runs on to Done; one that diverged waits for the top-up's rebuild. private func driveWhileFunding(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { @@ -172,8 +227,8 @@ struct SparkExitDriver { try await broadcastReady(&record, tip: tip) case .done: // Nothing is left to pay more for: back to running, whose next check finishes it (failed leaves included). + // A payment still on its way is followed after the end and swept back with the leftover. record.phase = .running - record.fundingPaymentTxid = nil case .redo: break } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index da00ef9ae..affbb40c4 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -372,6 +372,17 @@ enum SparkExitMath { return (sats, transactions.first { $0.kind == .sweep }?.txid ?? record.landedSweepTxid) } + /// The exit's sweeps to savings: the recovery's, else the one that landed before a retry replaced it, and those of + /// generations a Redo replaced. + static func sweepTxids(_ record: SparkExitRecord) -> [String] { + let current = (record.recovery?.response.transactions ?? []).filter { $0.kind == .sweep }.map(\.txid) + var txids = current.isEmpty ? [record.landedSweepTxid].compactMap { $0 } : current + for txid in (record.earlierReceivedSats ?? [:]).keys.sorted() where !txids.contains(txid) { + txids.append(txid) + } + return txids + } + /// What each sweep or cooperative recovery among `transactions` pays to `destination`, by txid. static func paidToDestination(_ transactions: [RecoveryTransaction], destination: String) -> [String: UInt64] { guard let script = try? SparkExitAddress.scriptPubKey(destination) else { return [:] } @@ -425,10 +436,18 @@ enum SparkExitMath { /// given, so several small payments fund the exit as well as one. `requiredSats` assumes one payment; each /// further one adds its own input fee, `inputFeeSats`. static func fundingTopUp(_ newFunding: [EsploraUtxo], requiredSats: UInt64, inputFeeSats: UInt64 = 0) -> [EsploraUtxo]? { - let confirmed = newFunding.filter(\.status.confirmed) - let total = confirmed.reduce(0) { $0 + $1.value } - let needed = requiredSats + inputFeeSats * UInt64(max(0, confirmed.count - 1)) - return !confirmed.isEmpty && total >= needed ? confirmed : nil + // Fewest coins, largest first; one worth no more than its own input fee stays for the leftover sweep. + let coins = newFunding.filter { $0.status.confirmed && $0.value > inputFeeSats }.sorted { $0.value > $1.value } + var taken: [EsploraUtxo] = [] + var total: UInt64 = 0 + for coin in coins { + taken.append(coin) + total += coin.value + if total >= requiredSats + inputFeeSats * UInt64(taken.count - 1) { + return taken + } + } + return nil } /// A signed P2WPKH input: each payment to the funding address is one more input for the build to pay for. diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index d465fb8da..b01e8091d 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -326,9 +326,17 @@ struct SparkExitRecord: Codable, Equatable { funding.first?.index ?? 0 } - /// Whether the driver still has work: the exit runs, or its leftover fee funding is on its way to savings. + /// Whether the driver still has work: the exit runs, its leftover fee funding is on its way to savings, or a + /// savings payment to the funding address is still to arrive and join that leftover. var needsDriving: Bool { - isActive || isLeftoverSweepPending + isActive || isLeftoverSweepPending || isEndedPaymentPending + } + + /// The exit ended while a savings payment Bitkit sent to its funding address had not arrived: it is followed + /// until it lands (and joins the leftover) or the own-payment expiry forgets it. + var isEndedPaymentPending: Bool { + guard !isActive, let paid = fundingPaymentTxid else { return false } + return !funding.contains { $0.txid == paid } && !leftover.contains { $0.txid == paid } } var fundingInputs: [CpfpInput] { diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 87d1694f9..46cfe0daf 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -701,6 +701,87 @@ final class SparkExitSweepTests: XCTestCase { withUnsafeBytes(of: value.littleEndian) { [UInt8]($0) } } + // MARK: Ended with a savings payment on its way + + private func endedWithPayment(_ paid: String) -> SparkExitRecord { + var record = endedRecord() + record.leftover = [] + record.leftoverSweep = nil + record.fundingPaymentTxid = paid + return record + } + + /// An exit that ended before its savings payment arrived keeps being driven; once it lands it is swept back. + func testEndedExitSweepsASavingsPaymentThatArrivesLater() async throws { + let paid = String(repeating: "d", count: 64) + chain.mempoolTxids = [paid] + var record = endedWithPayment(paid) + XCTAssertTrue(record.needsDriving) + + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, paid) + XCTAssertTrue(record.needsDriving) + XCTAssertNil(record.leftoverSweep) + + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(paid, value: 20000)] + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertEqual(record.leftover.map(\.txid), [paid]) + XCTAssertNotNil(record.leftoverSweep?.txid) + XCTAssertTrue(record.needsDriving) + } + + /// A payment no host sees is forgotten by the own-payment expiry, and the ended exit stops being driven. + func testEndedExitForgetsASavingsPaymentNoHostSees() async throws { + var record = endedWithPayment(String(repeating: "d", count: 64)) + + record = try await driver().tick(record) + XCTAssertNotNil(record.fundingPaymentTxid) + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertFalse(record.needsDriving) + } + + /// A payment already in the leftover needs no following. + func testEndedPaymentAlreadyInTheLeftoverIsNotFollowed() { + var record = endedRecord() + record.fundingPaymentTxid = record.leftover[0].txid + XCTAssertFalse(record.isEndedPaymentPending) + } + + /// With a sweep in flight the payment waits for it; once it confirms, the sweep is kept and the payment swept. + func testEndedPaymentWaitsForTheSweepInFlight() async throws { + let paid = String(repeating: "d", count: 64) + var record = endedRecord() + record.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4800, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) + record.fundingPaymentTxid = paid + chain.mempoolTxids = [paid, "s"] + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(paid, value: 20000)] + + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, paid) + XCTAssertEqual(record.leftoverSweep?.txid, "s") + + chain.confirmedTxids = ["s"] + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertEqual(record.sweptLeftover?.map(\.txid), ["s"]) + XCTAssertEqual(record.leftover.map(\.txid), [paid]) + XCTAssertNotEqual(record.leftoverSweep?.txid, "s") + } + + /// The exit's sweeps whose activity is marked as a transfer: the recovery's, else the one that landed before a + /// retry, and those of replaced generations. + func testSweepTxidsIncludeTheLandedSweep() { + var record = endedRecord() + record.landedSweepTxid = "landed" + record.earlierReceivedSats = ["coop": 1000] + XCTAssertEqual(SparkExitMath.sweepTxids(record), ["landed", "coop"]) + record.recovery = StoredRecovery(SparkExitFixtures.recovery([SparkExitFixtures.tx("sweep", kind: .sweep)])) + XCTAssertEqual(SparkExitMath.sweepTxids(record), ["sweep", "coop"]) + } + // MARK: Home balances /// The exit's own transfer counts what is still to come: a planned sweep counts only once it confirmed. diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 3ac8cd582..310048cba 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -798,7 +798,7 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.fundingShortfallSats, 186) XCTAssertEqual(SparkExitMath.fundingState(record, utxos: chain.utxosByAddress["bcrt1qfunding"]), .ask(sats: 294, receivedSats: 0)) - chain.utxosByAddress["bcrt1qfunding", default: []] += [SparkExitFixtures.utxo("t1", value: 200), SparkExitFixtures.utxo("t2", value: 100)] + chain.utxosByAddress["bcrt1qfunding", default: []] += [SparkExitFixtures.utxo("t1", value: 150), SparkExitFixtures.utxo("t2", value: 150)] record = try await driver().tick(record) XCTAssertEqual(record.phase, .needsFunding(requiredSats: 9221), "300 is short of 186 plus a second input's 136") @@ -807,8 +807,10 @@ final class SparkExitDriverTests: XCTestCase { sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] record = try await driver().tick(record) + // The fewest coins that cover: t3 alone; t1 and t2 stay for the leftover sweep. XCTAssertEqual(record.phase, .running) - XCTAssertEqual(sdk.recoverRequests.last?.fundingInputs.count, 4) + XCTAssertEqual(sdk.recoverRequests.last?.fundingInputs.count, 2) + XCTAssertEqual(record.funding.compactMap(\.txid), ["u0", "t3"]) } /// After a Redo the first inputs are partly spent: what they hold now is the CPFP change at the address. @@ -2094,22 +2096,39 @@ final class SparkExitMathTests: XCTestCase { XCTAssertEqual(SparkExitMath.orphanedTransferIds(transfers, record: nil), ["transfer-1", "orphan"]) } - func testFundingTopUpTakesEveryConfirmedPaymentOnceTogetherTheyCover() { + func testFundingTopUpTakesTheFewestConfirmedPaymentsThatCover() { let utxos = [ SparkExitFixtures.utxo("a", value: 999), SparkExitFixtures.utxo("pending", value: 50000, confirmed: false), SparkExitFixtures.utxo("b", value: 1000), ] - XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999)?.map(\.txid), ["a", "b"]) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999)?.map(\.txid), ["b", "a"]) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1000)?.map(\.txid), ["b"]) XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 2000)) XCTAssertNil(SparkExitMath.fundingTopUp([], requiredSats: 0)) // Each payment beyond the first is one more input to pay for: 68 vB at the exit's rate. XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999, inputFeeSats: 136)) - XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1863, inputFeeSats: 136)?.map(\.txid), ["a", "b"]) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1863, inputFeeSats: 136)?.map(\.txid), ["b", "a"]) XCTAssertEqual(SparkExitMath.inputFeeSats(SparkExitFixtures.record()), 136) } + /// Largest first, and never a coin worth no more than its own input fee: that one stays for the leftover sweep. + func testFundingTopUpLeavesUneconomicalCoins() { + let utxos = [ + SparkExitFixtures.utxo("dust", value: 136), + SparkExitFixtures.utxo("small", value: 500), + SparkExitFixtures.utxo("big", value: 3000), + SparkExitFixtures.utxo("mid", value: 1500), + ] + + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 2500, inputFeeSats: 136)?.map(\.txid), ["big"]) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 4300, inputFeeSats: 136)?.map(\.txid), ["big", "mid"]) + XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 4700, inputFeeSats: 136)?.map(\.txid), ["big", "mid", "small"]) + // A coin worth only its input fee never counts. + XCTAssertNil(SparkExitMath.fundingTopUp([utxos[0]], requiredSats: 1, inputFeeSats: 136)) + } + func testShowsTheSparkBalanceBeyondTheExitsLeaves() { var record = SparkExitFixtures.record(phase: .running) record.leafTotalSats = 17097 From 4521e568e0a78d2f537e2e83f551731921027e8c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 04:20:36 +0100 Subject: [PATCH 061/378] fix: keep spark contact withdrawals recoverable Keep the withdrawal journal until the proof, activity and tags are saved, match a recovered withdrawal to its own attempt, prepare and release a request on the Spark leg like a direct send, tell a pre-send refusal from the send itself, pick a request's source by what Savings covers, reroute on a source switch, and use the leg's amount and fee in Confirm warnings. --- Bitkit/Managers/SparkManager.swift | 142 ++++++++++++----- .../Services/PaykitPaymentProofService.swift | 6 +- Bitkit/Services/PaykitRouteService.swift | 42 +++++ Bitkit/ViewModels/AppViewModel.swift | 16 ++ .../Wallets/Send/SendConfirmationView.swift | 149 ++++++++++++----- .../PaykitPaymentProofServiceTests.swift | 2 + BitkitTests/PaykitRouteTests.swift | 150 +++++++++++++++++- 7 files changed, 426 insertions(+), 81 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index f6f2b4c31..db67b6524 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -475,34 +475,49 @@ final class SparkManager: ChannelFreeLightningRail { amountSats: UInt64, contact: String?, request: SparkContactWithdrawal.PaykitRequest? = nil, + tags: [String] = [], maxFeeSats: UInt64? = nil, beforeSend: () async throws -> Void = {} ) async throws -> String { - guard Self.pendingContactWithdrawal == nil else { - throw AppError(message: "wallet__payment_failed_description", debugMessage: "A Spark withdrawal to a contact is still settling") - } - let prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) - if let maxFeeSats, case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod { - // The fee Confirm showed caps the one sent, broadcast fee included. - try Self.checkWithdrawalFee( - Self.withdrawalDebitSats(amountSats: amountSats, fee: feeQuote.speedMedium) - amountSats, - shownFeeSats: maxFeeSats - ) + // Everything before the journal is written sends nothing, so a refusal here says so to the caller. + let sdk: BreezSdk + let prepared: PrepareSendPaymentResponse + let earlierPayments: [Payment] + do { + guard Self.pendingContactWithdrawal == nil else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "A Spark withdrawal to a contact is still settling") + } + sdk = try await requireSdk() + prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) + if let maxFeeSats, case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod { + // The fee Confirm showed caps the one sent, broadcast fee included. + try Self.checkWithdrawalFee( + Self.withdrawalDebitSats(amountSats: amountSats, fee: feeQuote.speedMedium) - amountSats, + shownFeeSats: maxFeeSats + ) + } + // Withdrawals Spark already lists for this address and amount belong to earlier attempts, so recovery never + // takes one of them for this attempt. + earlierPayments = try await sdk.listPayments(request: ListPaymentsRequest(sortAscending: false)).payments + try await beforeSend() + } catch { + throw SparkWithdrawalNotSent(underlying: error) } - try await beforeSend() - let pending = SparkContactWithdrawal( + var pending = SparkContactWithdrawal( address: address, amountSats: amountSats, contact: contact, startedAt: UInt64(Date().timeIntervalSince1970), - request: request + request: request, + tags: tags, + earlierPaymentIds: Self.withdrawalIds(to: address, amountSats: amountSats, in: earlierPayments) ) Self.pendingContactWithdrawal = pending isWithdrawingToContact = true defer { isWithdrawingToContact = false } let response: SendPaymentResponse do { - response = try await requireSdk().sendPayment(request: SendPaymentRequest( + response = try await sdk.sendPayment(request: SendPaymentRequest( prepareResponse: prepared, options: .bitcoinAddress(confirmationSpeed: .medium) )) @@ -515,6 +530,8 @@ final class SparkManager: ChannelFreeLightningRail { throw error } Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") + pending.paymentId = response.payment.id + Self.pendingContactWithdrawal = pending guard case let .withdraw(txId, _, _) = response.payment.details else { await refreshState() return response.payment.id @@ -524,15 +541,16 @@ final class SparkManager: ChannelFreeLightningRail { return txId } - /// Settles the open contact withdrawal from Spark's payments: a withdrawal of its amount sent since it started is - /// recorded; with `allowClear`, a synced list without one closes it as never sent. + /// Settles the open contact withdrawal from Spark's payments: its own payment, once Spark returned it, or else a + /// withdrawal to its address and amount that wasn't listed before it started. With `allowClear`, a synced list + /// without one closes it as never sent and releases the request's proof. func settleContactWithdrawal(allowClear: Bool) async { guard let pending = Self.pendingContactWithdrawal, !isWithdrawingToContact, let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments else { return } if let payment = Self.matchingWithdrawal(pending, in: payments), case let .withdraw(txId, _, _) = payment.details { await recordContactWithdrawal(payment, txId: txId, pending: pending) - } else if allowClear { + } else if allowClear, pending.paymentId == nil { Logger.info("Closed a Spark contact withdrawal Spark never listed", context: "SparkManager") Self.pendingContactWithdrawal = nil if let request = pending.request { @@ -542,43 +560,80 @@ final class SparkManager: ChannelFreeLightningRail { } nonisolated static func matchingWithdrawal(_ pending: SparkContactWithdrawal, in payments: [Payment]) -> Payment? { - payments.first { payment in - guard payment.paymentType == .send, case let .withdraw(_, _, destination) = payment.details else { return false } - return destination == pending.address && sats(payment.amount) == pending.amountSats && - payment.timestamp + contactWithdrawalClockSkewSecs >= pending.startedAt + if let paymentId = pending.paymentId { + return payments.first { $0.id == paymentId } + } + let earlier = Set(pending.earlierPaymentIds ?? []) + return payments.first { payment in + !earlier.contains(payment.id) && withdrawalIds(to: pending.address, amountSats: pending.amountSats, in: [payment]) == [payment.id] } } - private nonisolated static let contactWithdrawalClockSkewSecs: UInt64 = 120 + nonisolated static func withdrawalIds(to address: String, amountSats: UInt64, in payments: [Payment]) -> [String] { + payments.compactMap { payment in + guard payment.paymentType == .send, case let .withdraw(_, _, destination) = payment.details, + destination == address, sats(payment.amount) == amountSats + else { return nil } + return payment.id + } + } + /// Writes the withdrawal's sent activity, tags and request proof, and closes the journal only once all of them are + /// saved, so a failed or interrupted write is retried from the journal. private func recordContactWithdrawal(_ payment: Payment, txId: String, pending: SparkContactWithdrawal) async { - Self.pendingContactWithdrawal = nil guard payment.status != .failed else { + Self.pendingContactWithdrawal = nil if let request = pending.request { await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) } return } - if let request = pending.request { - _ = await PaykitPaymentProofService.shared.completeSparkOnchainPayment( - requestId: request.id, - identity: request.identity, - txid: txId - ) - } Self.contactWithdrawalTxIds.insert(txId) - _ = await CoreService.shared.activity.createSentOnchainActivityFromSendResult( - txid: txId, - address: pending.address, - amount: pending.amountSats, - fee: Self.sats(payment.fees), - feeRate: 0, - contact: pending.contact, - feeIsExact: true + let isDurable = await Self.recordWithdrawalLocally( + saveProof: { + guard let request = pending.request else { return true } + return await PaykitPaymentProofService.shared.completeSparkOnchainPayment( + requestId: request.id, + identity: request.identity, + txid: txId + ) + }, + saveActivity: { + await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: txId, + address: pending.address, + amount: pending.amountSats, + fee: Self.sats(payment.fees), + feeRate: 0, + contact: pending.contact, + feeIsExact: true + ) + }, + saveTags: { + guard let tags = pending.tags, !tags.isEmpty else { return true } + return await (try? CoreService.shared.activity.appendTags(toActivity: txId, tags)) != nil + } ) if payment.status == .completed { await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: txId) } + if isDurable { + Self.pendingContactWithdrawal = nil + } else { + Logger.warn("Kept the Spark contact withdrawal journal: its local records are not all saved yet", context: "SparkManager") + } + } + + /// Saves the proof, then the activity, then its tags; true only when every write succeeded. + static func recordWithdrawalLocally( + saveProof: () async -> Bool, + saveActivity: () async -> Bool, + saveTags: () async -> Bool + ) async -> Bool { + let proofSaved = await saveProof() + let activitySaved = await saveActivity() + let tagsSaved = activitySaved ? await saveTags() : false + return proofSaved && activitySaved && tagsSaved } private nonisolated static let pendingContactWithdrawalKey = "sparkPendingContactWithdrawal" @@ -1077,6 +1132,11 @@ enum SparkErrorCopy { } /// A Spark withdrawal to a contact's on-chain address, journaled before it is sent. +/// A contact withdrawal refused before its journal was written: nothing was sent. +struct SparkWithdrawalNotSent: Error { + let underlying: Error +} + struct SparkContactWithdrawal: Codable, Equatable { /// The Paykit request a withdrawal pays, and the identity its proof belongs to. struct PaykitRequest: Codable, Equatable { @@ -1089,6 +1149,12 @@ struct SparkContactWithdrawal: Codable, Equatable { let contact: String? let startedAt: UInt64 var request: PaykitRequest? + /// Tags picked on Confirm, saved on the sent activity. + var tags: [String]? + /// Withdrawals to the same address and amount Spark listed before this attempt; recovery skips them. + var earlierPaymentIds: [String]? + /// This attempt's Spark payment, once Spark returned it. + var paymentId: String? } struct SparkWithdrawal: Identifiable, Equatable { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 0e7c92211..7a856bbb7 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1348,8 +1348,10 @@ actor PaykitPaymentProofService { try await persist(proofs) return proofs[index] } - guard let completed else { return false } - submitInBackground(completed, priority: .interactive) + // No started proof left means an earlier pass already saved it, or the request no longer waits on one. + if let completed { + submitInBackground(completed, priority: .interactive) + } return true } catch { logWarning("Failed to retain a Spark on-chain proof for retry: \(error)") diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index e8124b591..889bdbbe8 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -137,10 +137,51 @@ enum PaykitRouteService { } /// Runs a service leg the router picked and returns the txid that pays the contact. + /// Whether a failed leg send releases this call's started proof: only when Spark refused it before sending, and no + /// open journal of the same request (an earlier attempt that may have paid) still owns that proof. Another + /// request's open journal never decides it. + static func legFailureReleasesProof( + _ error: Error, + openJournalRequest: SparkContactWithdrawal.PaykitRequest?, + proofRequest: SparkContactWithdrawal.PaykitRequest + ) -> Bool { + error is SparkWithdrawalNotSent && openJournalRequest != proofRequest + } + + /// Starts the on-chain proof of a request a Spark leg pays, in a direct send's order: the proof is prepared, then the + /// request (its checks and private list consumption), then the proof is marked started. Any failure releases the + /// private payment list as unsent, and one after the proof was prepared cancels it, so the request can be paid again. + static func startLegRequestProof( + prepareProof: () async throws -> Void, + prepareRequest: () async throws -> Void, + paymentIdentity: () async throws -> String, + markStarted: (String) async throws -> Void, + cancelProof: () async -> Void, + releasePaymentList: () async -> Void + ) async throws -> String { + do { + try await prepareProof() + } catch { + await releasePaymentList() + throw error + } + do { + try await prepareRequest() + let identity = try await paymentIdentity() + try await markStarted(identity) + return identity + } catch { + await cancelProof() + await releasePaymentList() + throw error + } + } + static func pay( _ leg: ServiceRouteLeg, contact: String?, request: SparkContactWithdrawal.PaykitRequest? = nil, + tags: [String] = [], beforeSend: () async throws -> Void = {} ) async throws -> String { guard leg.service == SparkManager.routeService else { @@ -151,6 +192,7 @@ enum PaykitRouteService { amountSats: leg.deliveredSat, contact: contact, request: request, + tags: tags, maxFeeSats: leg.debitSat.saturatingSub(leg.deliveredSat), beforeSend: beforeSend ) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index e76d3d805..739d96a47 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -824,6 +824,22 @@ extension AppViewModel { isUsdtEnabled: SettingsViewModel.shared.isUsdtEnabled ) || context.incomingPaymentRequest.map { PaykitUsdtPaymentService.shared.hasBinding(for: $0.id) } == true selectedWalletToPayFrom = canUseLightning || (hasLightning && scannedOnchainInvoice == nil) ? .lightning : .onchain + // A Spark leg can pay the on-chain endpoint from Spending here too, when Lightning can't pay directly. + if !canUseLightning, scannedOnchainInvoice != nil, + let route = PaykitRouteService.routeUi( + for: context, + canRouteThroughSpark: PaykitRouteService.canRouteThroughSpark, + canPayDirectly: false + ), route.offersSparkLeg + { + paykitRoute = route + // A request keeps its source here; its Confirm asks the router. + if !hasLightning, context.incomingPaymentRequest == nil, + max(state.spendableOnchainBalanceSats, alternativeOnchainBalanceSats) <= UInt64(Env.dustLimit) + { + selectedWalletToPayFrom = .lightning + } + } let canSelectUsdt = SettingsViewModel.shared.isUsdtEnabled && context.endpoints.contains { $0.methodId == .usdtArbitrum } if !paykitUsesUsdt, !canSelectUsdt, let request = context.incomingPaymentRequest { do { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 6773bfc25..d289467de 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -29,6 +29,8 @@ struct SendConfirmationView: View { @State private var showDetails = false @State private var routedIncomingRequestId: PaykitPaymentRequest.ID? + /// Set when Confirm picks a request's source itself, so that switch isn't routed twice. + @State private var skipNextSourceReroute = false @State private var hasShownAmountReview = false @State private var showingBiometricError = false @State private var biometricErrorMessage = "" @@ -465,7 +467,7 @@ struct SendConfirmationView: View { } guard preparingRequest == nil, !isRequestSetupPending, !returnToAmountReviewIfNeeded() else { return } ensureSendAmountFromScannedInvoicesIfNeeded() - await routeIncomingRequestIfNeeded() + await routeContactPaymentOnConfirm() if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay { showManualConfirmation(reason: "not automatic when opened") } @@ -505,6 +507,15 @@ struct SendConfirmationView: View { !returnToAmountReviewIfNeeded(rebindMethod: !hwSend.isActive) else { return } ensureSendAmountFromScannedInvoicesIfNeeded() walletSwitchContext = sendContext + if skipNextSourceReroute { + skipNextSourceReroute = false + } else if Self.sourceSwitchNeedsReroute( + route: app.paykitRoute, + walletType: app.selectedWalletToPayFrom, + hasLightningPayment: app.scannedLightningInvoice != nil || app.lnurlPayData != nil + ) { + Task { await routeContactPaymentOnConfirm(sourceChanged: true) } + } } .onChange(of: navigationPath) { previous, path in guard preparingRequest == nil, previous.last == .amount, path.isEmpty, requiresAmountReview, !amountIsRoot else { return } @@ -1044,13 +1055,23 @@ struct SendConfirmationView: View { if hwSend.isActive { navigationPath.append(.hardwareSign) - } else if let leg = app.paykitRoute?.serviceLeg { + } else if !app.paykitUsesUsdt, let leg = app.paykitRoute?.serviceLeg { await performServiceRoutedPayment(leg) } else { try await performPayment(isAutomatic: isAutomatic) } } + /// A source switch on Confirm needs the router only when it moves a Spark leg's send to Savings, or a send that has no + /// Lightning payment to Spending, where a Spark leg can carry it. The app's own switch to a leg's source asks nothing. + static func sourceSwitchNeedsReroute(route: PaykitRouteUi?, walletType: WalletType, hasLightningPayment: Bool) -> Bool { + guard let route else { return false } + if route.serviceLeg != nil { + return walletType != .lightning + } + return walletType == .lightning && !hasLightningPayment && route.offersSparkLeg + } + /// The wallet switch chooses coins separately from send setup, so the automatic start waits for both. static func canStartAutomaticPayment( shouldAutomaticallyPay: Bool, @@ -1366,19 +1387,44 @@ struct SendConfirmationView: View { } /// A request's Confirm opens with fixed terms and no amount screen, so the router is asked here: Direct switches the - /// send to the endpoint it picks, and a Spark leg pays an on-chain request from Spending. - private func routeIncomingRequestIfNeeded() async { - guard let context = app.contactPaymentContext, let request = context.incomingPaymentRequest, - !app.paykitUsesUsdt, !hwSend.isActive, routedIncomingRequestId != request.id, - let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false, canPayDirectly: true) + /// send to the endpoint it picks, and a Spark leg pays an on-chain request from Spending. A source switch on Confirm + /// asks it again for any contact payment, so a leg never pays a send the payer moved to Savings. + private func routeContactPaymentOnConfirm(sourceChanged: Bool = false) async { + guard let context = app.contactPaymentContext, !app.paykitUsesUsdt, !hwSend.isActive, !isSubmittingPayment else { return } + let request = context.incomingPaymentRequest + if !sourceChanged { + guard let request, routedIncomingRequestId != request.id else { return } + } + guard let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false, canPayDirectly: true) else { return } - let amount = app.paykitPaymentTerms?.amount ?? request.amount - guard amount.asset == .btc, amount.atomic > 0 else { return } - routedIncomingRequestId = request.id + let amountSats: UInt64 + if let request { + let amount = app.paykitPaymentTerms?.amount ?? request.amount + guard amount.asset == .btc else { return } + amountSats = amount.atomic + } else { + amountSats = route.deliveredSat ?? wallet.sendAmountSats ?? 0 + } + guard amountSats > 0 else { return } + if let request { + routedIncomingRequestId = request.id + // A request has no amount screen to pick its source, so pick it as that screen would: Savings when it covers + // the amount and fee, otherwise Spending, where the Spark leg can carry it. + if !sourceChanged, app.selectedWalletToPayFrom == .onchain, app.scannedLightningInvoice == nil, app.lnurlPayData == nil, + await Self.requestStartsOnSpending( + savingsSendableSats: savingsSendableSats(), + amountSats: amountSats, + offersSparkLeg: route.offersSparkLeg + ) + { + skipNextSourceReroute = true + app.selectedWalletToPayFrom = .lightning + } + } let source: PaykitRouteService.Source = app.selectedWalletToPayFrom == .lightning ? .spending : .savings _ = await app.routeContactPayment( route, - amountSats: amount.atomic, + amountSats: amountSats, source: source, lightningSpendableSats: source == .spending ? UInt64(max(0, wallet.maxSendLightningSats)) : 0, onchainSpendableSats: source == .savings ? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0, @@ -1386,6 +1432,17 @@ struct SendConfirmationView: View { ) } + /// What Savings can send to the request's address after the on-chain fee; its balance while no fee rate is known. + private func savingsSendableSats() async -> UInt64 { + let balance = UInt64(max(0, wallet.spendableOnchainBalanceSats)) + guard let address = app.scannedOnchainInvoice?.address, let rate = wallet.selectedFeeRateSatsPerVByte else { return balance } + return await (try? wallet.calculateMaxSendableAmount(address: address, satsPerVByte: rate)) ?? balance + } + + static func requestStartsOnSpending(savingsSendableSats: UInt64, amountSats: UInt64, offersSparkLeg: Bool) -> Bool { + offersSparkLeg && savingsSendableSats < amountSats + } + /// Withdraws from Spark to the contact's on-chain address. A request it pays gets an on-chain proof with the /// withdrawal's txid; the proof is started before the send, and Spark's journal settles it if the app stops mid-send. private func performServiceRoutedPayment(_ leg: ServiceRouteLeg) async { @@ -1399,41 +1456,54 @@ struct SendConfirmationView: View { do { if let request { guard let privateContext = context?.privatePaymentContext else { throw PaykitPaymentRequestError.requestUnavailable } - try await PaykitPaymentProofService.shared.prepare( - request: request, - paymentAppId: privateContext.paymentAppId(for: leg.endpoint.methodId), - paymentEndpointIdentifier: leg.endpoint.methodId, - kind: .onchain, - paymentTerms: app.paykitPaymentTerms + let identity = try await PaykitRouteService.startLegRequestProof( + prepareProof: { + try await PaykitPaymentProofService.shared.prepare( + request: request, + paymentAppId: privateContext.paymentAppId(for: leg.endpoint.methodId), + paymentEndpointIdentifier: leg.endpoint.methodId, + kind: .onchain, + paymentTerms: app.paykitPaymentTerms + ) + }, + prepareRequest: { try await prepareIncomingPaymentRequest() }, + paymentIdentity: { try await PaykitPaymentProofService.shared.onchainPaymentIdentity(requestId: request.id) }, + markStarted: { identity in + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( + request, + address: leg.endpoint.value, + paymentIdentity: identity + ) + }, + cancelProof: { await PaykitPaymentProofService.shared.cancelPreparation(request) }, + releasePaymentList: { await context?.resolvePrivatePaymentListConsumption(.definitePreBroadcastFailure) } ) - let identity = try await PaykitPaymentProofService.shared.onchainPaymentIdentity(requestId: request.id) - do { - try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( - request, - address: leg.endpoint.value, - paymentIdentity: identity - ) - } catch { - await PaykitPaymentProofService.shared.cancelPreparation(request) - throw error - } proofRequest = SparkContactWithdrawal.PaykitRequest(id: request.id, identity: identity) } let txid: String do { - txid = try await PaykitRouteService.pay(leg, contact: context?.publicKey, request: proofRequest) { + txid = try await PaykitRouteService.pay( + leg, + contact: context?.publicKey, + request: proofRequest, + tags: tagManager.selectedTagsArray + ) { if let request { try await paykitPaymentRequestManager.ensurePaymentAllowed(request) } } } catch { - // Refused before Spark's send: nothing left, so the started proof goes. After it, the journal settles it. - let refusedBeforeSend = SparkManager.pendingContactWithdrawal == nil - if let proofRequest, refusedBeforeSend { + // Refused before Spark's send: nothing left, so this call's started proof goes. After it, the journal settles it. + let refusedBeforeSend = error is SparkWithdrawalNotSent + if let proofRequest, PaykitRouteService.legFailureReleasesProof( + error, + openJournalRequest: SparkManager.pendingContactWithdrawal?.request, + proofRequest: proofRequest + ) { await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: proofRequest.id, identity: proofRequest.identity) } await context?.resolvePrivatePaymentListConsumption(refusedBeforeSend ? .definitePreBroadcastFailure : .uncertain) - throw error + throw (error as? SparkWithdrawalNotSent)?.underlying ?? error } await context?.resolvePrivatePaymentListConsumption(.succeeded) Logger.info("Paid Paykit contact through \(leg.service) in \(txid)", context: "SendConfirmation") @@ -1875,7 +1945,11 @@ struct SendConfirmationView: View { private func validatePayment() async -> [WarningType] { var warnings: [WarningType] = [] - let amount: UInt64 = if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { + // A Spark leg delivers its amount on-chain from Spending, so its amount and fee get the on-chain checks. + let leg = app.paykitRoute?.serviceLeg + let amount: UInt64 = if let leg { + leg.deliveredSat + } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { wallet.sendAmountSats ?? invoice.amountSatoshis } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { wallet.sendAmountSats ?? invoice.amountSatoshis @@ -1902,13 +1976,14 @@ struct SendConfirmationView: View { } // Check if fee > $10 (only for onchain) - if app.selectedWalletToPayFrom == .onchain { - if let feeUsd = currency.convert(sats: UInt64(transactionFee), to: "USD"), feeUsd.value > 10.0 { + if leg != nil || app.selectedWalletToPayFrom == .onchain { + let fee = leg.map { $0.debitSat.saturatingSub($0.deliveredSat) } ?? UInt64(max(0, transactionFee)) + if let feeUsd = currency.convert(sats: fee, to: "USD"), feeUsd.value > 10.0 { warnings.append(.fee) } // Check if fee > 50% of send amount - if transactionFee > 0 && UInt64(transactionFee) > amount / 2 { + if fee > 0 && fee > amount / 2 { warnings.append(.feePercentage) } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 0a8b934f9..6d78c9de4 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1900,6 +1900,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await fulfillment(of: [proofRemoved], timeout: 1) XCTAssertTrue(completed, "Spark's withdrawal is no node send attempt, yet its txid completes the proof") + let retried = await service.completeSparkOnchainPayment(requestId: request.id, identity: identity, txid: txid) + XCTAssertTrue(retried, "A journal retry after the proof was saved must not keep the withdrawal open forever") let lastSubmission = await sdk.lastSubmission() let submission = try XCTUnwrap(lastSubmission) XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index bf413c075..1abf66b2c 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -140,14 +140,156 @@ final class PaykitRouteTests: XCTestCase { // MARK: - Contact withdrawal journal - func testJournaledWithdrawalSettlesFromTheMatchingSparkPayment() { - let pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000) + func testJournaledWithdrawalMatchesOnlyItsOwnAttempt() { let other = Self.withdrawal(id: "other", to: "bcrt1qother", amountSats: 20000, timestamp: 1001) - let earlier = Self.withdrawal(id: "earlier", to: onchain.value, amountSats: 20000, timestamp: 100) + let earlier = Self.withdrawal(id: "earlier", to: onchain.value, amountSats: 20000, timestamp: 2000) let sent = Self.withdrawal(id: "sent", to: onchain.value, amountSats: 20000, timestamp: 1001) + var pending = SparkContactWithdrawal( + address: onchain.value, + amountSats: 20000, + contact: "contact", + startedAt: 1000, + earlierPaymentIds: SparkManager.withdrawalIds(to: onchain.value, amountSats: 20000, in: [other, earlier]) + ) + XCTAssertEqual(pending.earlierPaymentIds, ["earlier"]) XCTAssertEqual(SparkManager.matchingWithdrawal(pending, in: [other, earlier, sent])?.id, "sent") - XCTAssertNil(SparkManager.matchingWithdrawal(pending, in: [other, earlier])) + XCTAssertNil( + SparkManager.matchingWithdrawal(pending, in: [other, earlier]), + "An earlier withdrawal to the same address is never this attempt" + ) + + pending.paymentId = "sent" + XCTAssertNil(SparkManager.matchingWithdrawal(pending, in: [other, earlier]), "Once Spark returned the attempt, only that payment settles it") + XCTAssertEqual(SparkManager.matchingWithdrawal(pending, in: [earlier, sent])?.id, "sent") + } + + func testJournalStaysOpenUntilEveryLocalRecordIsSaved() async { + let saved = await SparkManager.recordWithdrawalLocally(saveProof: { true }, saveActivity: { true }, saveTags: { true }) + XCTAssertTrue(saved) + + var tagsTried = false + let activityFailed = await SparkManager.recordWithdrawalLocally( + saveProof: { true }, + saveActivity: { false }, + saveTags: { tagsTried = true; return true } + ) + XCTAssertFalse(activityFailed, "A failed activity write keeps the journal for a retry") + XCTAssertFalse(tagsTried, "Tags wait for the activity they belong to") + + var activityTried = false + let proofFailed = await SparkManager.recordWithdrawalLocally( + saveProof: { false }, + saveActivity: { activityTried = true; return true }, + saveTags: { true } + ) + XCTAssertFalse(proofFailed, "A proof that wasn't saved keeps the journal for a retry") + XCTAssertTrue(activityTried, "The activity is still written when the proof save fails") + + let tagsFailed = await SparkManager.recordWithdrawalLocally(saveProof: { true }, saveActivity: { true }, saveTags: { false }) + XCTAssertFalse(tagsFailed) + } + + func testLegPreparesTheRequestBeforeItsProofIsStarted() async throws { + var steps: [String] = [] + let identity = try await PaykitRouteService.startLegRequestProof( + prepareProof: { steps.append("proof") }, + prepareRequest: { steps.append("request") }, + paymentIdentity: { steps.append("identity"); return "payer" }, + markStarted: { steps.append("started \($0)") }, + cancelProof: { steps.append("cancel") }, + releasePaymentList: { steps.append("release") } + ) + + XCTAssertEqual(identity, "payer") + XCTAssertEqual(steps, ["proof", "request", "identity", "started payer"], "The request is approved before Spark's payment check") + } + + func testLegThatFailsBeforeSendingFreesTheRequest() async { + struct Refused: Error {} + for failing in ["proof", "request", "started"] { + var steps: [String] = [] + do { + _ = try await PaykitRouteService.startLegRequestProof( + prepareProof: { + if failing == "proof" { + throw Refused() + } + }, + prepareRequest: { + if failing == "request" { + throw Refused() + } + }, + paymentIdentity: { "payer" }, + markStarted: { + _ in if failing == "started" { + throw Refused() + } + }, + cancelProof: { steps.append("cancel") }, + releasePaymentList: { steps.append("release") } + ) + XCTFail("\(failing) must fail the leg") + } catch {} + + XCTAssertEqual(steps, failing == "proof" ? ["release"] : ["cancel", "release"], failing) + } + } + + func testSourceSwitchOnConfirmReroutesOnlyWhenTheLegNoLongerFits() throws { + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] + ) + let offered = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true, canPayDirectly: false)) + var routed = offered + routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) + + XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: routed, walletType: .onchain, hasLightningPayment: false)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: routed, walletType: .lightning, hasLightningPayment: false)) + XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .lightning, hasLightningPayment: false)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .lightning, hasLightningPayment: true)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .onchain, hasLightningPayment: false)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: nil, walletType: .onchain, hasLightningPayment: false)) + } + + func testOnchainRequestStartsOnSpendingWhenSavingsCantCoverIt() { + // Savings 5,000 can't cover a 20,000 request, so Spending (Spark 50,000) pays it through the leg. + XCTAssertTrue(SendConfirmationView.requestStartsOnSpending(savingsSendableSats: 5000, amountSats: 20000, offersSparkLeg: true)) + XCTAssertFalse(SendConfirmationView.requestStartsOnSpending(savingsSendableSats: 48000, amountSats: 20000, offersSparkLeg: true)) + XCTAssertFalse( + SendConfirmationView.requestStartsOnSpending(savingsSendableSats: 5000, amountSats: 20000, offersSparkLeg: false), + "Without a Spark leg the request stays on Savings" + ) + } + + func testRefusalWhileAnOlderJournalIsOpenReleasesThisCallsProof() { + struct Refused: Error {} + let older = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "older", counterparty: "payee"), + identity: "payer" + ) + let this = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "this", counterparty: "payee"), + identity: "payer" + ) + let notSent = SparkWithdrawalNotSent(underlying: Refused()) + + XCTAssertTrue( + PaykitRouteService.legFailureReleasesProof(notSent, openJournalRequest: older, proofRequest: this), + "An older journal still open must not keep a refused send's proof started" + ) + XCTAssertTrue(PaykitRouteService.legFailureReleasesProof(notSent, openJournalRequest: nil, proofRequest: this)) + XCTAssertFalse( + PaykitRouteService.legFailureReleasesProof(notSent, openJournalRequest: this, proofRequest: this), + "An earlier attempt of the same request still owns its proof" + ) + XCTAssertFalse( + PaykitRouteService.legFailureReleasesProof(Refused(), openJournalRequest: this, proofRequest: this), + "A failure once Spark's send started is settled by the journal" + ) } func testSparkLegDebitsTheL1BroadcastFeeToo() { From a2a34000bac11844bde065aa0127837d09ea991c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 04:54:47 +0100 Subject: [PATCH 062/378] fix: settle spark contact withdrawals only after a fresh sync Settle the journal after an uncertain send only when that pass's sync succeeded, and close an attempt Spark never returned nor listed as unsent only after a 10-minute expiry. --- Bitkit/Managers/SparkManager.swift | 20 +++++++++++++++----- BitkitTests/PaykitRouteTests.swift | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index db67b6524..504862eda 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -524,7 +524,8 @@ final class SparkManager: ChannelFreeLightningRail { } catch { Logger.warn("Spark withdrawal to a contact failed or its outcome is unknown: \(error)", context: "SparkManager") Task { - _ = try? await requireSdk().syncWallet(request: SyncWalletRequest()) + // Only a sync that succeeded now can tell whether this withdrawal went out. + guard await (try? requireSdk().syncWallet(request: SyncWalletRequest())) != nil else { return } await settleContactWithdrawal(allowClear: true) } throw error @@ -542,15 +543,17 @@ final class SparkManager: ChannelFreeLightningRail { } /// Settles the open contact withdrawal from Spark's payments: its own payment, once Spark returned it, or else a - /// withdrawal to its address and amount that wasn't listed before it started. With `allowClear`, a synced list - /// without one closes it as never sent and releases the request's proof. - func settleContactWithdrawal(allowClear: Bool) async { + /// withdrawal to its address and amount that wasn't listed before it started. With `allowClear` (after a sync that + /// succeeded), a list without one closes it as never sent and releases the request's proof, but only once the + /// attempt is past `contactWithdrawalExpirySecs`, so a withdrawal Spark hasn't listed yet is never dropped. One still + /// in flight in this process is never settled. + func settleContactWithdrawal(allowClear: Bool, now: UInt64 = UInt64(Date().timeIntervalSince1970)) async { guard let pending = Self.pendingContactWithdrawal, !isWithdrawingToContact, let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments else { return } if let payment = Self.matchingWithdrawal(pending, in: payments), case let .withdraw(txId, _, _) = payment.details { await recordContactWithdrawal(payment, txId: txId, pending: pending) - } else if allowClear, pending.paymentId == nil { + } else if allowClear, Self.canCloseUnlisted(pending, now: now) { Logger.info("Closed a Spark contact withdrawal Spark never listed", context: "SparkManager") Self.pendingContactWithdrawal = nil if let request = pending.request { @@ -559,6 +562,13 @@ final class SparkManager: ChannelFreeLightningRail { } } + nonisolated static let contactWithdrawalExpirySecs: UInt64 = 600 + + /// An attempt Spark never returned nor listed is taken as unsent only after the expiry. + nonisolated static func canCloseUnlisted(_ pending: SparkContactWithdrawal, now: UInt64) -> Bool { + pending.paymentId == nil && now >= pending.startedAt + contactWithdrawalExpirySecs + } + nonisolated static func matchingWithdrawal(_ pending: SparkContactWithdrawal, in payments: [Payment]) -> Payment? { if let paymentId = pending.paymentId { return payments.first { $0.id == paymentId } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 1abf66b2c..b4a0d3fba 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -164,6 +164,23 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(SparkManager.matchingWithdrawal(pending, in: [earlier, sent])?.id, "sent") } + func testUnlistedWithdrawalIsDroppedOnlyAfterTheExpiry() { + var pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000) + let expiry = SparkManager.contactWithdrawalExpirySecs + + XCTAssertFalse( + SparkManager.canCloseUnlisted(pending, now: 1001), + "A sync right after the send may not list the withdrawal yet" + ) + XCTAssertTrue(SparkManager.canCloseUnlisted(pending, now: 1000 + expiry)) + + pending.paymentId = "sent" + XCTAssertFalse( + SparkManager.canCloseUnlisted(pending, now: 1000 + expiry * 10), + "A withdrawal Spark returned went out and is never dropped as unsent" + ) + } + func testJournalStaysOpenUntilEveryLocalRecordIsSaved() async { let saved = await SparkManager.recordWithdrawalLocally(saveProof: { true }, saveActivity: { true }, saveTags: { true }) XCTAssertTrue(saved) From 7a9689c1ec8b891b8ee366a1eca3a6d8c917c8e6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 10:22:23 +0100 Subject: [PATCH 063/378] fix: ignore a move quote that a newer one replaced --- Bitkit/Managers/MoveToSparkManager.swift | 22 ++++++++++++++++++---- journeys/transfer/move-to-spark.xml | 4 ++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index e33ab632f..6cc973998 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -124,6 +124,9 @@ final class MoveToSparkManager { private(set) var state: MoveToSparkState = .quoting /// A move was started and its outcome is still the screen's, so showing the screen again keeps it. private var keepsOutcome = false + /// Counts quotes, moves and resets; a quote writes its result only while it is still the latest. + private var generation = 0 + private var isQuoting = false private let lightningService: LightningService private let spark: SparkManager @@ -133,33 +136,44 @@ final class MoveToSparkManager { spark = .shared } - /// Opens the screen: quotes a new move, unless a move started earlier is still running or showing its outcome. + /// Opens the screen: quotes a new move, unless a quote is still running or a move started earlier is still running + /// or showing its outcome. func start(wallet: WalletViewModel) async { - guard !keepsOutcome else { return } + guard !keepsOutcome, !isQuoting else { return } await quote(wallet: wallet) } /// Forgets the last move once its screen is left. func reset() { + generation += 1 + isQuoting = false keepsOutcome = false state = .quoting } /// Quotes moving `targetSats`, or everything the channels can send. func quote(wallet: WalletViewModel, targetSats: UInt64? = nil) async { + generation += 1 + let quoteGeneration = generation + isQuoting = true keepsOutcome = false state = .quoting await wallet.syncStateAsync() + let result: MoveToSparkState do { - state = try await .confirm(makeQuote(channels: wallet.channels, targetSats: targetSats), isMoving: false) + result = try await .confirm(makeQuote(channels: wallet.channels, targetSats: targetSats), isMoving: false) } catch { Logger.warn("Failed to quote move to Spark of '\(String(describing: targetSats))': \(error)", context: "MoveToSpark") - state = Self.state(for: error) + result = Self.state(for: error) } + guard quoteGeneration == generation else { return } + isQuoting = false + state = result } func move(wallet: WalletViewModel) async { guard case let .confirm(quote, isMoving) = state, !isMoving else { return } + generation += 1 keepsOutcome = true state = .confirm(quote, isMoving: true) do { diff --git a/journeys/transfer/move-to-spark.xml b/journeys/transfer/move-to-spark.xml index 4a47e1307..2379e4c49 100644 --- a/journeys/transfer/move-to-spark.xml +++ b/journeys/transfer/move-to-spark.xml @@ -22,8 +22,8 @@ Relaunch the app, wait on Home and verify the sheet does not show again Open the Spending wallet and verify the "Move To Spark" button (testTag "MoveToSpark") shows Tap "Move To Spark" and verify the screen shows an amount (testTag "MoveToSparkAmount") and a routing fee (testTag "MoveToSparkFee") - Swipe to move (testTag "MoveToSparkSwipe") and verify "Moved to Spark" shows the moved amount (testTag "MoveToSparkMoved") and "Close Connection" (testTag "MoveToSparkClose") - Tap "Close Connection", then "Close" on the close confirmation, and verify Lightning Connections shows no open connection and the Spark balance grew by the moved amount + Swipe to move (testTag "MoveToSparkSwipe") and verify "Moved to Spark" shows the moved amount (testTag "MoveToSparkMoved") and "Close" (testTag "MoveToSparkClose") + Tap "Close" (testTag "MoveToSparkClose"), then "Close" on the close confirmation, and verify Lightning Connections shows no open connection and the Spark balance grew by the moved amount Go back to Home, open Spending and verify the "Move To Spark" button is gone From 08c4c6339cbe7d0ff4c924a7d5a16fe9af11fac3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 10:42:24 +0100 Subject: [PATCH 064/378] fix: offer move to spark only when the minimum fits the fee --- Bitkit/Managers/MoveToSparkManager.swift | 13 ++++++++++--- BitkitTests/MoveToSparkMathTests.swift | 5 +++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 6cc973998..3e0b46192 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -24,7 +24,12 @@ enum MoveToSparkMath { /// Whether the channels hold a balance worth moving to a usable Spark wallet. static func canMove(channels: [ChannelDetails]?, isSparkUsable: Bool) -> Bool { - isSparkUsable && movableSats(channels: channels) >= minMoveSats + isSparkUsable && hasMovableBalance(limitSats: movableSats(channels: channels)) + } + + /// Whether `limitSats` still leaves at least `minMoveSats` to quote once the first fee guess is reserved. + static func hasMovableBalance(limitSats: UInt64) -> Bool { + firstCandidate(wantedSats: limitSats, limitSats: limitSats) >= minMoveSats } /// The first amount to quote: `wantedSats` when it leaves room for fees, else the limit less a guessed fee. @@ -190,7 +195,7 @@ final class MoveToSparkManager { amountSats: quote.amountSats, feeSats: settled.feePaidSats, closeChannel: openChannels.count == 1 ? openChannels.first : nil, - hasRest: MoveToSparkMath.movableSats(channels: wallet.channels) >= MoveToSparkMath.minMoveSats + hasRest: MoveToSparkMath.hasMovableBalance(limitSats: MoveToSparkMath.movableSats(channels: wallet.channels)) ) } catch { Logger.error("Failed to move '\(quote.amountSats)' sats to Spark: \(error)", context: "MoveToSpark") @@ -203,7 +208,9 @@ final class MoveToSparkManager { func makeQuote(channels: [ChannelDetails]?, targetSats: UInt64?) async throws -> MoveToSparkQuote { let limitSats = MoveToSparkMath.movableSats(channels: channels) let wantedSats = min(targetSats ?? limitSats, limitSats) - guard wantedSats >= MoveToSparkMath.minMoveSats else { throw MoveToSparkError.nothingToMove } + guard wantedSats >= MoveToSparkMath.minMoveSats, MoveToSparkMath.hasMovableBalance(limitSats: limitSats) else { + throw MoveToSparkError.nothingToMove + } guard MoveToSparkMath.isSparkUsable(spark) else { throw MoveToSparkError.sparkUnavailable } let invoice = try await spark.receive( diff --git a/BitkitTests/MoveToSparkMathTests.swift b/BitkitTests/MoveToSparkMathTests.swift index 21bc97362..f59096856 100644 --- a/BitkitTests/MoveToSparkMathTests.swift +++ b/BitkitTests/MoveToSparkMathTests.swift @@ -31,6 +31,11 @@ final class MoveToSparkMathTests: XCTestCase { XCTAssertNil(MoveToSparkMath.smallerAmount(MoveToSparkMath.minMoveSats)) } + func testMoveIsOfferedOnlyWhenTheMinimumFitsBesideTheFeeReserve() { + XCTAssertFalse(MoveToSparkMath.hasMovableBalance(limitSats: 1011)) + XCTAssertTrue(MoveToSparkMath.hasMovableBalance(limitSats: 1012)) + } + func testMoveIsOfferedOnlyWithAUsableSpark() { XCTAssertFalse(MoveToSparkMath.canMove(channels: [], isSparkUsable: true)) XCTAssertFalse(MoveToSparkMath.canMove(channels: nil, isSparkUsable: false)) From a9c5e3995fcb55d785b006c523ea9a3cc4e3e19f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 10:45:28 +0100 Subject: [PATCH 065/378] fix: never pay an invoice on both rails Before a Lightning send goes out on either rail, the other rail's record of the invoice's payment hash decides: a pending attempt refuses the send as a duplicate payment, a settled one returns the hash as paid, and a failed or unknown one lets it go. A Spark send checks Spark's own earlier sends too, as LDK already refuses its own repeats. The send watch also settles from the node's record, so a send skipped because the node already paid it ends as paid. --- Bitkit/Services/LightningService.swift | 38 +++++++++++++++++++++++ Bitkit/ViewModels/WalletViewModel.swift | 7 +++++ BitkitTests/ChannelFreeRailTests.swift | 41 +++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 20615fcd5..43247366f 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -994,7 +994,12 @@ class LightningService: NodeLifecycleService { params: RouteParametersConfig? = nil, beforeSubmission: @escaping () throws -> Void = {} ) async throws -> PaymentHash { + let paymentHash = (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).paymentHash()).map { String($0) } if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { + // LDK refuses its own repeats; Spark doesn't, so a Spark send checks both rails. + if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.ldk, .channelFree]) { + return paid + } // Spark takes no route parameters, so a caller's routing fee cap is checked against its estimate instead. if let maxFeeMsat = params?.maxTotalRoutingFeeMsat { let feeSats: UInt64 @@ -1020,6 +1025,9 @@ class LightningService: NodeLifecycleService { beforeSubmission: beforeSubmission ) } + if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.channelFree]) { + return paid + } guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -1044,6 +1052,36 @@ class LightningService: NodeLifecycleService { } } + /// The node's payment list; tests replace it, as they run without a node. + lazy var nodePayments: () async -> [PaymentDetails]? = { [unowned self] in await listPayments() } + + /// An invoice must never go out on both rails: an earlier attempt still pending on `rails` refuses the payment as a + /// duplicate, as LDK does its own, and a settled one returns its hash as paid. A failed, unknown or unlistable one + /// lets the payment go. + private func settledEarlierAttempt(paymentHash: String?, on rails: [LightningPaymentRail]) async throws -> PaymentHash? { + guard let paymentHash else { return nil } + var statuses: [PaymentStatus] = [] + if rails.contains(.ldk), let payment = await nodePayments()?.first(where: { + $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame + }) { + statuses.append(payment.status) + } + if rails.contains(.channelFree), let payments = try? await channelFreeRailPayments(), + let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) + { + statuses.append(payment.status) + } + if statuses.contains(.succeeded) { + Logger.info("Skipped paying '\(paymentHash)', an earlier attempt already paid it", context: "LightningService") + return paymentHash + } + if statuses.contains(.pending) { + Logger.info("Refused paying '\(paymentHash)', an earlier attempt is still pending", context: "LightningService") + throw NodeError.DuplicatePayment(message: "An earlier attempt to pay '\(paymentHash)' is still pending") + } + return nil + } + static func submitPayment( beforeSubmission: @escaping () throws -> Void, send: @escaping () throws -> Result diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index bab51d849..1b91723c3 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1201,6 +1201,13 @@ class WalletViewModel: ObservableObject { /// A Spark-to-Spark payment settles inside the send call, so its completion event can fire before `watchSend` /// starts listening; the rail's own payment list still has the outcome. private func finishWatchSendIfRailAlreadySettled(hash: String, eventId: String) async { + // A send skipped because the node already paid the invoice gets no new event. + if let paid = await lightningService.nodePayments()?.first(where: { + $0.direction == .outbound && $0.status == .succeeded && $0.id.caseInsensitiveCompare(hash) == .orderedSame + }) { + finishWatchSend(id: eventId, result: .success(SettledLightningPayment(paymentHash: hash, feePaidSats: (paid.feePaidMsat ?? 0) / 1000))) + return + } // A retry over a channel settles from LDK; an earlier Spark attempt's outcome doesn't decide it. guard lightningService.attemptRail(paymentHash: hash) != .ldk else { return } let payments = await (try? lightningService.channelFreeRailPayments()) ?? [] diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index dccb87a6f..dab1e9f7c 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -4,6 +4,10 @@ import XCTest @MainActor final class ChannelFreeRailTests: XCTestCase { + /// A 20 000 sat regtest invoice, so a payment can be looked up by its hash. + static let invoice = + "lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgted72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt" + private var rail: FakeRail! private var previousRail: Bitkit.ChannelFreeLightningRail? @@ -118,6 +122,43 @@ extension ChannelFreeRailTests { } } + func testAnInvoiceTheNodeIsStillPayingIsNotSentAgainThroughSpark() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let inFlight = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: nil, direction: .outbound, status: .pending, latestUpdateTimestamp: 0 + ) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { [inFlight] } + defer { service.nodePayments = nodePayments } + // The HTLC in flight leaves the channel too little outbound, so the retry falls to Spark. + rail.balanceSats = 100_000 + + do { + _ = try await service.send(bolt11: Self.invoice) + XCTFail("Expected the pending node payment to refuse the retry") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + + func testAnInvoiceSparkIsStillPayingIsNotSentAgainThroughTheNode() async { + let hash = try? String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash ?? "", direction: .outbound, status: .pending, preimage: nil)] + // Spark can't cover the retry, so it goes to the node. + rail.balanceSats = 0 + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the pending Spark payment to refuse the retry") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + func testCanSendLeavesRoomForTheSparkFee() { rail.balanceSats = 100_000 let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000) From 6c6307af36e96f98ce702f861ba07b132d17085e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 10:55:57 +0100 Subject: [PATCH 066/378] fix: cap the spark fee on user sends A send with no route parameters, as every user send is, now caps the Spark fee at LDK's own default routing fee limit, 1% of the amount plus 50 sats, instead of paying whatever the swipe-time quote says. --- Bitkit/Services/LightningService.swift | 16 ++++++++++++---- BitkitTests/ChannelFreeRailTests.swift | 9 +++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 43247366f..8164cc192 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -895,12 +895,19 @@ class LightningService: NodeLifecycleService { /// Pays on the channel-free rail when no channel has the outbound capacity and the rail's balance covers the amount. @MainActor private func shouldPayWithRail(_ rail: ChannelFreeLightningRail, bolt11: String, sats: UInt64?) -> Bool { - guard rail.isReady else { return false } - let invoiceSats = (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).amountMilliSatoshis()).flatMap { $0 }.map { $0 / 1000 } - guard let amountSats = sats ?? invoiceSats else { return false } + guard rail.isReady, let amountSats = Self.paymentAmountSats(bolt11: bolt11, sats: sats) else { return false } return !canSendWithChannels(amountSats: amountSats) && rail.sendableSats >= amountSats } + private static func paymentAmountSats(bolt11: String, sats: UInt64?) -> UInt64? { + sats ?? (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).amountMilliSatoshis()).flatMap { $0 }.map { $0 / 1000 } + } + + /// LDK's routing fee limit when a caller sets none, 1% + 50 sats, so a send over the rail is capped the same way. + private static func defaultMaxFeeSats(amountSats: UInt64) -> UInt64 { + amountSats / 100 + 50 + } + /// Checks if we have the correct outbound capacity to send the amount /// - Parameter amountSats: Amount to send in satoshis /// - Returns: True if we can send the amount @@ -1021,7 +1028,8 @@ class LightningService: NodeLifecycleService { return try await rail.send( bolt11: bolt11, sats: sats, - maxFeeSats: params?.maxTotalRoutingFeeMsat.map { $0 / 1000 }, + maxFeeSats: params?.maxTotalRoutingFeeMsat.map { $0 / 1000 } + ?? Self.paymentAmountSats(bolt11: bolt11, sats: sats).map(Self.defaultMaxFeeSats), beforeSubmission: beforeSubmission ) } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index dab1e9f7c..8c56ecc0b 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -159,6 +159,15 @@ extension ChannelFreeRailTests { XCTAssertNil(rail.sentBolt11) } + func testUserSendOverTheRailHasAFeeLimit() async throws { + // WalletViewModel.send and sendWithTimeout pass no route parameters, so LDK applies its default + // limit (1% + 50 sats); the rail must not be left without one. + _ = try await Bitkit.LightningService.shared.send(bolt11: "lnbcrt1rail", sats: 1000) + + XCTAssertEqual(rail.sentBolt11, "lnbcrt1rail") + XCTAssertNotNil(rail.sentFeeLimitSats, "A user send over Spark went out with no fee limit") + } + func testCanSendLeavesRoomForTheSparkFee() { rail.balanceSats = 100_000 let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000) From 54a0ff172c0d0d2d07b1d402b171e9978e7bdeff Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:07:14 +0100 Subject: [PATCH 067/378] fix: keep contact payments off the spark transfer list --- Bitkit/Managers/SparkManager.swift | 7 +++++-- BitkitTests/PaykitRouteTests.swift | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 504862eda..8c4baa5b8 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -834,9 +834,12 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Spark's withdrawals to Savings, without the ones that paid contacts. nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { - payments.compactMap { payment in - guard payment.status != .failed, case let .withdraw(txId, _, _) = payment.details else { return nil } + let contactWithdrawals = contactWithdrawalTxIds + return payments.compactMap { payment in + guard payment.status != .failed, case let .withdraw(txId, _, _) = payment.details, + !contactWithdrawals.contains(txId) else { return nil } return SparkWithdrawal(txid: txId, amountSats: sats(payment.amount), feeSats: sats(payment.fees)) } } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index b4a0d3fba..fb121f6eb 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -325,6 +325,20 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(transfers, ["tx-savings"]) } + func testSparkScreenWithdrawalsLeaveOutContactPayments() { + let saved = SparkManager.contactWithdrawalTxIds + defer { SparkManager.contactWithdrawalTxIds = saved } + SparkManager.contactWithdrawalTxIds = ["tx-contact"] + let toContact = Self.withdrawal(id: "contact", to: onchain.value, amountSats: 20000, timestamp: 1) + let toSavings = Self.withdrawal(id: "savings", to: "bcrt1qsavings", amountSats: 5000, timestamp: 1) + + XCTAssertEqual( + SparkManager.withdrawals([toContact, toSavings]).map(\.txid), + ["tx-savings"], + "A contact payment is not a transfer to savings" + ) + } + private static func endpoint(_ methodId: PublicPaykitService.MethodId, _ value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint(methodId: methodId, value: value, min: nil, max: nil, rawPayload: value) } From 5bb777fe4e7c0aacd47cc641a9bb75f2a8728a88 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:13:45 +0100 Subject: [PATCH 068/378] fix: refuse a channel retry of a spark send in flight Spark lists a send only once its send call returns, so until then the payment hash is held as in flight: a retry of the same invoice on the channel, or a second Spark send of it, is refused as a duplicate payment. --- Bitkit/Services/LightningService.swift | 26 ++++++++++++++++++++++++-- BitkitTests/ChannelFreeRailTests.swift | 26 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 8164cc192..cd16f7749 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -125,6 +125,8 @@ class LightningService: NodeLifecycleService { /// The rail each payment hash's latest attempt went out on, so a stale failure from the other rail can't settle a /// retry of the same invoice. private var attemptRails: [String: LightningPaymentRail] = [:] + /// Payment hashes inside a rail send, which Spark lists only once it returns. + private var railSendsInFlight: Set = [] weak var channelFreeRail: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? @@ -1007,6 +1009,8 @@ class LightningService: NodeLifecycleService { if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.ldk, .channelFree]) { return paid } + guard beginRailSend(paymentHash: paymentHash) else { throw Self.pendingDuplicate(paymentHash: paymentHash) } + defer { endRailSend(paymentHash: paymentHash) } // Spark takes no route parameters, so a caller's routing fee cap is checked against its estimate instead. if let maxFeeMsat = params?.maxTotalRoutingFeeMsat { let feeSats: UInt64 @@ -1068,7 +1072,7 @@ class LightningService: NodeLifecycleService { /// lets the payment go. private func settledEarlierAttempt(paymentHash: String?, on rails: [LightningPaymentRail]) async throws -> PaymentHash? { guard let paymentHash else { return nil } - var statuses: [PaymentStatus] = [] + var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] if rails.contains(.ldk), let payment = await nodePayments()?.first(where: { $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame }) { @@ -1085,11 +1089,29 @@ class LightningService: NodeLifecycleService { } if statuses.contains(.pending) { Logger.info("Refused paying '\(paymentHash)', an earlier attempt is still pending", context: "LightningService") - throw NodeError.DuplicatePayment(message: "An earlier attempt to pay '\(paymentHash)' is still pending") + throw Self.pendingDuplicate(paymentHash: paymentHash) } return nil } + private static func pendingDuplicate(paymentHash: String?) -> NodeError { + .DuplicatePayment(message: "An earlier attempt to pay '\(paymentHash ?? "")' is still pending") + } + + private func beginRailSend(paymentHash: String?) -> Bool { + guard let paymentHash else { return true } + return eventCallbackLock.withLock { railSendsInFlight.insert(paymentHash.lowercased()).inserted } + } + + private func endRailSend(paymentHash: String?) { + guard let paymentHash else { return } + _ = eventCallbackLock.withLock { railSendsInFlight.remove(paymentHash.lowercased()) } + } + + private func isRailSendInFlight(paymentHash: String) -> Bool { + eventCallbackLock.withLock { railSendsInFlight.contains(paymentHash.lowercased()) } + } + static func submitPayment( beforeSubmission: @escaping () throws -> Void, send: @escaping () throws -> Result diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 8c56ecc0b..18e410a33 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -168,6 +168,30 @@ extension ChannelFreeRailTests { XCTAssertNotNil(rail.sentFeeLimitSats, "A user send over Spark went out with no fee limit") } + func testASparkSendStillInFlightIsNotSentAgainThroughTheNode() async throws { + var release: CheckedContinuation? + rail.sendGate = { await withCheckedContinuation { release = $0 } } + rail.balanceSats = 100_000 + let first = Task { try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) } + var waited = 0 + while release == nil, waited < 500 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertNotNil(release, "The Spark send never started") + // Spark lists the send only once it returns; the retry falls to the node meanwhile. + rail.balanceSats = 0 + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the Spark send in flight to refuse the retry") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + release?.resume() + _ = try await first.value + } + func testCanSendLeavesRoomForTheSparkFee() { rail.balanceSats = 100_000 let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000) @@ -360,6 +384,7 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var listError: Error? var sentBolt11: String? var sentFeeLimitSats: UInt64? + var sendGate: (() async -> Void)? func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { "lnbcrt1fake" @@ -367,6 +392,7 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { func send(bolt11: String, sats: UInt64?, maxFeeSats: UInt64?, beforeSubmission: @escaping () throws -> Void) async throws -> PaymentHash { try beforeSubmission() + await sendGate?() sentFeeLimitSats = maxFeeSats sentBolt11 = bolt11 return "ee" From 0050855e3bc4628da9a48a54cce3971e6660eb7d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:13:47 +0100 Subject: [PATCH 069/378] fix: sync spark activities once per launch A Spark start rewrote every Spark activity each time, every foreground included. It now reads the balance on every start but runs the full activity sync only until one has succeeded in this launch; a wipe resets that, and a start whose refresh failed leaves the sync to the next one. --- Bitkit/Managers/SparkManager.swift | 24 +++++++++++++++-- BitkitTests/SparkPaymentSafetyTests.swift | 32 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 632725813..a0f454a97 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -49,6 +49,8 @@ final class SparkManager: ChannelFreeLightningRail { /// The refresh in flight; callers during it wait for it, and it reads once more for them. @ObservationIgnored private var refreshTask: Task? @ObservationIgnored private var refreshAgain = false + /// Set once a full activity sync succeeded in this process; `wipe` clears it. + @ObservationIgnored private var activitiesSynced = false private init() {} @@ -94,14 +96,31 @@ final class SparkManager: ChannelFreeLightningRail { // Registered before the first refresh marks Spark ready, so Lightning calls never see it ready while they // still go to LDK, even when that refresh or the activity sync fails. LightningService.shared.channelFreeRail = self - try await refresh() - try await syncActivities() + activitiesSynced = try await Self.startConnected( + activitiesSynced: activitiesSynced, + refresh: { try await refresh() }, + syncActivities: { try await syncActivities() } + ) } catch { connectTask = nil Logger.error("Failed to start Spark wallet: \(error)", context: "SparkManager") } } + /// A start reads the balance every time, but rewrites every Spark activity only until that has succeeded once, so + /// each foreground doesn't redo it; a start whose refresh failed leaves the sync to the next one. Returns whether + /// the activities are synced. + static func startConnected( + activitiesSynced: Bool, + refresh: () async throws -> Void, + syncActivities: () async throws -> Void + ) async throws -> Bool { + try await refresh() + guard !activitiesSynced else { return true } + try await syncActivities() + return true + } + private func connect(walletIndex: Int, network: BreezSdkSpark.Network, generation: Int) async throws { guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { throw CustomServiceError.mnemonicNotFound @@ -172,6 +191,7 @@ final class SparkManager: ChannelFreeLightningRail { sdk = nil listenerId = nil refreshAgain = false + activitiesSynced = false isReady = false balanceSats = 0 pendingDepositSats = 0 diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index 339207e29..fcc779246 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -7,6 +7,38 @@ import XCTest final class SparkPaymentSafetyTests: XCTestCase { private struct RefreshFailed: Error {} + // MARK: - Start + + func testAStartWhileConnectedDoesNotRewriteEveryActivity() async throws { + var upserts: [String: Int] = [:] + var synced = false + let sync = { ["a", "b"].forEach { upserts[$0, default: 0] += 1 } } + + for _ in 0 ..< 2 { + synced = try await SparkManager.startConnected(activitiesSynced: synced, refresh: {}, syncActivities: sync) + } + + XCTAssertEqual(upserts, ["a": 1, "b": 1]) + } + + func testAStartWhoseRefreshFailedLeavesTheActivitySyncToTheNext() async throws { + var syncs = 0 + var synced = false + + do { + synced = try await SparkManager.startConnected( + activitiesSynced: synced, + refresh: { throw RefreshFailed() }, + syncActivities: { syncs += 1 } + ) + XCTFail("Expected the refresh to fail") + } catch {} + synced = try await SparkManager.startConnected(activitiesSynced: synced, refresh: {}, syncActivities: { syncs += 1 }) + + XCTAssertTrue(synced) + XCTAssertEqual(syncs, 1) + } + // MARK: - Settled payment events func testSettledPaymentIsRecordedAndEmittedEvenWhenTheRefreshFails() async { From 5f7029ad3e51aca068c8bd1e9d759b242372a906 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:24:29 +0100 Subject: [PATCH 070/378] fix: show too large when the route is gone at send --- Bitkit/Managers/MoveToSparkManager.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 3e0b46192..2b389d5ba 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -199,7 +199,7 @@ final class MoveToSparkManager { ) } catch { Logger.error("Failed to move '\(quote.amountSats)' sats to Spark: \(error)", context: "MoveToSpark") - state = Self.isNoRoute(error) + state = Self.isNoRoute(error) || Self.isSendWithoutRoute(error, invoice: quote.invoice) ? .tooLarge(amountSats: quote.amountSats, smallerSats: MoveToSparkMath.smallerAmount(quote.amountSats)) : .failed(.unknown) } @@ -260,6 +260,12 @@ final class MoveToSparkManager { } } + /// LDK reports a route lost since the quote, before any payment exists, as a sending failure on a live invoice. + private static func isSendWithoutRoute(_ error: Error, invoice: String) -> Bool { + guard case .PaymentSendingFailed = ((error as? AppError)?.underlyingError ?? error) as? NodeError else { return false } + return (try? Bolt11Invoice.fromStr(invoiceStr: invoice).isExpired()) == false + } + private static func state(for error: Error) -> MoveToSparkState { switch error as? MoveToSparkError { case let .tooLarge(amountSats): .tooLarge(amountSats: amountSats, smallerSats: MoveToSparkMath.smallerAmount(amountSats)) From 0b5b5317d217c4838bcae2b13c67f5bb412b869b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:36:56 +0100 Subject: [PATCH 071/378] fix: keep the swipe off when confirm's reroute is refused A source switch on Confirm reroutes the contact payment. A refused route left the swipe live, so the send consumed the contact's private payment list and then failed on missing invoice data. The refused source now keeps the swipe disabled until another source is picked. --- .../Wallets/Send/SendConfirmationView.swift | 27 +++++++++++++++---- BitkitTests/PaykitRouteTests.swift | 26 ++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index d289467de..23defbe56 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -31,6 +31,7 @@ struct SendConfirmationView: View { @State private var routedIncomingRequestId: PaykitPaymentRequest.ID? /// Set when Confirm picks a request's source itself, so that switch isn't routed twice. @State private var skipNextSourceReroute = false + @State private var refusedRouteSource: WalletType? @State private var hasShownAmountReview = false @State private var showingBiometricError = false @State private var biometricErrorMessage = "" @@ -1260,9 +1261,10 @@ struct SendConfirmationView: View { isSetupPending: Bool = false, isPreparingWallet: Bool = false, isAmountReviewRequired: Bool = false, - isPreparingOnchainSend: Bool = false + isPreparingOnchainSend: Bool = false, + isRouteRefused: Bool = false ) -> Bool { - isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isPreparingOnchainSend + isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isPreparingOnchainSend || isRouteRefused || isHardwareConfirmationUnavailable || isFeeRateMissing(walletType: walletType, isHardwarePayment: isHardwarePayment, feeRate: feeRate) } @@ -1285,7 +1287,8 @@ struct SendConfirmationView: View { isSetupPending: isRequestSetupPending, isPreparingWallet: walletSwitchContext != nil, isAmountReviewRequired: requiresAmountReview, - isPreparingOnchainSend: isPreparingOnchainSend + isPreparingOnchainSend: isPreparingOnchainSend, + isRouteRefused: refusedRouteSource == app.selectedWalletToPayFrom ) } @@ -1421,8 +1424,9 @@ struct SendConfirmationView: View { app.selectedWalletToPayFrom = .lightning } } - let source: PaykitRouteService.Source = app.selectedWalletToPayFrom == .lightning ? .spending : .savings - _ = await app.routeContactPayment( + let walletType = app.selectedWalletToPayFrom + let source: PaykitRouteService.Source = walletType == .lightning ? .spending : .savings + let outcome = await app.routeContactPayment( route, amountSats: amountSats, source: source, @@ -1430,6 +1434,19 @@ struct SendConfirmationView: View { onchainSpendableSats: source == .savings ? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0, request: request ) + if sourceChanged { + refusedRouteSource = Self.refusedRouteSource(after: outcome, on: walletType, previous: refusedRouteSource) + } + } + + /// A source the router refused on Confirm keeps the swipe off while it's selected, so nothing consumes the contact's + /// private list for a send with nothing to pay; another source is still payable. A late outcome for a source never + /// clears another one's refusal. + static func refusedRouteSource(after outcome: PaykitRouteOutcome, on walletType: WalletType, previous: WalletType?) -> WalletType? { + if outcome == .refused { + return walletType + } + return previous == walletType ? nil : previous } /// What Savings can send to the request's address after the on-chain fee; its balance while no fee rate is known. diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index fb121f6eb..9fed20e87 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -272,6 +272,32 @@ final class PaykitRouteTests: XCTestCase { XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: nil, walletType: .onchain, hasLightningPayment: false)) } + func testARefusedRerouteOnConfirmKeepsTheSwipeOffUntilAnotherSourceIsPicked() { + let refused = SendConfirmationView.refusedRouteSource(after: .refused, on: .lightning, previous: nil) + + XCTAssertEqual(refused, .lightning) + XCTAssertTrue(SendConfirmationView.isSwipeDisabled( + walletType: .lightning, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isRouteRefused: refused == .lightning + ), "a refused route must keep the swipe off, so no private list is consumed for it") + XCTAssertFalse(SendConfirmationView.isSwipeDisabled( + walletType: .onchain, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isRouteRefused: refused == .onchain + ), "switching back to Savings pays the contact again") + XCTAssertNil(SendConfirmationView.refusedRouteSource(after: .leg, on: .lightning, previous: refused)) + } + + func testALateRerouteOutcomeKeepsAnotherSourcesRefusal() { + XCTAssertEqual(SendConfirmationView.refusedRouteSource(after: .direct, on: .onchain, previous: .lightning), .lightning) + XCTAssertEqual(SendConfirmationView.refusedRouteSource(after: .refused, on: .onchain, previous: .lightning), .onchain) + } + func testOnchainRequestStartsOnSpendingWhenSavingsCantCoverIt() { // Savings 5,000 can't cover a 20,000 request, so Spending (Spark 50,000) pays it through the leg. XCTAssertTrue(SendConfirmationView.requestStartsOnSpending(savingsSendableSats: 5000, amountSats: 20000, offersSparkLeg: true)) From d7c88bd6f9d500c3fc8b02b9b00d10aa4d8aa7da Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:37:03 +0100 Subject: [PATCH 072/378] fix: give back the private list when a spark withdrawal never sent The Spark leg's on-chain proof now stores the private payment list version the send consumed. When Spark's journal drops that proof as unsent, at relaunch or in process, the version is released exactly: only while the contact's list is still at that version and no automatic attempt owns it. --- .../Services/PaykitPaymentProofService.swift | 40 +++++++++++-- .../Wallets/Send/SendConfirmationView.swift | 9 ++- .../PaykitPaymentProofServiceTests.swift | 56 +++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 7a856bbb7..25b1b4fa3 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1359,11 +1359,41 @@ actor PaykitPaymentProofService { } } - /// Drops the started on-chain proof of a request whose Spark withdrawal never went out. - func failSparkOnchainPayment(requestId: PaykitPaymentRequest.ID, identity: String) async { - await removeProofs { - PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && - $0.paymentStarted && $0.onchainWalletId == nil && $0.paymentIdentifier == nil && $0.proofData == nil + /// Drops the started on-chain proof of a request whose Spark withdrawal never went out, giving back the private list + /// version it consumed when the contact's list is still at exactly that version. + func failSparkOnchainPayment( + requestId: PaykitPaymentRequest.ID, + identity: String, + privatePaykitService: PrivatePaykitService = .shared + ) async { + do { + try await mutationLock.withLock { + let proofs = try await loadProofs() + let unsent = proofs.filter { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && + $0.paymentStarted && $0.onchainWalletId == nil && $0.paymentIdentifier == nil && $0.proofData == nil + } + guard !unsent.isEmpty else { return } + for proof in unsent { + guard let version = proof.privatePaymentListVersion else { continue } + try await privatePaykitService.releaseUnsentPaymentListVersion( + publicKey: proof.requestId.counterparty, + version: version, + previousVersion: proof.previousPrivatePaymentListVersion + ) + } + do { + try await persist(proofs.filter { !unsent.contains($0) }) + } catch { + for proof in unsent { + guard let version = proof.privatePaymentListVersion else { continue } + try await privatePaykitService.retainOriginalPaymentListVersion(publicKey: proof.requestId.counterparty, version: version) + } + throw error + } + } + } catch { + logWarning("Failed to clear an unsent Spark on-chain proof: \(error)") } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 23defbe56..5ae8524a8 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1486,10 +1486,17 @@ struct SendConfirmationView: View { prepareRequest: { try await prepareIncomingPaymentRequest() }, paymentIdentity: { try await PaykitPaymentProofService.shared.onchainPaymentIdentity(requestId: request.id) }, markStarted: { identity in + // The proof keeps the list version this send consumed, so dropping it as unsent gives the list back. + let previousVersion = await PrivatePaykitService.shared.consumedPaymentListVersion( + publicKey: request.counterparty, + attemptId: context?.id + ) try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( request, address: leg.endpoint.value, - paymentIdentity: identity + paymentIdentity: identity, + privatePaymentListVersion: privateContext.paymentListVersion, + previousPrivatePaymentListVersion: previousVersion ) }, cancelProof: { await PaykitPaymentProofService.shared.cancelPreparation(request) }, diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 6d78c9de4..2ef863130 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1926,6 +1926,58 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remaining.isEmpty) } + func testAnUnsentSparkWithdrawalGivesBackItsPrivateListAfterARelaunch() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let privatePaykit = PrivatePaykitService() + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: sdk, store: store) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 6), attemptId: UUID()) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted( + request, address: onchainAddress, privatePaymentListVersion: 7, previousPrivatePaymentListVersion: 6 + ) + // Spark's journal closes the withdrawal as never sent after the app restarted. + let relaunched = paymentProofService(sdk: sdk, store: store) + await relaunched.failSparkOnchainPayment(requestId: request.id, identity: identity, privatePaykitService: privatePaykit) + + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + let released = await privatePaykit.consumedPaymentListVersion(publicKey: request.counterparty) + XCTAssertEqual(released, 6) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) + } + + func testAnUnsentSparkWithdrawalLeavesAListAnotherPaymentConsumedSince() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let privatePaykit = PrivatePaykitService() + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted( + request, address: onchainAddress, privatePaymentListVersion: 7, previousPrivatePaymentListVersion: nil + ) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 8), attemptId: UUID()) + await service.failSparkOnchainPayment(requestId: request.id, identity: identity, privatePaykitService: privatePaykit) + + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + let consumed = await privatePaykit.consumedPaymentListVersion(publicKey: request.counterparty) + XCTAssertEqual(consumed, 8) + } + func testOnchainCompletionDoesNotWaitForProofDelivery() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -3442,6 +3494,10 @@ final class PaykitPaymentProofServiceTests: XCTestCase { return (format(start), format(end)) } + private func listContext(version: UInt64) -> PrivatePaykitPaymentContext { + PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: version) + } + private func paymentProofService( sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, From a56c28fe225d13b7415d01af5d64ba5d0d57efb5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:49:19 +0100 Subject: [PATCH 073/378] fix: forget the spark exit quote on wallet wipe --- Bitkit/Managers/SparkExitManager.swift | 43 ++++++++++++++++++++------ BitkitTests/SparkExitReviewTests.swift | 32 +++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 09c93c675..5608512ee 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -53,11 +53,14 @@ final class SparkExitManager { @ObservationIgnored var savingsFeeSats: UInt64 = 0 @ObservationIgnored private var walletIndex = 0 @ObservationIgnored private var isTicking = false - @ObservationIgnored private var pendingDestination: String? + @ObservationIgnored private(set) var pendingDestination: String? @ObservationIgnored private var pendingFeeRate: UInt64? - @ObservationIgnored private var pendingFundingIndex: UInt32? + @ObservationIgnored private(set) var pendingFundingIndex: UInt32? - private init() {} + /// The app uses `shared`; tests make their own, on a wallet index no wallet uses. + init(walletIndex: Int = 0) { + self.walletIndex = walletIndex + } /// The fee rate of the quote on screen. var quoteFeeRate: UInt64? { @@ -178,7 +181,16 @@ final class SparkExitManager { try? lostLeafStore.delete() record = nil quote = nil + // The quote's destination and funding belong to the wiped wallet: the next wallet's exit never pays to them. + pendingDestination = nil + pendingFeeRate = nil pendingFundingIndex = nil + fundingAddress = nil + fundFromSavings = false + savingsFeeRate = 0 + savingsFeeSats = 0 + fundingSeenSats = 0 + fundingSeenCount = 0 Self.clearReminders() } @@ -539,11 +551,8 @@ final class SparkExitManager { lostLeafIds: (try? lostLeafStore.load()) ?? [] ) let fundingIndex = pendingFundingIndex ?? nextFundingIndex() - pendingDestination = destination - pendingFeeRate = feeRate - pendingFundingIndex = fundingIndex - quote = newQuote - fundingAddress = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex).address + let fundingAddress = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex).address + useQuote(newQuote, destination: destination, feeRate: feeRate, fundingIndex: fundingIndex, fundingAddress: fundingAddress) } catch { Logger.error("Failed to quote the Spark exit: \(error)", context: "SparkExitManager") quote = nil @@ -551,18 +560,32 @@ final class SparkExitManager { } } + /// The quote the funding and confirm screens show, and `start()` starts from. + func useQuote(_ quote: SparkExitQuote, destination: String, feeRate: UInt64, fundingIndex: UInt32, fundingAddress: String) { + pendingDestination = destination + pendingFeeRate = feeRate + pendingFundingIndex = fundingIndex + self.quote = quote + self.fundingAddress = fundingAddress + } + /// Watches the funding address while the user funds it from another wallet. func watchFundingAddress() async { guard let chain, let fundingAddress else { return } while !Task.isCancelled { if let utxos = try? await chain.utxos(address: fundingAddress) { - fundingSeenSats = utxos.reduce(0) { $0 + $1.value } - fundingSeenCount = utxos.count + noteFundingSeen(utxos) } try? await Task.sleep(nanoseconds: 15 * 1_000_000_000) } } + /// What reached the funding address before the exit starts. + func noteFundingSeen(_ utxos: [EsploraUtxo]) { + fundingSeenSats = utxos.reduce(0) { $0 + $1.value } + fundingSeenCount = utxos.count + } + // MARK: Start /// Starts the exit for the current quote: writes its record, moves the Spark balance into transfer to savings diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 1b69fdb84..3d4d8423f 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -471,4 +471,36 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(record.funding.first?.txid, "unk") XCTAssertNotNil(record.recovery) } + + // MARK: Wipe (review thread 4511769434) + + /// A quote taken before a wipe names the wiped wallet's address: the next wallet's exit must never pay to it. + @MainActor + func testAWipeForgetsTheQuotesDestinationAndFunding() async { + let exit = SparkExitManager(walletIndex: 9999) + exit.useQuote( + SparkExitQuote(prepared: SparkExitFixtures.prepared()), + destination: "bcrt1qwalleta", + feeRate: 4, + fundingIndex: 3, + fundingAddress: "bcrt1qfundinga" + ) + exit.noteFundingSeen([SparkExitFixtures.utxo("seen", value: 5000)]) + exit.fundFromSavings = true + exit.savingsFeeRate = 7 + exit.savingsFeeSats = 300 + + await exit.wipe() + + XCTAssertNil(exit.quote) + XCTAssertNil(exit.pendingDestination) + XCTAssertNil(exit.quoteFeeRate) + XCTAssertNil(exit.pendingFundingIndex) + XCTAssertNil(exit.fundingAddress) + XCTAssertFalse(exit.fundFromSavings) + XCTAssertEqual(exit.savingsFeeRate, 0) + XCTAssertEqual(exit.savingsFeeSats, 0) + XCTAssertEqual(exit.fundingSeenSats, 0) + XCTAssertEqual(exit.fundingSeenCount, 0) + } } From d308217c8ff0f75ea3b02c4201e1378243425214 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:51:09 +0100 Subject: [PATCH 074/378] fix: quote the spark exit again when its leaves changed before the start --- Bitkit/Managers/SparkExitManager.swift | 67 ++++++++++++++++++- .../Services/SparkExit/SparkExitRecord.swift | 4 +- BitkitTests/SparkExitReviewTests.swift | 55 +++++++++++++++ 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 5608512ee..f71507f94 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -633,11 +633,34 @@ final class SparkExitManager { Logger.info("Spark exit was cancelled before it started; nothing paid", context: "SparkExitManager") throw SparkExitStartCancelled() } + // An optimization since the quote swapped leaves: the exit is quoted again over the leaves Spark holds now. + var exitQuote = quote + if let recovering = SparkManager.shared.exitRecovering, + let fresh = await Self.requote( + recovering, + confirmed: quote, + feeRate: feeRate, + destination: destination, + lostLeafIds: (try? lostLeafStore.load()) ?? [] + ), + var requoted = Self.requoted(started, fresh: fresh) + { + let transfers = TransferService(lightningService: .shared) + requoted.transferId = try await transfers.createTransfer(type: .sparkExit, amountSats: fresh.incomingSats) + try store.save(requoted) + if let replaced = started.transferId { + try? await transfers.markSettled(id: replaced) + } + started = requoted + self.record = requoted + exitQuote = fresh + } await SparkManager.shared.reconnectForExit() - Logger.info("Started Spark exit of \(quote.exitSats) sats over \(quote.leafIds.count) leaves", context: "SparkExitManager") + Logger.info("Started Spark exit of \(exitQuote.exitSats) sats over \(exitQuote.leafIds.count) leaves", context: "SparkExitManager") - if fundFromSavings, quote.fundingSats > 0 { - let txid = try await payFunding(address: key.address, sats: quote.fundingSats, satsPerVbyte: savingsFeeRate, feeSats: savingsFeeSats) + let savingsSats = Self.savingsFundingSats(confirmed: quote, fresh: exitQuote) + if fundFromSavings, savingsSats > 0 { + let txid = try await payFunding(address: key.address, sats: savingsSats, satsPerVbyte: savingsFeeRate, feeSats: savingsFeeSats) // Saved before the lock is released, so a cancel after the payment finds it and refuses. started.fundingPaymentTxid = txid do { @@ -651,6 +674,44 @@ final class SparkExitManager { Task { await tick() } } + /// The exit quoted again at its start, when the leaves Spark holds changed since the confirmed quote; nil when they + /// did not, when none is left, or when the quote fails (the exit then goes on with the confirmed one). + nonisolated static func requote( + _ recovering: SparkExitRecovering, + confirmed: SparkExitQuote, + feeRate: UInt64, + destination: String, + lostLeafIds: Set + ) async -> SparkExitQuote? { + do { + let fresh = try await SparkExitQuote.prepare(recovering, feeRateSatPerVbyte: feeRate, destination: destination, lostLeafIds: lostLeafIds) + guard !fresh.leafIds.isEmpty, Set(fresh.leafIds) != Set(confirmed.leafIds) else { return nil } + Logger.warn("Quoting the Spark exit again: its leaves changed since the quote", context: "SparkExitManager") + return fresh + } catch { + Logger.warn("Failed to quote the Spark exit again at its start: \(error)", context: "SparkExitManager") + return nil + } + } + + /// The started exit over the fresh quote's leaves, amount and funding. + nonisolated static func requoted(_ record: SparkExitRecord, fresh: SparkExitQuote) -> SparkExitRecord? { + guard case .awaitingFunding = record.phase else { return nil } + var record = record + record.phase = .awaitingFunding(requiredSats: fresh.fundingSats) + record.leafIds = fresh.leafIds + record.leafTotalSats = fresh.leafTotalSats + record.expectedSats = fresh.incomingSats + record.skippedLeafCount = fresh.prepared.skipped.count + return record + } + + /// Savings pays at most the funding the user confirmed: a higher fresh amount goes through the shortfall flow on + /// Progress, which asks for the rest. + nonisolated static func savingsFundingSats(confirmed: SparkExitQuote, fresh: SparkExitQuote) -> UInt64 { + min(confirmed.fundingSats, fresh.fundingSats) + } + /// The exit a start began, still stored and active (not cancelled meanwhile); nil otherwise. The store keeps /// dates to the millisecond. nonisolated static func startedExit(stored: SparkExitRecord?, createdAt: Date) -> SparkExitRecord? { diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index b01e8091d..de53680b8 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -174,7 +174,7 @@ struct SparkExitRecord: Codable, Equatable { var feeRateSatPerVbyte: UInt64 let destination: String /// The leaves of the quote the user confirmed; every rebuild selects exactly these. - let leafIds: [String] + var leafIds: [String] /// What the quote's leaves hold together, for telling how much of the Spark balance is outside the exit. var leafTotalSats: UInt64? var funding: [Funding] @@ -196,7 +196,7 @@ struct SparkExitRecord: Codable, Equatable { /// Leftover sweeps that confirmed before outputs they left out were followed again: what each sent to savings. var sweptLeftover: [SweptLeftover]? /// What reaches savings: the exited value less the fees paid out of it. - let expectedSats: UInt64 + var expectedSats: UInt64 var claimableAtHeight: UInt32? var leftover: [Leftover] = [] var leftoverSweep: LeftoverSweep? diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 3d4d8423f..8fd88ed14 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -503,4 +503,59 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(exit.fundingSeenSats, 0) XCTAssertEqual(exit.fundingSeenCount, 0) } + + // MARK: Requote at the start (review 1ac10966a, Android 64004d7) + + /// An optimization swapped the leaves while the start waited: the exit is quoted again over the leaves Spark holds now. + func testAStartQuotesAgainWhenTheLeavesChanged() async throws { + let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared()) + sdk.preparedResponse = SparkExitFixtures.prepared(funding: 35000, leaves: ["leaf-2", "leaf-3"]) + + let requote = await SparkExitManager.requote(sdk, confirmed: confirmed, feeRate: 2, destination: "bcrt1qdestination", lostLeafIds: []) + let fresh = try XCTUnwrap(requote) + let record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: fresh)) + + XCTAssertEqual(record.leafIds, ["leaf-2", "leaf-3"]) + XCTAssertEqual(record.phase, .awaitingFunding(requiredSats: 35000)) + XCTAssertEqual(record.expectedSats, fresh.incomingSats) + XCTAssertEqual(record.leafTotalSats, 200_000) + XCTAssertEqual(sdk.prepareRequests.last?.destination, "bcrt1qdestination") + } + + /// The same leaves in another order, no leaf left, or a quote that fails: the exit goes on with the confirmed quote. + func testAStartKeepsTheConfirmedQuoteWhenTheLeavesDidNotChange() async { + let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared(leaves: ["leaf-1", "leaf-2"])) + func requote() async -> SparkExitQuote? { + await SparkExitManager.requote(sdk, confirmed: confirmed, feeRate: 2, destination: "bcrt1qdestination", lostLeafIds: []) + } + + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2", "leaf-1"]) + let reordered = await requote() + XCTAssertNil(reordered) + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: []) + let empty = await requote() + XCTAssertNil(empty) + sdk.prepareErrors = [URLError(.timedOut)] + let failed = await requote() + XCTAssertNil(failed) + } + + /// Savings never pays more than the funding the user confirmed; the rest is asked for on Progress once the payment + /// confirms. + func testSavingsPaysAtMostTheConfirmedFundingAndProgressAsksForTheRest() throws { + let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + let higher = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 35000, leaves: ["leaf-1", "leaf-2"])) + let lower = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 15000, leaves: ["leaf-2"])) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: higher), 20000) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: lower), 15000) + + var record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: higher)) + record.fundingPaymentTxid = "paid" + let paid = [SparkExitFixtures.utxo("paid", value: 20000)] + guard case let .ask(sats, receivedSats) = SparkExitMath.fundingState(record, utxos: paid) else { + return XCTFail("the shortfall is asked for") + } + XCTAssertEqual(receivedSats, 20000) + XCTAssertGreaterThanOrEqual(sats, 15000) + } } From 20736be28198d075320fa4f16a7191e4f1b3d55c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:52:34 +0100 Subject: [PATCH 075/378] fix: cap the spark exit's leftover sweep fee after a refusal --- .../Services/SparkExit/SparkExitDriver.swift | 15 +++++++--- .../Services/SparkExit/SparkExitFunding.swift | 6 ++++ BitkitTests/SparkExitSweepTests.swift | 29 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 3fa1780ca..cd5b95cb2 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -922,7 +922,8 @@ struct SparkExitDriver { } /// A sweep refused for its fee is not final: the leftover waits again, keeping when its wait began, and is built - /// again from the next block at the rate the refusal asks for (`refusedSweepRate`), or the economy rate if higher. + /// again from the next block at the rate the refusal asks for (`refusedSweepRate`, capped by `refusedSweepFloor`), or + /// the economy rate if higher. private func waitAfterFeeRefusal(_ record: inout SparkExitRecord, refusal: String) async { // Without a tip no height is written: the refusal is kept, and the next tick turns it into the wait. guard let sweep = record.leftoverSweep, let tip = try? await chain.tipHeight() else { @@ -983,9 +984,15 @@ struct SparkExitDriver { return } } - let economy = try? await chain.networkFees().economy - // A rate a node refused stays refused: the least it asked for lifts the economy rate, past the exit's if need be. - let rate = max(SparkExitMath.sweepFeeRate(economy: economy, exitRate: record.feeRateSatPerVbyte), waiting?.refusedFeeRate ?? 0) + let fees = try? await chain.networkFees() + // A rate a node refused stays refused: the least it asked for lifts the economy rate, up to the next block's or + // the exit's rate. A node asking more refuses again, and the sweep waits a block for fees to drop. + let refusedFloor = SparkExitMath.refusedSweepFloor( + waiting?.refusedFeeRate ?? 0, + nextBlock: fees?.fastest, + exitRate: record.feeRateSatPerVbyte + ) + let rate = max(SparkExitMath.sweepFeeRate(economy: fees?.economy, exitRate: record.feeRateSatPerVbyte), refusedFloor) let built = try await SparkExitLeftoverSweep.build( utxos: SparkExitMath.economicalUtxos(confirmed, rate: rate), fundingAddress: address, diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index affbb40c4..275b8f6cf 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -535,6 +535,12 @@ enum SparkExitMath { return max(leastRate, bumped) } + /// What a refused rate lifts the sweep to: never above the next block's rate or the exit's own, whichever is higher, + /// so a host's absurd ask never spends the leftover on fees. + static func refusedSweepFloor(_ refusedRate: UInt64, nextBlock: UInt64?, exitRate: UInt64) -> UInt64 { + min(refusedRate, max(nextBlock ?? 0, exitRate)) + } + /// The UTXOs worth sending at `rate`: each holds more than the fee of its own input. static func economicalUtxos(_ utxos: [EsploraUtxo], rate: UInt64) -> [EsploraUtxo] { utxos.filter { $0.value > inputVbytes * rate } diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 46cfe0daf..42eff7d6c 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -542,6 +542,35 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(SparkExitMath.refusedSweepRate("min relay fee not met, 450 < 674", paidRate: 1), 2) XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 900 < 2700", paidRate: 2), 6) XCTAssertEqual(SparkExitMath.refusedSweepRate("insufficient fee", paidRate: 3), 4, "no numbers: one sat/vB more") + let absurd = SparkExitMath.refusedSweepRate("min relay fee not met, 110 < 22000", paidRate: 1) + XCTAssertEqual(absurd, 200) + XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: 5, exitRate: 2), 5, "capped at the next block's rate") + XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: 5, exitRate: 8), 8, "or the exit's, if higher") + XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: nil, exitRate: 2), 2, "the exit's without a next-block rate") + XCTAssertEqual(SparkExitMath.refusedSweepFloor(2, nextBlock: 5, exitRate: 2), 2, "a modest ask is kept") + } + + /// A host answers a 1 sat/vB sweep with an absurd ask, 22,000 sats for ~110 vB: the retry pays the next block's + /// rate at most, never the 200 sat/vB the refusal names. + func testARefusalCannotPushTheSweepAboveTheNextBlockRate() async throws { + var record = endedRecord() + let utxo = SparkExitFixtures.utxo(String(repeating: "1", count: 64), value: 25000) + record.leftover = [.init(txid: utxo.txid, vout: 0, valueSats: 25000)] + chain.utxosByAddress[key.address] = [utxo] + chain.economyFee = 1 + chain.fastestFee = 5 + chain.outcome = .rejected("min relay fee not met, 110 < 22000") + + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.refusedFeeRate, 200) + + chain.tip = 1001 + chain.outcome = .accepted + record = try await driver().tick(record) + + let sweep = try XCTUnwrap(record.leftoverSweep) + XCTAssertEqual(sweep.isAccepted, true) + XCTAssertEqual(sweep.feeRateSatPerVbyte, 5) } /// The result names the leftover once, with its total, whatever state it is in. From e7401ce33817cda2011fd6ccfdd7c54e4999c0fd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:54:04 +0100 Subject: [PATCH 076/378] fix: show the savings payment fee and total on spark exit confirm --- Bitkit/Managers/SparkExitManager.swift | 7 +++++ .../Localization/ar.lproj/Localizable.strings | 2 ++ .../Localization/ca.lproj/Localizable.strings | 2 ++ .../Localization/cs.lproj/Localizable.strings | 2 ++ .../Localization/de.lproj/Localizable.strings | 2 ++ .../Localization/el.lproj/Localizable.strings | 2 ++ .../Localization/en.lproj/Localizable.strings | 2 ++ .../es-419.lproj/Localizable.strings | 2 ++ .../Localization/es.lproj/Localizable.strings | 2 ++ .../Localization/fr.lproj/Localizable.strings | 2 ++ .../Localization/it.lproj/Localizable.strings | 2 ++ .../Localization/nl.lproj/Localizable.strings | 2 ++ .../Localization/pl.lproj/Localizable.strings | 2 ++ .../pt-BR.lproj/Localizable.strings | 2 ++ .../Localization/pt.lproj/Localizable.strings | 2 ++ .../Localization/ru.lproj/Localizable.strings | 2 ++ .../Settings/Advanced/SparkExitViews.swift | 5 ++++ BitkitTests/SparkExitReviewTests.swift | 30 +++++++++++++++++++ 18 files changed, 72 insertions(+) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index f71507f94..73568c1e3 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -67,6 +67,13 @@ final class SparkExitManager { pendingFeeRate } + /// What paying the fee funding from savings costs: that payment's own fee, and the total that leaves savings. Nil + /// when another wallet pays, or there is nothing to fund. + var savingsPayment: (feeSats: UInt64, totalSats: UInt64)? { + guard fundFromSavings, let quote, quote.fundingSats > 0 else { return nil } + return (savingsFeeSats, quote.fundingSats + savingsFeeSats) + } + var isExiting: Bool { record?.isActive == true } diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 12e0eafc5..337f73e81 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -327,6 +327,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index dd2a2943d..9a522cb97 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -364,6 +364,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index abfd46a59..82daf81c3 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -380,6 +380,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 5c475fe3a..3498a01d1 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -378,6 +378,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 3a68f30e1..589dfd46e 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -351,6 +351,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 0e54b13f5..b88431bf8 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -373,6 +373,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index e4c984723..052f98832 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -381,6 +381,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 696a65733..9b3d2b3da 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -365,6 +365,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 51e707787..d4575fea6 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -381,6 +381,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index ed8e7fecb..b3379249a 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -374,6 +374,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 4f403226b..ca414fe8a 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -378,6 +378,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index fc7716322..4f8344c34 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -380,6 +380,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index af295be2d..dc7d386ef 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -380,6 +380,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index f9121f8bb..5530b90aa 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -378,6 +378,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index b1eae54fc..acdbe3435 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -375,6 +375,8 @@ "lightning__spark_exit__amount" = "Amount to exit"; "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; +"lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; "lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index cdb1ab9c6..1a158bfb7 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -429,6 +429,11 @@ struct SparkExitConfirmView: View { SparkExitQuoteRows(quote: quote) } + if let savingsPayment = exit.savingsPayment { + SparkExitAmountRow(label: t("lightning__spark_exit__savings_fee"), sats: savingsPayment.feeSats) + SparkExitAmountRow(label: t("lightning__spark_exit__savings_total"), sats: savingsPayment.totalSats) + } + SparkExitBackgroundNotes() .padding(.top, 16) } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 8fd88ed14..2efad968d 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -558,4 +558,34 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(receivedSats, 20000) XCTAssertGreaterThanOrEqual(sats, 15000) } + + // MARK: Savings payment on Confirm (review thread 4511769453) + + /// Pay from savings shows its own fee and the total that leaves savings; funding from another wallet shows neither. + @MainActor + func testConfirmShowsTheSavingsPaymentFeeAndTotal() { + let exit = SparkExitManager(walletIndex: 9999) + exit.useQuote( + SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 6000)), + destination: "bcrt1qdestination", + feeRate: 2, + fundingIndex: 0, + fundingAddress: "bcrt1qfunding" + ) + exit.savingsFeeSats = 453 + XCTAssertNil(exit.savingsPayment, "paid from another wallet") + + exit.fundFromSavings = true + XCTAssertEqual(exit.savingsPayment?.feeSats, 453) + XCTAssertEqual(exit.savingsPayment?.totalSats, 6453) + + exit.useQuote( + SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: nil)), + destination: "bcrt1qdestination", + feeRate: 2, + fundingIndex: 0, + fundingAddress: "bcrt1qfunding" + ) + XCTAssertNil(exit.savingsPayment, "nothing to fund") + } } From 2b0da4ae238848dc08389cfbaa9d93ef2cfd6893 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 12:22:43 +0100 Subject: [PATCH 077/378] fix: refuse paying a spark invoice again over a channel during an exit --- Bitkit/Managers/SparkManager.swift | 4 ++++ Bitkit/Services/LightningService.swift | 5 ++++- BitkitTests/ChannelFreeRailTests.swift | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 47dc5c787..d3aa497ec 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -271,6 +271,7 @@ final class SparkManager: ChannelFreeLightningRail { } // Registered before the first refresh marks Spark ready, so Lightning calls never see it ready while they // still go to LDK, even when that refresh or the activity sync fails. An emergency exit keeps it out. + LightningService.shared.channelFreePaymentHistory = self if !isExiting { LightningService.shared.channelFreeRail = self } @@ -359,6 +360,9 @@ final class SparkManager: ChannelFreeLightningRail { if LightningService.shared.channelFreeRail === self { LightningService.shared.channelFreeRail = nil } + if LightningService.shared.channelFreePaymentHistory === self { + LightningService.shared.channelFreePaymentHistory = nil + } var disconnectError: Error? if let sdk { if let listenerId { diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index cd16f7749..07098b615 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -128,6 +128,9 @@ class LightningService: NodeLifecycleService { /// Payment hashes inside a rail send, which Spark lists only once it returns. private var railSendsInFlight: Set = [] weak var channelFreeRail: ChannelFreeLightningRail? + /// The rail whose payments the duplicate-payment check reads, kept while an emergency exit takes Spark off + /// Lightning: an invoice Spark paid or is paying is never paid again over a channel. + weak var channelFreePaymentHistory: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? typealias OnchainActivityCompletion = @MainActor () async -> Void @@ -1078,7 +1081,7 @@ class LightningService: NodeLifecycleService { }) { statuses.append(payment.status) } - if rails.contains(.channelFree), let payments = try? await channelFreeRailPayments(), + if rails.contains(.channelFree), let payments = try? await (channelFreeRail ?? channelFreePaymentHistory)?.lightningPayments(), let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) { statuses.append(payment.status) diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 18e410a33..9a1c46755 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -159,6 +159,25 @@ extension ChannelFreeRailTests { XCTAssertNil(rail.sentBolt11) } + /// An emergency exit takes Spark off Lightning: an invoice Spark was paying when it started is still not paid again + /// over a channel. + func testAnInvoiceSparkWasPayingIsNotSentAgainThroughTheNodeDuringAnExit() async { + let hash = try? String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash ?? "", direction: .outbound, status: .pending, preimage: nil)] + let previousHistory = Bitkit.LightningService.shared.channelFreePaymentHistory + Bitkit.LightningService.shared.channelFreePaymentHistory = rail + Bitkit.LightningService.shared.channelFreeRail = nil + defer { Bitkit.LightningService.shared.channelFreePaymentHistory = previousHistory } + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the pending Spark payment to refuse the retry") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + func testUserSendOverTheRailHasAFeeLimit() async throws { // WalletViewModel.send and sendWithTimeout pass no route parameters, so LDK applies its default // limit (1% + 50 sats); the rail must not be left without one. From 6934e0508c63bbf29fa02d6916e979d7206a6b68 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 12:45:25 +0100 Subject: [PATCH 078/378] fix: use the brand orange accent on spark exit screens --- .../Views/Settings/Advanced/SparkExitViews.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 1a158bfb7..54890e4c1 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -10,7 +10,7 @@ struct SparkExitIntroView: View { NavigationBar(title: t("lightning__spark_exit__nav_title")) .padding(.bottom, 16) - DisplayText(t("lightning__spark_exit__intro_title"), accentColor: .yellowAccent) + DisplayText(t("lightning__spark_exit__intro_title"), accentColor: .brandAccent) BodyMText(t("lightning__spark_exit__intro_text"), accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) @@ -68,7 +68,7 @@ struct SparkExitQuoteView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - DisplayText(t("lightning__spark_exit__quote_title"), accentColor: .yellowAccent) + DisplayText(t("lightning__spark_exit__quote_title"), accentColor: .brandAccent) .padding(.bottom, 32) if let quote = exit.quote { @@ -195,7 +195,7 @@ struct SparkExitFundingView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - DisplayText(t("lightning__spark_exit__funding_title"), accentColor: .yellowAccent) + DisplayText(t("lightning__spark_exit__funding_title"), accentColor: .brandAccent) BodyMText( t("lightning__spark_exit__funding_text", variables: ["amount": currency.primaryAmountText(sats: fundingSats)]), @@ -418,7 +418,7 @@ struct SparkExitConfirmView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - DisplayText(t("lightning__spark_exit__confirm_title"), accentColor: .yellowAccent) + DisplayText(t("lightning__spark_exit__confirm_title"), accentColor: .brandAccent) BodyMText(t("lightning__spark_exit__confirm_text"), accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) @@ -440,7 +440,7 @@ struct SparkExitConfirmView: View { .padding(.bottom, 16) } - SwipeButton(title: t("lightning__spark_exit__swipe"), accentColor: .yellowAccent) { + SwipeButton(title: t("lightning__spark_exit__swipe"), accentColor: .brandAccent) { do { try await exit.start() navigation.navigate(.sparkExitProgress) @@ -550,7 +550,7 @@ struct SparkExitProgressView: View { NavigationBar(title: t("lightning__spark_exit__nav_title")) .padding(.bottom, 16) - DisplayText(title, accentColor: .yellowAccent) + DisplayText(title, accentColor: .brandAccent) .padding(.bottom, 16) BodyMText(text, accentFont: Fonts.bold) @@ -764,7 +764,7 @@ struct SparkExitFeeBumpView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - DisplayText(t("lightning__spark_exit__fee_bump_title"), accentColor: .yellowAccent) + DisplayText(t("lightning__spark_exit__fee_bump_title"), accentColor: .brandAccent) BodyMText( t("lightning__spark_exit__fee_bump_text", variables: ["rate": "\(exit.record?.feeRateSatPerVbyte ?? 0)"]), From 6d6d8a532d23a65d2f2ebe3de3fa420321d91ba6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 12:49:57 +0100 Subject: [PATCH 079/378] fix: match spark exit note colours to android The emergency exit moves funds out of Spark into savings, much like a transfer to savings, so its screens use the brand orange that means savings and bitcoin, and its notes the secondary text colour as on Android. The "fee rose" link stays yellow, the warning colour. --- Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 54890e4c1..c8db815a8 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -335,7 +335,7 @@ struct SparkExitWaitingPayment: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - BodySText(text, textColor: .greenAccent) + BodySText(text) BodySText(t("lightning__spark_exit__funding_txid", variables: ["txid": txid])) .lineLimit(2) .truncationMode(.middle) @@ -878,7 +878,7 @@ struct SparkExitBackgroundNotes: View { var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(notes, id: \.self) { note in - BodySText(note, textColor: .yellowAccent) + BodySText(note) } } .accessibilityElement(children: .contain) From 7363979186290ae65ee89b30774b79cd31ca73e3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 12:57:13 +0100 Subject: [PATCH 080/378] fix: follow a spark exit fee bump paid after the exit ended --- Bitkit/Managers/SparkExitManager.swift | 17 +++++++++++++ BitkitTests/SparkExitSweepTests.swift | 34 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 73568c1e3..1c36ae6ff 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -499,6 +499,18 @@ final class SparkExitManager { } } + /// An ended exit keeps a savings payment that went out after it ended as pending, so the exit is driven until the + /// payment reaches the funding address and joins the leftover sweep (`followEndedPayment`). Nil while the exit is + /// active (its funding adopts the payment), without a txid, or while an earlier payment is still followed. + nonisolated static func followingLatePayment(_ record: SparkExitRecord, txid: String?) -> SparkExitRecord? { + guard !record.isActive, let txid, !record.isEndedPaymentPending else { return nil } + var record = record + record.fundingPaymentTxid = txid + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + return record + } + /// Notes what savings pays in fees to send the extra funding of `rate`. func setFeeBumpPaymentFee(rate: UInt64, sats: UInt64) { guard let i = feeBumpOptions.firstIndex(where: { $0.rate == rate }) else { return } @@ -523,6 +535,11 @@ final class SparkExitManager { // The exit may have ended while savings paid: the extra stays at the funding address for the leftover sweep. guard record.phase == .running, !record.isRebuilding else { Logger.warn("Spark exit ended while its higher fee was paid; the payment goes back with the leftover", context: "SparkExitManager") + if let followed = Self.followingLatePayment(record, txid: txid) { + try saveRecord(followed) + self.record = followed + Task { await tick() } + } return } record.applyFeeBump(rate: option.rate, extraSats: extraSats, paymentTxid: txid) diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 42eff7d6c..fbe6ae245 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -760,6 +760,40 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertTrue(record.needsDriving) } + /// A fee bump's savings payment that went out after the exit ended (pwltr's Android finding on c554a0185): its txid + /// is kept as pending, so the exit is driven until the payment reaches the funding address and is swept back, even + /// after the leftover sweep confirmed. + func testAFeeBumpPaymentSentAfterTheExitEndedIsFollowedUntilItArrives() async throws { + let late = String(repeating: "e", count: 64) + var record = endedRecord() + record.leftover = [.init(txid: String(repeating: "1", count: 64), vout: 0, valueSats: 600)] + record.leftoverSweep = .init(txid: "swept", txHex: "02", valueSats: 400, isAccepted: true, isConfirmed: true) + XCTAssertFalse(record.needsDriving, "the leftover sweep confirmed") + + record = try XCTUnwrap(SparkExitManager.followingLatePayment(record, txid: late)) + XCTAssertTrue(record.needsDriving) + chain.mempoolTxids = [late] + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, late, "still on its way") + XCTAssertTrue(record.needsDriving) + + chain.confirmedTxids = ["swept"] + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(late, value: 5000)] + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) + XCTAssertTrue(record.leftover.contains { $0.txid == late }) + XCTAssertTrue(record.isLeftoverSweepPending, "swept back to savings") + } + + func testOnlyAnEndedExitFollowsALateFeeBumpPayment() { + XCTAssertNil(SparkExitManager.followingLatePayment(SparkExitFixtures.record(phase: .running), txid: "late"), "funding adopts it") + XCTAssertNil(SparkExitManager.followingLatePayment(endedRecord(), txid: nil)) + var following = endedRecord() + following.leftover = [] + following.fundingPaymentTxid = "earlier" + XCTAssertNil(SparkExitManager.followingLatePayment(following, txid: "late"), "an earlier payment is still followed") + } + /// A payment no host sees is forgotten by the own-payment expiry, and the ended exit stops being driven. func testEndedExitForgetsASavingsPaymentNoHostSees() async throws { var record = endedWithPayment(String(repeating: "d", count: 64)) From a456b6ac476412226c33d786052208610896fbf7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 12:57:34 +0100 Subject: [PATCH 081/378] fix: keep the spark exit transfer while failed leaves are retried --- Bitkit/Managers/SparkExitManager.swift | 18 ++++++++++++------ BitkitTests/SparkExitReviewTests.swift | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 1c36ae6ff..3e5fe6e50 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -381,24 +381,30 @@ final class SparkExitManager { } } - /// Ends the exit's transfer once the sweep reaches savings, once only leaves the operators moved on-chain are left, - /// or once the exit ended, so its amount is never counted both as in transfer and in the on-chain balance. Runs on - /// every tick and launch until it holds. A sweep of a generation the failed-leaf retry replaced is looked for too. + /// Ends the exit's transfer once the sweep reaches savings or once the exit ended, so its amount is never counted + /// both as in transfer and in the on-chain balance. Runs on every tick and launch until it holds. A sweep of a + /// generation the failed-leaf retry replaced is looked for too. private func settleIfFinished(_ record: SparkExitRecord) async { guard let transferId = record.transferId, let transfer = try? TransferStorage.shared.getById(transferId) else { return } let activity = CoreService.shared.activity - var isFinished = !record.isActive || record.isRetryingFailedLeaves + var isSweepSeen = false // The sweep's activity is marked as a transfer even after the transfer settled: it may reach the activity // list only later. for txid in SparkExitMath.sweepTxids(record) { guard await activity.hasOnchainActivityForTxid(txid: txid) else { continue } await activity.markOnchainActivityAsTransfer(txId: txid) - isFinished = true + isSweepSeen = true } - guard isFinished, !transfer.isSettled else { return } + guard Self.isTransferFinished(record, isSweepSeen: isSweepSeen), !transfer.isSettled else { return } try? await TransferService(lightningService: .shared).markSettled(id: transferId) } + /// The exit's transfer ends once the exit ended, or once its sweep reached savings and no failed cooperative leaf is + /// still retried: the value those leaves hold stays in transfer, less what already reached savings (`inTransferSats`). + nonisolated static func isTransferFinished(_ record: SparkExitRecord, isSweepSeen: Bool) -> Bool { + !record.isActive || (isSweepSeen && !record.isRetryingFailedLeaves) + } + /// Settles exit transfers no running exit owns, so their amount never stays in transfer to savings for good. private func settleOrphanedTransfers(_ record: SparkExitRecord?) async { guard let active = try? TransferStorage.shared.getActiveTransfers() else { return } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 2efad968d..0e688f5a2 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -588,4 +588,23 @@ final class SparkExitReviewTests: XCTestCase { ) XCTAssertNil(exit.savingsPayment, "nothing to fund") } + + // MARK: Transfer while failed leaves are retried (pwltr's Android finding on c554a0185) + + /// A confirmed sweep never ends the transfer while failed cooperative leaves are still retried: their value stays + /// in transfer to savings until the exit ends. + func testTheTransferStaysWhileFailedLeavesAreRetried() { + var record = SparkExitFixtures.record(phase: .running) + record.failedLeafRetrySince = 1000 + XCTAssertTrue(record.isRetryingFailedLeaves) + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true)) + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: false)) + + record.failedLeafRetrySince = nil + XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "the sweep reached savings") + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: false)) + + record.phase = .done + XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: false), "the exit ended") + } } From b3e226124fc7ee1b280e45c1421251532915ee14 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 13:40:47 +0100 Subject: [PATCH 082/378] feat: open the hardware transfer from funding With Spark ready, the Funding screen's Transfer from HW wallet option is enabled and opens the hardware-wallet Transfer to Spending flow, as the hardware wallet screen does: the intro the first time, then the amount screen, for the first paired wallet with a balance, else the first paired wallet. With no wallet paired it opens the connect sheet. While Spark isn't ready it stays disabled. --- Bitkit/Views/Transfer/FundingOptions.swift | 29 +++++++++++++++---- BitkitTests/FundingHardwareTests.swift | 26 +++++++++++++++++ .../transfer/spark-transfer-to-spending.xml | 11 ++++--- 3 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 BitkitTests/FundingHardwareTests.swift diff --git a/Bitkit/Views/Transfer/FundingOptions.swift b/Bitkit/Views/Transfer/FundingOptions.swift index 39962f6d5..8c710cd97 100644 --- a/Bitkit/Views/Transfer/FundingOptions.swift +++ b/Bitkit/Views/Transfer/FundingOptions.swift @@ -5,6 +5,7 @@ struct FundingOptions: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager @State private var showNoFundsAlert = false @@ -13,6 +14,20 @@ struct FundingOptions: View { SparkManager.shared.isReady } + /// Where the hardware-wallet option starts Transfer to Spending, from a funded wallet first; nil when none is paired. + static func hardwareFundingRoute(wallets: [HwWallet], hasSeenIntro: Bool) -> Route? { + guard let wallet = wallets.first(where: { $0.balanceSats > 0 }) ?? wallets.first else { return nil } + return hasSeenIntro ? .spendingAmountHw(walletId: wallet.id) : .spendingIntroHw(walletId: wallet.id) + } + + private func fundWithHardware() { + if let route = Self.hardwareFundingRoute(wallets: hwWalletManager.wallets, hasSeenIntro: app.hasSeenTransferToSpendingIntro) { + navigation.navigate(route) + } else { + sheets.showSheet(.hardwareConnect) + } + } + var body: some View { VStack(spacing: 0) { NavigationBar(title: t("lightning__funding__nav_title")) @@ -56,13 +71,15 @@ struct FundingOptions: View { if !canFundWithSpark { RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), isDisabled: true, testID: "FundTransfer") {} } + RectangleButton( + icon: "device-tablet-speaker", + title: t("lightning__funding__button_hw"), + isDisabled: !canFundWithSpark, + testID: "FundHardware" + ) { + fundWithHardware() + } if !canFundWithSpark { - RectangleButton( - icon: "device-tablet-speaker", - title: t("lightning__funding__button_hw"), - isDisabled: true, - testID: "FundHardware" - ) {} RectangleButton(icon: "qr", title: t("lightning__funding__button2"), isDisabled: true, testID: "FundReceive") {} } } diff --git a/BitkitTests/FundingHardwareTests.swift b/BitkitTests/FundingHardwareTests.swift new file mode 100644 index 000000000..36c466c19 --- /dev/null +++ b/BitkitTests/FundingHardwareTests.swift @@ -0,0 +1,26 @@ +@testable import Bitkit +import XCTest + +/// Where the Funding screen's hardware-wallet option starts Transfer to Spending once Spark is ready. +final class FundingHardwareTests: XCTestCase { + private func wallet(_ id: String, sats: UInt64) -> HwWallet { + HwWallet(id: id, walletId: id, name: id, model: nil, isConnected: false, balanceSats: sats) + } + + func testNoPairedWalletHasNoRoute() { + XCTAssertNil(FundingOptions.hardwareFundingRoute(wallets: [], hasSeenIntro: true)) + } + + func testFundedWalletIsChosenFirst() { + let wallets = [wallet("empty", sats: 0), wallet("funded", sats: 5000)] + XCTAssertEqual(FundingOptions.hardwareFundingRoute(wallets: wallets, hasSeenIntro: true), .spendingAmountHw(walletId: "funded")) + XCTAssertEqual(FundingOptions.hardwareFundingRoute(wallets: wallets, hasSeenIntro: false), .spendingIntroHw(walletId: "funded")) + } + + func testUnfundedWalletStillOpensItsTransfer() { + XCTAssertEqual( + FundingOptions.hardwareFundingRoute(wallets: [wallet("empty", sats: 0)], hasSeenIntro: true), + .spendingAmountHw(walletId: "empty") + ) + } +} diff --git a/journeys/transfer/spark-transfer-to-spending.xml b/journeys/transfer/spark-transfer-to-spending.xml index 0827d2ead..39ca4c6eb 100644 --- a/journeys/transfer/spark-transfer-to-spending.xml +++ b/journeys/transfer/spark-transfer-to-spending.xml @@ -2,9 +2,11 @@ Verifies the transfer to spending flow on Spark. With Spark ready, Funding leads with "Transfer from Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") above "Manual Setup" (testTag - "FundManual"), and the Savings and Spending transfer buttons open the spending intro, then the amount screen - (testTag "SpendingAmount"), instead of the Transfer Unavailable sheet. The confirm screen shows the network fee, - the spending fee, what reaches spending and the total, and a choice between Instant and Later. What happens after + "FundManual") and an enabled "Transfer from HW wallet" (testTag "FundHardware"), which opens a paired hardware + wallet's transfer to spending, a funded one first, or the connect sheet when none is paired; and the Savings and + Spending transfer buttons open the spending intro, then the amount screen (testTag "SpendingAmount"), + instead of the Transfer Unavailable sheet. The confirm screen shows the network fee, the spending fee, what reaches + spending and the total, and a choice between Instant and Later. What happens after the swipe depends on the deposit Spark sees and the provider's quote; the setting-up states are covered by the previews and unit tests, so this journey stops before the swipe. @@ -21,7 +23,8 @@ Tap "Learn More" (testTag "SpendingConfirmMore"), verify the screen explains Instant and Later and shows a spending-only balance bar, then tap "Understood" (testTag "LiquidityContinue") Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap "Transfer From Savings" (testTag "TransferFromSavings") and verify the amount screen opens without the intro Go back to the wallet home, open Settings › Advanced › Lightning Connections and tap add connection (testTag "add_connection_button") - Verify "Transfer from Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") are enabled above "Manual Setup" (testTag "FundManual") + Verify "Transfer from Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") are enabled above "Manual Setup" (testTag "FundManual"), and "Transfer from HW wallet" (testTag "FundHardware") is enabled below it + With no hardware wallet paired, tap "Transfer from HW wallet" and verify the connect hardware wallet sheet opens; close it Tap "Transfer from Savings" and verify the amount screen (testTag "SpendingAmount") opens From 642b1826a28718e6c486be1574dc903ae47171bc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 13:57:06 +0100 Subject: [PATCH 083/378] fix: refuse a channel send while spark can't list an earlier attempt The cross-rail duplicate check read a Spark history error as no earlier attempt, so an invoice Spark had tried, before a restart or while Spark was disconnected, could go out again over the channel. Invoices sent over Spark are now remembered across launches, and while Spark's payments can't be read (listing fails, or Spark runs but isn't connected) such an invoice is refused as a pending duplicate. Once Spark lists no live attempt it goes through. An invoice Spark never tried, and every send where Spark doesn't run, still pays over the channel; a Spark wipe forgets the remembered invoices. --- Bitkit/Managers/SparkManager.swift | 1 + Bitkit/Services/LightningService.swift | 59 +++++++++++++++-- Bitkit/ViewModels/WalletViewModel.swift | 1 + BitkitTests/ChannelFreeRailTests.swift | 87 +++++++++++++++++++++++-- 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index a0f454a97..659118435 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -176,6 +176,7 @@ final class SparkManager: ChannelFreeLightningRail { if LightningService.shared.channelFreeRail === self { LightningService.shared.channelFreeRail = nil } + LightningService.shared.forgetRailAttempts() var disconnectError: Error? if let sdk { if let listenerId { diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index cd16f7749..5f615fc70 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1067,9 +1067,37 @@ class LightningService: NodeLifecycleService { /// The node's payment list; tests replace it, as they run without a node. lazy var nodePayments: () async -> [PaymentDetails]? = { [unowned self] in await listPayments() } + /// Whether this build runs the channel-free rail, so a rail that isn't connected may still hold payments; the app + /// sets it, as extensions build this service without the rail. + var channelFreeRailSupported: () async -> Bool = { false } + + /// Payment hashes sent over the rail, kept across launches: while the rail can't list its payments, only these may + /// have an attempt there that a node payment would repeat. + var railAttemptsStore: UserDefaults = .standard + private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" + private static let railAttemptsLimit = 500 + + private func recordRailAttempt(paymentHash: String) { + var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + guard !hashes.contains(paymentHash) else { return } + hashes.append(paymentHash) + railAttemptsStore.set(Array(hashes.suffix(Self.railAttemptsLimit)), forKey: Self.railAttemptsKey) + } + + private func hasRailAttempt(paymentHash: String) -> Bool { + eventCallbackLock.withLock { + railAttemptsStore.stringArray(forKey: Self.railAttemptsKey)?.contains(paymentHash.lowercased()) ?? false + } + } + + /// Called when the rail's wallet is wiped, as its payments go with it. + func forgetRailAttempts() { + eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.railAttemptsKey) } + } + /// An invoice must never go out on both rails: an earlier attempt still pending on `rails` refuses the payment as a - /// duplicate, as LDK does its own, and a settled one returns its hash as paid. A failed, unknown or unlistable one - /// lets the payment go. + /// duplicate, as LDK does its own, and a settled one returns its hash as paid. A failed or unknown one lets the + /// payment go. While the rail can't list its payments, an invoice it was sent over is refused as pending too. private func settledEarlierAttempt(paymentHash: String?, on rails: [LightningPaymentRail]) async throws -> PaymentHash? { guard let paymentHash else { return nil } var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] @@ -1078,10 +1106,15 @@ class LightningService: NodeLifecycleService { }) { statuses.append(payment.status) } - if rails.contains(.channelFree), let payments = try? await channelFreeRailPayments(), - let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) - { - statuses.append(payment.status) + if rails.contains(.channelFree) { + if let payments = await readableRailPayments() { + if let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) { + statuses.append(payment.status) + } + } else if hasRailAttempt(paymentHash: paymentHash) { + Logger.warn("Refused paying '\(paymentHash)', Spark can't list the earlier attempt now", context: "LightningService") + throw Self.pendingDuplicate(paymentHash: paymentHash) + } } if statuses.contains(.succeeded) { Logger.info("Skipped paying '\(paymentHash)', an earlier attempt already paid it", context: "LightningService") @@ -1094,13 +1127,25 @@ class LightningService: NodeLifecycleService { return nil } + /// Nil while the rail's payments can't be read: listing them failed, or the rail runs here but isn't connected. + private func readableRailPayments() async -> [ChannelFreeRailPayment]? { + if let rail = channelFreeRail { + return try? await rail.lightningPayments() + } + return await channelFreeRailSupported() ? nil : [] + } + private static func pendingDuplicate(paymentHash: String?) -> NodeError { .DuplicatePayment(message: "An earlier attempt to pay '\(paymentHash ?? "")' is still pending") } private func beginRailSend(paymentHash: String?) -> Bool { guard let paymentHash else { return true } - return eventCallbackLock.withLock { railSendsInFlight.insert(paymentHash.lowercased()).inserted } + return eventCallbackLock.withLock { + let hash = paymentHash.lowercased() + recordRailAttempt(paymentHash: hash) + return railSendsInFlight.insert(hash).inserted + } } private func endRailSend(paymentHash: String?) { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1b91723c3..c7a5e5ba1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -174,6 +174,7 @@ class WalletViewModel: ObservableObject { } lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } + lightningService.channelFreeRailSupported = { await MainActor.run { SparkManager.shared.isSupported } } SparkManager.shared.onStateChanged = { [weak self] in Task { @MainActor in guard let self else { return } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 18e410a33..93a354842 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -10,17 +10,29 @@ final class ChannelFreeRailTests: XCTestCase { private var rail: FakeRail! private var previousRail: Bitkit.ChannelFreeLightningRail? + private var previousAttemptsStore: UserDefaults! + private var previousRailSupported: (() async -> Bool)! override func setUp() { super.setUp() rail = FakeRail() - previousRail = Bitkit.LightningService.shared.channelFreeRail - Bitkit.LightningService.shared.channelFreeRail = rail + let service = Bitkit.LightningService.shared + previousRail = service.channelFreeRail + service.channelFreeRail = rail + previousAttemptsStore = service.railAttemptsStore + service.railAttemptsStore = UserDefaults(suiteName: "ChannelFreeRailTests")! + service.forgetRailAttempts() + previousRailSupported = service.channelFreeRailSupported + service.channelFreeRailSupported = { true } } override func tearDown() { - Bitkit.LightningService.shared.clearEventCallback() - Bitkit.LightningService.shared.channelFreeRail = previousRail + let service = Bitkit.LightningService.shared + service.forgetRailAttempts() + service.railAttemptsStore = previousAttemptsStore + service.channelFreeRailSupported = previousRailSupported + service.clearEventCallback() + service.channelFreeRail = previousRail rail = nil super.tearDown() } @@ -192,6 +204,73 @@ extension ChannelFreeRailTests { _ = try await first.value } + func testAnInvoiceSparkTriedIsNotSentThroughTheNodeWhileSparkCannotListPayments() async throws { + try await sendOverSpark() + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + } + + func testAnInvoiceSparkTriedIsNotSentThroughTheNodeWhileSparkIsNotConnected() async throws { + try await sendOverSpark() + Bitkit.LightningService.shared.channelFreeRail = nil + + await assertRefusedAsDuplicate() + } + + func testAnInvoiceSparkTriedGoesToTheNodeOnceSparkListsNoLiveAttempt() async throws { + try await sendOverSpark() + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testAnInvoiceSparkNeverTriedGoesToTheNodeWhileSparkCannotListPayments() async { + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testAnInvoiceGoesToTheNodeWhenSparkIsUnsupported() async { + let service = Bitkit.LightningService.shared + service.channelFreeRail = nil + service.channelFreeRailSupported = { false } + + await assertReachesTheNode() + } + + private func sendOverSpark() async throws { + rail.balanceSats = 100_000 + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTAssertEqual(rail.sentBolt11, Self.invoice) + rail.sentBolt11 = nil + } + + private func assertRefusedAsDuplicate(file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the send to be refused as a duplicate", file: file, line: line) + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)", file: file, line: line) + } + XCTAssertNil(rail.sentBolt11, file: file, line: line) + } + + /// Tests run without a node, so a payment that gets past the duplicate check fails as no node set up. + private func assertReachesTheNode(file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("Expected the send to reach the missing node", file: file, line: line) + } catch { + XCTAssertFalse(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)", file: file, line: line) + XCTAssertTrue("\(error)".contains("nodeNotSetup"), "\(error)", file: file, line: line) + } + } + func testCanSendLeavesRoomForTheSparkFee() { rail.balanceSats = 100_000 let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: 100_000) From bbcc069ca84647959b668d244c1eb391505cc857 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 13:59:41 +0100 Subject: [PATCH 084/378] fix: keep the spark withdrawal journal until its proof is released --- Bitkit/Managers/SparkManager.swift | 27 +++++++++++++------ .../Services/PaykitPaymentProofService.swift | 8 ++++-- .../PaykitPaymentProofServiceTests.swift | 21 +++++++++++++++ BitkitTests/PaykitRouteTests.swift | 18 +++++++++++++ 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index bb6b8b66a..6fec8b94d 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -575,10 +575,7 @@ final class SparkManager: ChannelFreeLightningRail { await recordContactWithdrawal(payment, txId: txId, pending: pending) } else if allowClear, Self.canCloseUnlisted(pending, now: now) { Logger.info("Closed a Spark contact withdrawal Spark never listed", context: "SparkManager") - Self.pendingContactWithdrawal = nil - if let request = pending.request { - await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) - } + await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof) } } @@ -612,10 +609,7 @@ final class SparkManager: ChannelFreeLightningRail { /// saved, so a failed or interrupted write is retried from the journal. private func recordContactWithdrawal(_ payment: Payment, txId: String, pending: SparkContactWithdrawal) async { guard payment.status != .failed else { - Self.pendingContactWithdrawal = nil - if let request = pending.request { - await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) - } + await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof) return } Self.contactWithdrawalTxIds.insert(txId) @@ -654,6 +648,23 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Closes the journal of a withdrawal that never went out, but only once its request's started proof is released: + /// a proof left behind would block the request, so the journal stays for the next sync to retry. + static func closeUnsentWithdrawal( + _ pending: SparkContactWithdrawal, + releaseProof: (SparkContactWithdrawal.PaykitRequest) async -> Bool + ) async { + if let request = pending.request, await !releaseProof(request) { + Logger.warn("Kept the Spark contact withdrawal journal: its unsent proof is not released yet", context: "SparkManager") + return + } + pendingContactWithdrawal = nil + } + + private static func releaseUnsentProof(_ request: SparkContactWithdrawal.PaykitRequest) async -> Bool { + await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) + } + /// Saves the proof, then the activity, then its tags; true only when every write succeeded. static func recordWithdrawalLocally( saveProof: () async -> Bool, diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 25b1b4fa3..d816e93e4 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1360,12 +1360,14 @@ actor PaykitPaymentProofService { } /// Drops the started on-chain proof of a request whose Spark withdrawal never went out, giving back the private list - /// version it consumed when the contact's list is still at exactly that version. + /// version it consumed when the contact's list is still at exactly that version. False when the proof could not be + /// dropped, so the withdrawal's journal stays open for a retry. + @discardableResult func failSparkOnchainPayment( requestId: PaykitPaymentRequest.ID, identity: String, privatePaykitService: PrivatePaykitService = .shared - ) async { + ) async -> Bool { do { try await mutationLock.withLock { let proofs = try await loadProofs() @@ -1392,8 +1394,10 @@ actor PaykitPaymentProofService { throw error } } + return true } catch { logWarning("Failed to clear an unsent Spark on-chain proof: \(error)") + return false } } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 2ef863130..9c170c3e7 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1926,6 +1926,27 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remaining.isEmpty) } + func testAnUnsentSparkWithdrawalProofThatCantBeDroppedSaysSo() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await store.failNextSave() + let failed = await service.failSparkOnchainPayment(requestId: request.id, identity: identity) + + XCTAssertFalse(failed, "The journal stays open only when the caller learns the proof is still there") + let remaining = await store.snapshot() + XCTAssertEqual(remaining.count, 1) + let retried = await service.failSparkOnchainPayment(requestId: request.id, identity: identity) + XCTAssertTrue(retried) + let afterRetry = await store.snapshot() + XCTAssertTrue(afterRetry.isEmpty) + } + func testAnUnsentSparkWithdrawalGivesBackItsPrivateListAfterARelaunch() async throws { snapshotAppDefaultsDomain() UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 9fed20e87..ea01c0a7e 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -207,6 +207,24 @@ final class PaykitRouteTests: XCTestCase { XCTAssertFalse(tagsFailed) } + func testUnsentWithdrawalKeepsItsJournalUntilTheProofIsReleased() async { + snapshotAppDefaultsDomain() + let request = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee"), + identity: "payer" + ) + let pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) + SparkManager.pendingContactWithdrawal = pending + + await SparkManager.closeUnsentWithdrawal(pending) { _ in false } + XCTAssertEqual(SparkManager.pendingContactWithdrawal, pending, "A proof that wasn't released keeps the journal, so the next sync retries it") + + var released: SparkContactWithdrawal.PaykitRequest? + await SparkManager.closeUnsentWithdrawal(pending) { released = $0; return true } + XCTAssertEqual(released, request) + XCTAssertNil(SparkManager.pendingContactWithdrawal) + } + func testLegPreparesTheRequestBeforeItsProofIsStarted() async throws { var steps: [String] = [] let identity = try await PaykitRouteService.startLegRequestProof( From d7883b01e60b0bf5b7b07c30572dd219834ba2c0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 14:08:32 +0100 Subject: [PATCH 085/378] fix: count spark-routed requests in the allowance ledger --- Bitkit/Services/PaykitRouteService.swift | 32 ++++++++++++++++ .../Wallets/Send/SendConfirmationView.swift | 32 ++++++++++++---- BitkitTests/PaykitRouteTests.swift | 38 +++++++++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 889bdbbe8..c8b33dfd1 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -1,5 +1,6 @@ import BitkitCore import Foundation +import Paykit /// A contact payment bitkit-core's Paykit router picks the way for: the contact's bitcoin endpoints, whether a Spark leg /// may reach its on-chain ones, and the leg once the router picked one. @@ -177,6 +178,37 @@ enum PaykitRouteService { } } + /// Pays a leg as a manual payment the allowance ledger counts, as a direct send is: the attempt is reserved right + /// before Spark's send, marked sending once the request is authorized, and closed with the send's outcome: + /// succeeded with its txid, failed when Spark refused it before sending, and unknown otherwise. + static func payReportingAllowance( + beginAllowance: @escaping () async throws -> String?, + authorize: @escaping () async throws -> Void, + markSending: @escaping (String) async -> Void, + finishAllowance: (String, Paykit.PaymentOutcome, String?) async -> Void, + pay: (_ beforeSend: () async throws -> Void) async throws -> String + ) async throws -> String { + var attemptId: String? + do { + let txid = try await pay { + attemptId = try await beginAllowance() + try await authorize() + if let attemptId { + await markSending(attemptId) + } + } + if let attemptId { + await finishAllowance(attemptId, .succeeded, txid) + } + return txid + } catch { + if let attemptId { + await finishAllowance(attemptId, error is SparkWithdrawalNotSent ? .failed : .unknown, nil) + } + throw error + } + } + static func pay( _ leg: ServiceRouteLeg, contact: String?, diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 5ae8524a8..1dbf11cb1 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1506,15 +1506,31 @@ struct SendConfirmationView: View { } let txid: String do { - txid = try await PaykitRouteService.pay( - leg, - contact: context?.publicKey, - request: proofRequest, - tags: tagManager.selectedTagsArray - ) { - if let request { - try await paykitPaymentRequestManager.ensurePaymentAllowed(request) + txid = try await PaykitRouteService.payReportingAllowance( + beginAllowance: { + guard let request else { return nil } + return try await PaykitAllowanceExecutor.shared.beginManualPayment( + request, + paymentEndpointIdentifier: leg.endpoint.methodId + ) + }, + authorize: { + if let request { + try await paykitPaymentRequestManager.ensurePaymentAllowed(request) + } + }, + markSending: { await PaykitAllowanceExecutor.shared.manualOnchainPaymentSending(attemptId: $0) }, + finishAllowance: { + await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: $0, outcome: $1, transactionId: $2) } + ) { beforeSend in + try await PaykitRouteService.pay( + leg, + contact: context?.publicKey, + request: proofRequest, + tags: tagManager.selectedTagsArray, + beforeSend: beforeSend + ) } } catch { // Refused before Spark's send: nothing left, so this call's started proof goes. After it, the journal settles it. diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index ea01c0a7e..a88d7eb10 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -1,6 +1,7 @@ @testable import Bitkit import BitkitCore import BreezSdkSpark +import Paykit import XCTest final class PaykitRouteTests: XCTestCase { @@ -225,6 +226,43 @@ final class PaykitRouteTests: XCTestCase { XCTAssertNil(SparkManager.pendingContactWithdrawal) } + func testLegReportsItsAllowanceAttemptWithTheSendsOutcome() async { + struct Refused: Error {} + struct Lost: Error {} + var events: [String] = [] + func pay( + authorize: @escaping () async throws -> Void = {}, + send: @escaping (() async throws -> Void) async throws -> String + ) async -> String? { + try? await PaykitRouteService.payReportingAllowance( + beginAllowance: { events.append("begin"); return "attempt" }, + authorize: { events.append("authorize"); try await authorize() }, + markSending: { events.append("sending \($0)") }, + finishAllowance: { events.append("finish \($0) \($1) \($2 ?? "-")") }, + pay: send + ) + } + + let txid = await pay { beforeSend in try await beforeSend(); return "txid" } + XCTAssertEqual(txid, "txid") + XCTAssertEqual(events, ["begin", "authorize", "sending attempt", "finish attempt succeeded txid"]) + + events = [] + _ = await pay(authorize: { throw Refused() }) { beforeSend in + do { try await beforeSend() } catch { throw SparkWithdrawalNotSent(underlying: error) } + return "txid" + } + XCTAssertEqual(events, ["begin", "authorize", "finish attempt failed -"], "A send Spark refused paid nothing") + + events = [] + _ = await pay { beforeSend in try await beforeSend(); throw Lost() } + XCTAssertEqual( + events, + ["begin", "authorize", "sending attempt", "finish attempt unknown -"], + "A send that may have gone out stays unknown until recovery settles it" + ) + } + func testLegPreparesTheRequestBeforeItsProofIsStarted() async throws { var steps: [String] = [] let identity = try await PaykitRouteService.startLegRequestProof( From e88af9720eadb6bb6e3dabd12cfc11b1a22049ef Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 14:21:22 +0100 Subject: [PATCH 086/378] fix: show an unresolved spark send as pending --- .../Wallets/Send/SendConfirmationView.swift | 26 ++++++++++++++++++- BitkitTests/SendConfirmationViewTests.swift | 20 ++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 1dbf11cb1..60a270956 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1460,6 +1460,24 @@ struct SendConfirmationView: View { offersSparkLeg && savingsSendableSats < amountSats } + /// Where a failed leg goes: a leg Spark refused before sending paid nothing, so its failure offers Try Again; any + /// other failure may have sent it, so it stays pending until Spark's journal settles it. + static func serviceLegFailureRoute( + _ error: Error, + requestId: PaykitPaymentRequest.ID?, + routingCacheResetAttempted: Bool + ) -> SendRoute { + guard let refusal = error as? SparkWithdrawalNotSent else { + return .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId) + } + return .failure(SendFailureContext( + error: refusal.underlying, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: nil + )) + } + /// Withdraws from Spark to the contact's on-chain address. A request it pays gets an on-chain proof with the /// withdrawal's txid; the proof is started before the send, and Spark's journal settles it if the app stops mid-send. private func performServiceRoutedPayment(_ leg: ServiceRouteLeg) async { @@ -1543,7 +1561,13 @@ struct SendConfirmationView: View { await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: proofRequest.id, identity: proofRequest.identity) } await context?.resolvePrivatePaymentListConsumption(refusedBeforeSend ? .definitePreBroadcastFailure : .uncertain) - throw (error as? SparkWithdrawalNotSent)?.underlying ?? error + Logger.error("Service route payment failed: \(error)", context: "SendConfirmation") + navigationPath.append(Self.serviceLegFailureRoute( + error, + requestId: request?.id, + routingCacheResetAttempted: routingCacheResetAttempted + )) + return } await context?.resolvePrivatePaymentListConsumption(.succeeded) Logger.info("Paid Paykit contact through \(leg.service) in \(txid)", context: "SendConfirmation") diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 9ee79a26b..a4e485692 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -189,4 +189,24 @@ final class SendConfirmationViewTests: XCTestCase { .uncertain ) } + + func testASparkLegThatMayHaveSentStaysPendingInsteadOfOfferingTryAgain() { + struct Refused: Error {} + struct Lost: Error {} + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee") + + let unresolved = SendConfirmationView.serviceLegFailureRoute(Lost(), requestId: requestId, routingCacheResetAttempted: false) + XCTAssertEqual( + unresolved, + .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId), + "Spark may have sent it, so the request stays pending until the journal settles" + ) + + let refused = SendConfirmationView.serviceLegFailureRoute( + SparkWithdrawalNotSent(underlying: Refused()), + requestId: requestId, + routingCacheResetAttempted: false + ) + guard case .failure = refused else { return XCTFail("A leg Spark refused before sending can be tried again") } + } } From 8650abbd6a9162968c158ea3b050a5a1f53d7d24 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 14:09:52 +0100 Subject: [PATCH 087/378] fix: ask for another swipe when the transfer total rises The re-quote before a transfer to spending compared only the network plus claim fees, so a higher network fee offset by a lower claim fee went through while the Total leaving savings rose, and the reverse let less reach spending. A fresh quote now needs another swipe when the Total rises or what reaches spending falls; the toast names the larger change. The hardware-wallet transfer applies the same bound before its device confirmation. --- Bitkit/ViewModels/SparkTransferFlow.swift | 10 ++++++++++ .../Transfer/Hardware/SpendingHwSign.swift | 8 +++----- Bitkit/Views/Transfer/SpendingConfirm.swift | 8 +++----- BitkitTests/SparkPaymentSafetyTests.swift | 19 +++++++++++++++++++ 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 73f428c35..206064004 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -35,6 +35,16 @@ struct SparkTransferQuote: Equatable { depositSats.saturatingAdd(networkFeeSats) } + /// How much more this fresh quote asks than the approved one at `speed`, 0 when it asks no more, so a rise needs + /// another swipe: the larger of the rise in the Total leaving savings and the fall in what reaches spending. A lower + /// fee on one side doesn't offset a higher one on the other. + func increase(over approved: SparkTransferQuote, speed: SparkTransferSpeed) -> UInt64 { + max( + totalSats.saturatingSub(approved.totalSats), + approved.toSpendingSats(for: speed).saturatingSub(toSpendingSats(for: speed)) + ) + } + /// Whether the deposit stays claimable at the most Spark may charge to claim it, at either speed: the mature claim /// runs at that hour's rate under the same cap, so a deposit at or below `instantMaxSats` could be left unclaimed. var coversClaimFee: Bool { diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index b21410338..5802cf78f 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -200,16 +200,14 @@ struct SpendingHwSign: View { ) return nil } - let speed = flow.speed - let shownCost = shown.networkFeeSats.saturatingAdd(shown.claimFeeSats(for: speed)) - let freshCost = fresh.networkFeeSats.saturatingAdd(fresh.claimFeeSats(for: speed)) - guard freshCost <= shownCost else { + let increase = fresh.increase(over: shown, speed: flow.speed) + guard increase == 0 else { app.toast( type: .info, title: t("lightning__spending_confirm__fees_changed_title"), description: t( "lightning__spending_confirm__fees_changed_network", - variables: ["amount": currency.primaryAmountText(sats: freshCost - shownCost)] + variables: ["amount": currency.primaryAmountText(sats: increase)] ) ) return nil diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 4edaab18d..cf265d1a4 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -121,16 +121,14 @@ struct SpendingConfirm: View { ) throw SpendingFeesIncreasedError() } - let speed = flow.speed - let shownCost = shown.networkFeeSats.saturatingAdd(shown.claimFeeSats(for: speed)) - let freshCost = fresh.networkFeeSats.saturatingAdd(fresh.claimFeeSats(for: speed)) - if freshCost > shownCost { + let increase = fresh.increase(over: shown, speed: flow.speed) + if increase > 0 { app.toast( type: .info, title: t("lightning__spending_confirm__fees_changed_title"), description: t( "lightning__spending_confirm__fees_changed_network", - variables: ["amount": currency.primaryAmountText(sats: freshCost - shownCost)] + variables: ["amount": currency.primaryAmountText(sats: increase)] ) ) throw SpendingFeesIncreasedError() diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index fcc779246..0da816b03 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -182,6 +182,25 @@ final class SparkPaymentSafetyTests: XCTestCase { } } + func testAFreshQuoteThatRaisesTheTotalNeedsAnotherSwipe() { + func quote(networkFee: UInt64, instantClaimFee: UInt64) -> SparkTransferQuote { + SparkTransferQuote( + address: "bcrt1q-spark", satsPerVbyte: 2, networkFeeSats: networkFee, depositSats: 100_000, + claimFees: SparkClaimFees(instantMaxSats: instantClaimFee, laterSats: 198) + ) + } + let approved = quote(networkFee: 200, instantClaimFee: 1190) + + // A lower claim fee doesn't offset the higher network fee that leaves savings. + XCTAssertEqual(quote(networkFee: 400, instantClaimFee: 893).increase(over: approved, speed: .instant), 200) + // A higher claim fee leaves less for spending. + XCTAssertEqual(quote(networkFee: 200, instantClaimFee: 1290).increase(over: approved, speed: .instant), 100) + // A lower network fee doesn't offset a higher claim fee that leaves less for spending. + XCTAssertEqual(quote(networkFee: 150, instantClaimFee: 1240).increase(over: approved, speed: .instant), 50) + XCTAssertEqual(quote(networkFee: 150, instantClaimFee: 1190).increase(over: approved, speed: .instant), 0) + XCTAssertEqual(approved.increase(over: approved, speed: .instant), 0) + } + func testHardwareTransferBelowTheClaimFeeNamesTheMinimum() { let quote = SparkTransferQuote( address: "bcrt1q-spark", satsPerVbyte: 20, networkFeeSats: 300, depositSats: 1000, From 7db039f867e113e079666da0bbb8562d55598399 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 14:34:34 +0100 Subject: [PATCH 088/378] fix: settle a spark send's allowance attempt from its journal --- Bitkit/Managers/SparkManager.swift | 26 ++++++++++++++----- Bitkit/Services/PaykitAllowanceExecutor.swift | 11 ++++++++ .../PaykitAllowanceExecutorTests.swift | 20 ++++++++++++++ BitkitTests/PaykitRouteTests.swift | 11 ++++++-- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 6fec8b94d..ed83d1a9b 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -575,7 +575,7 @@ final class SparkManager: ChannelFreeLightningRail { await recordContactWithdrawal(payment, txId: txId, pending: pending) } else if allowClear, Self.canCloseUnlisted(pending, now: now) { Logger.info("Closed a Spark contact withdrawal Spark never listed", context: "SparkManager") - await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof) + await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) } } @@ -609,7 +609,7 @@ final class SparkManager: ChannelFreeLightningRail { /// saved, so a failed or interrupted write is retried from the journal. private func recordContactWithdrawal(_ payment: Payment, txId: String, pending: SparkContactWithdrawal) async { guard payment.status != .failed else { - await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof) + await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) return } Self.contactWithdrawalTxIds.insert(txId) @@ -642,6 +642,9 @@ final class SparkManager: ChannelFreeLightningRail { await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: txId) } if isDurable { + if let request = pending.request { + await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .succeeded, transactionId: txId) + } Self.pendingContactWithdrawal = nil } else { Logger.warn("Kept the Spark contact withdrawal journal: its local records are not all saved yet", context: "SparkManager") @@ -649,14 +652,19 @@ final class SparkManager: ChannelFreeLightningRail { } /// Closes the journal of a withdrawal that never went out, but only once its request's started proof is released: - /// a proof left behind would block the request, so the journal stays for the next sync to retry. + /// a proof left behind would block the request, so the journal stays for the next sync to retry. The request's + /// allowance attempt, left unknown when the send's outcome was, then fails, so the ledger lets it be paid again. static func closeUnsentWithdrawal( _ pending: SparkContactWithdrawal, - releaseProof: (SparkContactWithdrawal.PaykitRequest) async -> Bool + releaseProof: (SparkContactWithdrawal.PaykitRequest) async -> Bool, + failAllowance: (SparkContactWithdrawal.PaykitRequest) async -> Void ) async { - if let request = pending.request, await !releaseProof(request) { - Logger.warn("Kept the Spark contact withdrawal journal: its unsent proof is not released yet", context: "SparkManager") - return + if let request = pending.request { + guard await releaseProof(request) else { + Logger.warn("Kept the Spark contact withdrawal journal: its unsent proof is not released yet", context: "SparkManager") + return + } + await failAllowance(request) } pendingContactWithdrawal = nil } @@ -665,6 +673,10 @@ final class SparkManager: ChannelFreeLightningRail { await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) } + private static func failAllowance(_ request: SparkContactWithdrawal.PaykitRequest) async { + await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .failed) + } + /// Saves the proof, then the activity, then its tags; true only when every write succeeded. static func recordWithdrawalLocally( saveProof: () async -> Bool, diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 3a743e6a9..fa20526ff 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -1373,6 +1373,17 @@ actor PaykitAllowanceExecutor { } } + /// Settles the manual attempt still open for a request once its outcome is learned later, as a Spark withdrawal's + /// journal learns it, so an unsent send no longer blocks the request on the ledger. + func settleManualPayment(requestId: PaykitPaymentRequest.ID, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async { + guard let identity = activeIdentity, + let entry = localState(identity: identity).journal.last(where: { + !$0.isAutomatic && $0.requestId == requestId && !$0.stage.isTerminal + }) + else { return } + await finishManualPayment(attemptId: entry.attemptId, outcome: outcome, transactionId: transactionId) + } + // MARK: Helpers private func checks(_ request: PaykitPaymentRequest, endpointIdentifier: String, trustedTime: String) throws -> Paykit.PaymentExecutionChecks { diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 1f30747a9..ca39c69d0 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1821,6 +1821,26 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertNil(blocked) } + func testALaterSettlementClosesTheOpenManualAttemptOfItsRequest() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + let attemptId = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.onchainIdentifier) + let openAttempt = try XCTUnwrap(attemptId) + await harness.executor.finishManualPayment(attemptId: openAttempt, outcome: .unknown) + + await harness.executor.settleManualPayment(requestId: request.id, outcome: .failed) + await harness.executor.settleManualPayment(requestId: request.id, outcome: .succeeded) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [ + Paykit.PaymentOutcomeReport(attemptId: openAttempt, outcome: .unknown), + Paykit.PaymentOutcomeReport(attemptId: openAttempt, outcome: .failed), + ], "Only the open attempt settles, once") + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.failed]) + } + func testRecoverySettlesAManualLightningPaymentRestoredFromABackupTakenBeforeTheSend() async throws { let harness = AllowanceHarness() await harness.executor.activate(identity: Fixtures.identityKey) diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index a88d7eb10..8a3272a1c 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -217,12 +217,19 @@ final class PaykitRouteTests: XCTestCase { let pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) SparkManager.pendingContactWithdrawal = pending - await SparkManager.closeUnsentWithdrawal(pending) { _ in false } + var failedAllowances: [SparkContactWithdrawal.PaykitRequest] = [] + await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in false }, failAllowance: { failedAllowances.append($0) }) XCTAssertEqual(SparkManager.pendingContactWithdrawal, pending, "A proof that wasn't released keeps the journal, so the next sync retries it") + XCTAssertEqual(failedAllowances, [], "The attempt stays unknown while the journal is still open") var released: SparkContactWithdrawal.PaykitRequest? - await SparkManager.closeUnsentWithdrawal(pending) { released = $0; return true } + await SparkManager.closeUnsentWithdrawal( + pending, + releaseProof: { released = $0; return true }, + failAllowance: { failedAllowances.append($0) } + ) XCTAssertEqual(released, request) + XCTAssertEqual(failedAllowances, [request], "An unsent withdrawal fails its allowance attempt, so the ledger lets the request be paid again") XCTAssertNil(SparkManager.pendingContactWithdrawal) } From f0ee36d86980c54f500c1c7b1c9d62e17b48f3c7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 14:43:13 +0100 Subject: [PATCH 089/378] fix: keep a spark exit sweep pending until its leftover is read --- .../Services/SparkExit/SparkExitDriver.swift | 16 +++++++--- .../Services/SparkExit/SparkExitRecord.swift | 9 ++++-- BitkitTests/SparkExitReviewTests.swift | 29 +++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index cd5b95cb2..77477220e 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -747,6 +747,10 @@ struct SparkExitDriver { /// accepted but unconfirmed for `sweepReplaceBlocks`, is built again over the same coins at the network's current /// rate, which replaces it (RBF). private func sweepLeftover(_ record: inout SparkExitRecord) async throws { + if record.leftoverSweep?.isRemainingUnread == true { + await followRemainingOutputs(&record) + return + } // A sweep it replaced may confirm instead: that ends the sweep as well. for txid in record.leftoverSweep?.replacedTxids ?? [] where record.leftoverSweep?.isConfirmed != true { if let status = try? await chain.txStatus(txid: txid), status.confirmed { @@ -896,11 +900,15 @@ struct SparkExitDriver { /// After a sweep confirms, reads the funding address again: outputs the sweep left out (too small for their fee, /// or unconfirmed when it was built) start a new wait of their own, so they are swept once worth it or kept with - /// their path after `sweepWaitBlocks`. What the confirmed sweeps sent is kept for the result. + /// their path after `sweepWaitBlocks`. What the confirmed sweeps sent is kept for the result. While the tip or the + /// address cannot be read the sweep stays pending, and a later tick reads them again. private func followRemainingOutputs(_ record: inout SparkExitRecord) async { - guard let sweep = record.leftoverSweep, let txid = sweep.txid, let address = record.funding.first?.address, - let tip = try? await chain.tipHeight(), let listed = try? await chain.utxos(address: address) - else { return } + guard let sweep = record.leftoverSweep, let txid = sweep.txid, let address = record.funding.first?.address else { return } + guard let tip = try? await chain.tipHeight(), let listed = try? await chain.utxos(address: address) else { + record.leftoverSweep?.isRemainingUnread = true + return + } + record.leftoverSweep?.isRemainingUnread = nil // A host behind the block can still list what the sweep spent. let spent = Set((sweep.txHex.flatMap(SparkExitTxid.inputOutpoints(hex:)) ?? []).map { "\($0.txid):\($0.vout)" }) let utxos = listed.filter { !spent.contains("\($0.txid):\($0.vout)") } diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index de53680b8..3908e2427 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -103,6 +103,9 @@ struct SparkExitRecord: Codable, Equatable { var rejection: String? /// Set once the chain host shows the accepted sweep in a block. var isConfirmed: Bool? + /// Confirmed while the tip or the funding address could not be read: what the sweep left out is read on a + /// later tick, and the sweep stays pending until then. + var isRemainingUnread: Bool? /// The rate it was built at; nil for the exit's own rate. var feeRateSatPerVbyte: UInt64? /// The tip when a node first accepted it, so one that does not confirm is replaced at a higher rate. @@ -258,12 +261,12 @@ struct SparkExitRecord: Codable, Equatable { } } - /// An ended exit whose leftover fee funding still has to reach savings: not swept yet, or sent but not - /// confirmed. + /// An ended exit whose leftover fee funding still has to reach savings: not swept yet, sent but not confirmed, or + /// confirmed before what it left out was read. var isLeftoverSweepPending: Bool { guard !isActive, !leftover.isEmpty else { return false } guard let leftoverSweep else { return true } - if leftoverSweep.isWaitingForFees { + if leftoverSweep.isWaitingForFees || leftoverSweep.isRemainingUnread == true { return true } return leftoverSweep.txid != nil && (leftoverSweep.rejection == nil || leftoverSweep.isFeeRejection) && leftoverSweep.isConfirmed != true diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 0e688f5a2..2c6a28590 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -308,6 +308,35 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(record.leftoverLine, .waiting(sats: 300)) } + /// A partial sweep that confirms while the funding address cannot be read stays driven, and follows what it left + /// out once the address reads again. + func testAConfirmedPartialSweepStaysDrivenUntilTheAddressReads() async throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000), .init(txid: "c2", vout: 0, valueSats: 300)] + record.leftoverSweep = .init(txid: "s1", txHex: "02", valueSats: 4700, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) + chain.confirmedTxids = ["s1"] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c2", value: 300)] + + chain.isTipUnreadable = true + record = try await driver(withSdk: false).tick(record) + XCTAssertTrue(record.needsDriving, "the tip could not be read") + + chain.isTipUnreadable = false + chain.isUtxosUnreadable = true + record = try await driver(withSdk: false).tick(record) + XCTAssertTrue(record.needsDriving, "the funding address could not be read") + XCTAssertNil(record.sweptLeftover) + record = try JSONDecoder().decode(SparkExitRecord.self, from: JSONEncoder().encode(record)) + XCTAssertTrue(record.needsDriving, "after a restart") + + chain.isUtxosUnreadable = false + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(record.sweptLeftover, [.init(txid: "s1", valueSats: 4700)]) + XCTAssertEqual(record.leftover, [.init(txid: "c2", vout: 0, valueSats: 300)]) + XCTAssertEqual(record.leftoverSweep?.isWaitingForFees, true) + XCTAssertTrue(record.needsDriving) + } + /// A first build holding only cooperative leaves the operators could not co-sign enters the bounded retry. func testAFirstBuildOfOnlyFailedCooperativeLeavesWaitsForTheOperators() async throws { let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "w", outputVout: 0, error: .operatorsUnavailable(message: "down")) From 77a1e49a30d73511850ec47692f433243fb7d2d4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 14:43:23 +0100 Subject: [PATCH 090/378] fix: keep the spark exit transfer while failed leaves await retry --- Bitkit/Managers/SparkExitManager.swift | 5 +++-- Bitkit/Services/SparkExit/SparkExitRecord.swift | 6 ++++++ BitkitTests/SparkExitReviewTests.swift | 13 +++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 3e5fe6e50..5fa9edc56 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -400,9 +400,10 @@ final class SparkExitManager { } /// The exit's transfer ends once the exit ended, or once its sweep reached savings and no failed cooperative leaf is - /// still retried: the value those leaves hold stays in transfer, less what already reached savings (`inTransferSats`). + /// outstanding, before or during its retry: the value those leaves hold stays in transfer, less what already + /// reached savings (`inTransferSats`). nonisolated static func isTransferFinished(_ record: SparkExitRecord, isSweepSeen: Bool) -> Bool { - !record.isActive || (isSweepSeen && !record.isRetryingFailedLeaves) + !record.isActive || (isSweepSeen && !record.hasFailedLeavesOutstanding) } /// Settles exit transfers no running exit owns, so their amount never stays in transfer to savings for good. diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 3908e2427..0af84b6d8 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -416,6 +416,12 @@ struct SparkExitRecord: Codable, Equatable { isActive && failedLeafRetrySince != nil } + /// Cooperative leaves the operators could not co-sign are still on their way to savings: the running recovery lists + /// them, or their retry runs. + var hasFailedLeavesOutstanding: Bool { + isRetryingFailedLeaves || (isActive && !(recovery?.response.failed.isEmpty ?? true)) + } + /// Waiting for the operators to co-sign leaves they moved on-chain: nothing of the exit's own is left to send. var isWaitingForOperators: Bool { isRetryingFailedLeaves && !(recovery?.response.failed.isEmpty ?? true) diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 2c6a28590..52cbd2e36 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -636,4 +636,17 @@ final class SparkExitReviewTests: XCTestCase { record.phase = .done XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: false), "the exit ended") } + + /// A sweep seen before the failed-leaf retry starts never ends the transfer: the running recovery already lists + /// cooperative leaves the operators could not co-sign, and their value stays in transfer. + func testTheTransferStaysWhileTheRecoveryListsFailedLeavesBeforeTheirRetry() { + var record = SparkExitFixtures.record(phase: .running) + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "w", outputVout: 0, error: .operatorsUnavailable(message: "down")) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")], failed: [failure])) + XCTAssertNil(record.failedLeafRetrySince) + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true)) + + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])) + XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "every leaf is on its way to savings") + } } From 0a7b433e9a7b6ac5144c82a0701b61c0868bde5a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 14:51:48 +0100 Subject: [PATCH 091/378] fix: keep an in-flight move when its screen is left --- Bitkit/Managers/MoveToSparkManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 2b389d5ba..0a7c369ed 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -132,6 +132,8 @@ final class MoveToSparkManager { /// Counts quotes, moves and resets; a quote writes its result only while it is still the latest. private var generation = 0 private var isQuoting = false + /// A swiped payment that has not settled yet; leaving the screen keeps it, so it is never quoted over. + private var isMoveInFlight = false private let lightningService: LightningService private let spark: SparkManager @@ -148,8 +150,9 @@ final class MoveToSparkManager { await quote(wallet: wallet) } - /// Forgets the last move once its screen is left. + /// Forgets the last move once its screen is left, unless its payment is still in flight. func reset() { + guard !isMoveInFlight else { return } generation += 1 isQuoting = false keepsOutcome = false @@ -180,6 +183,8 @@ final class MoveToSparkManager { guard case let .confirm(quote, isMoving) = state, !isMoving else { return } generation += 1 keepsOutcome = true + isMoveInFlight = true + defer { isMoveInFlight = false } state = .confirm(quote, isMoving: true) do { let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: quote.invoice).paymentHash() From 736e552e89729adddc86dfdbc2c374a26dfa9547 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:00:34 +0100 Subject: [PATCH 092/378] fix: remind about the rest of a short spark exit savings payment --- .../Services/SparkExit/SparkExitDriver.swift | 18 ++++++++++++++- .../Services/SparkExit/SparkExitFunding.swift | 6 +++-- .../Services/SparkExit/SparkExitRecord.swift | 2 ++ BitkitTests/SparkExitReviewTests.swift | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 77477220e..7c0059ab9 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -99,7 +99,10 @@ struct SparkExitDriver { guard let recovering else { return try saveIfChanged(record, from: before) } // Nothing to fund when every leaf is recovered cooperatively. if let shortfall = record.fundingShortfallSats { - guard try await adoptFunding(&record, requiredSats: shortfall) else { return try saveIfChanged(record, from: before) } + guard try await adoptFunding(&record, requiredSats: shortfall) else { + await countShortOwnPayment(&record) + return try saveIfChanged(record, from: before) + } } try await build(&record, recovering: recovering) } @@ -166,6 +169,19 @@ struct SparkExitDriver { record.fundingPaymentMissingSince = nil } + /// A savings payment that confirmed at the funding address without covering the funding is no longer waited for: it + /// counts as funding seen there, and the rest is asked for and reminded about at once. + private func countShortOwnPayment(_ record: inout SparkExitRecord) async { + guard record.isAwaitingOwnPayment, let paid = record.fundingPaymentTxid, let address = record.funding.first?.address, + let utxos = try? await chain.utxos(address: address), utxos.contains(where: { $0.txid == paid && $0.status.confirmed }) + else { return } + Logger.info("Spark exit savings payment \(paid) confirmed short, asking for the rest", context: "SparkExitDriver") + record.fundingPaymentTxid = nil + record.fundingPaymentSeenAtHeight = nil + record.fundingPaymentMissingSince = nil + record.isFundingPaymentShort = true + } + /// An ended exit's savings payment that had not arrived: once at the funding address it joins the leftover and is /// swept with it, after any sweep in flight settles (a confirmed one is kept in `sweptLeftover`). One no host /// sees is forgotten by the own-payment expiry. diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 275b8f6cf..f740dba23 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -295,8 +295,10 @@ enum SparkExitMath { // forgets it (gone from every host, or not adopted in time). if case let .awaitingFunding(requiredSats) = record.phase { guard record.fundingShortfallSats != nil, !record.isAwaitingOwnPayment else { return lostReminder } - // Started but never funded: Spark stays detached until the user funds or cancels. - let fireAt = max(now, record.createdAt.addingTimeInterval(reminderSeconds(network: network))) + // Started but never funded: Spark stays detached until the user funds or cancels. A savings payment that + // confirmed short is reminded about at once. + let remindAt = record.isFundingPaymentShort == true ? now : record.createdAt.addingTimeInterval(reminderSeconds(network: network)) + let fireAt = max(now, remindAt) return lostReminder + [SparkExitReminder(id: "await-funding-\(requiredSats)", fireAt: fireAt, isFunding: true)] } var reminders = lostReminder diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 0af84b6d8..14d920039 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -194,6 +194,8 @@ struct SparkExitRecord: Codable, Equatable { /// exit asks and reminds again. var fundingPaymentSeenAtHeight: UInt32? var fundingPaymentMissingSince: UInt32? + /// That payment confirmed short of the funding: the funding reminder for the rest fires at once. + var isFundingPaymentShort: Bool? /// The user cancelled after funding reached the address: the exit ends and the funding goes back to savings. var isCancelled: Bool? /// Leftover sweeps that confirmed before outputs they left out were followed again: what each sent to savings. diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 52cbd2e36..0e3ee6651 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -136,6 +136,28 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertNil(record.fundingPaymentTxid) } + /// A savings payment that confirms short of a higher re-quoted funding is no longer waited for: the rest is asked + /// for, and reminded about at once. + func testASavingsPaymentConfirmedShortRemindsForTheRestAtOnce() async throws { + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 35000)) + record.fundingPaymentTxid = "paid" + chain.mempoolTxids = ["paid"] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("paid", value: 20000, confirmed: false)] + + record = try await driver().tick(record) + XCTAssertTrue(record.isAwaitingOwnPayment, "still confirming") + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest).isEmpty) + + chain.confirmedTxids = ["paid"] + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("paid", value: 20000)] + record = try await driver().tick(record) + + XCTAssertFalse(record.isAwaitingOwnPayment) + let reminders = SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest) + XCTAssertEqual(reminders.map(\.isFunding), [true]) + XCTAssertEqual(reminders.first?.fireAt, record.createdAt, "at once, not 4 hours after the start") + } + /// A short rebuild after a Redo stays parked: its generation diverged and is never sent again. func testADivergedRebuildsTopUpSendsNothing() async throws { var record = bumpedRecord() From 2dcd5389c94a99285ee8bf75e3b8d10cad80b23a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:04:34 +0100 Subject: [PATCH 093/378] fix: end a spark withdrawal spark returns as failed as a failure --- Bitkit/Managers/SparkManager.swift | 20 ++++++++++++++++---- BitkitTests/PaykitRouteTests.swift | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index ed83d1a9b..7d76b297e 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -553,12 +553,24 @@ final class SparkManager: ChannelFreeLightningRail { Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") pending.paymentId = response.payment.id Self.pendingContactWithdrawal = pending - guard case let .withdraw(txId, _, _) = response.payment.details else { - await refreshState() - return response.payment.id + if case let .withdraw(txId, _, _) = response.payment.details { + await recordContactWithdrawal(response.payment, txId: txId, pending: pending) } - await recordContactWithdrawal(response.payment, txId: txId, pending: pending) await refreshState() + return try Self.sentWithdrawalId(response.payment) + } + + /// What `withdraw` returns for the payment Spark sent: its txid, or its id while Spark gives none yet. A withdrawal + /// Spark returned as failed paid nothing, so the call ends as a refusal: the caller releases the request's proof, + /// private list and allowance attempt and shows a failure, never a success. + nonisolated static func sentWithdrawalId(_ payment: Payment) throws -> String { + guard payment.status != .failed else { + throw SparkWithdrawalNotSent(underlying: AppError( + message: "wallet__payment_failed_description", + debugMessage: "Spark returned withdrawal '\(payment.id)' as failed" + )) + } + guard case let .withdraw(txId, _, _) = payment.details else { return payment.id } return txId } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 8a3272a1c..aa49589c9 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -261,6 +261,17 @@ final class PaykitRouteTests: XCTestCase { } XCTAssertEqual(events, ["begin", "authorize", "finish attempt failed -"], "A send Spark refused paid nothing") + events = [] + _ = await pay { beforeSend in + try await beforeSend() + throw SparkWithdrawalNotSent(underlying: Refused()) + } + XCTAssertEqual( + events, + ["begin", "authorize", "sending attempt", "finish attempt failed -"], + "A withdrawal Spark returned as failed paid nothing either" + ) + events = [] _ = await pay { beforeSend in try await beforeSend(); throw Lost() } XCTAssertEqual( @@ -270,6 +281,17 @@ final class PaykitRouteTests: XCTestCase { ) } + func testAWithdrawalSparkReturnsAsFailedEndsAsARefusal() throws { + let sent = Self.withdrawal(id: "sent", to: onchain.value, amountSats: 20000, timestamp: 1001) + XCTAssertEqual(try SparkManager.sentWithdrawalId(sent), "tx-sent") + + var failed = sent + failed.status = .failed + XCTAssertThrowsError(try SparkManager.sentWithdrawalId(failed), "A failed withdrawal paid nothing, so it never shows success") { + XCTAssertTrue($0 is SparkWithdrawalNotSent, "A refusal releases the request's proof, private list and allowance attempt") + } + } + func testLegPreparesTheRequestBeforeItsProofIsStarted() async throws { var steps: [String] = [] let identity = try await PaykitRouteService.startLegRequestProof( From ca61254ebb81a4e0f4ee4d59e94c61f16b07c0c4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:07:24 +0100 Subject: [PATCH 094/378] test: let the amount change journey keep two inputs at the shown fee --- journeys/transfer/spending-confirm-amount-change.xml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/journeys/transfer/spending-confirm-amount-change.xml b/journeys/transfer/spending-confirm-amount-change.xml index 6b2813456..18f6a4971 100644 --- a/journeys/transfer/spending-confirm-amount-change.xml +++ b/journeys/transfer/spending-confirm-amount-change.xml @@ -6,10 +6,12 @@ the same or less pays at once; one that costs more pays nothing: the screen shows the new amounts, a "Fees changed" toast explains the increase, and the next swipe pays them. - This journey covers the cheaper path and the re-quote after an amount change. A larger UTXO confirms - while Spending Confirm is open, so the fee priced at the swipe spends one input, costs less and pays - at once. The higher-total path (the "Fees changed" toast) needs fee rates to rise between the quote - and the swipe, which is not a listed capability. + This journey covers the no-higher path and the re-quote after an amount change. A larger UTXO confirms + while Spending Confirm is open, so the transfer is priced at the swipe against a changed wallet. Coin + selection may keep two inputs (150 000 + 150 000, or 150 000 + 500 000) at the same fee, or spend the + new UTXO alone at a lower fee; either way the fresh price costs no more than the one shown, so the + swipe pays at once. The higher-total path (the "Fees changed" toast) needs fee rates to rise between + the quote and the swipe, which is not a listed capability. Precondition: a freshly onboarded Debug build on the staging regtest backend with Spark connected, no spending balance and exactly two confirmed on-chain UTXOs of 150 000 sats each (two From a7d5fb9dccee58319ea680046d74356d0d36c3ae Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:10:29 +0100 Subject: [PATCH 095/378] fix: release a held invoice once spark reports it failed An invoice remembered as sent over Spark stayed held from the channel while Spark couldn't list its payments, even after Spark had reported that send failed. It is now remembered only once Spark submits the payment, and forgotten when Spark reports it failed or lists it as failed or absent, so a channel retry goes through while Spark is disconnected. --- Bitkit/Services/LightningService.swift | 43 +++++++++++++++++++------- BitkitTests/ChannelFreeRailTests.swift | 37 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 5f615fc70..2e3c85642 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1034,7 +1034,10 @@ class LightningService: NodeLifecycleService { sats: sats, maxFeeSats: params?.maxTotalRoutingFeeMsat.map { $0 / 1000 } ?? Self.paymentAmountSats(bolt11: bolt11, sats: sats).map(Self.defaultMaxFeeSats), - beforeSubmission: beforeSubmission + beforeSubmission: { [weak self] in + try beforeSubmission() + self?.recordRailAttempt(paymentHash: paymentHash) + } ) } if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.channelFree]) { @@ -1077,11 +1080,24 @@ class LightningService: NodeLifecycleService { private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" private static let railAttemptsLimit = 500 - private func recordRailAttempt(paymentHash: String) { - var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] - guard !hashes.contains(paymentHash) else { return } - hashes.append(paymentHash) - railAttemptsStore.set(Array(hashes.suffix(Self.railAttemptsLimit)), forKey: Self.railAttemptsKey) + private func recordRailAttempt(paymentHash: String?) { + guard let paymentHash = paymentHash?.lowercased() else { return } + eventCallbackLock.withLock { + var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + guard !hashes.contains(paymentHash) else { return } + hashes.append(paymentHash) + railAttemptsStore.set(Array(hashes.suffix(Self.railAttemptsLimit)), forKey: Self.railAttemptsKey) + } + } + + /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. + private func forgetRailAttempt(paymentHash: String) { + let hash = paymentHash.lowercased() + eventCallbackLock.withLock { + guard var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), hashes.contains(hash) else { return } + hashes.removeAll { $0 == hash } + railAttemptsStore.set(hashes, forKey: Self.railAttemptsKey) + } } private func hasRailAttempt(paymentHash: String) -> Bool { @@ -1108,9 +1124,13 @@ class LightningService: NodeLifecycleService { } if rails.contains(.channelFree) { if let payments = await readableRailPayments() { - if let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) { + let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) + if let payment { statuses.append(payment.status) } + if payment == nil || payment?.status == .failed, !isRailSendInFlight(paymentHash: paymentHash) { + forgetRailAttempt(paymentHash: paymentHash) + } } else if hasRailAttempt(paymentHash: paymentHash) { Logger.warn("Refused paying '\(paymentHash)', Spark can't list the earlier attempt now", context: "LightningService") throw Self.pendingDuplicate(paymentHash: paymentHash) @@ -1141,11 +1161,7 @@ class LightningService: NodeLifecycleService { private func beginRailSend(paymentHash: String?) -> Bool { guard let paymentHash else { return true } - return eventCallbackLock.withLock { - let hash = paymentHash.lowercased() - recordRailAttempt(paymentHash: hash) - return railSendsInFlight.insert(hash).inserted - } + return eventCallbackLock.withLock { railSendsInFlight.insert(paymentHash.lowercased()).inserted } } private func endRailSend(paymentHash: String?) { @@ -1675,6 +1691,9 @@ extension LightningService { /// Spark runs before the node starts, so its events wait until the node's event callback is set rather than being lost. func emitChannelFreeRailEvent(_ event: Event) { + if case let .paymentFailed(_, paymentHash?, _) = event { + forgetRailAttempt(paymentHash: paymentHash) + } if isStaleFailure(event, from: .channelFree) { Logger.info("Ignored a Spark failure for a payment retried over a channel: \(event)", context: "LightningService") return diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 93a354842..30dea9703 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -243,6 +243,39 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } + func testASendSparkReportedFailedIsNotHeldFromTheNodeWhileSparkCannotList() async throws { + try await sendOverSpark() + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: nil)) + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + func testASendSparkListedAsFailedIsNotHeldOnceSparkCannotList() async throws { + try await sendOverSpark() + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.balanceSats = 0 + await assertReachesTheNode() + + rail.listError = NSError(domain: "spark", code: 1) + + await assertReachesTheNode() + } + + func testASendSparkRefusedBeforeDispatchIsNotHeldWhileSparkCannotList() async { + rail.balanceSats = 100_000 + rail.refusal = Bitkit.ChannelFreeRailSendRefused(reason: "refused") + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.refusal = nil + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) @@ -464,12 +497,16 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var sentBolt11: String? var sentFeeLimitSats: UInt64? var sendGate: (() async -> Void)? + var refusal: Bitkit.ChannelFreeRailSendRefused? func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { "lnbcrt1fake" } func send(bolt11: String, sats: UInt64?, maxFeeSats: UInt64?, beforeSubmission: @escaping () throws -> Void) async throws -> PaymentHash { + if let refusal { + throw refusal + } try beforeSubmission() await sendGate?() sentFeeLimitSats = maxFeeSats From efc69adefbfe6fedf7a57668b6cd5b0869dd37a0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:10:30 +0100 Subject: [PATCH 096/378] fix: back up spark exit data once per connection Spark stays connected while the node stops, so backup observing restarted on every foreground found Spark connected and marked its exit data for upload again. The mark now happens once per Spark connection, whether observing started before or after it connected. --- Bitkit/Managers/SparkManager.swift | 9 +++++++++ Bitkit/Services/BackupService.swift | 24 ++++++++++++++++++++++-- BitkitTests/SparkExitBackupTests.swift | 9 +++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 659118435..c8a9a1a27 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -62,6 +62,14 @@ final class SparkManager: ChannelFreeLightningRail { sdk != nil } + /// Counts connects in this process, nil while disconnected, so a backup can tell a new connection from observing + /// that restarted while Spark stayed connected. + var connectionId: Int? { + sdk == nil ? nil : connectionCount + } + + @ObservationIgnored private var connectionCount = 0 + /// Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a /// Spark transfer, which lets two dev builds pay each other's invoices. nonisolated static var prefersSpark: Bool { @@ -144,6 +152,7 @@ final class SparkManager: ChannelFreeLightningRail { await self?.onSdkEvent(event) }) sdk = connected + connectionCount += 1 Logger.info("Connected Spark wallet on '\(network)'", context: "SparkManager") await importRestoredExitState(walletIndex: walletIndex) Self.exitStateChanged.send() diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index f836a0de7..be89f62c2 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -140,6 +140,7 @@ class BackupService { private let statusUpdateQueue = DispatchQueue(label: "backup-service-status-update", qos: .userInitiated) private let stateQueue = DispatchQueue(label: "backup-service-state", qos: .userInitiated) + private var sparkExitMark = SparkExitBackupMark() private let backupStatusesSubject = PassthroughSubject<[BackupCategory: BackupItemStatus], Never>() private let backupFailureSubject = PassthroughSubject() @@ -311,8 +312,10 @@ class BackupService { Logger.debug("Start observing backup statuses and data store changes", context: "BackupService") await installBackupObservers(generation: observation.generation, foregroundGeneration: observation.foregroundGeneration) - // Spark's connect signal is missed when it connected before observing started. - if await SparkManager.shared.isConnected { + // Spark's connect signal is missed when it connected before observing started. Spark stays connected while + // the node stops, so observing restarts on each foreground: mark it once per connection. + let connection = await SparkManager.shared.connectionId + if stateQueue.sync(execute: { sparkExitMark.claim(connection: connection) }) { markBackupRequired(category: .sparkExit) } } @@ -879,6 +882,10 @@ class BackupService { .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } markBackupRequired(category: .sparkExit) + Task { @MainActor in + let connection = SparkManager.shared.connectionId + _ = self.stateQueue.sync { self.sparkExitMark.claim(connection: connection) } + } } .store(in: cancellables) @@ -1638,3 +1645,16 @@ struct BackupFailureNotificationGate { return true } } + +/// Which Spark connection the exit data backup was last marked for, so observing restarting on a foreground doesn't +/// mark it again while Spark stayed connected. +struct SparkExitBackupMark { + private var markedConnection: Int? + + /// True when `connection` is a Spark connection not marked yet, and records it. + mutating func claim(connection: Int?) -> Bool { + guard let connection, connection != markedConnection else { return false } + markedConnection = connection + return true + } +} diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift index e451aed3d..73144a465 100644 --- a/BitkitTests/SparkExitBackupTests.swift +++ b/BitkitTests/SparkExitBackupTests.swift @@ -48,4 +48,13 @@ final class SparkExitBackupTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: Env.sparkExitRestore(walletIndex: walletIndex).path)) } + + func testObservingRestartedOnAForegroundDoesNotMarkTheSameConnectionAgain() { + var mark = SparkExitBackupMark() + + XCTAssertFalse(mark.claim(connection: nil), "Nothing to back up while Spark is disconnected") + XCTAssertTrue(mark.claim(connection: 1)) + XCTAssertFalse(mark.claim(connection: 1), "Spark stayed connected while observing restarted") + XCTAssertTrue(mark.claim(connection: 2), "A new connection is backed up again") + } } From e04fcf4c86e8150159a471f74bec6730aef512c4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:11:17 +0100 Subject: [PATCH 097/378] fix: keep fee row amounts whole beside a long subtitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Spending Confirm and the hardware sign screen, the Later row's long subtitle squeezed its amount, so "≈ ₿ 99" wrapped or clipped at the right edge. A fee row's amount now keeps its full width and the subtitle wraps instead. --- Bitkit/Components/FeeItem.swift | 50 +++++++++++++++++----------- BitkitTests/FeeItemAmountTests.swift | 21 ++++++++++++ 2 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 BitkitTests/FeeItemAmountTests.swift diff --git a/Bitkit/Components/FeeItem.swift b/Bitkit/Components/FeeItem.swift index eb4f70627..e66fd2394 100644 --- a/Bitkit/Components/FeeItem.swift +++ b/Bitkit/Components/FeeItem.swift @@ -78,25 +78,7 @@ struct FeeItem: View { Spacer() if amount > 0 { - VStack(alignment: .trailing, spacing: 0) { - MoneyText( - sats: Int(amount), - unitType: .primary, - size: .bodyMSB, - symbol: true, - prefix: amountPrefix, - color: isDisabled ? .gray3 : .textPrimary - ) - - MoneyText( - sats: Int(amount), - unitType: .secondary, - size: .bodySSB, - symbol: true, - prefix: amountPrefix, - color: isDisabled ? .gray3 : .textSecondary - ) - } + FeeItemAmount(amount: amount, prefix: amountPrefix, isDisabled: isDisabled) } } } @@ -112,3 +94,33 @@ struct FeeItem: View { } } } + +/// The fee in both units, kept whole beside a long subtitle. +struct FeeItemAmount: View { + let amount: UInt64 + var prefix: String? + var isDisabled = false + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + MoneyText( + sats: Int(amount), + unitType: .primary, + size: .bodyMSB, + symbol: true, + prefix: prefix, + color: isDisabled ? .gray3 : .textPrimary + ) + + MoneyText( + sats: Int(amount), + unitType: .secondary, + size: .bodySSB, + symbol: true, + prefix: prefix, + color: isDisabled ? .gray3 : .textSecondary + ) + } + .fixedSize() + } +} diff --git a/BitkitTests/FeeItemAmountTests.swift b/BitkitTests/FeeItemAmountTests.swift new file mode 100644 index 000000000..4b56ce244 --- /dev/null +++ b/BitkitTests/FeeItemAmountTests.swift @@ -0,0 +1,21 @@ +@testable import Bitkit +import SwiftUI +import XCTest + +/// The Later row pairs a long subtitle with "≈ ₿ 99", so the amount must keep its full width. +@MainActor +final class FeeItemAmountTests: XCTestCase { + func testTheAmountKeepsItsWidthWhenTheRowIsNarrow() { + let view = FeeItemAmount(amount: 99, prefix: "≈") + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + let controller = UIHostingController(rootView: view) + + let ideal = controller.sizeThatFits(in: CGSize(width: 1000, height: 1000)) + let squeezed = controller.sizeThatFits(in: CGSize(width: 20, height: 1000)) + + XCTAssertGreaterThan(ideal.width, 20) + XCTAssertEqual(squeezed.width, ideal.width, accuracy: 0.5, "The amount was clipped or wrapped") + XCTAssertEqual(squeezed.height, ideal.height, accuracy: 0.5, "The amount wrapped onto more lines") + } +} From ce4f86be4047e5d822c51614a2ebefd50ba755b9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:36:06 +0100 Subject: [PATCH 098/378] fix: retry spark exit leaves a partial build skipped --- Bitkit/Managers/SparkExitManager.swift | 8 +- .../Services/SparkExit/SparkExitDriver.swift | 75 ++++++++++++++++++- .../Services/SparkExit/SparkExitRecord.swift | 11 ++- BitkitTests/SparkExitTests.swift | 57 ++++++++++++++ 4 files changed, 142 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 5fa9edc56..0b1cd4dd0 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -399,11 +399,11 @@ final class SparkExitManager { try? await TransferService(lightningService: .shared).markSettled(id: transferId) } - /// The exit's transfer ends once the exit ended, or once its sweep reached savings and no failed cooperative leaf is - /// outstanding, before or during its retry: the value those leaves hold stays in transfer, less what already - /// reached savings (`inTransferSats`). + /// The exit's transfer ends once the exit ended, or once its sweep reached savings and no leaf is outstanding (a + /// failed cooperative one before or during its retry, or one a build skipped): the value those leaves hold stays in + /// transfer, less what already reached savings (`inTransferSats`). nonisolated static func isTransferFinished(_ record: SparkExitRecord, isSweepSeen: Bool) -> Bool { - !record.isActive || (isSweepSeen && !record.hasFailedLeavesOutstanding) + !record.isActive || (isSweepSeen && !record.hasLeavesOutstanding) } /// Settles exit transfers no running exit owns, so their amount never stays in transfer to savings for good. diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 7c0059ab9..f2ed888b1 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -121,7 +121,7 @@ struct SparkExitDriver { // Every step in a block, as the chain host shows: the exit ends without waiting for the SDK. Leaves the // operators could not co-sign need the SDK's retry, so that exit waits for it. if let recovery = record.recovery?.response, !recovery.transactions.isEmpty, recovery.failed.isEmpty, - recovery.transactions.allSatisfy(\.isConfirmed) + record.waitingLeafIds == nil, recovery.transactions.allSatisfy(\.isConfirmed) { await finish(&record, phase: .done) } @@ -307,6 +307,11 @@ struct SparkExitDriver { await finish(&record, phase: isRecovered ? .done : .failed(reason: "Nothing left to recover")) return } + // A leaf that may still move is kept apart and tried again before the exit ends. + let isWaitReleased = record.retrySince.flatMap { since in tip.map { $0.saturatingSub(since) >= Self.releaseBlocks } } == true + let waitingLeafIds: [String] = isWaitReleased ? [] : prepared.skipped + .filter { isProvisional($0, prepared: prepared, record: record, tip: tip) } + .map(\.leafId) record.retrySince = nil record.retriedAt = nil let built: RecoverFundsResponse @@ -361,6 +366,7 @@ struct SparkExitDriver { return } record.adopt(built, at: now()) + record.waitingLeafIds = waitingLeafIds.isEmpty ? nil : waitingLeafIds record.spentStepStrikes = strikes.isEmpty ? nil : strikes // Saved before any of it is broadcast, so a crash never leaves broadcast transactions without their record. try save(record) @@ -491,6 +497,9 @@ struct SparkExitDriver { if try await retryFailedLeaves(&record, failed: stored.failed, recovering: recovering, tip: tip) { return } + if try await retryWaitingLeaves(&record, recovering: recovering, tip: tip) { + return + } await finish(&record, phase: .done) return } @@ -530,6 +539,9 @@ struct SparkExitDriver { if try await retryFailedLeaves(&record, failed: checked.recovery.failed, recovering: recovering, tip: tip) { return } + if try await retryWaitingLeaves(&record, recovering: recovering, tip: tip) { + return + } await finish(&record, phase: .done) case .redo: Logger.warn("Spark exit diverged on-chain, rebuilding generation \(record.generation + 1)", context: "SparkExitDriver") @@ -584,6 +596,67 @@ struct SparkExitDriver { return true } + /// Leaves a build skipped while they may still move (`waitingLeafIds`), prepared again on their own once the rest of + /// the exit is done: at once, then at Glow's doubling gaps (`isRetryDue`), until they build or about a week passed + /// (`releaseBlocks`). True while the exit keeps waiting for them, false once it can end. + private func retryWaitingLeaves(_ record: inout SparkExitRecord, recovering: SparkExitRecovering, tip: UInt32?) async throws -> Bool { + let ids = (record.waitingLeafIds ?? []).filter(record.exitLeafIds.contains) + guard !ids.isEmpty else { + record.waitingLeafIds = nil + return false + } + guard let tip else { return true } + let since = record.retrySince ?? tip + if tip.saturatingSub(since) >= Self.releaseBlocks { + Logger.warn("Spark exit lets go of the skipped leaves it waited about a week for: \(ids)", context: "SparkExitDriver") + letGoOfWaitingLeaves(&record) + return false + } + if record.retrySince != nil, !Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) { + return true + } + record.retrySince = since + record.retriedAt = tip + try save(record) + guard !Task.isCancelled else { return true } + await useExitStateSnapshot(&record, recovering: recovering) + do { + let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: ids))) + noteStoppedLeaves(prepared, tip: tip, in: &record) + let still = prepared.skipped.filter { isProvisional($0, prepared: prepared, record: record, tip: tip) }.map(\.leafId) + guard !prepared.leaves.isEmpty else { + guard !still.isEmpty else { + letGoOfWaitingLeaves(&record) + return false + } + record.waitingLeafIds = still + return true + } + let built = try await recovering.recover( + RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs), + signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex) + ) + guard !built.transactions.isEmpty else { return true } + Logger.info("Spark exit recovers \(built.leaves.count) leaves it skipped while they could still move", context: "SparkExitDriver") + record.adopt(built, at: now()) + record.waitingLeafIds = still.isEmpty ? nil : still + record.retrySince = nil + record.retriedAt = nil + try save(record) + try await broadcastReady(&record, tip: tip) + } catch { + Logger.warn("Spark exit could not recover the leaves it skipped yet: \(error)", context: "SparkExitDriver") + } + return true + } + + private func letGoOfWaitingLeaves(_ record: inout SparkExitRecord) { + record.skippedLeafCount = max(record.skippedLeafCount, record.waitingLeafIds?.count ?? 0) + record.waitingLeafIds = nil + record.retrySince = nil + record.retriedAt = nil + } + /// Keeps the value of each failed leaf from what the SDK reports for it, listed or skipped. private func noteFailedLeafValues(_ prepared: PrepareRecoverFundsResponse, failedIds: [String], in record: inout SparkExitRecord) { let reported = Dictionary( diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 14d920039..e402ee435 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -240,6 +240,9 @@ struct SparkExitRecord: Codable, Equatable { /// The tip at which a build first found nothing to build while a leaf may still move, and of its last try. var retrySince: UInt32? var retriedAt: UInt32? + /// The exit's leaves a build skipped while they may still move, with the rest built: tried again on their own + /// before the exit ends, at the same gaps, and let go about a week later. + var waitingLeafIds: [String]? /// The SDK's exit data from just before the first build, imported again before every rebuild. var exitStateSnapshot: String? /// The stored record could not be read, so the exit is rebuilt from the chain with the funding it kept. Kept, @@ -418,10 +421,10 @@ struct SparkExitRecord: Codable, Equatable { isActive && failedLeafRetrySince != nil } - /// Cooperative leaves the operators could not co-sign are still on their way to savings: the running recovery lists - /// them, or their retry runs. - var hasFailedLeavesOutstanding: Bool { - isRetryingFailedLeaves || (isActive && !(recovery?.response.failed.isEmpty ?? true)) + /// Leaves are still on their way to savings outside the running recovery's steps: cooperative ones the operators + /// could not co-sign (the recovery lists them, or their retry runs), or ones a build skipped while they may move. + var hasLeavesOutstanding: Bool { + isRetryingFailedLeaves || (isActive && (!(recovery?.response.failed.isEmpty ?? true) || !(waitingLeafIds ?? []).isEmpty)) } /// Waiting for the operators to co-sign leaves they moved on-chain: nothing of the exit's own is left to send. diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 310048cba..8c24d870a 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1576,6 +1576,63 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertTrue(record.isWaitingForLeaves) } + /// R3 with a leaf that builds: one skipped while it may still move is kept, and tried again before the exit ends. + func testAPartialBuildRetriesTheSkippedLeafBeforeTheExitEnds() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 3) + sdk.preparedResponse.leaves = [] + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running, "leaf-2 may still move") + XCTAssertTrue(record.isWaitingForLeaves) + XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "leaf-2 is still in transfer") + guard case let .specific(leafIds) = sdk.prepareRequests.last?.selection else { return XCTFail("Expected a specific selection") } + XCTAssertEqual(leafIds, ["leaf-2"]) + + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(record.phase, .running, "the exit waits for the SDK to retry leaf-2") + + chain.tip = 1006 + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + record = try await driver().tick(record) + XCTAssertNil(record.retrySince) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"]) + + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + } + + /// A skipped leaf still unverified after about a week is let go, and the exit ends. + func testAPartialBuildLetsTheSkippedLeafGoAfterAWeek() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .fundsNotFound)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2) + sdk.preparedResponse.leaves = [] + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running) + + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertNil(record.retrySince) + } + func testIsRetryDueDoublesUpToADay() { XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 105)) XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 106)) From f54a2469c1fc8961bacba02608746eee7f33ef21 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:41:18 +0100 Subject: [PATCH 099/378] fix: show the spark exit sweep version that confirmed --- .../Services/SparkExit/SparkExitDriver.swift | 9 +++++++ .../Services/SparkExit/SparkExitRecord.swift | 9 +++++++ BitkitTests/SparkExitSweepTests.swift | 24 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index f2ed888b1..b290f002f 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -843,6 +843,12 @@ struct SparkExitDriver { // A sweep it replaced may confirm instead: that ends the sweep as well. for txid in record.leftoverSweep?.replacedTxids ?? [] where record.leftoverSweep?.isConfirmed != true { if let status = try? await chain.txStatus(txid: txid), status.confirmed { + // Its own amount and inputs are what reached savings, not the replacement's. + if let landed = record.acceptedSweeps?.first(where: { $0.txid == txid }) { + record.leftoverSweep?.txid = landed.txid + record.leftoverSweep?.txHex = landed.txHex + record.leftoverSweep?.valueSats = landed.valueSats + } record.leftoverSweep?.isConfirmed = true await followRemainingOutputs(&record) return @@ -942,6 +948,9 @@ struct SparkExitDriver { switch outcome { case .accepted: record.leftoverSweep?.isAccepted = true + if !(record.acceptedSweeps ?? []).contains(where: { $0.txid == txid }) { + record.acceptedSweeps = (record.acceptedSweeps ?? []) + [.init(txid: txid, txHex: hex, valueSats: sweep.valueSats)] + } if record.leftoverSweep?.sentAtHeight == nil { record.leftoverSweep?.sentAtHeight = try? await chain.tipHeight() } diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index e402ee435..5af6837d5 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -85,6 +85,12 @@ struct SparkExitRecord: Codable, Equatable { let valueSats: UInt64 } + struct AcceptedSweep: Codable, Equatable { + let txid: String + let txHex: String + let valueSats: UInt64 + } + struct Leftover: Codable, Equatable { let txid: String let vout: UInt32 @@ -200,6 +206,9 @@ struct SparkExitRecord: Codable, Equatable { var isCancelled: Bool? /// Leftover sweeps that confirmed before outputs they left out were followed again: what each sent to savings. var sweptLeftover: [SweptLeftover]? + /// Every leftover sweep a node accepted, so the one that confirms is shown and followed with its own amount and + /// inputs even after a replacement took its place. + var acceptedSweeps: [AcceptedSweep]? /// What reaches savings: the exited value less the fees paid out of it. var expectedSats: UInt64 var claimableAtHeight: UInt32? diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index fbe6ae245..987bbf71c 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -286,6 +286,30 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertTrue(accepted.needsDriving) } + /// The replaced sweep that confirms is the one shown and followed: its txid, amount and inputs, not the replacement's. + func testAReplacedSweepThatConfirmsIsTheOneRecorded() async throws { + let utxo = SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035) + chain.utxosByAddress[key.address] = [utxo] + chain.fastestFee = 1 + var record = try await driver().tick(endedRecord()) + let first = try XCTUnwrap(record.leftoverSweep) + chain.tip = 1144 + record = try await driver().tick(record) + let replacement = try XCTUnwrap(record.leftoverSweep) + XCTAssertLessThan(replacement.valueSats, first.valueSats) + + // The first confirms, with a later payment still at the address: that one is followed on its own. + let late = SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: 7000) + chain.utxosByAddress[key.address] = [utxo, late] + chain.confirmedTxids = try [XCTUnwrap(first.txid)] + record = try await driver().tick(record) + + XCTAssertEqual(record.sweptLeftover, try [.init(txid: XCTUnwrap(first.txid), valueSats: first.valueSats)]) + XCTAssertEqual(record.leftover.map(\.txid), [late.txid]) + let decoded = try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)) + XCTAssertEqual(decoded, record) + } + /// R11: the Done screen reads what the sweep paid to savings, and its txid. func testDoneShowsWhatReachedSavingsAndTheSweepTxid() throws { let script = try SparkExitAddress.scriptPubKey(destination) From 6ad6a85eca5107352b1ff42f4369e5ef10c2e7b9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:57:13 +0100 Subject: [PATCH 100/378] fix: keep the spark exit start going when its requote write fails --- Bitkit/Managers/SparkExitManager.swift | 55 ++++++++++++++++++++++---- BitkitTests/SparkExitReviewTests.swift | 43 ++++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 0b1cd4dd0..13dff8f00 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -674,17 +674,23 @@ final class SparkExitManager { destination: destination, lostLeafIds: (try? lostLeafStore.load()) ?? [] ), - var requoted = Self.requoted(started, fresh: fresh) + let requoted = Self.requoted(started, fresh: fresh) { let transfers = TransferService(lightningService: .shared) - requoted.transferId = try await transfers.createTransfer(type: .sparkExit, amountSats: fresh.incomingSats) - try store.save(requoted) - if let replaced = started.transferId { - try? await transfers.markSettled(id: replaced) + let store = store + let written = await Self.writeRequote( + requoted, + replacing: started, + incomingSats: fresh.incomingSats, + createTransfer: { try await transfers.createTransfer(type: .sparkExit, amountSats: $0) }, + save: { try store.save($0) }, + markSettled: { try? await transfers.markSettled(id: $0) } + ) + if let written { + started = written + self.record = written + exitQuote = fresh } - started = requoted - self.record = requoted - exitQuote = fresh } await SparkManager.shared.reconnectForExit() Logger.info("Started Spark exit of \(exitQuote.exitSats) sats over \(exitQuote.leafIds.count) leaves", context: "SparkExitManager") @@ -737,6 +743,39 @@ final class SparkExitManager { return record } + /// The started exit moved onto the fresh quote: a transfer for its amount, saved, and the confirmed quote's transfer + /// settled. Nil when that write fails, with a transfer made for it settled again: the start goes on with the + /// confirmed quote, as after a failed requote, so it still reconnects, pays and ticks. + nonisolated static func writeRequote( + _ requoted: SparkExitRecord, + replacing started: SparkExitRecord, + incomingSats: UInt64, + createTransfer: (UInt64) async throws -> String, + save: (SparkExitRecord) throws -> Void, + markSettled: (String) async -> Void + ) async -> SparkExitRecord? { + var requoted = requoted + do { + requoted.transferId = try await createTransfer(incomingSats) + } catch { + Logger.warn("Failed to create the requoted Spark exit transfer, going on with the confirmed quote: \(error)", context: "SparkExitManager") + return nil + } + do { + try save(requoted) + } catch { + Logger.warn("Failed to save the requoted Spark exit, going on with the confirmed quote: \(error)", context: "SparkExitManager") + if let created = requoted.transferId { + await markSettled(created) + } + return nil + } + if let replaced = started.transferId { + await markSettled(replaced) + } + return requoted + } + /// Savings pays at most the funding the user confirmed: a higher fresh amount goes through the shortfall flow on /// Progress, which asks for the rest. nonisolated static func savingsFundingSats(confirmed: SparkExitQuote, fresh: SparkExitQuote) -> UInt64 { diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 0e3ee6651..0a886a0d3 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -591,6 +591,49 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertNil(failed) } + /// A requote whose write fails leaves the start going on with the confirmed quote, its transfer kept and no new + /// one left counted. + func testAFailedRequoteWriteGoesOnWithTheConfirmedQuote() async throws { + struct WriteFailed: Error {} + let started = SparkExitFixtures.record() + let fresh = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 25000, leaves: ["leaf-2"])) + let requoted = try XCTUnwrap(SparkExitManager.requoted(started, fresh: fresh)) + var settled: [String] = [] + + let noTransfer = await SparkExitManager.writeRequote( + requoted, + replacing: started, + incomingSats: fresh.incomingSats, + createTransfer: { _ in throw WriteFailed() }, + save: { _ in }, + markSettled: { settled.append($0) } + ) + XCTAssertNil(noTransfer) + + let notSaved = await SparkExitManager.writeRequote( + requoted, + replacing: started, + incomingSats: fresh.incomingSats, + createTransfer: { _ in "transfer-2" }, + save: { _ in throw WriteFailed() }, + markSettled: { settled.append($0) } + ) + XCTAssertNil(notSaved) + XCTAssertEqual(settled, ["transfer-2"], "the confirmed quote's transfer stays; the unsaved one is settled") + + let written = await SparkExitManager.writeRequote( + requoted, + replacing: started, + incomingSats: fresh.incomingSats, + createTransfer: { _ in "transfer-3" }, + save: { _ in }, + markSettled: { settled.append($0) } + ) + XCTAssertEqual(written?.transferId, "transfer-3") + XCTAssertEqual(written?.leafIds, ["leaf-2"]) + XCTAssertEqual(settled, ["transfer-2", "transfer-1"]) + } + /// Savings never pays more than the funding the user confirmed; the rest is asked for on Progress once the payment /// confirms. func testSavingsPaysAtMostTheConfirmedFundingAndProgressAsksForTheRest() throws { From 1cf08414f679077e367d15628be0bf8ccf35fdd8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:57:39 +0100 Subject: [PATCH 101/378] fix: translate the spark exit strings --- .../Localization/ar.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/ca.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/cs.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/de.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/el.lproj/Localizable.strings | 174 +++++++++--------- .../es-419.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/es.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/fr.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/it.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/nl.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/pl.lproj/Localizable.strings | 174 +++++++++--------- .../pt-BR.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/pt.lproj/Localizable.strings | 174 +++++++++--------- .../Localization/ru.lproj/Localizable.strings | 174 +++++++++--------- 14 files changed, 1218 insertions(+), 1218 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 337f73e81..23ec4c3a3 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -319,93 +319,93 @@ "lightning__spark_provider_value" = "Spark عبر Breez"; "lightning__spark_receiving_unlimited" = "بلا حد"; "lightning__spark_trust" = "يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "خروج طارئ"; +"lightning__spark_exit__nav_title" = "الخروج الطارئ"; +"lightning__spark_exit__intro_title" = "غادر Spark\nعلى السلسلة"; +"lightning__spark_exit__intro_text" = "استخدم هذا فقط إذا توقف Spark عن العمل لديك. ينقل رصيد الإنفاق إلى المدخرات من دون مشغّلي Spark.\n\nيستغرق حتى 4 أسابيع، ويكلّف رسومًا على السلسلة تُدفع من المدخرات، وقد تبقى مبالغ صغيرة جدًا. يعمل في الخلفية، ويُعلمك Bitkit إذا احتاج إليك."; +"lightning__spark_exit__quote_title" = "عرض\nالخروج"; +"lightning__spark_exit__amount" = "المبلغ المراد إخراجه"; +"lightning__spark_exit__fees" = "رسوم الشبكة"; +"lightning__spark_exit__receive" = "ستستلم نحو"; +"lightning__spark_exit__savings_fee" = "رسوم الدفع من المدخرات"; +"lightning__spark_exit__savings_total" = "الإجمالي من المدخرات"; +"lightning__spark_exit__funding" = "تمويل الرسوم المطلوب"; +"lightning__spark_exit__left_behind" = "يبقى على Spark"; +"lightning__spark_exit__outside_exit" = "{amount} على Spark ليس جزءًا من هذا الخروج ويبقى على Spark."; +"lightning__spark_exit__quote_loading" = "جارٍ الحصول على عرض…"; +"lightning__spark_exit__quote_settling" = "بانتظار أن ينتهي Spark من نقل رصيدك. قد يستغرق ذلك حتى 5 دقائق."; +"lightning__spark_exit__quote_error" = "تعذّر الحصول على عرض للخروج. حاول مرة أخرى."; +"lightning__spark_exit__nothing" = "لا يوجد ما يمكن إخراجه الآن."; +"lightning__spark_exit__uneconomic" = "الرسوم أعلى مما ستستلمه."; +"lightning__spark_exit__funding_title" = "موّل\nالرسوم"; +"lightning__spark_exit__funding_text" = "يدفع الخروج رسومه من دفعة منفصلة على السلسلة بقيمة {amount}. ما لا يُنفق منها يعود إلى المدخرات في النهاية."; +"lightning__spark_exit__fund_savings" = "ادفع من المدخرات"; +"lightning__spark_exit__fund_external" = "موّل من محفظة أخرى"; +"lightning__spark_exit__fund_external_text" = "أرسل {amount} على الأقل إلى هذا العنوان. يبدأ الخروج بمجرد تأكيدها."; +"lightning__spark_exit__fund_insufficient" = "لا تكفي مدخراتك لتغطية تمويل الرسوم."; +"lightning__spark_exit__fund_seen" = "تم رصد الدفعة."; +"lightning__spark_exit__fund_partial" = "تم استلام {received} من {required}، أرسل {missing} إضافية."; +"lightning__spark_exit__confirm_title" = "بدء\nالخروج؟"; +"lightning__spark_exit__confirm_text" = "لن تتمكن من استخدام هذه الأموال لمدة تصل إلى 4 أسابيع."; +"lightning__spark_exit__swipe" = "اسحب للخروج"; +"lightning__spark_exit__progress_title" = "الخروج\nقيد التنفيذ"; +"lightning__spark_exit__progress_text" = "يعمل هذا في الخلفية. يُعلمك Bitkit إذا احتاج إليك."; +"lightning__spark_exit__step_funding" = "تأكيد تمويل الرسوم"; +"lightning__spark_exit__step_publish" = "نشر معاملات الخروج"; +"lightning__spark_exit__step_timelock" = "انتظار القفل الزمني"; +"lightning__spark_exit__step_sweep" = "الإرسال إلى المدخرات"; +"lightning__spark_exit__ready_in" = "جاهز خلال {duration}"; +"lightning__spark_exit__done_title" = "اكتمل\nالخروج"; +"lightning__spark_exit__done_text" = "أموالك في المدخرات."; +"lightning__spark_exit__partial_text" = "بقيت {count} مبالغ صغيرة على Spark."; +"lightning__spark_exit__failed_leaves" = "نقل مشغّلو Spark مبلغ {amount} إلى السلسلة وهو ليس على Spark. لا يزال بإمكان المشغّلين تحريره لك."; +"lightning__spark_exit__leftover" = "لا يزال {amount} من تمويل الرسوم في {address}."; +"lightning__spark_exit__leftover_swept" = "أُرسل {amount} من تمويل الرسوم المتبقي إلى المدخرات."; +"lightning__spark_exit__leftover_sending" = "جارٍ إرسال {amount} من تمويل الرسوم المتبقي إلى المدخرات."; +"lightning__spark_exit__leftover_dust" = "{amount} من تمويل الرسوم المتبقي صغير جدًا بحيث لا يمكن إرساله ويبقى في {address}، مسار الاشتقاق {path}."; +"lightning__spark_exit__leftover_txid" = "معرّف المعاملة: {txid}"; +"lightning__spark_exit__fund_paid" = "تم الدفع من المدخرات، بانتظار التأكيد."; +"lightning__spark_exit__fund_pending" = "تم رصد الدفعة، بانتظار التأكيد."; +"lightning__spark_exit__funding_txid" = "معرّف المعاملة: {txid}"; +"lightning__spark_exit__lost_leaves" = "طالب مالك سابق لهذه الأموال بمبلغ {amount} على السلسلة ولا يمكن استرداده."; +"lightning__spark_exit__notification_lost" = "لا يمكن استرداد {amount} من خروجك. افتح Bitkit لمعرفة السبب."; +"lightning__spark_exit__notification_failed" = "توقف خروجك الطارئ. افتح Bitkit لمعرفة السبب."; +"lightning__spark_exit__quote_outside" = "لا يمكن إخراج {amount} من رصيد Spark الآن: مبالغ أصغر من أن تدفع رسومها، أو لا تزال تتحرك داخل Spark. يبقى على Spark."; +"lightning__spark_exit__left_behind_leaves" = "لا يمكن إخراج {amount} بمعدل الرسوم هذا ويبقى على Spark."; +"lightning__spark_exit__needs_funding_title" = "مطلوب\nرسوم إضافية"; +"lightning__spark_exit__needs_funding_text" = "يحتاج الخروج إلى {amount} إضافية لدفع رسومه. موّله للمتابعة."; +"lightning__spark_exit__failed_title" = "توقف\nالخروج"; +"lightning__spark_exit__cancel" = "إلغاء الخروج"; +"lightning__spark_exit__failed_text" = "تعذّر استرداد أي شيء."; +"lightning__spark_exit__failed_stays" = "يبقى {amount} على Spark."; +"lightning__spark_exit__notification_title" = "خروج طارئ"; +"lightning__spark_exit__notification_body" = "افتح Bitkit لمتابعة خروجك."; +"lightning__spark_exit__notification_funding" = "يحتاج خروجك إلى رسوم إضافية للمتابعة. افتح Bitkit لتمويله."; +"lightning__spark_exit__bg_refresh_off" = "تحديث التطبيقات في الخلفية متوقف لـ Bitkit، لذا لا يتقدم الخروج إلا عندما يكون Bitkit مفتوحًا. فعّله في الإعدادات ليعمل في الخلفية."; +"lightning__spark_exit__low_power" = "يوقف وضع الطاقة المنخفضة العمل في الخلفية مؤقتًا. يتابع الخروج بمجرد إيقافه، أو عند فتح Bitkit."; +"lightning__spark_exit__notifications_off" = "الإشعارات متوقفة، لذا لا يستطيع Bitkit إعلامك عندما يحتاج إليك الخروج. فعّلها في الإعدادات لتلقي التذكيرات."; +"lightning__spark_exit__not_moving" = "الخروج لا يتقدم: {error}"; +"lightning__spark_exit__fee_low" = "الشبكة أكثر ازدحامًا من معدل رسوم هذا الخروج البالغ {rate} sat/vB. ادفع رسومًا أعلى، أو انتظر انخفاض الرسوم."; +"lightning__spark_exit__waiting_leaves" = "تتعذّر قراءة جزء من هذا الخروج على السلسلة الآن. يحاول Bitkit مجددًا لمدة تصل إلى أسبوع."; +"lightning__spark_exit__rebuilt_from_chain" = "تعذّر على Bitkit قراءة التقدم المحفوظ لهذا الخروج، لذا يعيد بناءه من السلسلة."; +"lightning__spark_exit__done_received" = "وصل {amount} إلى مدخراتك."; +"lightning__spark_exit__sweep_txid" = "معرّف المعاملة: {txid}"; +"lightning__spark_exit__leftover_rejected" = "تعذّر إرسال {amount} من تمويل الرسوم المتبقي ويبقى في {address}، مسار الاشتقاق {path}."; +"lightning__spark_exit__notification_not_moving" = "خروجك الطارئ لا يتقدم. افتح Bitkit لمعرفة السبب."; +"lightning__spark_exit__funding_split" = "يقسّمه Bitkit إلى {count} أجزاء، جزء لكل خطوة، حتى لا تعطّل خطوة عالقة الخطوات الأخرى."; +"lightning__spark_exit__waiting_operators" = "{amount} الذي نقله مشغّلو Spark إلى السلسلة ينتظر أن يحرروه. يطلب Bitkit ذلك مجددًا لمدة تصل إلى أسبوع."; +"lightning__spark_exit__fee_rose" = "ارتفعت رسوم الشبكة فوق رسوم هذا الخروج. قد يتباطأ."; +"lightning__spark_exit__fee_bump_button" = "ادفع رسومًا أعلى"; +"lightning__spark_exit__fee_bump_title" = "ادفع\nرسومًا أعلى"; +"lightning__spark_exit__fee_bump_text" = "يدفع هذا الخروج {rate} sat/vB، وهو أقل مما تطلبه الشبكة الآن. اختر معدلًا أعلى وسيعيد Bitkit إرسال الخطوات المنتظرة. يدفع الفرق من المدخرات: المبلغ الظاهر بجانب كل سرعة."; +"lightning__spark_exit__fee_bump_loading" = "جارٍ حساب تكلفة رسوم أعلى…"; +"lightning__spark_exit__fee_bump_error" = "تعذّر تسعير رسوم أعلى. حاول مرة أخرى."; +"lightning__spark_exit__fee_bump_confirm" = "استخدم هذه الرسوم"; +"lightning__spark_exit__leftover_waiting" = "سيصل {amount} من فكة الرسوم إلى مدخراتك عند انخفاض رسوم الشبكة."; +"lightning__spark_exit__cancelled_title" = "أُلغي\nالخروج"; +"lightning__spark_exit__cancelled_text" = "لقد ألغيت الخروج. يعود تمويل الرسوم الذي وصل إلى المدخرات."; +"lightning__spark_exit__cancel_failed" = "تعذّر التحقق من عنوان التمويل. حاول مرة أخرى."; +"lightning__spark_exit__fee_bump_leaves_behind" = "قد تترك رسوم أعلى جزءًا من هذا الخروج على Spark، لذا لا يُعرض أي منها. يتأكد الخروج بمجرد انخفاض رسوم الشبكة."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 9a522cb97..d554efef9 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -356,93 +356,93 @@ "lightning__spark_provider_value" = "Spark mitjançant Breez"; "lightning__spark_receiving_unlimited" = "Sense límit"; "lightning__spark_trust" = "Aquest saldo es guarda a Spark, gestionat per Lightspark, Breez i Flashnet. Està segur mentre almenys un d’ells actuï amb honestedat. Poden pausar l’accés, i sortir-ne sense ells requereix una sortida on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Sortida d'emergència"; +"lightning__spark_exit__nav_title" = "Sortida d'emergència"; +"lightning__spark_exit__intro_title" = "Surt de Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Fes-ho servir només si Spark deixa de funcionar-te. Mou el teu saldo de despesa a estalvis sense els operadors de Spark.\n\nTriga fins a 4 setmanes, té comissions on-chain pagades des d'estalvis, i és possible que quedin enrere quantitats molt petites. S'executa en segon pla i Bitkit t'avisa si et necessita."; +"lightning__spark_exit__quote_title" = "Pressupost\nde sortida"; +"lightning__spark_exit__amount" = "Import a retirar"; +"lightning__spark_exit__fees" = "Comissions de xarxa"; +"lightning__spark_exit__receive" = "Rebràs aproximadament"; +"lightning__spark_exit__savings_fee" = "Comissió del pagament des d'estalvis"; +"lightning__spark_exit__savings_total" = "Total des d'estalvis"; +"lightning__spark_exit__funding" = "Finançament de comissions necessari"; +"lightning__spark_exit__left_behind" = "Es queda a Spark"; +"lightning__spark_exit__outside_exit" = "{amount} a Spark no forma part d'aquesta sortida i es queda a Spark."; +"lightning__spark_exit__quote_loading" = "Obtenint un pressupost…"; +"lightning__spark_exit__quote_settling" = "Esperant que Spark acabi de moure el teu saldo. Pot trigar fins a 5 minuts."; +"lightning__spark_exit__quote_error" = "No s'ha pogut obtenir un pressupost de sortida. Torna-ho a provar."; +"lightning__spark_exit__nothing" = "Ara mateix no es pot retirar res."; +"lightning__spark_exit__uneconomic" = "Les comissions són més altes que el que rebries."; +"lightning__spark_exit__funding_title" = "Finança les\ncomissions"; +"lightning__spark_exit__funding_text" = "La sortida paga les comissions amb un pagament on-chain separat de {amount}. El que no gasti torna a estalvis al final."; +"lightning__spark_exit__fund_savings" = "Paga des d'estalvis"; +"lightning__spark_exit__fund_external" = "Finança des d'una altra cartera"; +"lightning__spark_exit__fund_external_text" = "Envia almenys {amount} a aquesta adreça. La sortida comença quan es confirmi."; +"lightning__spark_exit__fund_insufficient" = "Els teus estalvis no poden cobrir el finançament de comissions."; +"lightning__spark_exit__fund_seen" = "Pagament detectat."; +"lightning__spark_exit__fund_partial" = "S'han rebut {received} de {required}, envia'n {missing} més."; +"lightning__spark_exit__confirm_title" = "Iniciar la\nsortida?"; +"lightning__spark_exit__confirm_text" = "No podràs fer servir aquests fons durant fins a 4 setmanes."; +"lightning__spark_exit__swipe" = "Llisca per sortir"; +"lightning__spark_exit__progress_title" = "Sortida\nen curs"; +"lightning__spark_exit__progress_text" = "S'executa en segon pla. Bitkit t'avisa si et necessita."; +"lightning__spark_exit__step_funding" = "Confirmació del finançament de comissions"; +"lightning__spark_exit__step_publish" = "Publicant les transaccions de sortida"; +"lightning__spark_exit__step_timelock" = "Esperant el bloqueig temporal"; +"lightning__spark_exit__step_sweep" = "Enviant a estalvis"; +"lightning__spark_exit__ready_in" = "A punt d'aquí a {duration}"; +"lightning__spark_exit__done_title" = "Sortida\ncompletada"; +"lightning__spark_exit__done_text" = "Els teus fons són a estalvis."; +"lightning__spark_exit__partial_text" = "{count} quantitats petites s'han quedat a Spark."; +"lightning__spark_exit__failed_leaves" = "Els operadors de Spark han mogut {amount} on-chain i no és a Spark. Els operadors encara te'ls poden alliberar."; +"lightning__spark_exit__leftover" = "{amount} del finançament de comissions encara és a {address}."; +"lightning__spark_exit__leftover_swept" = "S'han enviat a estalvis {amount} del finançament de comissions sobrant."; +"lightning__spark_exit__leftover_sending" = "Enviant a estalvis {amount} del finançament de comissions sobrant."; +"lightning__spark_exit__leftover_dust" = "{amount} del finançament de comissions sobrant és massa poc per enviar i es queda a {address}, camí de derivació {path}."; +"lightning__spark_exit__leftover_txid" = "ID de transacció: {txid}"; +"lightning__spark_exit__fund_paid" = "Pagat des d'estalvis, esperant la confirmació."; +"lightning__spark_exit__fund_pending" = "Pagament detectat, esperant la confirmació."; +"lightning__spark_exit__funding_txid" = "ID de transacció: {txid}"; +"lightning__spark_exit__lost_leaves" = "Un propietari anterior d'aquests fons ha reclamat {amount} on-chain i no es pot recuperar."; +"lightning__spark_exit__notification_lost" = "{amount} de la teva sortida no es pot recuperar. Obre Bitkit per saber per què."; +"lightning__spark_exit__notification_failed" = "La teva sortida d'emergència s'ha aturat. Obre Bitkit per saber per què."; +"lightning__spark_exit__quote_outside" = "{amount} del teu saldo Spark no es pot retirar ara mateix: quantitats massa petites per pagar les seves pròpies comissions, o que encara s'estan movent dins de Spark. Es queda a Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} no es pot retirar amb aquesta taxa de comissió i es queda a Spark."; +"lightning__spark_exit__needs_funding_title" = "Calen més\ncomissions"; +"lightning__spark_exit__needs_funding_text" = "La sortida necessita {amount} més per pagar les comissions. Finança-la per continuar."; +"lightning__spark_exit__failed_title" = "Sortida\naturada"; +"lightning__spark_exit__cancel" = "Cancel·la la sortida"; +"lightning__spark_exit__failed_text" = "No s'ha pogut recuperar res."; +"lightning__spark_exit__failed_stays" = "{amount} es queda a Spark."; +"lightning__spark_exit__notification_title" = "Sortida d'emergència"; +"lightning__spark_exit__notification_body" = "Obre Bitkit per continuar la teva sortida."; +"lightning__spark_exit__notification_funding" = "La teva sortida necessita més comissions per continuar. Obre Bitkit per finançar-la."; +"lightning__spark_exit__bg_refresh_off" = "L'actualització en segon pla està desactivada per a Bitkit, així que la sortida només avança mentre Bitkit està obert. Activa-la a Configuració perquè s'executi en segon pla."; +"lightning__spark_exit__low_power" = "El mode de baix consum pausa la feina en segon pla. La sortida continua quan es desactiva, o quan obres Bitkit."; +"lightning__spark_exit__notifications_off" = "Les notificacions estan desactivades, així que Bitkit no et pot avisar quan la sortida et necessiti. Activa-les a Configuració per rebre recordatoris."; +"lightning__spark_exit__not_moving" = "La sortida no avança: {error}"; +"lightning__spark_exit__fee_low" = "La xarxa està més saturada que la taxa de comissió d'aquesta sortida, de {rate} sat/vB. Paga una comissió més alta o espera que baixin les comissions."; +"lightning__spark_exit__waiting_leaves" = "Ara mateix no es pot llegir on-chain una part d'aquesta sortida. Bitkit ho torna a provar durant una setmana com a màxim."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no ha pogut llegir el progrés desat d'aquesta sortida, així que el reconstrueix a partir de la cadena."; +"lightning__spark_exit__done_received" = "{amount} ha arribat als teus estalvis."; +"lightning__spark_exit__sweep_txid" = "ID de transacció: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} del finançament de comissions sobrant no s'ha pogut enviar i es queda a {address}, camí de derivació {path}."; +"lightning__spark_exit__notification_not_moving" = "La teva sortida d'emergència no avança. Obre Bitkit per saber per què."; +"lightning__spark_exit__funding_split" = "Bitkit el divideix en {count} parts, una per a cada pas, perquè un pas encallat no pugui retenir els altres."; +"lightning__spark_exit__waiting_operators" = "{amount} que els operadors de Spark han mogut on-chain espera que l'alliberin. Bitkit ho torna a demanar durant una setmana com a màxim."; +"lightning__spark_exit__fee_rose" = "Les comissions de xarxa han pujat per sobre de la comissió d'aquesta sortida. Pot alentir-se."; +"lightning__spark_exit__fee_bump_button" = "Paga una comissió més alta"; +"lightning__spark_exit__fee_bump_title" = "Paga una\ncomissió més alta"; +"lightning__spark_exit__fee_bump_text" = "Aquesta sortida paga {rate} sat/vB, menys del que la xarxa demana ara. Tria una taxa més alta i Bitkit tornarà a enviar els passos pendents. Paga la diferència des d'estalvis: l'import al costat de cada velocitat."; +"lightning__spark_exit__fee_bump_loading" = "Calculant quant costa una comissió més alta…"; +"lightning__spark_exit__fee_bump_error" = "No s'ha pogut calcular el preu d'una comissió més alta. Torna-ho a provar."; +"lightning__spark_exit__fee_bump_confirm" = "Fes servir aquesta comissió"; +"lightning__spark_exit__leftover_waiting" = "{amount} de canvi de comissions arribarà als teus estalvis quan baixin les comissions de xarxa."; +"lightning__spark_exit__cancelled_title" = "Sortida\ncancel·lada"; +"lightning__spark_exit__cancelled_text" = "Has cancel·lat la sortida. El finançament de comissions que ha arribat torna a estalvis."; +"lightning__spark_exit__cancel_failed" = "No s'ha pogut comprovar l'adreça de finançament. Torna-ho a provar."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Una comissió més alta deixaria part d'aquesta sortida a Spark, així que no se n'ofereix cap. La sortida es confirma quan baixin les comissions de xarxa."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 82daf81c3..8012e8398 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -372,93 +372,93 @@ "lightning__spark_provider_value" = "Spark přes Breez"; "lightning__spark_receiving_unlimited" = "Bez limitu"; "lightning__spark_trust" = "Tento zůstatek je uložen na Spark, který provozují Lightspark, Breez a Flashnet. Je v bezpečí, dokud alespoň jeden z nich jedná poctivě. Mohou pozastavit přístup a odchod bez nich vyžaduje on-chain výstup."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Nouzový výstup"; +"lightning__spark_exit__nav_title" = "Nouzový výstup"; +"lightning__spark_exit__intro_title" = "Opusťte Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Použijte to jen tehdy, když vám Spark přestane fungovat. Přesune váš zůstatek k utrácení do úspor bez operátorů Spark.\n\nTrvá to až 4 týdny, stojí to on-chain poplatky placené z úspor a velmi malé částky mohou zůstat. Běží to na pozadí a Bitkit vás upozorní, pokud vás bude potřebovat."; +"lightning__spark_exit__quote_title" = "Nabídka\nvýstupu"; +"lightning__spark_exit__amount" = "Částka k výstupu"; +"lightning__spark_exit__fees" = "Síťové poplatky"; +"lightning__spark_exit__receive" = "Obdržíte přibližně"; +"lightning__spark_exit__savings_fee" = "Poplatek za platbu z úspor"; +"lightning__spark_exit__savings_total" = "Celkem z úspor"; +"lightning__spark_exit__funding" = "Potřebné krytí poplatků"; +"lightning__spark_exit__left_behind" = "Zůstává na Spark"; +"lightning__spark_exit__outside_exit" = "{amount} na Spark není součástí tohoto výstupu a zůstává na Spark."; +"lightning__spark_exit__quote_loading" = "Získáváme nabídku…"; +"lightning__spark_exit__quote_settling" = "Čekáme, až Spark dokončí přesun vašeho zůstatku. Může to trvat až 5 minut."; +"lightning__spark_exit__quote_error" = "Nabídku výstupu se nepodařilo získat. Zkuste to znovu."; +"lightning__spark_exit__nothing" = "Momentálně nelze nic vyvést."; +"lightning__spark_exit__uneconomic" = "Poplatky jsou vyšší než částka, kterou byste obdrželi."; +"lightning__spark_exit__funding_title" = "Uhraďte\npoplatky"; +"lightning__spark_exit__funding_text" = "Výstup platí své poplatky ze samostatné on-chain platby ve výši {amount}. Co neutratí, se na konci vrátí do úspor."; +"lightning__spark_exit__fund_savings" = "Zaplatit z úspor"; +"lightning__spark_exit__fund_external" = "Uhradit z jiné peněženky"; +"lightning__spark_exit__fund_external_text" = "Pošlete na tuto adresu alespoň {amount}. Výstup začne, jakmile se platba potvrdí."; +"lightning__spark_exit__fund_insufficient" = "Vaše úspory nepokryjí krytí poplatků."; +"lightning__spark_exit__fund_seen" = "Platba zaznamenána."; +"lightning__spark_exit__fund_partial" = "Přijato {received} z {required}, pošlete ještě {missing}."; +"lightning__spark_exit__confirm_title" = "Zahájit\nvýstup?"; +"lightning__spark_exit__confirm_text" = "Tyto prostředky nebudete moci používat až 4 týdny."; +"lightning__spark_exit__swipe" = "Přejeďte pro výstup"; +"lightning__spark_exit__progress_title" = "Výstup\nprobíhá"; +"lightning__spark_exit__progress_text" = "Běží to na pozadí. Bitkit vás upozorní, pokud vás bude potřebovat."; +"lightning__spark_exit__step_funding" = "Potvrzení krytí poplatků"; +"lightning__spark_exit__step_publish" = "Zveřejňování výstupních transakcí"; +"lightning__spark_exit__step_timelock" = "Čekání na časový zámek"; +"lightning__spark_exit__step_sweep" = "Odesílání do úspor"; +"lightning__spark_exit__ready_in" = "Připraveno za {duration}"; +"lightning__spark_exit__done_title" = "Výstup\ndokončen"; +"lightning__spark_exit__done_text" = "Vaše prostředky jsou v úsporách."; +"lightning__spark_exit__partial_text" = "Malé částky ({count}) zůstaly na Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} přesunuli operátoři Spark na blockchain a není na Spark. Operátoři vám ji stále mohou uvolnit."; +"lightning__spark_exit__leftover" = "{amount} z krytí poplatků je stále na adrese {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} zbývajícího krytí poplatků bylo odesláno do úspor."; +"lightning__spark_exit__leftover_sending" = "Odesíláme {amount} zbývajícího krytí poplatků do úspor."; +"lightning__spark_exit__leftover_dust" = "{amount} zbývajícího krytí poplatků je příliš málo k odeslání a zůstává na adrese {address}, derivační cesta {path}."; +"lightning__spark_exit__leftover_txid" = "ID transakce: {txid}"; +"lightning__spark_exit__fund_paid" = "Zaplaceno z úspor, čeká se na potvrzení."; +"lightning__spark_exit__fund_pending" = "Platba zaznamenána, čeká se na potvrzení."; +"lightning__spark_exit__funding_txid" = "ID transakce: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} si on-chain nárokoval dřívější vlastník těchto prostředků a nelze to získat zpět."; +"lightning__spark_exit__notification_lost" = "{amount} z vašeho výstupu nelze získat zpět. Otevřete Bitkit a zjistěte proč."; +"lightning__spark_exit__notification_failed" = "Váš nouzový výstup se zastavil. Otevřete Bitkit a zjistěte proč."; +"lightning__spark_exit__quote_outside" = "{amount} z vašeho zůstatku Spark nyní nelze vyvést: částky příliš malé na zaplacení vlastních poplatků nebo se stále přesouvají uvnitř Spark. Zůstává to na Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} nelze při této sazbě poplatku vyvést a zůstává na Spark."; +"lightning__spark_exit__needs_funding_title" = "Potřeba více\npoplatků"; +"lightning__spark_exit__needs_funding_text" = "Výstup potřebuje na poplatky ještě {amount}. Pro pokračování je uhraďte."; +"lightning__spark_exit__failed_title" = "Výstup\nzastaven"; +"lightning__spark_exit__cancel" = "Zrušit výstup"; +"lightning__spark_exit__failed_text" = "Nic se nepodařilo získat zpět."; +"lightning__spark_exit__failed_stays" = "{amount} zůstává na Spark."; +"lightning__spark_exit__notification_title" = "Nouzový výstup"; +"lightning__spark_exit__notification_body" = "Otevřete Bitkit a pokračujte ve výstupu."; +"lightning__spark_exit__notification_funding" = "Váš výstup potřebuje k pokračování více poplatků. Otevřete Bitkit a uhraďte je."; +"lightning__spark_exit__bg_refresh_off" = "Aktualizace aplikací na pozadí je pro Bitkit vypnutá, takže výstup postupuje jen při otevřeném Bitkitu. Zapněte ji v Nastavení, aby mohl běžet na pozadí."; +"lightning__spark_exit__low_power" = "Režim nízké spotřeby pozastavuje práci na pozadí. Výstup bude pokračovat po jeho vypnutí nebo po otevření Bitkitu."; +"lightning__spark_exit__notifications_off" = "Oznámení jsou vypnutá, takže vám Bitkit nemůže dát vědět, když vás výstup potřebuje. Zapněte je v Nastavení, abyste dostávali připomenutí."; +"lightning__spark_exit__not_moving" = "Výstup nepostupuje: {error}"; +"lightning__spark_exit__fee_low" = "Síť je vytíženější, než odpovídá sazbě poplatku tohoto výstupu {rate} sat/vB. Zaplaťte vyšší poplatek, nebo počkejte, až poplatky klesnou."; +"lightning__spark_exit__waiting_leaves" = "Část tohoto výstupu teď nelze na blockchainu načíst. Bitkit to bude zkoušet znovu až týden."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit nedokázal načíst uložený postup tohoto výstupu, proto ho obnovuje z blockchainu."; +"lightning__spark_exit__done_received" = "{amount} dorazilo do vašich úspor."; +"lightning__spark_exit__sweep_txid" = "ID transakce: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} zbývajícího krytí poplatků se nepodařilo odeslat a zůstává na adrese {address}, derivační cesta {path}."; +"lightning__spark_exit__notification_not_moving" = "Váš nouzový výstup nepostupuje. Otevřete Bitkit a zjistěte proč."; +"lightning__spark_exit__funding_split" = "Bitkit ji rozdělí na {count} částí, jednu pro každý krok, aby jeden zaseknutý krok nezdržel ostatní."; +"lightning__spark_exit__waiting_operators" = "{amount}, které operátoři Spark přesunuli na blockchain, čeká, až je uvolní. Bitkit se bude ptát znovu až týden."; +"lightning__spark_exit__fee_rose" = "Síťové poplatky vzrostly nad poplatek tohoto výstupu. Může se zpomalit."; +"lightning__spark_exit__fee_bump_button" = "Zaplatit vyšší poplatek"; +"lightning__spark_exit__fee_bump_title" = "Zaplatit\nvyšší poplatek"; +"lightning__spark_exit__fee_bump_text" = "Tento výstup platí {rate} sat/vB, méně, než síť nyní požaduje. Zvolte vyšší sazbu a Bitkit čekající kroky odešle znovu. Rozdíl zaplatí z úspor: částku uvedenou u každé rychlosti."; +"lightning__spark_exit__fee_bump_loading" = "Zjišťujeme, kolik stojí vyšší poplatek…"; +"lightning__spark_exit__fee_bump_error" = "Cenu vyššího poplatku se nepodařilo zjistit. Zkuste to znovu."; +"lightning__spark_exit__fee_bump_confirm" = "Použít tento poplatek"; +"lightning__spark_exit__leftover_waiting" = "{amount} vrácených poplatků dorazí do vašich úspor, až síťové poplatky klesnou."; +"lightning__spark_exit__cancelled_title" = "Výstup\nzrušen"; +"lightning__spark_exit__cancelled_text" = "Výstup jste zrušili. Krytí poplatků, které dorazilo, se vrací do úspor."; +"lightning__spark_exit__cancel_failed" = "Adresu pro krytí poplatků se nepodařilo zkontrolovat. Zkuste to znovu."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Vyšší poplatek by část tohoto výstupu ponechal na Spark, proto žádný není nabízen. Výstup se potvrdí, až síťové poplatky klesnou."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 3498a01d1..bc2db9084 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -370,93 +370,93 @@ "lightning__spark_provider_value" = "Spark über Breez"; "lightning__spark_receiving_unlimited" = "Kein Limit"; "lightning__spark_trust" = "Dieses Guthaben liegt auf Spark, betrieben von Lightspark, Breez und Flashnet. Es ist sicher, solange mindestens einer von ihnen ehrlich handelt. Sie können den Zugang sperren, und ein Ausstieg ohne sie erfordert einen On-Chain-Exit."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Notausstieg"; +"lightning__spark_exit__nav_title" = "Notausstieg"; +"lightning__spark_exit__intro_title" = "Spark verlassen\non-chain"; +"lightning__spark_exit__intro_text" = "Nutze dies nur, wenn Spark für dich nicht mehr funktioniert. Es verschiebt dein Ausgabenguthaben ins Sparkonto, ohne die Spark-Betreiber.\n\nEs dauert bis zu 4 Wochen, kostet On-Chain-Gebühren aus dem Sparkonto, und sehr kleine Beträge können zurückbleiben. Es läuft im Hintergrund, und Bitkit benachrichtigt dich, wenn es dich braucht."; +"lightning__spark_exit__quote_title" = "Ausstiegs-\nangebot"; +"lightning__spark_exit__amount" = "Auszahlungsbetrag"; +"lightning__spark_exit__fees" = "Netzwerkgebühren"; +"lightning__spark_exit__receive" = "Du erhältst etwa"; +"lightning__spark_exit__savings_fee" = "Gebühr für Zahlung aus dem Sparkonto"; +"lightning__spark_exit__savings_total" = "Gesamt aus dem Sparkonto"; +"lightning__spark_exit__funding" = "Benötigte Gebührendeckung"; +"lightning__spark_exit__left_behind" = "Bleibt auf Spark"; +"lightning__spark_exit__outside_exit" = "{amount} auf Spark sind nicht Teil dieses Ausstiegs und bleiben auf Spark."; +"lightning__spark_exit__quote_loading" = "Angebot wird abgerufen…"; +"lightning__spark_exit__quote_settling" = "Warte, bis Spark dein Guthaben fertig verschoben hat. Das kann bis zu 5 Minuten dauern."; +"lightning__spark_exit__quote_error" = "Ausstiegsangebot konnte nicht abgerufen werden. Versuche es erneut."; +"lightning__spark_exit__nothing" = "Derzeit kann nichts ausgezahlt werden."; +"lightning__spark_exit__uneconomic" = "Die Gebühren sind höher als der Betrag, den du erhalten würdest."; +"lightning__spark_exit__funding_title" = "Gebühren\ndecken"; +"lightning__spark_exit__funding_text" = "Der Ausstieg zahlt seine Gebühren aus einer separaten On-Chain-Zahlung von {amount}. Was nicht verbraucht wird, kommt am Ende zurück ins Sparkonto."; +"lightning__spark_exit__fund_savings" = "Aus dem Sparkonto zahlen"; +"lightning__spark_exit__fund_external" = "Aus einer anderen Wallet zahlen"; +"lightning__spark_exit__fund_external_text" = "Sende mindestens {amount} an diese Adresse. Der Ausstieg startet, sobald die Zahlung bestätigt ist."; +"lightning__spark_exit__fund_insufficient" = "Dein Sparkonto reicht nicht für die Gebührendeckung."; +"lightning__spark_exit__fund_seen" = "Zahlung erkannt."; +"lightning__spark_exit__fund_partial" = "{received} von {required} erhalten, sende noch {missing}."; +"lightning__spark_exit__confirm_title" = "Ausstieg\nstarten?"; +"lightning__spark_exit__confirm_text" = "Du kannst diese Mittel bis zu 4 Wochen lang nicht nutzen."; +"lightning__spark_exit__swipe" = "Swipe zum Aussteigen"; +"lightning__spark_exit__progress_title" = "Ausstieg\nläuft"; +"lightning__spark_exit__progress_text" = "Dies läuft im Hintergrund. Bitkit benachrichtigt dich, wenn es dich braucht."; +"lightning__spark_exit__step_funding" = "Gebührendeckung wird bestätigt"; +"lightning__spark_exit__step_publish" = "Ausstiegstransaktionen werden veröffentlicht"; +"lightning__spark_exit__step_timelock" = "Warten auf den Timelock"; +"lightning__spark_exit__step_sweep" = "Senden ans Sparkonto"; +"lightning__spark_exit__ready_in" = "Bereit in {duration}"; +"lightning__spark_exit__done_title" = "Ausstieg\nabgeschlossen"; +"lightning__spark_exit__done_text" = "Deine Mittel sind im Sparkonto."; +"lightning__spark_exit__partial_text" = "{count} kleine Beträge sind auf Spark geblieben."; +"lightning__spark_exit__failed_leaves" = "{amount} wurden von den Spark-Betreibern on-chain verschoben und sind nicht auf Spark. Die Betreiber können sie dir weiterhin freigeben."; +"lightning__spark_exit__leftover" = "{amount} der Gebührendeckung liegen noch auf {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} übrige Gebührendeckung wurden ans Sparkonto gesendet."; +"lightning__spark_exit__leftover_sending" = "{amount} übrige Gebührendeckung werden ans Sparkonto gesendet."; +"lightning__spark_exit__leftover_dust" = "{amount} übrige Gebührendeckung sind zu klein zum Senden und bleiben auf {address}, Ableitungspfad {path}."; +"lightning__spark_exit__leftover_txid" = "Transaktions-ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Aus dem Sparkonto bezahlt, warte auf Bestätigung."; +"lightning__spark_exit__fund_pending" = "Zahlung erkannt, warte auf Bestätigung."; +"lightning__spark_exit__funding_txid" = "Transaktions-ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} wurden von einem früheren Besitzer dieser Mittel on-chain beansprucht und können nicht wiederhergestellt werden."; +"lightning__spark_exit__notification_lost" = "{amount} deines Ausstiegs können nicht wiederhergestellt werden. Öffne Bitkit, um den Grund zu sehen."; +"lightning__spark_exit__notification_failed" = "Dein Notausstieg wurde gestoppt. Öffne Bitkit, um den Grund zu sehen."; +"lightning__spark_exit__quote_outside" = "{amount} deines Spark-Guthabens können derzeit nicht ausgezahlt werden: Beträge, die zu klein sind, um ihre eigenen Gebühren zu zahlen, oder die sich noch innerhalb von Spark bewegen. Sie bleiben auf Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} können bei diesem Gebührensatz nicht ausgezahlt werden und bleiben auf Spark."; +"lightning__spark_exit__needs_funding_title" = "Mehr Gebühren\nnötig"; +"lightning__spark_exit__needs_funding_text" = "Der Ausstieg braucht {amount} mehr, um seine Gebühren zu zahlen. Decke sie, um fortzufahren."; +"lightning__spark_exit__failed_title" = "Ausstieg\ngestoppt"; +"lightning__spark_exit__cancel" = "Ausstieg abbrechen"; +"lightning__spark_exit__failed_text" = "Es konnte nichts wiederhergestellt werden."; +"lightning__spark_exit__failed_stays" = "{amount} bleiben auf Spark."; +"lightning__spark_exit__notification_title" = "Notausstieg"; +"lightning__spark_exit__notification_body" = "Öffne Bitkit, um deinen Ausstieg fortzusetzen."; +"lightning__spark_exit__notification_funding" = "Dein Ausstieg braucht mehr Gebühren, um fortzufahren. Öffne Bitkit, um sie zu decken."; +"lightning__spark_exit__bg_refresh_off" = "Hintergrundaktualisierung ist für Bitkit deaktiviert, daher läuft der Ausstieg nur, solange Bitkit geöffnet ist. Aktiviere sie in den Einstellungen, damit er im Hintergrund laufen kann."; +"lightning__spark_exit__low_power" = "Der Stromsparmodus pausiert Hintergrundaktivität. Der Ausstieg läuft weiter, sobald er aus ist oder wenn du Bitkit öffnest."; +"lightning__spark_exit__notifications_off" = "Benachrichtigungen sind deaktiviert, daher kann Bitkit dir nicht sagen, wenn der Ausstieg dich braucht. Aktiviere sie in den Einstellungen, um erinnert zu werden."; +"lightning__spark_exit__not_moving" = "Der Ausstieg kommt nicht voran: {error}"; +"lightning__spark_exit__fee_low" = "Das Netzwerk ist stärker ausgelastet als der Gebührensatz dieses Ausstiegs von {rate} sat/vB. Zahle eine höhere Gebühr oder warte, bis die Gebühren sinken."; +"lightning__spark_exit__waiting_leaves" = "Ein Teil dieses Ausstiegs kann derzeit nicht on-chain gelesen werden. Bitkit versucht es bis zu einer Woche lang erneut."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit konnte den gespeicherten Fortschritt dieses Ausstiegs nicht lesen und stellt ihn daher aus der Blockchain wieder her."; +"lightning__spark_exit__done_received" = "{amount} sind in deinem Sparkonto angekommen."; +"lightning__spark_exit__sweep_txid" = "Transaktions-ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} übrige Gebührendeckung konnten nicht gesendet werden und bleiben auf {address}, Ableitungspfad {path}."; +"lightning__spark_exit__notification_not_moving" = "Dein Notausstieg kommt nicht voran. Öffne Bitkit, um den Grund zu sehen."; +"lightning__spark_exit__funding_split" = "Bitkit teilt sie in {count} Teile auf, einen pro Schritt, damit ein hängender Schritt die anderen nicht aufhält."; +"lightning__spark_exit__waiting_operators" = "{amount}, die die Spark-Betreiber on-chain verschoben haben, warten darauf, dass sie sie freigeben. Bitkit fragt bis zu einer Woche lang erneut nach."; +"lightning__spark_exit__fee_rose" = "Die Netzwerkgebühren sind über die Gebühr dieses Ausstiegs gestiegen. Er kann sich verlangsamen."; +"lightning__spark_exit__fee_bump_button" = "Höhere Gebühr zahlen"; +"lightning__spark_exit__fee_bump_title" = "Höhere Gebühr\nzahlen"; +"lightning__spark_exit__fee_bump_text" = "Dieser Ausstieg zahlt {rate} sat/vB, weniger als das Netzwerk derzeit verlangt. Wähle einen höheren Satz, und Bitkit sendet die wartenden Schritte erneut. Die Differenz wird aus dem Sparkonto bezahlt: der Betrag neben jeder Geschwindigkeit."; +"lightning__spark_exit__fee_bump_loading" = "Kosten einer höheren Gebühr werden berechnet…"; +"lightning__spark_exit__fee_bump_error" = "Höhere Gebühr konnte nicht berechnet werden. Versuche es erneut."; +"lightning__spark_exit__fee_bump_confirm" = "Diese Gebühr nutzen"; +"lightning__spark_exit__leftover_waiting" = "{amount} Gebührenwechselgeld erreichen dein Sparkonto, wenn die Netzwerkgebühren sinken."; +"lightning__spark_exit__cancelled_title" = "Ausstieg\nabgebrochen"; +"lightning__spark_exit__cancelled_text" = "Du hast den Ausstieg abgebrochen. Die eingegangene Gebührendeckung geht zurück ins Sparkonto."; +"lightning__spark_exit__cancel_failed" = "Die Adresse für die Gebührendeckung konnte nicht geprüft werden. Versuche es erneut."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Eine höhere Gebühr würde einen Teil dieses Ausstiegs auf Spark lassen, daher wird keine angeboten. Der Ausstieg wird bestätigt, sobald die Netzwerkgebühren sinken."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 589dfd46e..f063cd7fb 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -343,93 +343,93 @@ "lightning__spark_provider_value" = "Spark μέσω Breez"; "lightning__spark_receiving_unlimited" = "Χωρίς όριο"; "lightning__spark_trust" = "Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Έξοδος έκτακτης ανάγκης"; +"lightning__spark_exit__nav_title" = "Έξοδος Έκτακτης Ανάγκης"; +"lightning__spark_exit__intro_title" = "Αποχώρηση από το Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Χρησιμοποιήστε το μόνο αν το Spark σταματήσει να λειτουργεί για εσάς. Μεταφέρει το υπόλοιπο δαπανών σας στις αποταμιεύσεις χωρίς τους διαχειριστές του Spark.\n\nΔιαρκεί έως 4 εβδομάδες, κοστίζει τέλη on-chain που πληρώνονται από τις αποταμιεύσεις, και πολύ μικρά ποσά μπορεί να μείνουν πίσω. Εκτελείται στο παρασκήνιο και το Bitkit σας ειδοποιεί αν σας χρειαστεί."; +"lightning__spark_exit__quote_title" = "Προσφορά\nεξόδου"; +"lightning__spark_exit__amount" = "Ποσό εξόδου"; +"lightning__spark_exit__fees" = "Τέλη δικτύου"; +"lightning__spark_exit__receive" = "Λαμβάνετε περίπου"; +"lightning__spark_exit__savings_fee" = "Τέλος πληρωμής από αποταμιεύσεις"; +"lightning__spark_exit__savings_total" = "Σύνολο από αποταμιεύσεις"; +"lightning__spark_exit__funding" = "Απαιτούμενη χρηματοδότηση τελών"; +"lightning__spark_exit__left_behind" = "Παραμένει στο Spark"; +"lightning__spark_exit__outside_exit" = "{amount} στο Spark δεν περιλαμβάνεται σε αυτή την έξοδο και παραμένει στο Spark."; +"lightning__spark_exit__quote_loading" = "Λήψη προσφοράς…"; +"lightning__spark_exit__quote_settling" = "Αναμονή για να ολοκληρώσει το Spark τη μεταφορά του υπολοίπου σας. Αυτό μπορεί να διαρκέσει έως 5 λεπτά."; +"lightning__spark_exit__quote_error" = "Δεν ήταν δυνατή η λήψη προσφοράς εξόδου. Δοκιμάστε ξανά."; +"lightning__spark_exit__nothing" = "Δεν υπάρχει τίποτα για έξοδο αυτή τη στιγμή."; +"lightning__spark_exit__uneconomic" = "Τα τέλη είναι υψηλότερα από όσα θα λαμβάνατε."; +"lightning__spark_exit__funding_title" = "Χρηματοδοτήστε\nτα τέλη"; +"lightning__spark_exit__funding_text" = "Η έξοδος πληρώνει τα τέλη της από ξεχωριστή πληρωμή on-chain ύψους {amount}. Ό,τι δεν ξοδευτεί επιστρέφει στις αποταμιεύσεις στο τέλος."; +"lightning__spark_exit__fund_savings" = "Πληρωμή από αποταμιεύσεις"; +"lightning__spark_exit__fund_external" = "Χρηματοδότηση από άλλο πορτοφόλι"; +"lightning__spark_exit__fund_external_text" = "Στείλτε τουλάχιστον {amount} σε αυτή τη διεύθυνση. Η έξοδος ξεκινά μόλις επιβεβαιωθεί."; +"lightning__spark_exit__fund_insufficient" = "Οι αποταμιεύσεις σας δεν επαρκούν για τη χρηματοδότηση των τελών."; +"lightning__spark_exit__fund_seen" = "Η πληρωμή εντοπίστηκε."; +"lightning__spark_exit__fund_partial" = "Ελήφθησαν {received} από {required}, στείλτε ακόμη {missing}."; +"lightning__spark_exit__confirm_title" = "Έναρξη\nεξόδου;"; +"lightning__spark_exit__confirm_text" = "Δεν θα μπορείτε να χρησιμοποιήσετε αυτά τα κεφάλαια για έως 4 εβδομάδες."; +"lightning__spark_exit__swipe" = "Σύρετε για Έξοδο"; +"lightning__spark_exit__progress_title" = "Έξοδος\nσε εξέλιξη"; +"lightning__spark_exit__progress_text" = "Εκτελείται στο παρασκήνιο. Το Bitkit σας ειδοποιεί αν σας χρειαστεί."; +"lightning__spark_exit__step_funding" = "Επιβεβαίωση χρηματοδότησης τελών"; +"lightning__spark_exit__step_publish" = "Δημοσίευση συναλλαγών εξόδου"; +"lightning__spark_exit__step_timelock" = "Αναμονή για το χρονοκλείδωμα"; +"lightning__spark_exit__step_sweep" = "Αποστολή στις αποταμιεύσεις"; +"lightning__spark_exit__ready_in" = "Έτοιμο σε {duration}"; +"lightning__spark_exit__done_title" = "Η έξοδος\nολοκληρώθηκε"; +"lightning__spark_exit__done_text" = "Τα κεφάλαιά σας βρίσκονται στις αποταμιεύσεις."; +"lightning__spark_exit__partial_text" = "{count} μικρά ποσά παρέμειναν στο Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} μεταφέρθηκε on-chain από τους διαχειριστές του Spark και δεν βρίσκεται στο Spark. Οι διαχειριστές μπορούν ακόμη να το αποδεσμεύσουν σε εσάς."; +"lightning__spark_exit__leftover" = "{amount} της χρηματοδότησης τελών βρίσκεται ακόμη στη διεύθυνση {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} από την υπόλοιπη χρηματοδότηση τελών στάλθηκε στις αποταμιεύσεις."; +"lightning__spark_exit__leftover_sending" = "Αποστολή {amount} από την υπόλοιπη χρηματοδότηση τελών στις αποταμιεύσεις."; +"lightning__spark_exit__leftover_dust" = "{amount} από την υπόλοιπη χρηματοδότηση τελών είναι πολύ μικρό για αποστολή και παραμένει στη διεύθυνση {address}, διαδρομή παραγωγής {path}."; +"lightning__spark_exit__leftover_txid" = "ID Συναλλαγής: {txid}"; +"lightning__spark_exit__fund_paid" = "Πληρώθηκε από τις αποταμιεύσεις, αναμονή επιβεβαίωσης."; +"lightning__spark_exit__fund_pending" = "Η πληρωμή εντοπίστηκε, αναμονή επιβεβαίωσης."; +"lightning__spark_exit__funding_txid" = "ID Συναλλαγής: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} διεκδικήθηκε on-chain από προηγούμενο κάτοχο αυτών των κεφαλαίων και δεν μπορεί να ανακτηθεί."; +"lightning__spark_exit__notification_lost" = "{amount} της εξόδου σας δεν μπορεί να ανακτηθεί. Ανοίξτε το Bitkit για να δείτε γιατί."; +"lightning__spark_exit__notification_failed" = "Η έξοδος έκτακτης ανάγκης σταμάτησε. Ανοίξτε το Bitkit για να δείτε γιατί."; +"lightning__spark_exit__quote_outside" = "{amount} του υπολοίπου Spark σας δεν μπορεί να εξέλθει αυτή τη στιγμή: ποσά πολύ μικρά για να πληρώσουν τα δικά τους τέλη, ή που ακόμη μετακινούνται μέσα στο Spark. Παραμένει στο Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} δεν μπορεί να εξέλθει με αυτόν τον ρυθμό τελών και παραμένει στο Spark."; +"lightning__spark_exit__needs_funding_title" = "Απαιτούνται\nεπιπλέον τέλη"; +"lightning__spark_exit__needs_funding_text" = "Η έξοδος χρειάζεται ακόμη {amount} για να πληρώσει τα τέλη της. Χρηματοδοτήστε τη για να συνεχίσει."; +"lightning__spark_exit__failed_title" = "Η έξοδος\nσταμάτησε"; +"lightning__spark_exit__cancel" = "Ακύρωση εξόδου"; +"lightning__spark_exit__failed_text" = "Δεν ήταν δυνατή η ανάκτηση κανενός ποσού."; +"lightning__spark_exit__failed_stays" = "{amount} παραμένει στο Spark."; +"lightning__spark_exit__notification_title" = "Έξοδος έκτακτης ανάγκης"; +"lightning__spark_exit__notification_body" = "Ανοίξτε το Bitkit για να συνεχίσετε την έξοδό σας."; +"lightning__spark_exit__notification_funding" = "Η έξοδός σας χρειάζεται επιπλέον τέλη για να συνεχίσει. Ανοίξτε το Bitkit για να τη χρηματοδοτήσετε."; +"lightning__spark_exit__bg_refresh_off" = "Η Ανανέωση εφαρμογών στο παρασκήνιο είναι απενεργοποιημένη για το Bitkit, οπότε η έξοδος προχωρά μόνο όσο το Bitkit είναι ανοιχτό. Ενεργοποιήστε τη στις Ρυθμίσεις για να εκτελείται στο παρασκήνιο."; +"lightning__spark_exit__low_power" = "Η Λειτουργία χαμηλής κατανάλωσης διακόπτει την εργασία στο παρασκήνιο. Η έξοδος συνεχίζεται μόλις απενεργοποιηθεί, ή όταν ανοίξετε το Bitkit."; +"lightning__spark_exit__notifications_off" = "Οι ειδοποιήσεις είναι απενεργοποιημένες, οπότε το Bitkit δεν μπορεί να σας ενημερώσει όταν η έξοδος σας χρειάζεται. Ενεργοποιήστε τις στις Ρυθμίσεις για να λαμβάνετε υπενθυμίσεις."; +"lightning__spark_exit__not_moving" = "Η έξοδος δεν προχωρά: {error}"; +"lightning__spark_exit__fee_low" = "Το δίκτυο είναι πιο φορτωμένο από τον ρυθμό τελών αυτής της εξόδου, {rate} sat/vB. Πληρώστε υψηλότερο τέλος ή περιμένετε να μειωθούν τα τέλη."; +"lightning__spark_exit__waiting_leaves" = "Μέρος αυτής της εξόδου δεν μπορεί να διαβαστεί on-chain αυτή τη στιγμή. Το Bitkit προσπαθεί ξανά για έως μία εβδομάδα."; +"lightning__spark_exit__rebuilt_from_chain" = "Το Bitkit δεν μπόρεσε να διαβάσει την αποθηκευμένη πρόοδο αυτής της εξόδου, οπότε την ανακατασκευάζει από την αλυσίδα."; +"lightning__spark_exit__done_received" = "{amount} έφτασε στις αποταμιεύσεις σας."; +"lightning__spark_exit__sweep_txid" = "ID Συναλλαγής: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} από την υπόλοιπη χρηματοδότηση τελών δεν ήταν δυνατό να σταλεί και παραμένει στη διεύθυνση {address}, διαδρομή παραγωγής {path}."; +"lightning__spark_exit__notification_not_moving" = "Η έξοδος έκτακτης ανάγκης δεν προχωρά. Ανοίξτε το Bitkit για να δείτε γιατί."; +"lightning__spark_exit__funding_split" = "Το Bitkit τη χωρίζει σε {count} μέρη, ένα για κάθε βήμα, ώστε ένα κολλημένο βήμα να μην καθυστερεί τα υπόλοιπα."; +"lightning__spark_exit__waiting_operators" = "{amount} που μετέφεραν on-chain οι διαχειριστές του Spark περιμένει να το αποδεσμεύσουν. Το Bitkit ζητά ξανά για έως μία εβδομάδα."; +"lightning__spark_exit__fee_rose" = "Τα τέλη δικτύου αυξήθηκαν πάνω από το τέλος αυτής της εξόδου. Μπορεί να καθυστερήσει."; +"lightning__spark_exit__fee_bump_button" = "Πληρωμή υψηλότερου τέλους"; +"lightning__spark_exit__fee_bump_title" = "Πληρώστε\nυψηλότερο τέλος"; +"lightning__spark_exit__fee_bump_text" = "Αυτή η έξοδος πληρώνει {rate} sat/vB, λιγότερο από όσο ζητά τώρα το δίκτυο. Επιλέξτε υψηλότερο ρυθμό και το Bitkit στέλνει ξανά τα βήματα σε αναμονή. Πληρώνει τη διαφορά από τις αποταμιεύσεις: το ποσό δίπλα σε κάθε ταχύτητα."; +"lightning__spark_exit__fee_bump_loading" = "Υπολογισμός κόστους υψηλότερου τέλους…"; +"lightning__spark_exit__fee_bump_error" = "Δεν ήταν δυνατός ο υπολογισμός υψηλότερου τέλους. Δοκιμάστε ξανά."; +"lightning__spark_exit__fee_bump_confirm" = "Χρήση αυτού του τέλους"; +"lightning__spark_exit__leftover_waiting" = "{amount} ρέστα τελών θα φτάσουν στις αποταμιεύσεις σας όταν μειωθούν τα τέλη δικτύου."; +"lightning__spark_exit__cancelled_title" = "Η έξοδος\nακυρώθηκε"; +"lightning__spark_exit__cancelled_text" = "Ακυρώσατε την έξοδο. Η χρηματοδότηση τελών που έφτασε επιστρέφει στις αποταμιεύσεις."; +"lightning__spark_exit__cancel_failed" = "Δεν ήταν δυνατός ο έλεγχος της διεύθυνσης χρηματοδότησης. Δοκιμάστε ξανά."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Ένα υψηλότερο τέλος θα άφηνε μέρος αυτής της εξόδου στο Spark, οπότε δεν προσφέρεται κανένα. Η έξοδος επιβεβαιώνεται μόλις μειωθούν τα τέλη δικτύου."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 052f98832..ff3103406 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -373,93 +373,93 @@ "lightning__spark_provider_value" = "Spark mediante Breez"; "lightning__spark_receiving_unlimited" = "Sin límite"; "lightning__spark_trust" = "Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Salida de emergencia"; +"lightning__spark_exit__nav_title" = "Salida de emergencia"; +"lightning__spark_exit__intro_title" = "Sal de Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Usa esto solo si Spark deja de funcionarte. Mueve tu saldo de gastos a ahorros sin los operadores de Spark.\n\nTarda hasta 4 semanas, cuesta comisiones on-chain que se pagan desde ahorros, y las cantidades muy pequeñas pueden quedarse atrás. Se ejecuta en segundo plano y Bitkit te avisa si te necesita."; +"lightning__spark_exit__quote_title" = "Cotización\nde salida"; +"lightning__spark_exit__amount" = "Cantidad a retirar"; +"lightning__spark_exit__fees" = "Comisiones de red"; +"lightning__spark_exit__receive" = "Recibes aproximadamente"; +"lightning__spark_exit__savings_fee" = "Comisión del pago desde ahorros"; +"lightning__spark_exit__savings_total" = "Total desde ahorros"; +"lightning__spark_exit__funding" = "Fondos para comisiones necesarios"; +"lightning__spark_exit__left_behind" = "Se queda en Spark"; +"lightning__spark_exit__outside_exit" = "{amount} en Spark no forma parte de esta salida y se queda en Spark."; +"lightning__spark_exit__quote_loading" = "Obteniendo una cotización…"; +"lightning__spark_exit__quote_settling" = "Esperando a que Spark termine de mover tu saldo. Esto puede tardar hasta 5 minutos."; +"lightning__spark_exit__quote_error" = "No se pudo obtener una cotización de salida. Vuelve a intentarlo."; +"lightning__spark_exit__nothing" = "En este momento no se puede retirar nada."; +"lightning__spark_exit__uneconomic" = "Las comisiones son más altas que lo que recibirías."; +"lightning__spark_exit__funding_title" = "Financia las\ncomisiones"; +"lightning__spark_exit__funding_text" = "La salida paga sus comisiones con un pago on-chain aparte de {amount}. Lo que no gaste vuelve a ahorros al final."; +"lightning__spark_exit__fund_savings" = "Pagar desde ahorros"; +"lightning__spark_exit__fund_external" = "Financiar desde otro billetera"; +"lightning__spark_exit__fund_external_text" = "Envía al menos {amount} a esta dirección. La salida empieza en cuanto se confirme."; +"lightning__spark_exit__fund_insufficient" = "Tus ahorros no alcanzan para financiar las comisiones."; +"lightning__spark_exit__fund_seen" = "Pago detectado."; +"lightning__spark_exit__fund_partial" = "Recibidos {received} de {required}, envía {missing} más."; +"lightning__spark_exit__confirm_title" = "¿Iniciar\nla salida?"; +"lightning__spark_exit__confirm_text" = "No podrás usar estos fondos durante hasta 4 semanas."; +"lightning__spark_exit__swipe" = "Deslizar para salir"; +"lightning__spark_exit__progress_title" = "Salida\nen curso"; +"lightning__spark_exit__progress_text" = "Esto se ejecuta en segundo plano. Bitkit te avisa si te necesita."; +"lightning__spark_exit__step_funding" = "Confirmación de los fondos para comisiones"; +"lightning__spark_exit__step_publish" = "Publicando las transacciones de salida"; +"lightning__spark_exit__step_timelock" = "Esperando el bloqueo temporal"; +"lightning__spark_exit__step_sweep" = "Enviando a ahorros"; +"lightning__spark_exit__ready_in" = "Listo en {duration}"; +"lightning__spark_exit__done_title" = "Salida\ncompletada"; +"lightning__spark_exit__done_text" = "Tus fondos están en ahorros."; +"lightning__spark_exit__partial_text" = "{count} cantidades pequeñas se quedaron en Spark."; +"lightning__spark_exit__failed_leaves" = "Los operadores de Spark movieron {amount} on-chain y ya no está en Spark. Los operadores todavía pueden liberártelo."; +"lightning__spark_exit__leftover" = "{amount} de los fondos para comisiones sigue en {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} sobrante de los fondos para comisiones se envió a ahorros."; +"lightning__spark_exit__leftover_sending" = "Enviando a ahorros {amount} sobrante de los fondos para comisiones."; +"lightning__spark_exit__leftover_dust" = "{amount} sobrante de los fondos para comisiones es demasiado pequeño para enviarlo y se queda en {address}, ruta de derivación {path}."; +"lightning__spark_exit__leftover_txid" = "ID de la transacción: {txid}"; +"lightning__spark_exit__fund_paid" = "Pagado desde ahorros, esperando confirmación."; +"lightning__spark_exit__fund_pending" = "Pago detectado, esperando confirmación."; +"lightning__spark_exit__funding_txid" = "ID de la transacción: {txid}"; +"lightning__spark_exit__lost_leaves" = "Un propietario anterior de estos fondos reclamó {amount} on-chain y no se puede recuperar."; +"lightning__spark_exit__notification_lost" = "{amount} de tu salida no se puede recuperar. Abre Bitkit para ver por qué."; +"lightning__spark_exit__notification_failed" = "Tu salida de emergencia se detuvo. Abre Bitkit para ver por qué."; +"lightning__spark_exit__quote_outside" = "{amount} de tu saldo de Spark no se puede retirar en este momento: cantidades demasiado pequeñas para pagar sus propias comisiones o que aún se están moviendo dentro de Spark. Se queda en Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} no se puede retirar con esta tasa de comisión y se queda en Spark."; +"lightning__spark_exit__needs_funding_title" = "Se necesitan\nmás comisiones"; +"lightning__spark_exit__needs_funding_text" = "La salida necesita {amount} más para pagar sus comisiones. Agrega fondos para continuar."; +"lightning__spark_exit__failed_title" = "Salida\ndetenida"; +"lightning__spark_exit__cancel" = "Cancelar salida"; +"lightning__spark_exit__failed_text" = "No se pudo recuperar nada."; +"lightning__spark_exit__failed_stays" = "{amount} se queda en Spark."; +"lightning__spark_exit__notification_title" = "Salida de emergencia"; +"lightning__spark_exit__notification_body" = "Abre Bitkit para continuar tu salida."; +"lightning__spark_exit__notification_funding" = "Tu salida necesita más comisiones para continuar. Abre Bitkit para agregar fondos."; +"lightning__spark_exit__bg_refresh_off" = "La Actualización en segundo plano está desactivada para Bitkit, así que la salida solo avanza mientras Bitkit está abierto. Actívala en Ajustes para que funcione en segundo plano."; +"lightning__spark_exit__low_power" = "El Modo de bajo consumo pausa el trabajo en segundo plano. La salida continúa cuando se desactive o cuando abras Bitkit."; +"lightning__spark_exit__notifications_off" = "Las notificaciones están desactivadas, así que Bitkit no puede avisarte cuando la salida te necesite. Actívalas en Ajustes para recibir recordatorios."; +"lightning__spark_exit__not_moving" = "La salida no avanza: {error}"; +"lightning__spark_exit__fee_low" = "La red está más saturada que la tasa de comisión de esta salida, de {rate} sat/vB. Paga una comisión más alta o espera a que bajen las comisiones."; +"lightning__spark_exit__waiting_leaves" = "En este momento no se puede leer on-chain una parte de esta salida. Bitkit lo vuelve a intentar durante hasta una semana."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no pudo leer el progreso guardado de esta salida, así que lo reconstruye a partir de la cadena."; +"lightning__spark_exit__done_received" = "{amount} llegó a tus ahorros."; +"lightning__spark_exit__sweep_txid" = "ID de la transacción: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} sobrante de los fondos para comisiones no se pudo enviar y se queda en {address}, ruta de derivación {path}."; +"lightning__spark_exit__notification_not_moving" = "Tu salida de emergencia no avanza. Abre Bitkit para ver por qué."; +"lightning__spark_exit__funding_split" = "Bitkit lo divide en {count} partes, una para cada paso, para que un paso atascado no retrase a los demás."; +"lightning__spark_exit__waiting_operators" = "{amount} que los operadores de Spark movieron on-chain espera a que lo liberen. Bitkit vuelve a pedirlo durante hasta una semana."; +"lightning__spark_exit__fee_rose" = "Las comisiones de red superan la comisión de esta salida. Puede ir más lenta."; +"lightning__spark_exit__fee_bump_button" = "Pagar una comisión más alta"; +"lightning__spark_exit__fee_bump_title" = "Paga una\ncomisión más alta"; +"lightning__spark_exit__fee_bump_text" = "Esta salida paga {rate} sat/vB, menos de lo que pide la red ahora. Elige una tasa más alta y Bitkit vuelve a enviar los pasos pendientes. Paga la diferencia desde ahorros: la cantidad junto a cada velocidad."; +"lightning__spark_exit__fee_bump_loading" = "Calculando cuánto cuesta una comisión más alta…"; +"lightning__spark_exit__fee_bump_error" = "No se pudo calcular una comisión más alta. Vuelve a intentarlo."; +"lightning__spark_exit__fee_bump_confirm" = "Usar esta comisión"; +"lightning__spark_exit__leftover_waiting" = "{amount} de cambio de comisiones llegará a tus ahorros cuando bajen las comisiones de red."; +"lightning__spark_exit__cancelled_title" = "Salida\ncancelada"; +"lightning__spark_exit__cancelled_text" = "Cancelaste la salida. Los fondos para comisiones que llegaron vuelven a ahorros."; +"lightning__spark_exit__cancel_failed" = "No se pudo verificar la dirección de financiación. Vuelve a intentarlo."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Una comisión más alta dejaría parte de esta salida en Spark, así que no se ofrece ninguna. La salida se confirma cuando bajen las comisiones de red."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 9b3d2b3da..f9d0999a1 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -357,93 +357,93 @@ "lightning__spark_provider_value" = "Spark mediante Breez"; "lightning__spark_receiving_unlimited" = "Sin límite"; "lightning__spark_trust" = "Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Salida de emergencia"; +"lightning__spark_exit__nav_title" = "Salida de emergencia"; +"lightning__spark_exit__intro_title" = "Sal de Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Usa esto solo si Spark deja de funcionarte. Mueve tu saldo de gastos a ahorros sin los operadores de Spark.\n\nTarda hasta 4 semanas, cuesta comisiones on-chain que se pagan desde ahorros, y las cantidades muy pequeñas pueden quedarse atrás. Se ejecuta en segundo plano y Bitkit te avisa si te necesita."; +"lightning__spark_exit__quote_title" = "Presupuesto\nde salida"; +"lightning__spark_exit__amount" = "Cantidad a retirar"; +"lightning__spark_exit__fees" = "Comisiones de red"; +"lightning__spark_exit__receive" = "Recibes aproximadamente"; +"lightning__spark_exit__savings_fee" = "Comisión del pago desde ahorros"; +"lightning__spark_exit__savings_total" = "Total desde ahorros"; +"lightning__spark_exit__funding" = "Fondos para comisiones necesarios"; +"lightning__spark_exit__left_behind" = "Se queda en Spark"; +"lightning__spark_exit__outside_exit" = "{amount} en Spark no forma parte de esta salida y se queda en Spark."; +"lightning__spark_exit__quote_loading" = "Obteniendo un presupuesto…"; +"lightning__spark_exit__quote_settling" = "Esperando a que Spark termine de mover tu saldo. Esto puede tardar hasta 5 minutos."; +"lightning__spark_exit__quote_error" = "No se pudo obtener un presupuesto de salida. Inténtalo de nuevo."; +"lightning__spark_exit__nothing" = "Ahora mismo no se puede retirar nada."; +"lightning__spark_exit__uneconomic" = "Las comisiones son más altas que lo que recibirías."; +"lightning__spark_exit__funding_title" = "Financia las\ncomisiones"; +"lightning__spark_exit__funding_text" = "La salida paga sus comisiones con un pago on-chain aparte de {amount}. Lo que no gaste vuelve a ahorros al final."; +"lightning__spark_exit__fund_savings" = "Pagar desde ahorros"; +"lightning__spark_exit__fund_external" = "Financiar desde otro monedero"; +"lightning__spark_exit__fund_external_text" = "Envía al menos {amount} a esta dirección. La salida empieza en cuanto se confirme."; +"lightning__spark_exit__fund_insufficient" = "Tus ahorros no alcanzan para financiar las comisiones."; +"lightning__spark_exit__fund_seen" = "Pago detectado."; +"lightning__spark_exit__fund_partial" = "Recibidos {received} de {required}, envía {missing} más."; +"lightning__spark_exit__confirm_title" = "¿Iniciar\nla salida?"; +"lightning__spark_exit__confirm_text" = "No podrás usar estos fondos durante hasta 4 semanas."; +"lightning__spark_exit__swipe" = "Deslizar para salir"; +"lightning__spark_exit__progress_title" = "Salida\nen curso"; +"lightning__spark_exit__progress_text" = "Esto se ejecuta en segundo plano. Bitkit te avisa si te necesita."; +"lightning__spark_exit__step_funding" = "Confirmación de los fondos para comisiones"; +"lightning__spark_exit__step_publish" = "Publicando las transacciones de salida"; +"lightning__spark_exit__step_timelock" = "Esperando el bloqueo temporal"; +"lightning__spark_exit__step_sweep" = "Enviando a ahorros"; +"lightning__spark_exit__ready_in" = "Listo en {duration}"; +"lightning__spark_exit__done_title" = "Salida\ncompletada"; +"lightning__spark_exit__done_text" = "Tus fondos están en ahorros."; +"lightning__spark_exit__partial_text" = "{count} cantidades pequeñas se quedaron en Spark."; +"lightning__spark_exit__failed_leaves" = "Los operadores de Spark movieron {amount} on-chain y ya no está en Spark. Los operadores todavía pueden liberártelo."; +"lightning__spark_exit__leftover" = "{amount} de los fondos para comisiones sigue en {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} sobrante de los fondos para comisiones se envió a ahorros."; +"lightning__spark_exit__leftover_sending" = "Enviando a ahorros {amount} sobrante de los fondos para comisiones."; +"lightning__spark_exit__leftover_dust" = "{amount} sobrante de los fondos para comisiones es demasiado pequeño para enviarlo y se queda en {address}, ruta de derivación {path}."; +"lightning__spark_exit__leftover_txid" = "ID de la transacción: {txid}"; +"lightning__spark_exit__fund_paid" = "Pagado desde ahorros, esperando confirmación."; +"lightning__spark_exit__fund_pending" = "Pago detectado, esperando confirmación."; +"lightning__spark_exit__funding_txid" = "ID de la transacción: {txid}"; +"lightning__spark_exit__lost_leaves" = "Un propietario anterior de estos fondos reclamó {amount} on-chain y no se puede recuperar."; +"lightning__spark_exit__notification_lost" = "{amount} de tu salida no se puede recuperar. Abre Bitkit para ver por qué."; +"lightning__spark_exit__notification_failed" = "Tu salida de emergencia se detuvo. Abre Bitkit para ver por qué."; +"lightning__spark_exit__quote_outside" = "{amount} de tu saldo de Spark no se puede retirar ahora mismo: cantidades demasiado pequeñas para pagar sus propias comisiones o que aún se están moviendo dentro de Spark. Se queda en Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} no se puede retirar con esta tasa de comisión y se queda en Spark."; +"lightning__spark_exit__needs_funding_title" = "Se necesitan\nmás comisiones"; +"lightning__spark_exit__needs_funding_text" = "La salida necesita {amount} más para pagar sus comisiones. Añade fondos para continuar."; +"lightning__spark_exit__failed_title" = "Salida\ndetenida"; +"lightning__spark_exit__cancel" = "Cancelar salida"; +"lightning__spark_exit__failed_text" = "No se pudo recuperar nada."; +"lightning__spark_exit__failed_stays" = "{amount} se queda en Spark."; +"lightning__spark_exit__notification_title" = "Salida de emergencia"; +"lightning__spark_exit__notification_body" = "Abre Bitkit para continuar tu salida."; +"lightning__spark_exit__notification_funding" = "Tu salida necesita más comisiones para continuar. Abre Bitkit para añadir fondos."; +"lightning__spark_exit__bg_refresh_off" = "La Actualización en segundo plano está desactivada para Bitkit, así que la salida solo avanza mientras Bitkit está abierto. Actívala en Ajustes para que funcione en segundo plano."; +"lightning__spark_exit__low_power" = "El Modo de bajo consumo pausa el trabajo en segundo plano. La salida continúa cuando se desactive o cuando abras Bitkit."; +"lightning__spark_exit__notifications_off" = "Las notificaciones están desactivadas, así que Bitkit no puede avisarte cuando la salida te necesite. Actívalas en Ajustes para recibir recordatorios."; +"lightning__spark_exit__not_moving" = "La salida no avanza: {error}"; +"lightning__spark_exit__fee_low" = "La red está más congestionada que la tasa de comisión de esta salida, de {rate} sat/vB. Paga una comisión más alta o espera a que bajen las comisiones."; +"lightning__spark_exit__waiting_leaves" = "Ahora mismo no se puede leer on-chain una parte de esta salida. Bitkit lo vuelve a intentar durante hasta una semana."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no pudo leer el progreso guardado de esta salida, así que lo reconstruye a partir de la cadena."; +"lightning__spark_exit__done_received" = "{amount} llegó a tus ahorros."; +"lightning__spark_exit__sweep_txid" = "ID de la transacción: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} sobrante de los fondos para comisiones no se pudo enviar y se queda en {address}, ruta de derivación {path}."; +"lightning__spark_exit__notification_not_moving" = "Tu salida de emergencia no avanza. Abre Bitkit para ver por qué."; +"lightning__spark_exit__funding_split" = "Bitkit lo divide en {count} partes, una para cada paso, para que un paso atascado no retrase a los demás."; +"lightning__spark_exit__waiting_operators" = "{amount} que los operadores de Spark movieron on-chain espera a que lo liberen. Bitkit vuelve a pedirlo durante hasta una semana."; +"lightning__spark_exit__fee_rose" = "Las comisiones de red superan la comisión de esta salida. Puede ir más lenta."; +"lightning__spark_exit__fee_bump_button" = "Pagar una comisión más alta"; +"lightning__spark_exit__fee_bump_title" = "Paga una\ncomisión más alta"; +"lightning__spark_exit__fee_bump_text" = "Esta salida paga {rate} sat/vB, menos de lo que pide la red ahora. Elige una tasa más alta y Bitkit vuelve a enviar los pasos pendientes. Paga la diferencia desde ahorros: la cantidad junto a cada velocidad."; +"lightning__spark_exit__fee_bump_loading" = "Calculando cuánto cuesta una comisión más alta…"; +"lightning__spark_exit__fee_bump_error" = "No se pudo calcular una comisión más alta. Inténtalo de nuevo."; +"lightning__spark_exit__fee_bump_confirm" = "Usar esta comisión"; +"lightning__spark_exit__leftover_waiting" = "{amount} de cambio de comisiones llegará a tus ahorros cuando bajen las comisiones de red."; +"lightning__spark_exit__cancelled_title" = "Salida\ncancelada"; +"lightning__spark_exit__cancelled_text" = "Cancelaste la salida. Los fondos para comisiones que llegaron vuelven a ahorros."; +"lightning__spark_exit__cancel_failed" = "No se pudo comprobar la dirección de financiación. Inténtalo de nuevo."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Una comisión más alta dejaría parte de esta salida en Spark, así que no se ofrece ninguna. La salida se confirma cuando bajen las comisiones de red."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index d4575fea6..a6b2a31e9 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -373,93 +373,93 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "Sans limite"; "lightning__spark_trust" = "Ce solde est détenu sur Spark, géré par Lightspark, Breez et Flashnet. Il est en sécurité tant qu’au moins l’un d’eux agit honnêtement. Ils peuvent suspendre l’accès, et en sortir sans eux nécessite une sortie on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Sortie d'urgence"; +"lightning__spark_exit__nav_title" = "Sortie d'urgence"; +"lightning__spark_exit__intro_title" = "Quitter Spark\non-chain"; +"lightning__spark_exit__intro_text" = "N'utilisez ceci que si Spark ne fonctionne plus pour vous. Cela transfère votre solde de dépenses vers l'épargne sans les opérateurs Spark.\n\nCela prend jusqu'à 4 semaines, coûte des frais on-chain payés depuis l'épargne, et de très petits montants peuvent rester sur Spark. Cela s'exécute en arrière-plan, et Bitkit vous avertit s'il a besoin de vous."; +"lightning__spark_exit__quote_title" = "Devis de\nsortie"; +"lightning__spark_exit__amount" = "Montant à sortir"; +"lightning__spark_exit__fees" = "Frais de réseau"; +"lightning__spark_exit__receive" = "Vous recevez environ"; +"lightning__spark_exit__savings_fee" = "Frais de paiement depuis l'épargne"; +"lightning__spark_exit__savings_total" = "Total depuis l'épargne"; +"lightning__spark_exit__funding" = "Financement des frais requis"; +"lightning__spark_exit__left_behind" = "Reste sur Spark"; +"lightning__spark_exit__outside_exit" = "{amount} sur Spark ne fait pas partie de cette sortie et reste sur Spark."; +"lightning__spark_exit__quote_loading" = "Obtention d'un devis…"; +"lightning__spark_exit__quote_settling" = "En attente que Spark termine le transfert de votre solde. Cela peut prendre jusqu'à 5 minutes."; +"lightning__spark_exit__quote_error" = "Impossible d'obtenir un devis de sortie. Réessayez."; +"lightning__spark_exit__nothing" = "Rien ne peut être sorti pour le moment."; +"lightning__spark_exit__uneconomic" = "Les frais sont supérieurs à ce que vous recevriez."; +"lightning__spark_exit__funding_title" = "Financer les\nfrais"; +"lightning__spark_exit__funding_text" = "La sortie paie ses frais à partir d'un paiement on-chain séparé de {amount}. Ce qui n'est pas dépensé revient dans l'épargne à la fin."; +"lightning__spark_exit__fund_savings" = "Payer depuis l'épargne"; +"lightning__spark_exit__fund_external" = "Financer depuis un autre portefeuille"; +"lightning__spark_exit__fund_external_text" = "Envoyez au moins {amount} à cette adresse. La sortie démarre une fois le paiement confirmé."; +"lightning__spark_exit__fund_insufficient" = "Votre épargne ne peut pas couvrir le financement des frais."; +"lightning__spark_exit__fund_seen" = "Paiement détecté."; +"lightning__spark_exit__fund_partial" = "{received} sur {required} reçus, envoyez encore {missing}."; +"lightning__spark_exit__confirm_title" = "Lancer la\nsortie ?"; +"lightning__spark_exit__confirm_text" = "Vous ne pourrez pas utiliser ces fonds pendant jusqu'à 4 semaines."; +"lightning__spark_exit__swipe" = "Glissez pour sortir"; +"lightning__spark_exit__progress_title" = "Sortie\nen cours"; +"lightning__spark_exit__progress_text" = "Cela s'exécute en arrière-plan. Bitkit vous avertit s'il a besoin de vous."; +"lightning__spark_exit__step_funding" = "Confirmation du financement des frais"; +"lightning__spark_exit__step_publish" = "Publication des transactions de sortie"; +"lightning__spark_exit__step_timelock" = "En attente du timelock"; +"lightning__spark_exit__step_sweep" = "Envoi vers l'épargne"; +"lightning__spark_exit__ready_in" = "Prêt dans {duration}"; +"lightning__spark_exit__done_title" = "Sortie\nterminée"; +"lightning__spark_exit__done_text" = "Vos fonds sont dans l'épargne."; +"lightning__spark_exit__partial_text" = "{count} petits montants sont restés sur Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} a été déplacé on-chain par les opérateurs Spark et n'est plus sur Spark. Les opérateurs peuvent encore vous le libérer."; +"lightning__spark_exit__leftover" = "{amount} de financement des frais se trouve encore à {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} de financement des frais restant a été envoyé vers l'épargne."; +"lightning__spark_exit__leftover_sending" = "Envoi de {amount} de financement des frais restant vers l'épargne."; +"lightning__spark_exit__leftover_dust" = "{amount} de financement des frais restant est trop faible pour être envoyé et reste à {address}, chemin de dérivation {path}."; +"lightning__spark_exit__leftover_txid" = "ID de la transaction : {txid}"; +"lightning__spark_exit__fund_paid" = "Payé depuis l'épargne, en attente de confirmation."; +"lightning__spark_exit__fund_pending" = "Paiement détecté, en attente de confirmation."; +"lightning__spark_exit__funding_txid" = "ID de la transaction : {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} a été réclamé on-chain par un ancien propriétaire de ces fonds et ne peut pas être récupéré."; +"lightning__spark_exit__notification_lost" = "{amount} de votre sortie ne peut pas être récupéré. Ouvrez Bitkit pour savoir pourquoi."; +"lightning__spark_exit__notification_failed" = "Votre sortie d'urgence s'est arrêtée. Ouvrez Bitkit pour savoir pourquoi."; +"lightning__spark_exit__quote_outside" = "{amount} de votre solde Spark ne peut pas être sorti pour le moment : des montants trop faibles pour payer leurs propres frais, ou encore en mouvement au sein de Spark. Ils restent sur Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} ne peut pas être sorti à ce taux de frais et reste sur Spark."; +"lightning__spark_exit__needs_funding_title" = "Frais supplémentaires\nrequis"; +"lightning__spark_exit__needs_funding_text" = "La sortie a besoin de {amount} de plus pour payer ses frais. Financez-la pour continuer."; +"lightning__spark_exit__failed_title" = "Sortie\narrêtée"; +"lightning__spark_exit__cancel" = "Annuler la sortie"; +"lightning__spark_exit__failed_text" = "Rien n'a pu être récupéré."; +"lightning__spark_exit__failed_stays" = "{amount} reste sur Spark."; +"lightning__spark_exit__notification_title" = "Sortie d'urgence"; +"lightning__spark_exit__notification_body" = "Ouvrez Bitkit pour poursuivre votre sortie."; +"lightning__spark_exit__notification_funding" = "Votre sortie a besoin de plus de frais pour continuer. Ouvrez Bitkit pour la financer."; +"lightning__spark_exit__bg_refresh_off" = "L'actualisation en arrière-plan est désactivée pour Bitkit, la sortie n'avance donc que lorsque Bitkit est ouvert. Activez-la dans Réglages pour qu'elle s'exécute en arrière-plan."; +"lightning__spark_exit__low_power" = "Le mode Économie d'énergie met en pause le travail en arrière-plan. La sortie reprend une fois qu'il est désactivé, ou lorsque vous ouvrez Bitkit."; +"lightning__spark_exit__notifications_off" = "Les notifications sont désactivées, Bitkit ne peut donc pas vous prévenir quand la sortie a besoin de vous. Activez-les dans Réglages pour recevoir des rappels."; +"lightning__spark_exit__not_moving" = "La sortie n'avance pas : {error}"; +"lightning__spark_exit__fee_low" = "Le réseau est plus chargé que le taux de frais de cette sortie de {rate} sat/vB. Payez des frais plus élevés, ou attendez que les frais baissent."; +"lightning__spark_exit__waiting_leaves" = "Une partie de cette sortie ne peut pas être lue on-chain pour le moment. Bitkit réessaie pendant une semaine au maximum."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit n'a pas pu lire la progression enregistrée de cette sortie, il la reconstruit donc à partir de la blockchain."; +"lightning__spark_exit__done_received" = "{amount} est arrivé dans votre épargne."; +"lightning__spark_exit__sweep_txid" = "ID de la transaction : {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} de financement des frais restant n'a pas pu être envoyé et reste à {address}, chemin de dérivation {path}."; +"lightning__spark_exit__notification_not_moving" = "Votre sortie d'urgence n'avance pas. Ouvrez Bitkit pour savoir pourquoi."; +"lightning__spark_exit__funding_split" = "Bitkit le divise en {count} parties, une pour chaque étape, afin qu'une étape bloquée ne retarde pas les autres."; +"lightning__spark_exit__waiting_operators" = "{amount} que les opérateurs Spark ont déplacé on-chain attend qu'ils le libèrent. Bitkit redemande pendant une semaine au maximum."; +"lightning__spark_exit__fee_rose" = "Les frais de réseau ont dépassé les frais de cette sortie. Elle pourrait ralentir."; +"lightning__spark_exit__fee_bump_button" = "Payer des frais plus élevés"; +"lightning__spark_exit__fee_bump_title" = "Payer des\nfrais plus élevés"; +"lightning__spark_exit__fee_bump_text" = "Cette sortie paie {rate} sat/vB, moins que ce que le réseau demande actuellement. Choisissez un taux plus élevé et Bitkit renvoie les étapes en attente. La différence est payée depuis l'épargne : le montant affiché à côté de chaque vitesse."; +"lightning__spark_exit__fee_bump_loading" = "Calcul du coût de frais plus élevés…"; +"lightning__spark_exit__fee_bump_error" = "Impossible d'estimer des frais plus élevés. Réessayez."; +"lightning__spark_exit__fee_bump_confirm" = "Utiliser ces frais"; +"lightning__spark_exit__leftover_waiting" = "{amount} de monnaie de frais arrivera dans votre épargne lorsque les frais de réseau baisseront."; +"lightning__spark_exit__cancelled_title" = "Sortie\nannulée"; +"lightning__spark_exit__cancelled_text" = "Vous avez annulé la sortie. Le financement des frais déjà reçu retourne dans l'épargne."; +"lightning__spark_exit__cancel_failed" = "Impossible de vérifier l'adresse de financement. Réessayez."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Des frais plus élevés laisseraient une partie de cette sortie sur Spark, donc aucun n'est proposé. La sortie se confirme une fois que les frais de réseau baissent."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index b3379249a..abf954889 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -366,93 +366,93 @@ "lightning__spark_provider_value" = "Spark tramite Breez"; "lightning__spark_receiving_unlimited" = "Nessun limite"; "lightning__spark_trust" = "Questo saldo è custodito su Spark, gestito da Lightspark, Breez e Flashnet. È al sicuro finché almeno uno di loro agisce onestamente. Possono sospendere l’accesso e uscire senza di loro richiede un’uscita on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Uscita di emergenza"; +"lightning__spark_exit__nav_title" = "Uscita di emergenza"; +"lightning__spark_exit__intro_title" = "Lascia Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Usala solo se Spark smette di funzionare per te. Sposta il tuo saldo di spesa nei risparmi senza gli operatori Spark.\n\nRichiede fino a 4 settimane, costa commissioni on-chain pagate dai risparmi e importi molto piccoli possono restare su Spark. Funziona in background e Bitkit ti avvisa se ha bisogno di te."; +"lightning__spark_exit__quote_title" = "Preventivo\ndi uscita"; +"lightning__spark_exit__amount" = "Importo da far uscire"; +"lightning__spark_exit__fees" = "Commissioni di rete"; +"lightning__spark_exit__receive" = "Ricevi circa"; +"lightning__spark_exit__savings_fee" = "Commissione del pagamento dai risparmi"; +"lightning__spark_exit__savings_total" = "Totale dai risparmi"; +"lightning__spark_exit__funding" = "Finanziamento commissioni necessario"; +"lightning__spark_exit__left_behind" = "Resta su Spark"; +"lightning__spark_exit__outside_exit" = "{amount} su Spark non fa parte di questa uscita e resta su Spark."; +"lightning__spark_exit__quote_loading" = "Recupero del preventivo…"; +"lightning__spark_exit__quote_settling" = "In attesa che Spark finisca di spostare il tuo saldo. Può richiedere fino a 5 minuti."; +"lightning__spark_exit__quote_error" = "Impossibile ottenere un preventivo di uscita. Riprova."; +"lightning__spark_exit__nothing" = "Al momento non è possibile far uscire nulla."; +"lightning__spark_exit__uneconomic" = "Le commissioni superano quanto riceveresti."; +"lightning__spark_exit__funding_title" = "Finanzia le\ncommissioni"; +"lightning__spark_exit__funding_text" = "L'uscita paga le sue commissioni con un pagamento on-chain separato di {amount}. Ciò che non spende torna nei risparmi alla fine."; +"lightning__spark_exit__fund_savings" = "Paga dai risparmi"; +"lightning__spark_exit__fund_external" = "Finanzia da un altro wallet"; +"lightning__spark_exit__fund_external_text" = "Invia almeno {amount} a questo indirizzo. L'uscita inizia appena il pagamento è confermato."; +"lightning__spark_exit__fund_insufficient" = "I tuoi risparmi non bastano a coprire il finanziamento delle commissioni."; +"lightning__spark_exit__fund_seen" = "Pagamento rilevato."; +"lightning__spark_exit__fund_partial" = "Ricevuti {received} su {required}, invia altri {missing}."; +"lightning__spark_exit__confirm_title" = "Avviare\nl'uscita?"; +"lightning__spark_exit__confirm_text" = "Non potrai usare questi fondi per fino a 4 settimane."; +"lightning__spark_exit__swipe" = "Scorri Per Uscire"; +"lightning__spark_exit__progress_title" = "Uscita\nin corso"; +"lightning__spark_exit__progress_text" = "Funziona in background. Bitkit ti avvisa se ha bisogno di te."; +"lightning__spark_exit__step_funding" = "Conferma del finanziamento commissioni"; +"lightning__spark_exit__step_publish" = "Pubblicazione delle transazioni di uscita"; +"lightning__spark_exit__step_timelock" = "In attesa del timelock"; +"lightning__spark_exit__step_sweep" = "Invio ai risparmi"; +"lightning__spark_exit__ready_in" = "Pronto tra {duration}"; +"lightning__spark_exit__done_title" = "Uscita\ncompletata"; +"lightning__spark_exit__done_text" = "I tuoi fondi sono nei risparmi."; +"lightning__spark_exit__partial_text" = "{count} piccoli importi sono rimasti su Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} è stato spostato on-chain dagli operatori Spark e non è su Spark. Gli operatori possono ancora rilasciarlo a te."; +"lightning__spark_exit__leftover" = "{amount} del finanziamento commissioni è ancora su {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} di finanziamento commissioni avanzato è stato inviato ai risparmi."; +"lightning__spark_exit__leftover_sending" = "Invio di {amount} di finanziamento commissioni avanzato ai risparmi."; +"lightning__spark_exit__leftover_dust" = "{amount} di finanziamento commissioni avanzato è troppo poco per essere inviato e resta su {address}, percorso di derivazione {path}."; +"lightning__spark_exit__leftover_txid" = "ID Transazione: {txid}"; +"lightning__spark_exit__fund_paid" = "Pagato dai risparmi, in attesa di conferma."; +"lightning__spark_exit__fund_pending" = "Pagamento rilevato, in attesa di conferma."; +"lightning__spark_exit__funding_txid" = "ID Transazione: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} è stato riscattato on-chain da un precedente proprietario di questi fondi e non può essere recuperato."; +"lightning__spark_exit__notification_lost" = "{amount} della tua uscita non può essere recuperato. Apri Bitkit per scoprire perché."; +"lightning__spark_exit__notification_failed" = "La tua uscita di emergenza si è fermata. Apri Bitkit per scoprire perché."; +"lightning__spark_exit__quote_outside" = "{amount} del tuo saldo Spark non può uscire al momento: importi troppo piccoli per pagare le proprie commissioni, o ancora in movimento dentro Spark. Resta su Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} non può uscire con questo tasso di commissione e resta su Spark."; +"lightning__spark_exit__needs_funding_title" = "Servono altre\ncommissioni"; +"lightning__spark_exit__needs_funding_text" = "L'uscita ha bisogno di altri {amount} per pagare le commissioni. Finanziala per continuare."; +"lightning__spark_exit__failed_title" = "Uscita\ninterrotta"; +"lightning__spark_exit__cancel" = "Annulla uscita"; +"lightning__spark_exit__failed_text" = "Non è stato possibile recuperare nulla."; +"lightning__spark_exit__failed_stays" = "{amount} resta su Spark."; +"lightning__spark_exit__notification_title" = "Uscita di emergenza"; +"lightning__spark_exit__notification_body" = "Apri Bitkit per continuare la tua uscita."; +"lightning__spark_exit__notification_funding" = "La tua uscita ha bisogno di altre commissioni per continuare. Apri Bitkit per finanziarla."; +"lightning__spark_exit__bg_refresh_off" = "L'aggiornamento app in background è disattivato per Bitkit, quindi l'uscita avanza solo mentre Bitkit è aperto. Attivalo nelle Impostazioni per farla procedere in background."; +"lightning__spark_exit__low_power" = "La modalità Risparmio energetico sospende le attività in background. L'uscita riprende quando la disattivi o quando apri Bitkit."; +"lightning__spark_exit__notifications_off" = "Le notifiche sono disattivate, quindi Bitkit non può avvisarti quando l'uscita ha bisogno di te. Attivale nelle Impostazioni per ricevere promemoria."; +"lightning__spark_exit__not_moving" = "L'uscita non avanza: {error}"; +"lightning__spark_exit__fee_low" = "La rete è più congestionata del tasso di commissione di questa uscita di {rate} sat/vB. Paga una commissione più alta o attendi che le commissioni scendano."; +"lightning__spark_exit__waiting_leaves" = "Parte di questa uscita non può essere letta on-chain al momento. Bitkit riprova per un massimo di una settimana."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit non è riuscito a leggere i progressi salvati di questa uscita, quindi li ricostruisce dalla blockchain."; +"lightning__spark_exit__done_received" = "{amount} è arrivato nei tuoi risparmi."; +"lightning__spark_exit__sweep_txid" = "ID Transazione: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} di finanziamento commissioni avanzato non è stato inviato e resta su {address}, percorso di derivazione {path}."; +"lightning__spark_exit__notification_not_moving" = "La tua uscita di emergenza non avanza. Apri Bitkit per scoprire perché."; +"lightning__spark_exit__funding_split" = "Bitkit lo divide in {count} parti, una per ogni passaggio, così un passaggio bloccato non può trattenere gli altri."; +"lightning__spark_exit__waiting_operators" = "{amount} che gli operatori Spark hanno spostato on-chain attende che lo rilascino. Bitkit lo richiede per un massimo di una settimana."; +"lightning__spark_exit__fee_rose" = "Le commissioni di rete hanno superato la commissione di questa uscita. Potrebbe rallentare."; +"lightning__spark_exit__fee_bump_button" = "Paga una commissione più alta"; +"lightning__spark_exit__fee_bump_title" = "Paga una\ncommissione più alta"; +"lightning__spark_exit__fee_bump_text" = "Questa uscita paga {rate} sat/vB, meno di quanto chiede ora la rete. Scegli un tasso più alto e Bitkit invia di nuovo i passaggi in attesa. Paga la differenza dai risparmi: l'importo accanto a ogni velocità."; +"lightning__spark_exit__fee_bump_loading" = "Calcolo del costo di una commissione più alta…"; +"lightning__spark_exit__fee_bump_error" = "Impossibile calcolare una commissione più alta. Riprova."; +"lightning__spark_exit__fee_bump_confirm" = "Usa questa commissione"; +"lightning__spark_exit__leftover_waiting" = "{amount} di resto delle commissioni arriverà nei tuoi risparmi quando le commissioni di rete scenderanno."; +"lightning__spark_exit__cancelled_title" = "Uscita\nannullata"; +"lightning__spark_exit__cancelled_text" = "Hai annullato l'uscita. Il finanziamento commissioni arrivato torna nei risparmi."; +"lightning__spark_exit__cancel_failed" = "Impossibile controllare l'indirizzo di finanziamento. Riprova."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Una commissione più alta lascerebbe parte di questa uscita su Spark, quindi non ne viene offerta nessuna. L'uscita si conferma quando le commissioni di rete scendono."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index ca414fe8a..c25d7fce8 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -370,93 +370,93 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "Geen limiet"; "lightning__spark_trust" = "Dit saldo staat op Spark, beheerd door Lightspark, Breez en Flashnet. Het is veilig zolang ten minste één van hen eerlijk handelt. Zij kunnen de toegang pauzeren, en vertrekken zonder hen vereist een on-chain exit."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Nooduitgang"; +"lightning__spark_exit__nav_title" = "Nooduitgang"; +"lightning__spark_exit__intro_title" = "Spark verlaten\non-chain"; +"lightning__spark_exit__intro_text" = "Gebruik dit alleen als Spark niet meer voor je werkt. Het zet je bestedingssaldo over naar spaargeld zonder de Spark-operators.\n\nHet duurt tot 4 weken, kost on-chain vergoedingen die uit je spaargeld worden betaald, en zeer kleine bedragen kunnen achterblijven. Het loopt op de achtergrond, en Bitkit stuurt je een melding als het je nodig heeft."; +"lightning__spark_exit__quote_title" = "Exit-\nofferte"; +"lightning__spark_exit__amount" = "Bedrag om op te nemen"; +"lightning__spark_exit__fees" = "Netwerkkosten"; +"lightning__spark_exit__receive" = "Je ontvangt ongeveer"; +"lightning__spark_exit__savings_fee" = "Vergoeding betaling uit spaargeld"; +"lightning__spark_exit__savings_total" = "Totaal uit spaargeld"; +"lightning__spark_exit__funding" = "Benodigde vergoedingsdekking"; +"lightning__spark_exit__left_behind" = "Blijft op Spark"; +"lightning__spark_exit__outside_exit" = "{amount} op Spark valt niet onder deze exit en blijft op Spark."; +"lightning__spark_exit__quote_loading" = "Offerte ophalen…"; +"lightning__spark_exit__quote_settling" = "Wachten tot Spark klaar is met het verplaatsen van je saldo. Dit kan tot 5 minuten duren."; +"lightning__spark_exit__quote_error" = "Kon geen exit-offerte ophalen. Probeer het opnieuw."; +"lightning__spark_exit__nothing" = "Er kan nu niets worden opgenomen."; +"lightning__spark_exit__uneconomic" = "De vergoedingen zijn hoger dan wat je zou ontvangen."; +"lightning__spark_exit__funding_title" = "Dek de\nvergoedingen"; +"lightning__spark_exit__funding_text" = "De exit betaalt zijn vergoedingen uit een aparte on-chain betaling van {amount}. Wat niet wordt gebruikt, komt aan het einde terug naar je spaargeld."; +"lightning__spark_exit__fund_savings" = "Betalen uit spaargeld"; +"lightning__spark_exit__fund_external" = "Betalen vanuit een andere wallet"; +"lightning__spark_exit__fund_external_text" = "Stuur minimaal {amount} naar dit adres. De exit start zodra de betaling is bevestigd."; +"lightning__spark_exit__fund_insufficient" = "Je spaargeld is niet genoeg om de vergoedingen te dekken."; +"lightning__spark_exit__fund_seen" = "Betaling gezien."; +"lightning__spark_exit__fund_partial" = "{received} van {required} ontvangen, stuur nog {missing}."; +"lightning__spark_exit__confirm_title" = "Exit\nstarten?"; +"lightning__spark_exit__confirm_text" = "Je kunt dit geld tot 4 weken niet gebruiken."; +"lightning__spark_exit__swipe" = "Veeg Om Te Verlaten"; +"lightning__spark_exit__progress_title" = "Exit\nbezig"; +"lightning__spark_exit__progress_text" = "Dit loopt op de achtergrond. Bitkit stuurt je een melding als het je nodig heeft."; +"lightning__spark_exit__step_funding" = "Vergoedingsdekking wordt bevestigd"; +"lightning__spark_exit__step_publish" = "Exit-transacties publiceren"; +"lightning__spark_exit__step_timelock" = "Wachten op de timelock"; +"lightning__spark_exit__step_sweep" = "Versturen naar spaargeld"; +"lightning__spark_exit__ready_in" = "Klaar over {duration}"; +"lightning__spark_exit__done_title" = "Exit\nvoltooid"; +"lightning__spark_exit__done_text" = "Je geld staat in je spaargeld."; +"lightning__spark_exit__partial_text" = "{count} kleine bedragen zijn op Spark gebleven."; +"lightning__spark_exit__failed_leaves" = "{amount} is door de Spark-operators on-chain gezet en staat niet op Spark. De operators kunnen het nog steeds aan je vrijgeven."; +"lightning__spark_exit__leftover" = "{amount} aan vergoedingsdekking staat nog op {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} aan overgebleven vergoedingsdekking is naar je spaargeld gestuurd."; +"lightning__spark_exit__leftover_sending" = "{amount} aan overgebleven vergoedingsdekking wordt naar je spaargeld gestuurd."; +"lightning__spark_exit__leftover_dust" = "{amount} aan overgebleven vergoedingsdekking is te klein om te versturen en blijft op {address}, afleidingspad {path}."; +"lightning__spark_exit__leftover_txid" = "Transactie-ID: {txid}"; +"lightning__spark_exit__fund_paid" = "Betaald uit spaargeld, wachten op bevestiging."; +"lightning__spark_exit__fund_pending" = "Betaling gezien, wachten op bevestiging."; +"lightning__spark_exit__funding_txid" = "Transactie-ID: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} is on-chain geclaimd door een eerdere eigenaar van dit geld en kan niet worden teruggehaald."; +"lightning__spark_exit__notification_lost" = "{amount} van je exit kan niet worden teruggehaald. Open Bitkit om te zien waarom."; +"lightning__spark_exit__notification_failed" = "Je nooduitgang is gestopt. Open Bitkit om te zien waarom."; +"lightning__spark_exit__quote_outside" = "{amount} van je Spark-saldo kan nu niet worden opgenomen: bedragen die te klein zijn om hun eigen vergoedingen te betalen, of die nog binnen Spark worden verplaatst. Het blijft op Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} kan bij dit vergoedingstarief niet worden opgenomen en blijft op Spark."; +"lightning__spark_exit__needs_funding_title" = "Meer vergoeding\nnodig"; +"lightning__spark_exit__needs_funding_text" = "De exit heeft nog {amount} nodig om zijn vergoedingen te betalen. Dek dit om door te gaan."; +"lightning__spark_exit__failed_title" = "Exit\ngestopt"; +"lightning__spark_exit__cancel" = "Exit annuleren"; +"lightning__spark_exit__failed_text" = "Er kon niets worden teruggehaald."; +"lightning__spark_exit__failed_stays" = "{amount} blijft op Spark."; +"lightning__spark_exit__notification_title" = "Nooduitgang"; +"lightning__spark_exit__notification_body" = "Open Bitkit om je exit voort te zetten."; +"lightning__spark_exit__notification_funding" = "Je exit heeft meer vergoeding nodig om door te gaan. Open Bitkit om dit te dekken."; +"lightning__spark_exit__bg_refresh_off" = "Ververs op achtergrond staat uit voor Bitkit, dus de exit gaat alleen verder als Bitkit open is. Zet het aan in Instellingen zodat het op de achtergrond kan lopen."; +"lightning__spark_exit__low_power" = "De energiebesparingsmodus pauzeert achtergrondtaken. De exit gaat verder zodra die uit staat, of wanneer je Bitkit opent."; +"lightning__spark_exit__notifications_off" = "Meldingen staan uit, dus Bitkit kan je niet laten weten wanneer de exit je nodig heeft. Zet ze aan in Instellingen om herinnerd te worden."; +"lightning__spark_exit__not_moving" = "De exit komt niet verder: {error}"; +"lightning__spark_exit__fee_low" = "Het netwerk is drukker dan het vergoedingstarief van deze exit van {rate} sat/vB. Betaal een hogere vergoeding, of wacht tot de vergoedingen dalen."; +"lightning__spark_exit__waiting_leaves" = "Een deel van deze exit kan nu niet on-chain worden gelezen. Bitkit probeert het tot een week lang opnieuw."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit kon de opgeslagen voortgang van deze exit niet lezen, dus bouwt het die opnieuw op vanuit de blockchain."; +"lightning__spark_exit__done_received" = "{amount} is aangekomen in je spaargeld."; +"lightning__spark_exit__sweep_txid" = "Transactie-ID: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} aan overgebleven vergoedingsdekking kon niet worden verstuurd en blijft op {address}, afleidingspad {path}."; +"lightning__spark_exit__notification_not_moving" = "Je nooduitgang komt niet verder. Open Bitkit om te zien waarom."; +"lightning__spark_exit__funding_split" = "Bitkit splitst het in {count} delen, één per stap, zodat één vastgelopen stap de andere niet ophoudt."; +"lightning__spark_exit__waiting_operators" = "{amount} dat de Spark-operators on-chain hebben gezet, wacht tot zij het vrijgeven. Bitkit vraagt het tot een week lang opnieuw."; +"lightning__spark_exit__fee_rose" = "De netwerkkosten zijn boven de vergoeding van deze exit gestegen. Het kan trager gaan."; +"lightning__spark_exit__fee_bump_button" = "Hogere vergoeding betalen"; +"lightning__spark_exit__fee_bump_title" = "Betaal een\nhogere vergoeding"; +"lightning__spark_exit__fee_bump_text" = "Deze exit betaalt {rate} sat/vB, minder dan het netwerk nu vraagt. Kies een hoger tarief en Bitkit verstuurt de wachtende stappen opnieuw. Het verschil wordt uit je spaargeld betaald: het bedrag naast elke snelheid."; +"lightning__spark_exit__fee_bump_loading" = "Berekenen wat een hogere vergoeding kost…"; +"lightning__spark_exit__fee_bump_error" = "Kon de prijs van een hogere vergoeding niet berekenen. Probeer het opnieuw."; +"lightning__spark_exit__fee_bump_confirm" = "Deze vergoeding gebruiken"; +"lightning__spark_exit__leftover_waiting" = "{amount} aan wisselgeld van de vergoeding komt in je spaargeld wanneer de netwerkkosten dalen."; +"lightning__spark_exit__cancelled_title" = "Exit\ngeannuleerd"; +"lightning__spark_exit__cancelled_text" = "Je hebt de exit geannuleerd. De vergoedingsdekking die is binnengekomen, gaat terug naar je spaargeld."; +"lightning__spark_exit__cancel_failed" = "Kon het dekkingsadres niet controleren. Probeer het opnieuw."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Een hogere vergoeding zou een deel van deze exit op Spark laten, dus wordt er geen aangeboden. De exit wordt bevestigd zodra de netwerkkosten dalen."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 4f8344c34..bf3d032c0 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -372,93 +372,93 @@ "lightning__spark_provider_value" = "Spark przez Breez"; "lightning__spark_receiving_unlimited" = "Bez limitu"; "lightning__spark_trust" = "To saldo jest przechowywane na Spark, prowadzonym przez Lightspark, Breez i Flashnet. Jest bezpieczne, dopóki co najmniej jeden z nich działa uczciwie. Mogą wstrzymać dostęp, a wyjście bez nich wymaga wyjścia on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Wyjście awaryjne"; +"lightning__spark_exit__nav_title" = "Wyjście awaryjne"; +"lightning__spark_exit__intro_title" = "Opuść Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Używaj tego tylko wtedy, gdy Spark przestanie u Ciebie działać. Przenosi to Twoje saldo wydatków do oszczędności bez operatorów Spark.\n\nTrwa to do 4 tygodni, kosztuje opłaty on-chain płacone z oszczędności, a bardzo małe kwoty mogą pozostać. Działa w tle, a Bitkit powiadomi Cię, jeśli będziesz potrzebny."; +"lightning__spark_exit__quote_title" = "Wycena\nwyjścia"; +"lightning__spark_exit__amount" = "Kwota do wypłaty"; +"lightning__spark_exit__fees" = "Opłaty sieciowe"; +"lightning__spark_exit__receive" = "Otrzymasz około"; +"lightning__spark_exit__savings_fee" = "Opłata za płatność z oszczędności"; +"lightning__spark_exit__savings_total" = "Łącznie z oszczędności"; +"lightning__spark_exit__funding" = "Potrzebne środki na opłaty"; +"lightning__spark_exit__left_behind" = "Zostaje na Spark"; +"lightning__spark_exit__outside_exit" = "{amount} na Spark nie jest częścią tego wyjścia i zostaje na Spark."; +"lightning__spark_exit__quote_loading" = "Pobieranie wyceny…"; +"lightning__spark_exit__quote_settling" = "Czekamy, aż Spark zakończy przenoszenie Twojego salda. Może to potrwać do 5 minut."; +"lightning__spark_exit__quote_error" = "Nie udało się pobrać wyceny wyjścia. Spróbuj ponownie."; +"lightning__spark_exit__nothing" = "W tej chwili nie ma nic do wypłacenia."; +"lightning__spark_exit__uneconomic" = "Opłaty są wyższe niż kwota, którą otrzymasz."; +"lightning__spark_exit__funding_title" = "Opłać\nopłaty"; +"lightning__spark_exit__funding_text" = "Wyjście pokrywa opłaty z osobnej płatności on-chain w wysokości {amount}. To, czego nie wyda, wraca na koniec do oszczędności."; +"lightning__spark_exit__fund_savings" = "Zapłać z oszczędności"; +"lightning__spark_exit__fund_external" = "Zasil z innego portfela"; +"lightning__spark_exit__fund_external_text" = "Wyślij co najmniej {amount} na ten adres. Wyjście rozpocznie się po potwierdzeniu."; +"lightning__spark_exit__fund_insufficient" = "Twoje oszczędności nie wystarczą na pokrycie opłat."; +"lightning__spark_exit__fund_seen" = "Płatność wykryta."; +"lightning__spark_exit__fund_partial" = "Otrzymano {received} z {required}, wyślij jeszcze {missing}."; +"lightning__spark_exit__confirm_title" = "Rozpocząć\nwyjście?"; +"lightning__spark_exit__confirm_text" = "Nie będziesz mógł korzystać z tych środków przez do 4 tygodni."; +"lightning__spark_exit__swipe" = "Przesuń, aby wyjść"; +"lightning__spark_exit__progress_title" = "Wyjście\nw toku"; +"lightning__spark_exit__progress_text" = "To działa w tle. Bitkit powiadomi Cię, jeśli będziesz potrzebny."; +"lightning__spark_exit__step_funding" = "Potwierdzenie środków na opłaty"; +"lightning__spark_exit__step_publish" = "Publikowanie transakcji wyjścia"; +"lightning__spark_exit__step_timelock" = "Oczekiwanie na timelock"; +"lightning__spark_exit__step_sweep" = "Wysyłanie do oszczędności"; +"lightning__spark_exit__ready_in" = "Gotowe za {duration}"; +"lightning__spark_exit__done_title" = "Wyjście\nzakończone"; +"lightning__spark_exit__done_text" = "Twoje środki są w oszczędnościach."; +"lightning__spark_exit__partial_text" = "Małe kwoty pozostałe na Spark: {count}."; +"lightning__spark_exit__failed_leaves" = "{amount} zostało przeniesione on-chain przez operatorów Spark i nie jest na Spark. Operatorzy wciąż mogą Ci je wydać."; +"lightning__spark_exit__leftover" = "{amount} środków na opłaty wciąż jest pod adresem {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} pozostałych środków na opłaty wysłano do oszczędności."; +"lightning__spark_exit__leftover_sending" = "Wysyłanie {amount} pozostałych środków na opłaty do oszczędności."; +"lightning__spark_exit__leftover_dust" = "{amount} pozostałych środków na opłaty to za mało, by je wysłać, i zostaje pod adresem {address}, ścieżka derywacji {path}."; +"lightning__spark_exit__leftover_txid" = "ID transakcji: {txid}"; +"lightning__spark_exit__fund_paid" = "Zapłacono z oszczędności, czekamy na potwierdzenie."; +"lightning__spark_exit__fund_pending" = "Płatność wykryta, czekamy na potwierdzenie."; +"lightning__spark_exit__funding_txid" = "ID transakcji: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} zostało przejęte on-chain przez poprzedniego właściciela tych środków i nie można go odzyskać."; +"lightning__spark_exit__notification_lost" = "Nie można odzyskać {amount} z Twojego wyjścia. Otwórz Bitkit, aby sprawdzić dlaczego."; +"lightning__spark_exit__notification_failed" = "Twoje wyjście awaryjne zostało zatrzymane. Otwórz Bitkit, aby sprawdzić dlaczego."; +"lightning__spark_exit__quote_outside" = "{amount} Twojego salda Spark nie można teraz wypłacić: kwoty są zbyt małe, by pokryć własne opłaty, albo wciąż przemieszczają się w Spark. Zostają na Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} nie można wypłacić przy tej stawce opłaty i zostaje na Spark."; +"lightning__spark_exit__needs_funding_title" = "Potrzeba więcej\nopłat"; +"lightning__spark_exit__needs_funding_text" = "Wyjście potrzebuje jeszcze {amount} na opłaty. Zasil je, aby kontynuować."; +"lightning__spark_exit__failed_title" = "Wyjście\nzatrzymane"; +"lightning__spark_exit__cancel" = "Anuluj wyjście"; +"lightning__spark_exit__failed_text" = "Nic nie udało się odzyskać."; +"lightning__spark_exit__failed_stays" = "{amount} zostaje na Spark."; +"lightning__spark_exit__notification_title" = "Wyjście awaryjne"; +"lightning__spark_exit__notification_body" = "Otwórz Bitkit, aby kontynuować wyjście."; +"lightning__spark_exit__notification_funding" = "Twoje wyjście potrzebuje więcej opłat, aby kontynuować. Otwórz Bitkit, aby je zasilić."; +"lightning__spark_exit__bg_refresh_off" = "Odświeżanie aplikacji w tle jest wyłączone dla Bitkit, więc wyjście postępuje tylko przy otwartym Bitkit. Włącz je w Ustawieniach, aby działało w tle."; +"lightning__spark_exit__low_power" = "Tryb niskiego zużycia energii wstrzymuje pracę w tle. Wyjście będzie kontynuowane po jego wyłączeniu lub po otwarciu Bitkit."; +"lightning__spark_exit__notifications_off" = "Powiadomienia są wyłączone, więc Bitkit nie może Cię powiadomić, gdy wyjście będzie Cię potrzebować. Włącz je w Ustawieniach, aby otrzymywać przypomnienia."; +"lightning__spark_exit__not_moving" = "Wyjście nie postępuje: {error}"; +"lightning__spark_exit__fee_low" = "Sieć jest bardziej obciążona, niż pozwala stawka opłaty tego wyjścia wynosząca {rate} sat/vB. Zapłać wyższą opłatę lub poczekaj, aż opłaty spadną."; +"lightning__spark_exit__waiting_leaves" = "Części tego wyjścia nie można teraz odczytać on-chain. Bitkit będzie próbować ponownie przez maksymalnie tydzień."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit nie mógł odczytać zapisanego postępu tego wyjścia, więc odtwarza go z łańcucha."; +"lightning__spark_exit__done_received" = "{amount} trafiło do Twoich oszczędności."; +"lightning__spark_exit__sweep_txid" = "ID transakcji: {txid}"; +"lightning__spark_exit__leftover_rejected" = "Nie udało się wysłać {amount} pozostałych środków na opłaty, które zostają pod adresem {address}, ścieżka derywacji {path}."; +"lightning__spark_exit__notification_not_moving" = "Twoje wyjście awaryjne nie postępuje. Otwórz Bitkit, aby sprawdzić dlaczego."; +"lightning__spark_exit__funding_split" = "Bitkit dzieli je na części ({count}), po jednej na każdy krok, aby jeden zablokowany krok nie wstrzymywał pozostałych."; +"lightning__spark_exit__waiting_operators" = "{amount} przeniesione on-chain przez operatorów Spark czeka, aż je wydadzą. Bitkit będzie pytać ponownie przez maksymalnie tydzień."; +"lightning__spark_exit__fee_rose" = "Opłaty sieciowe wzrosły powyżej opłaty tego wyjścia. Może ono zwolnić."; +"lightning__spark_exit__fee_bump_button" = "Zapłać wyższą opłatę"; +"lightning__spark_exit__fee_bump_title" = "Zapłać\nwyższą opłatę"; +"lightning__spark_exit__fee_bump_text" = "To wyjście płaci {rate} sat/vB, mniej niż sieć wymaga teraz. Wybierz wyższą stawkę, a Bitkit ponownie wyśle oczekujące kroki. Różnicę zapłaci z oszczędności: kwota obok każdej prędkości."; +"lightning__spark_exit__fee_bump_loading" = "Obliczanie kosztu wyższej opłaty…"; +"lightning__spark_exit__fee_bump_error" = "Nie udało się wycenić wyższej opłaty. Spróbuj ponownie."; +"lightning__spark_exit__fee_bump_confirm" = "Użyj tej opłaty"; +"lightning__spark_exit__leftover_waiting" = "{amount} reszty z opłat trafi do Twoich oszczędności, gdy opłaty sieciowe spadną."; +"lightning__spark_exit__cancelled_title" = "Wyjście\nanulowane"; +"lightning__spark_exit__cancelled_text" = "Anulowałeś wyjście. Środki na opłaty, które dotarły, wracają do oszczędności."; +"lightning__spark_exit__cancel_failed" = "Nie udało się sprawdzić adresu środków na opłaty. Spróbuj ponownie."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Wyższa opłata pozostawiłaby część tego wyjścia na Spark, więc nie jest oferowana. Wyjście potwierdzi się, gdy opłaty sieciowe spadną."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index dc7d386ef..d697d7b10 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -372,93 +372,93 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "Sem limite"; "lightning__spark_trust" = "Este saldo fica na Spark, operada pela Lightspark, Breez e Flashnet. Ele está seguro enquanto pelo menos um deles agir com honestidade. Eles podem pausar o acesso, e sair sem eles exige uma saída on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Saída de emergência"; +"lightning__spark_exit__nav_title" = "Saída de Emergência"; +"lightning__spark_exit__intro_title" = "Sair da Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use isto somente se a Spark parar de funcionar para você. Isso move seu saldo de gastos para a poupança sem os operadores da Spark.\n\nLeva até 4 semanas, tem taxas on-chain pagas da poupança, e valores muito pequenos podem ficar para trás. Roda em segundo plano, e a Bitkit avisa você se precisar."; +"lightning__spark_exit__quote_title" = "Cotação\nda saída"; +"lightning__spark_exit__amount" = "Valor a sair"; +"lightning__spark_exit__fees" = "Taxas de rede"; +"lightning__spark_exit__receive" = "Você recebe cerca de"; +"lightning__spark_exit__savings_fee" = "Taxa do pagamento da poupança"; +"lightning__spark_exit__savings_total" = "Total da poupança"; +"lightning__spark_exit__funding" = "Fundos para taxas necessários"; +"lightning__spark_exit__left_behind" = "Fica na Spark"; +"lightning__spark_exit__outside_exit" = "{amount} na Spark não faz parte desta saída e fica na Spark."; +"lightning__spark_exit__quote_loading" = "Obtendo uma cotação…"; +"lightning__spark_exit__quote_settling" = "Aguardando a Spark terminar de mover seu saldo. Isso pode levar até 5 minutos."; +"lightning__spark_exit__quote_error" = "Não foi possível obter uma cotação da saída. Tente novamente."; +"lightning__spark_exit__nothing" = "Nada pode sair no momento."; +"lightning__spark_exit__uneconomic" = "As taxas são maiores do que o que você receberia."; +"lightning__spark_exit__funding_title" = "Financiar as\ntaxas"; +"lightning__spark_exit__funding_text" = "A saída paga suas taxas com um pagamento on-chain separado de {amount}. O que ela não gastar volta para a poupança no final."; +"lightning__spark_exit__fund_savings" = "Pagar da poupança"; +"lightning__spark_exit__fund_external" = "Financiar de outra carteira"; +"lightning__spark_exit__fund_external_text" = "Envie pelo menos {amount} para este endereço. A saída começa assim que ele for confirmado."; +"lightning__spark_exit__fund_insufficient" = "Sua poupança não cobre os fundos para taxas."; +"lightning__spark_exit__fund_seen" = "Pagamento detectado."; +"lightning__spark_exit__fund_partial" = "{received} de {required} recebidos, envie mais {missing}."; +"lightning__spark_exit__confirm_title" = "Iniciar\nsaída?"; +"lightning__spark_exit__confirm_text" = "Você não poderá usar esses fundos por até 4 semanas."; +"lightning__spark_exit__swipe" = "Deslizar Para Sair"; +"lightning__spark_exit__progress_title" = "Saída em\nandamento"; +"lightning__spark_exit__progress_text" = "Isso roda em segundo plano. A Bitkit avisa você se precisar."; +"lightning__spark_exit__step_funding" = "Confirmação dos fundos para taxas"; +"lightning__spark_exit__step_publish" = "Publicando as transações de saída"; +"lightning__spark_exit__step_timelock" = "Aguardando o bloqueio de tempo"; +"lightning__spark_exit__step_sweep" = "Enviando para a poupança"; +"lightning__spark_exit__ready_in" = "Pronto em {duration}"; +"lightning__spark_exit__done_title" = "Saída\nconcluída"; +"lightning__spark_exit__done_text" = "Seus fundos estão na poupança."; +"lightning__spark_exit__partial_text" = "{count} valores pequenos ficaram na Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} foi movido on-chain pelos operadores da Spark e não está na Spark. Os operadores ainda podem liberá-lo para você."; +"lightning__spark_exit__leftover" = "{amount} dos fundos para taxas ainda está em {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} de fundos para taxas restantes foi enviado para a poupança."; +"lightning__spark_exit__leftover_sending" = "Enviando {amount} de fundos para taxas restantes para a poupança."; +"lightning__spark_exit__leftover_dust" = "{amount} de fundos para taxas restantes é pequeno demais para enviar e fica em {address}, caminho de derivação {path}."; +"lightning__spark_exit__leftover_txid" = "ID da transação: {txid}"; +"lightning__spark_exit__fund_paid" = "Pago da poupança, aguardando confirmação."; +"lightning__spark_exit__fund_pending" = "Pagamento detectado, aguardando confirmação."; +"lightning__spark_exit__funding_txid" = "ID da transação: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} foi resgatado on-chain por um antigo dono desses fundos e não pode ser recuperado."; +"lightning__spark_exit__notification_lost" = "{amount} da sua saída não pode ser recuperado. Abra a Bitkit para ver o motivo."; +"lightning__spark_exit__notification_failed" = "Sua saída de emergência parou. Abra a Bitkit para ver o motivo."; +"lightning__spark_exit__quote_outside" = "{amount} do seu saldo Spark não pode sair no momento: valores pequenos demais para pagar as próprias taxas, ou ainda em movimento dentro da Spark. Ele fica na Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} não pode sair com esta taxa e fica na Spark."; +"lightning__spark_exit__needs_funding_title" = "Mais taxas\nnecessárias"; +"lightning__spark_exit__needs_funding_text" = "A saída precisa de mais {amount} para pagar suas taxas. Financie para continuar."; +"lightning__spark_exit__failed_title" = "Saída\ninterrompida"; +"lightning__spark_exit__cancel" = "Cancelar saída"; +"lightning__spark_exit__failed_text" = "Nada pôde ser recuperado."; +"lightning__spark_exit__failed_stays" = "{amount} fica na Spark."; +"lightning__spark_exit__notification_title" = "Saída de emergência"; +"lightning__spark_exit__notification_body" = "Abra a Bitkit para continuar sua saída."; +"lightning__spark_exit__notification_funding" = "Sua saída precisa de mais taxas para continuar. Abra a Bitkit para financiá-la."; +"lightning__spark_exit__bg_refresh_off" = "A Atualização em 2º Plano está desativada para a Bitkit, então a saída só avança enquanto a Bitkit estiver aberta. Ative-a nos Ajustes para deixá-la rodar em segundo plano."; +"lightning__spark_exit__low_power" = "O Modo Pouca Energia pausa o trabalho em segundo plano. A saída continua quando ele for desativado, ou quando você abrir a Bitkit."; +"lightning__spark_exit__notifications_off" = "As notificações estão desativadas, então a Bitkit não pode avisar quando a saída precisar de você. Ative-as nos Ajustes para receber lembretes."; +"lightning__spark_exit__not_moving" = "A saída não está avançando: {error}"; +"lightning__spark_exit__fee_low" = "A rede está mais congestionada do que a taxa desta saída de {rate} sat/vB. Pague uma taxa maior, ou espere as taxas caírem."; +"lightning__spark_exit__waiting_leaves" = "Parte desta saída não pode ser lida on-chain no momento. A Bitkit tenta novamente por até uma semana."; +"lightning__spark_exit__rebuilt_from_chain" = "A Bitkit não conseguiu ler o progresso salvo desta saída, então o reconstrói a partir da blockchain."; +"lightning__spark_exit__done_received" = "{amount} chegou à sua poupança."; +"lightning__spark_exit__sweep_txid" = "ID da transação: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} de fundos para taxas restantes não pôde ser enviado e fica em {address}, caminho de derivação {path}."; +"lightning__spark_exit__notification_not_moving" = "Sua saída de emergência não está avançando. Abra a Bitkit para ver o motivo."; +"lightning__spark_exit__funding_split" = "A Bitkit divide em {count} partes, uma para cada etapa, para que uma etapa travada não segure as outras."; +"lightning__spark_exit__waiting_operators" = "{amount} que os operadores da Spark moveram on-chain aguarda que eles o liberem. A Bitkit pede novamente por até uma semana."; +"lightning__spark_exit__fee_rose" = "As taxas de rede subiram acima da taxa desta saída. Ela pode ficar mais lenta."; +"lightning__spark_exit__fee_bump_button" = "Pagar uma taxa maior"; +"lightning__spark_exit__fee_bump_title" = "Pagar uma\ntaxa maior"; +"lightning__spark_exit__fee_bump_text" = "Esta saída paga {rate} sat/vB, menos do que a rede pede agora. Escolha uma taxa maior e a Bitkit envia novamente as etapas pendentes. Ela paga a diferença da poupança: o valor ao lado de cada velocidade."; +"lightning__spark_exit__fee_bump_loading" = "Calculando quanto custa uma taxa maior…"; +"lightning__spark_exit__fee_bump_error" = "Não foi possível calcular o preço de uma taxa maior. Tente novamente."; +"lightning__spark_exit__fee_bump_confirm" = "Usar esta taxa"; +"lightning__spark_exit__leftover_waiting" = "{amount} de troco das taxas chegará à sua poupança quando as taxas de rede caírem."; +"lightning__spark_exit__cancelled_title" = "Saída\ncancelada"; +"lightning__spark_exit__cancelled_text" = "Você cancelou a saída. Os fundos para taxas que chegaram voltam para a poupança."; +"lightning__spark_exit__cancel_failed" = "Não foi possível verificar o endereço de financiamento. Tente novamente."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Uma taxa maior deixaria parte desta saída na Spark, então nenhuma é oferecida. A saída é confirmada quando as taxas de rede caírem."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 5530b90aa..480139804 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -370,93 +370,93 @@ "lightning__spark_provider_value" = "Spark via Breez"; "lightning__spark_receiving_unlimited" = "Sem limite"; "lightning__spark_trust" = "Este saldo é mantido na Spark, operada pela Lightspark, Breez e Flashnet. Está seguro enquanto pelo menos um deles agir com honestidade. Podem suspender o acesso, e sair sem eles exige uma saída on-chain."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Saída de emergência"; +"lightning__spark_exit__nav_title" = "Saída de Emergência"; +"lightning__spark_exit__intro_title" = "Sair da Spark\non-chain"; +"lightning__spark_exit__intro_text" = "Use isto apenas se a Spark deixar de funcionar para si. Move o seu saldo de gastos para a poupança sem os operadores da Spark.\n\nDemora até 4 semanas, tem taxas on-chain pagas da poupança, e montantes muito pequenos podem ficar para trás. Corre em segundo plano, e a Bitkit notifica-o se precisar de si."; +"lightning__spark_exit__quote_title" = "Cotação\nda saída"; +"lightning__spark_exit__amount" = "Montante a retirar"; +"lightning__spark_exit__fees" = "Taxas de rede"; +"lightning__spark_exit__receive" = "Recebe cerca de"; +"lightning__spark_exit__savings_fee" = "Taxa do pagamento da poupança"; +"lightning__spark_exit__savings_total" = "Total da poupança"; +"lightning__spark_exit__funding" = "Fundos para taxas necessários"; +"lightning__spark_exit__left_behind" = "Fica na Spark"; +"lightning__spark_exit__outside_exit" = "{amount} na Spark não faz parte desta saída e fica na Spark."; +"lightning__spark_exit__quote_loading" = "A obter uma cotação…"; +"lightning__spark_exit__quote_settling" = "A aguardar que a Spark termine de mover o seu saldo. Isto pode demorar até 5 minutos."; +"lightning__spark_exit__quote_error" = "Não foi possível obter uma cotação da saída. Tente novamente."; +"lightning__spark_exit__nothing" = "Nada pode ser retirado neste momento."; +"lightning__spark_exit__uneconomic" = "As taxas são superiores ao que receberia."; +"lightning__spark_exit__funding_title" = "Financiar as\ntaxas"; +"lightning__spark_exit__funding_text" = "A saída paga as suas taxas a partir de um pagamento on-chain separado de {amount}. O que não gastar volta para a poupança no final."; +"lightning__spark_exit__fund_savings" = "Pagar da poupança"; +"lightning__spark_exit__fund_external" = "Financiar a partir de outra carteira"; +"lightning__spark_exit__fund_external_text" = "Envie pelo menos {amount} para este endereço. A saída começa assim que for confirmado."; +"lightning__spark_exit__fund_insufficient" = "A sua poupança não cobre os fundos para taxas."; +"lightning__spark_exit__fund_seen" = "Pagamento detetado."; +"lightning__spark_exit__fund_partial" = "{received} de {required} recebidos, envie mais {missing}."; +"lightning__spark_exit__confirm_title" = "Iniciar\nsaída?"; +"lightning__spark_exit__confirm_text" = "Não poderá usar estes fundos durante até 4 semanas."; +"lightning__spark_exit__swipe" = "Deslizar Para Sair"; +"lightning__spark_exit__progress_title" = "Saída em\ncurso"; +"lightning__spark_exit__progress_text" = "Isto corre em segundo plano. A Bitkit notifica-o se precisar de si."; +"lightning__spark_exit__step_funding" = "Confirmação dos fundos para taxas"; +"lightning__spark_exit__step_publish" = "A publicar as transações de saída"; +"lightning__spark_exit__step_timelock" = "A aguardar o bloqueio temporal"; +"lightning__spark_exit__step_sweep" = "A enviar para a poupança"; +"lightning__spark_exit__ready_in" = "Pronto em {duration}"; +"lightning__spark_exit__done_title" = "Saída\nconcluída"; +"lightning__spark_exit__done_text" = "Os seus fundos estão na poupança."; +"lightning__spark_exit__partial_text" = "{count} montantes pequenos ficaram na Spark."; +"lightning__spark_exit__failed_leaves" = "{amount} foi movido on-chain pelos operadores da Spark e não está na Spark. Os operadores ainda o podem libertar para si."; +"lightning__spark_exit__leftover" = "{amount} dos fundos para taxas ainda está em {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} de fundos para taxas restantes foi enviado para a poupança."; +"lightning__spark_exit__leftover_sending" = "A enviar {amount} de fundos para taxas restantes para a poupança."; +"lightning__spark_exit__leftover_dust" = "{amount} de fundos para taxas restantes é demasiado pequeno para enviar e fica em {address}, caminho de derivação {path}."; +"lightning__spark_exit__leftover_txid" = "ID da transação: {txid}"; +"lightning__spark_exit__fund_paid" = "Pago da poupança, a aguardar confirmação."; +"lightning__spark_exit__fund_pending" = "Pagamento detetado, a aguardar confirmação."; +"lightning__spark_exit__funding_txid" = "ID da transação: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} foi reclamado on-chain por um antigo dono destes fundos e não pode ser recuperado."; +"lightning__spark_exit__notification_lost" = "{amount} da sua saída não pode ser recuperado. Abra a Bitkit para ver porquê."; +"lightning__spark_exit__notification_failed" = "A sua saída de emergência parou. Abra a Bitkit para ver porquê."; +"lightning__spark_exit__quote_outside" = "{amount} do seu saldo Spark não pode ser retirado neste momento: montantes demasiado pequenos para pagar as próprias taxas, ou ainda em movimento dentro da Spark. Fica na Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} não pode ser retirado com esta taxa e fica na Spark."; +"lightning__spark_exit__needs_funding_title" = "Mais taxas\nnecessárias"; +"lightning__spark_exit__needs_funding_text" = "A saída precisa de mais {amount} para pagar as suas taxas. Financie-a para continuar."; +"lightning__spark_exit__failed_title" = "Saída\nparada"; +"lightning__spark_exit__cancel" = "Cancelar saída"; +"lightning__spark_exit__failed_text" = "Não foi possível recuperar nada."; +"lightning__spark_exit__failed_stays" = "{amount} fica na Spark."; +"lightning__spark_exit__notification_title" = "Saída de emergência"; +"lightning__spark_exit__notification_body" = "Abra a Bitkit para continuar a sua saída."; +"lightning__spark_exit__notification_funding" = "A sua saída precisa de mais taxas para continuar. Abra a Bitkit para a financiar."; +"lightning__spark_exit__bg_refresh_off" = "A Atualização em segundo plano está desativada para a Bitkit, por isso a saída só avança enquanto a Bitkit estiver aberta. Ative-a nas Definições para a deixar correr em segundo plano."; +"lightning__spark_exit__low_power" = "O Modo de baixo consumo pausa o trabalho em segundo plano. A saída continua quando for desativado, ou quando abrir a Bitkit."; +"lightning__spark_exit__notifications_off" = "As notificações estão desativadas, por isso a Bitkit não o pode avisar quando a saída precisar de si. Ative-as nas Definições para receber lembretes."; +"lightning__spark_exit__not_moving" = "A saída não está a avançar: {error}"; +"lightning__spark_exit__fee_low" = "A rede está mais congestionada do que a taxa desta saída de {rate} sat/vB. Pague uma taxa mais alta, ou aguarde que as taxas desçam."; +"lightning__spark_exit__waiting_leaves" = "Parte desta saída não pode ser lida on-chain neste momento. A Bitkit tenta novamente durante até uma semana."; +"lightning__spark_exit__rebuilt_from_chain" = "A Bitkit não conseguiu ler o progresso guardado desta saída, por isso reconstrói-o a partir da blockchain."; +"lightning__spark_exit__done_received" = "{amount} chegou à sua poupança."; +"lightning__spark_exit__sweep_txid" = "ID da transação: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} de fundos para taxas restantes não pôde ser enviado e fica em {address}, caminho de derivação {path}."; +"lightning__spark_exit__notification_not_moving" = "A sua saída de emergência não está a avançar. Abra a Bitkit para ver porquê."; +"lightning__spark_exit__funding_split" = "A Bitkit divide-o em {count} partes, uma para cada passo, para que um passo bloqueado não atrase os outros."; +"lightning__spark_exit__waiting_operators" = "{amount} que os operadores da Spark moveram on-chain aguarda que o libertem. A Bitkit volta a pedir durante até uma semana."; +"lightning__spark_exit__fee_rose" = "As taxas de rede subiram acima da taxa desta saída. Pode ficar mais lenta."; +"lightning__spark_exit__fee_bump_button" = "Pagar uma taxa mais alta"; +"lightning__spark_exit__fee_bump_title" = "Pagar uma\ntaxa mais alta"; +"lightning__spark_exit__fee_bump_text" = "Esta saída paga {rate} sat/vB, menos do que a rede pede agora. Escolha uma taxa mais alta e a Bitkit volta a enviar os passos em espera. Paga a diferença da poupança: o montante ao lado de cada velocidade."; +"lightning__spark_exit__fee_bump_loading" = "A calcular quanto custa uma taxa mais alta…"; +"lightning__spark_exit__fee_bump_error" = "Não foi possível calcular o preço de uma taxa mais alta. Tente novamente."; +"lightning__spark_exit__fee_bump_confirm" = "Usar esta taxa"; +"lightning__spark_exit__leftover_waiting" = "{amount} de troco das taxas chegará à sua poupança quando as taxas de rede descerem."; +"lightning__spark_exit__cancelled_title" = "Saída\ncancelada"; +"lightning__spark_exit__cancelled_text" = "Cancelou a saída. Os fundos para taxas que chegaram voltam para a poupança."; +"lightning__spark_exit__cancel_failed" = "Não foi possível verificar o endereço de financiamento. Tente novamente."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Uma taxa mais alta deixaria parte desta saída na Spark, por isso não é oferecida nenhuma. A saída confirma quando as taxas de rede descerem."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index acdbe3435..c8a97c4e0 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -367,93 +367,93 @@ "lightning__spark_provider_value" = "Spark через Breez"; "lightning__spark_receiving_unlimited" = "Без ограничений"; "lightning__spark_trust" = "Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода."; -"lightning__spark_exit__button" = "Emergency exit"; -"lightning__spark_exit__nav_title" = "Emergency Exit"; -"lightning__spark_exit__intro_title" = "Leave Spark\non-chain"; -"lightning__spark_exit__intro_text" = "Use this only if Spark stops working for you. It moves your spending balance to savings without the Spark operators.\n\nIt takes up to 4 weeks, costs on-chain fees paid from savings, and very small amounts can stay behind. It runs in the background, and Bitkit notifies you if it needs you."; -"lightning__spark_exit__quote_title" = "Exit\nquote"; -"lightning__spark_exit__amount" = "Amount to exit"; -"lightning__spark_exit__fees" = "Network fees"; -"lightning__spark_exit__receive" = "You receive about"; -"lightning__spark_exit__savings_fee" = "Savings payment fee"; -"lightning__spark_exit__savings_total" = "Total from savings"; -"lightning__spark_exit__funding" = "Fee funding needed"; -"lightning__spark_exit__left_behind" = "Stays on Spark"; -"lightning__spark_exit__outside_exit" = "{amount} on Spark is not part of this exit and stays on Spark."; -"lightning__spark_exit__quote_loading" = "Getting a quote…"; -"lightning__spark_exit__quote_settling" = "Waiting for Spark to finish moving your balance. This can take up to 5 minutes."; -"lightning__spark_exit__quote_error" = "Could not get an exit quote. Try again."; -"lightning__spark_exit__nothing" = "Nothing can be exited right now."; -"lightning__spark_exit__uneconomic" = "The fees are higher than what you would receive."; -"lightning__spark_exit__funding_title" = "Fund the\nfees"; -"lightning__spark_exit__funding_text" = "The exit pays its fees from a separate on-chain payment of {amount}. What it doesn't spend comes back to savings at the end."; -"lightning__spark_exit__fund_savings" = "Pay from savings"; -"lightning__spark_exit__fund_external" = "Fund from another wallet"; -"lightning__spark_exit__fund_external_text" = "Send at least {amount} to this address. The exit starts once it confirms."; -"lightning__spark_exit__fund_insufficient" = "Your savings can't cover the fee funding."; -"lightning__spark_exit__fund_seen" = "Payment seen."; -"lightning__spark_exit__fund_partial" = "{received} of {required} received, send {missing} more."; -"lightning__spark_exit__confirm_title" = "Start\nexit?"; -"lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; -"lightning__spark_exit__swipe" = "Swipe To Exit"; -"lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; -"lightning__spark_exit__step_funding" = "Fee funding confirms"; -"lightning__spark_exit__step_publish" = "Publishing exit transactions"; -"lightning__spark_exit__step_timelock" = "Waiting for the timelock"; -"lightning__spark_exit__step_sweep" = "Sending to savings"; -"lightning__spark_exit__ready_in" = "Ready in {duration}"; -"lightning__spark_exit__done_title" = "Exit\ncomplete"; -"lightning__spark_exit__done_text" = "Your funds are in savings."; -"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark."; -"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you."; -"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; -"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; -"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; -"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; -"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; -"lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; -"lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; -"lightning__spark_exit__quote_outside" = "{amount} of your Spark balance can't be exited right now: amounts too small to pay their own fees, or still moving inside Spark. It stays on Spark."; -"lightning__spark_exit__left_behind_leaves" = "{amount} can't be exited at this fee rate and stays on Spark."; -"lightning__spark_exit__needs_funding_title" = "More fees\nneeded"; -"lightning__spark_exit__needs_funding_text" = "The exit needs {amount} more to pay its fees. Fund it to continue."; -"lightning__spark_exit__failed_title" = "Exit\nstopped"; -"lightning__spark_exit__cancel" = "Cancel exit"; -"lightning__spark_exit__failed_text" = "Nothing could be recovered."; -"lightning__spark_exit__failed_stays" = "{amount} stays on Spark."; -"lightning__spark_exit__notification_title" = "Emergency exit"; -"lightning__spark_exit__notification_body" = "Open Bitkit to continue your exit."; -"lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; -"lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; -"lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; -"lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; -"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; -"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; -"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; -"lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; -"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; -"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; -"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; -"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week."; -"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down."; -"lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; -"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; -"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; -"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; -"lightning__spark_exit__fee_bump_confirm" = "Use this fee"; -"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; -"lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; -"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; -"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; -"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; +"lightning__spark_exit__button" = "Экстренный выход"; +"lightning__spark_exit__nav_title" = "Экстренный Выход"; +"lightning__spark_exit__intro_title" = "Выход из Spark\nончейн"; +"lightning__spark_exit__intro_text" = "Используйте это, только если Spark перестал у вас работать. Выход переводит ваш баланс расходов в сбережения без операторов Spark.\n\nОн занимает до 4 недель, требует ончейн-комиссий, оплачиваемых из сбережений, а очень маленькие суммы могут остаться. Выход идёт в фоне, и Bitkit уведомит вас, если вы понадобитесь."; +"lightning__spark_exit__quote_title" = "Расчёт\nвыхода"; +"lightning__spark_exit__amount" = "Сумма к выводу"; +"lightning__spark_exit__fees" = "Сетевые комиссии"; +"lightning__spark_exit__receive" = "Вы получите примерно"; +"lightning__spark_exit__savings_fee" = "Комиссия платежа из сбережений"; +"lightning__spark_exit__savings_total" = "Всего из сбережений"; +"lightning__spark_exit__funding" = "Нужно средств на комиссии"; +"lightning__spark_exit__left_behind" = "Остаётся в Spark"; +"lightning__spark_exit__outside_exit" = "{amount} в Spark не входит в этот выход и остаётся в Spark."; +"lightning__spark_exit__quote_loading" = "Получаем расчёт…"; +"lightning__spark_exit__quote_settling" = "Ждём, пока Spark завершит перемещение вашего баланса. Это может занять до 5 минут."; +"lightning__spark_exit__quote_error" = "Не удалось получить расчёт выхода. Попробуйте снова."; +"lightning__spark_exit__nothing" = "Сейчас нечего выводить."; +"lightning__spark_exit__uneconomic" = "Комиссии выше суммы, которую вы получите."; +"lightning__spark_exit__funding_title" = "Оплатите\nкомиссии"; +"lightning__spark_exit__funding_text" = "Выход оплачивает комиссии отдельным ончейн-платежом на {amount}. Неизрасходованное вернётся в сбережения в конце."; +"lightning__spark_exit__fund_savings" = "Оплатить из сбережений"; +"lightning__spark_exit__fund_external" = "Пополнить с другого кошелька"; +"lightning__spark_exit__fund_external_text" = "Отправьте не менее {amount} на этот адрес. Выход начнётся после подтверждения."; +"lightning__spark_exit__fund_insufficient" = "В ваших сбережениях недостаточно средств на комиссии."; +"lightning__spark_exit__fund_seen" = "Платёж обнаружен."; +"lightning__spark_exit__fund_partial" = "Получено {received} из {required}, отправьте ещё {missing}."; +"lightning__spark_exit__confirm_title" = "Начать\nвыход?"; +"lightning__spark_exit__confirm_text" = "Вы не сможете пользоваться этими средствами до 4 недель."; +"lightning__spark_exit__swipe" = "Проведите для Выхода"; +"lightning__spark_exit__progress_title" = "Выход\nв процессе"; +"lightning__spark_exit__progress_text" = "Выход идёт в фоне. Bitkit уведомит вас, если вы понадобитесь."; +"lightning__spark_exit__step_funding" = "Подтверждение оплаты комиссий"; +"lightning__spark_exit__step_publish" = "Публикация транзакций выхода"; +"lightning__spark_exit__step_timelock" = "Ожидание таймлока"; +"lightning__spark_exit__step_sweep" = "Отправка в сбережения"; +"lightning__spark_exit__ready_in" = "Готово через {duration}"; +"lightning__spark_exit__done_title" = "Выход\nзавершён"; +"lightning__spark_exit__done_text" = "Ваши средства в сбережениях."; +"lightning__spark_exit__partial_text" = "Небольших сумм, оставшихся в Spark: {count}."; +"lightning__spark_exit__failed_leaves" = "{amount} перевели ончейн операторы Spark, и в Spark этой суммы нет. Операторы всё ещё могут выдать её вам."; +"lightning__spark_exit__leftover" = "{amount} средств на комиссии всё ещё на адресе {address}."; +"lightning__spark_exit__leftover_swept" = "{amount} оставшихся средств на комиссии отправлено в сбережения."; +"lightning__spark_exit__leftover_sending" = "Отправляем {amount} оставшихся средств на комиссии в сбережения."; +"lightning__spark_exit__leftover_dust" = "{amount} оставшихся средств на комиссии слишком мало для отправки, они остаются на адресе {address}, путь деривации {path}."; +"lightning__spark_exit__leftover_txid" = "ID транзакции: {txid}"; +"lightning__spark_exit__fund_paid" = "Оплачено из сбережений, ждём подтверждения."; +"lightning__spark_exit__fund_pending" = "Платёж обнаружен, ждём подтверждения."; +"lightning__spark_exit__funding_txid" = "ID транзакции: {txid}"; +"lightning__spark_exit__lost_leaves" = "{amount} забрал ончейн один из прежних владельцев этих средств, их нельзя вернуть."; +"lightning__spark_exit__notification_lost" = "{amount} из вашего выхода нельзя вернуть. Откройте Bitkit, чтобы узнать причину."; +"lightning__spark_exit__notification_failed" = "Ваш экстренный выход остановлен. Откройте Bitkit, чтобы узнать причину."; +"lightning__spark_exit__quote_outside" = "{amount} вашего баланса Spark сейчас нельзя вывести: суммы слишком малы, чтобы покрыть свои комиссии, или ещё перемещаются внутри Spark. Они остаются в Spark."; +"lightning__spark_exit__left_behind_leaves" = "{amount} нельзя вывести при этой ставке комиссии, сумма остаётся в Spark."; +"lightning__spark_exit__needs_funding_title" = "Нужно больше\nкомиссий"; +"lightning__spark_exit__needs_funding_text" = "Выходу нужно ещё {amount} на комиссии. Пополните, чтобы продолжить."; +"lightning__spark_exit__failed_title" = "Выход\nостановлен"; +"lightning__spark_exit__cancel" = "Отменить выход"; +"lightning__spark_exit__failed_text" = "Ничего не удалось вернуть."; +"lightning__spark_exit__failed_stays" = "{amount} остаётся в Spark."; +"lightning__spark_exit__notification_title" = "Экстренный выход"; +"lightning__spark_exit__notification_body" = "Откройте Bitkit, чтобы продолжить выход."; +"lightning__spark_exit__notification_funding" = "Для продолжения выходу нужно больше комиссий. Откройте Bitkit, чтобы пополнить."; +"lightning__spark_exit__bg_refresh_off" = "Фоновое обновление приложений для Bitkit выключено, поэтому выход продвигается только при открытом Bitkit. Включите его в Настройках, чтобы выход шёл в фоне."; +"lightning__spark_exit__low_power" = "Режим энергосбережения приостанавливает фоновую работу. Выход продолжится, когда режим будет выключен или когда вы откроете Bitkit."; +"lightning__spark_exit__notifications_off" = "Уведомления выключены, поэтому Bitkit не сможет сообщить, когда выходу понадобится ваше участие. Включите их в Настройках, чтобы получать напоминания."; +"lightning__spark_exit__not_moving" = "Выход не продвигается: {error}"; +"lightning__spark_exit__fee_low" = "Сеть загружена сильнее, чем позволяет ставка комиссии этого выхода в {rate} sat/vB. Заплатите более высокую комиссию или дождитесь снижения комиссий."; +"lightning__spark_exit__waiting_leaves" = "Часть этого выхода сейчас не удаётся прочитать в блокчейне. Bitkit будет пробовать снова в течение недели."; +"lightning__spark_exit__rebuilt_from_chain" = "Bitkit не смог прочитать сохранённый прогресс этого выхода и восстанавливает его по блокчейну."; +"lightning__spark_exit__done_received" = "{amount} поступило в ваши сбережения."; +"lightning__spark_exit__sweep_txid" = "ID транзакции: {txid}"; +"lightning__spark_exit__leftover_rejected" = "{amount} оставшихся средств на комиссии не удалось отправить, они остаются на адресе {address}, путь деривации {path}."; +"lightning__spark_exit__notification_not_moving" = "Ваш экстренный выход не продвигается. Откройте Bitkit, чтобы узнать причину."; +"lightning__spark_exit__funding_split" = "Bitkit делит их на части ({count}), по одной на каждый шаг, чтобы один застрявший шаг не задерживал остальные."; +"lightning__spark_exit__waiting_operators" = "{amount}, которые операторы Spark перевели ончейн, ждут, пока операторы их выдадут. Bitkit будет запрашивать снова в течение недели."; +"lightning__spark_exit__fee_rose" = "Сетевые комиссии превысили комиссию этого выхода. Он может замедлиться."; +"lightning__spark_exit__fee_bump_button" = "Заплатить больше"; +"lightning__spark_exit__fee_bump_title" = "Повысить\nкомиссию"; +"lightning__spark_exit__fee_bump_text" = "Этот выход платит {rate} sat/vB, меньше, чем сейчас требует сеть. Выберите более высокую ставку, и Bitkit заново отправит ожидающие шаги. Разницу он оплатит из сбережений: сумма указана рядом с каждой скоростью."; +"lightning__spark_exit__fee_bump_loading" = "Считаем стоимость более высокой комиссии…"; +"lightning__spark_exit__fee_bump_error" = "Не удалось рассчитать более высокую комиссию. Попробуйте снова."; +"lightning__spark_exit__fee_bump_confirm" = "Использовать эту комиссию"; +"lightning__spark_exit__leftover_waiting" = "{amount} сдачи с комиссий поступит в ваши сбережения, когда сетевые комиссии снизятся."; +"lightning__spark_exit__cancelled_title" = "Выход\nотменён"; +"lightning__spark_exit__cancelled_text" = "Вы отменили выход. Поступившие средства на комиссии вернутся в сбережения."; +"lightning__spark_exit__cancel_failed" = "Не удалось проверить адрес для оплаты комиссий. Попробуйте снова."; +"lightning__spark_exit__fee_bump_leaves_behind" = "Более высокая комиссия оставила бы часть этого выхода в Spark, поэтому она не предлагается. Выход подтвердится, когда сетевые комиссии снизятся."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; "lightning__spark_learn__label" = "Spending balance after transfer"; From 52da092f469f18d224fc8d2c7f166f907f7ecac3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:57:39 +0100 Subject: [PATCH 102/378] fix: hand a recovered spark funding to spark and leave its confirm A transfer to spending whose broadcast outcome was unknown could later resolve, from the Send sheet's pending screen or once the chain showed it, without Spark being told about the deposit, and the old Spending Confirm stayed swipeable for the same transfer. The attempt is now marked as a Spark funding; when it resolves, the deposit is listed as a transfer and handed to Spark so it shows as pending and is claimed, the pending screen closes, and the funding flow returns home, also when Spending Confirm is still open while it resolves on its own. --- .../Services/OnchainSendAttemptService.swift | 24 +++++++-- Bitkit/ViewModels/SparkTransferFlow.swift | 20 +++++++ Bitkit/ViewModels/TransferViewModel.swift | 6 +-- Bitkit/ViewModels/WalletViewModel.swift | 8 +++ Bitkit/Views/Transfer/SpendingConfirm.swift | 13 +++++ .../Wallets/Send/SendPendingScreen.swift | 5 ++ BitkitTests/SparkFundingRecoveryTests.swift | 52 +++++++++++++++++++ 7 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 BitkitTests/SparkFundingRecoveryTests.swift diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index f222a4d85..d0c61d216 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -292,6 +292,12 @@ struct OnchainSendAttempt: Codable, Equatable { var releaseApproved: Bool? = nil /// The transaction spending the original's coins back to the wallet, saved before it is broadcast. var releaseTxid: String? = nil + /// Set on a transfer from savings to the Spark deposit address, which pays no order. + var sparkFunding: Bool? = nil + + var isSparkFunding: Bool { + sparkFunding == true && requestId == nil && orderId == nil + } var blocksNewSend: Bool { status.blocksNewSend || !localFollowupComplete @@ -358,6 +364,8 @@ struct OnchainSendLocalResolution { let amountSats: UInt64 let contact: String? let activity: OnchainActivity + /// A recovered transfer to the Spark deposit address, which Spark must be told about and the funding flow left. + var isSparkFunding = false } protocol OnchainSendLocalFollowupHandling { @@ -591,6 +599,7 @@ actor OnchainSendAttemptService { paymentIdentity: String? = nil, followupContext: OnchainSendFollowupContext? = nil, transferContext: OnchainSendTransferContext? = nil, + isSparkFunding: Bool = false, paymentDeadline: PaykitPreciseInstant? = nil, beforeBroadcastAttempt: () async throws -> Void = {}, onPreDispatchFailure: (Error) async throws -> Void = { _ in throw OnchainSendAttemptError.preDispatchCleanupFailed } @@ -613,7 +622,8 @@ actor OnchainSendAttemptService { amountSats: amountSats, isMaxAmount: isMaxAmount, followupContext: followupContext, - transferContext: transferContext + transferContext: transferContext, + isSparkFunding: isSparkFunding ) nativeDispatchInProgress = attemptId defer { nativeDispatchInProgress = nil } @@ -869,7 +879,8 @@ actor OnchainSendAttemptService { amountSats: UInt64, isMaxAmount: Bool, followupContext: OnchainSendFollowupContext? = nil, - transferContext: OnchainSendTransferContext? = nil + transferContext: OnchainSendTransferContext? = nil, + isSparkFunding: Bool = false ) throws -> UUID { guard nativeDispatchInProgress == nil else { throw OnchainSendAttemptError.blocked(try? currentAttempt()) } if let previous = try currentAdmissionAttempt() { @@ -890,7 +901,8 @@ actor OnchainSendAttemptService { isMaxAmount: isMaxAmount, status: .pending, followupContext: followupContext, - transferContext: transferContext + transferContext: transferContext, + sparkFunding: isSparkFunding ? true : nil ) try store.save([attempt]) knownAttempt = attempt @@ -987,7 +999,8 @@ actor OnchainSendAttemptService { saved.txId.caseInsensitiveCompare(txid) == .orderedSame, saved.txType == .sent else { throw OnchainSendAttemptError.localFollowupNotSaved } return OnchainSendLocalResolution( - attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: saved.value, contact: saved.contact, activity: saved + attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: saved.value, contact: saved.contact, activity: saved, + isSparkFunding: attempt.isSparkFunding ) } ordinaryFollowupInProgress = attempt.id @@ -996,7 +1009,8 @@ actor OnchainSendAttemptService { let activity = try await localFollowup.save(attempt) try acknowledgeLocalFollowup(txid: txid) let resolution = OnchainSendLocalResolution( - attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: activity.value, contact: activity.contact, activity: activity + attemptId: attempt.id, walletId: walletId, txid: txid, amountSats: activity.value, contact: activity.contact, activity: activity, + isSparkFunding: attempt.isSparkFunding ) Self.localResolutionSubject.send(resolution) return resolution diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 206064004..08ee5478d 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -364,3 +364,23 @@ final class SparkTransferFlow { setClaimState(.notSeen, for: txid) } } + +/// A transfer to the Spark deposit address whose outcome was unknown, resolved later from the Send sheet's pending screen. +enum SparkFundingRecovery { + /// Lists the deposit as a transfer and tells Spark about it, so it shows as pending and is claimed. + static func handOff(txid: String, amountSats: UInt64) async { + await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) + await SparkManager.shared.onDepositBroadcast(txid: txid, amountSats: amountSats) + } + + /// Hands a recovered Spark funding to Spark and returns true, so the caller leaves the funding flow whose confirm + /// screen would offer the sent transfer again; false for any other resolution. + static func follow( + _ resolution: OnchainSendLocalResolution, + handOff: (String, UInt64) async -> Void = { await handOff(txid: $0, amountSats: $1) } + ) async -> Bool { + guard resolution.isSparkFunding else { return false } + await handOff(resolution.txid, resolution.amountSats) + return true + } +} diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 47f349cd6..566b5cfb9 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -422,14 +422,14 @@ class TransferViewModel: ObservableObject { tags: [], contact: nil, createdAt: UInt64(Date().timeIntervalSince1970) - ) + ), + isSparkFunding: true ) guard case let .accepted(txid) = result else { throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark funding outcome: \(result)") } _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: walletId, observedTxid: txid) - await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) - SparkManager.shared.onDepositBroadcast(txid: txid, amountSats: amountSats) + await SparkFundingRecovery.handOff(txid: txid, amountSats: amountSats) Logger.info("Funded Spark wallet with \(amountSats) sats in \(txid)", context: "TransferViewModel") await onBalanceRefresh?() return txid diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index c7a5e5ba1..47179ea8a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1,4 +1,5 @@ import BitkitCore +import Combine import LDKNode import SwiftUI @@ -9,6 +10,7 @@ struct SwapUpdatesStopTimeoutError: Error {} class WalletViewModel: ObservableObject { let usdtWallet: UsdtWalletManager @Published var walletExists: Bool? = nil + private var cancellables = Set() @Published var isSyncingWallet = false // Syncing both LN and on chain @AppStorage("totalBalanceSats") var totalBalanceSats: Int = 0 // Combined onchain and LN @AppStorage("totalOnchainSats") var totalOnchainSats: Int = 0 // The total balance of our on-chain wallet @@ -175,6 +177,12 @@ class WalletViewModel: ObservableObject { lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } lightningService.channelFreeRailSupported = { await MainActor.run { SparkManager.shared.isSupported } } + // A Spark funding whose outcome was unknown resolves later, from the pending screen or once the chain shows it. + OnchainSendAttemptService.localResolutionPublisher + .sink { resolution in + Task { _ = await SparkFundingRecovery.follow(resolution) } + } + .store(in: &cancellables) SparkManager.shared.onStateChanged = { [weak self] in Task { @MainActor in guard let self else { return } diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index cf265d1a4..990b6f977 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -77,6 +77,11 @@ struct SpendingConfirm: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .offlineOverlay(title: t("lightning__transfer__nav_title")) + .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in + if Self.leavesAfter(resolution, isPaying: isPaying) { + navigation.reset() + } + } .task(id: QuoteInputs(amountSats: flow.amountSats, isMaxAmount: flow.isMaxAmount, speed: flow.speed)) { guard !SparkTransferFlow.isRunningInPreview else { return } while !Task.isCancelled { @@ -160,6 +165,14 @@ struct SpendingConfirm: View { } } +extension SpendingConfirm { + /// A Spark funding that resolved on its own while this screen stayed open, after an unknown outcome the user left + /// pending, would let the swipe send the transfer again; the swipe's own funding resolves while it is paying. + static func leavesAfter(_ resolution: OnchainSendLocalResolution, isPaying: Bool) -> Bool { + resolution.isSparkFunding && !isPaying + } +} + /// The swipe reset after the fees went up while the screen was open; the toast says by how much. struct SpendingFeesIncreasedError: Error {} diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 3e80b6374..f6bc770a6 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -616,6 +616,11 @@ struct SendPendingScreen: View { foundActivity = .onchain(resolution.activity) onchainAttempt?.status = .accepted onchainAttempt?.localFollowupComplete = true + if resolution.isSparkFunding { + // Leave the funding flow, whose confirm screen would offer the sent transfer again. + sheets.hideSheet(reason: "Recovered Spark funding") + navigation.reset() + } } static func matchesLocalResolution(_ resolution: OnchainSendLocalResolution, attempt: OnchainSendAttempt?, diff --git a/BitkitTests/SparkFundingRecoveryTests.swift b/BitkitTests/SparkFundingRecoveryTests.swift new file mode 100644 index 000000000..7db08162c --- /dev/null +++ b/BitkitTests/SparkFundingRecoveryTests.swift @@ -0,0 +1,52 @@ +@testable import Bitkit +import XCTest + +/// A Spark funding whose outcome was unknown, resolved from the Send sheet's pending screen. +final class SparkFundingRecoveryTests: XCTestCase { + private let walletId = OnchainSendAttemptService.walletId(index: 0) + + private func resolvedSend(isSparkFunding: Bool, txid: String) async throws -> OnchainSendLocalResolution { + let service = OnchainSendAttemptService(store: MemoryAttemptStore()) + let id = try await service.admit( + walletId: walletId, requestId: nil, orderId: nil, address: "bcrt1qspark-deposit", amountSats: 30000, isMaxAmount: false, + followupContext: OnchainSendFollowupContext(feeSats: 200, feeRate: 2, tags: [], contact: nil, createdAt: 100), + isSparkFunding: isSparkFunding + ) + try await service.record(.accepted(txid: txid), attemptId: id) + let resolution = try await service.resumeAcceptedOrdinarySend(walletId: walletId) + return try XCTUnwrap(resolution) + } + + func testARecoveredSparkFundingIsHandedToSparkAndLeavesTheFundingFlow() async throws { + let txid = String(repeating: "5a", count: 32) + let resolution = try await resolvedSend(isSparkFunding: true, txid: txid) + var handedOff: [(String, UInt64)] = [] + + let leaves = await SparkFundingRecovery.follow(resolution) { handedOff.append(($0, $1)) } + + XCTAssertTrue(resolution.isSparkFunding) + XCTAssertTrue(leaves, "The old confirm screen would offer the sent transfer again") + XCTAssertEqual(handedOff.map(\.0), [txid]) + XCTAssertEqual(handedOff.map(\.1), [30000]) + } + + func testARecoveredOrdinarySendIsNotHandedToSpark() async throws { + let resolution = try await resolvedSend(isSparkFunding: false, txid: String(repeating: "6b", count: 32)) + var handedOff = 0 + + let leaves = await SparkFundingRecovery.follow(resolution) { _, _ in handedOff += 1 } + + XCTAssertFalse(resolution.isSparkFunding) + XCTAssertFalse(leaves) + XCTAssertEqual(handedOff, 0) + } + + func testSpendingConfirmLeavesOnceItsFundingResolvesOnItsOwn() async throws { + let spark = try await resolvedSend(isSparkFunding: true, txid: String(repeating: "7c", count: 32)) + let ordinary = try await resolvedSend(isSparkFunding: false, txid: String(repeating: "8d", count: 32)) + + XCTAssertTrue(SpendingConfirm.leavesAfter(spark, isPaying: false)) + XCTAssertFalse(SpendingConfirm.leavesAfter(spark, isPaying: true), "The swipe's own funding goes on to Setting Up") + XCTAssertFalse(SpendingConfirm.leavesAfter(ordinary, isPaying: false)) + } +} From ae9c96174efbd217182aad67e7e00ec6041510e7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:57:39 +0100 Subject: [PATCH 103/378] fix: release an invoice spark failed while the app was closed The first Spark sync of a launch now releases the channel hold on every invoice whose Spark sends all failed, so a send that failed while the app was closed doesn't block a channel retry while Spark later can't list its payments. --- Bitkit/Managers/SparkManager.swift | 19 +++++++++++++++++++ Bitkit/Services/LightningService.swift | 6 +++--- BitkitTests/SparkPaymentSafetyTests.swift | 16 ++++++++++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index c8a9a1a27..6acc94b26 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -674,11 +674,30 @@ final class SparkManager: ChannelFreeLightningRail { return result } + /// Payment hashes whose every Lightning send failed, so a hold on a channel retry can be released. + nonisolated static func failedSendHashes(_ payments: [Payment]) -> [String] { + var failed: [String] = [] + var live: Set = [] + for payment in payments where payment.paymentType == .send { + guard case let .lightning(_, _, _, htlcDetails, _, _, _, _) = payment.details else { continue } + if payment.status == .failed { + failed.append(htlcDetails.paymentHash) + } else { + live.insert(htlcDetails.paymentHash) + } + } + return failed.filter { !live.contains($0) } + } + private func syncActivities() async throws { let payments = try await requireSdk().listPayments(request: ListPaymentsRequest()).payments for payment in payments { await recordActivity(payment) } + // A send that failed while the app was closed released no hold on a channel retry. + for paymentHash in Self.failedSendHashes(payments) { + LightningService.shared.releaseRailAttempt(paymentHash: paymentHash) + } } private func onSdkEvent(_ event: SdkEvent) async { diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 2e3c85642..a33f410ee 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1091,7 +1091,7 @@ class LightningService: NodeLifecycleService { } /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. - private func forgetRailAttempt(paymentHash: String) { + func releaseRailAttempt(paymentHash: String) { let hash = paymentHash.lowercased() eventCallbackLock.withLock { guard var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), hashes.contains(hash) else { return } @@ -1129,7 +1129,7 @@ class LightningService: NodeLifecycleService { statuses.append(payment.status) } if payment == nil || payment?.status == .failed, !isRailSendInFlight(paymentHash: paymentHash) { - forgetRailAttempt(paymentHash: paymentHash) + releaseRailAttempt(paymentHash: paymentHash) } } else if hasRailAttempt(paymentHash: paymentHash) { Logger.warn("Refused paying '\(paymentHash)', Spark can't list the earlier attempt now", context: "LightningService") @@ -1692,7 +1692,7 @@ extension LightningService { /// Spark runs before the node starts, so its events wait until the node's event callback is set rather than being lost. func emitChannelFreeRailEvent(_ event: Event) { if case let .paymentFailed(_, paymentHash?, _) = event { - forgetRailAttempt(paymentHash: paymentHash) + releaseRailAttempt(paymentHash: paymentHash) } if isStaleFailure(event, from: .channelFree) { Logger.info("Ignored a Spark failure for a payment retried over a channel: \(event)", context: "LightningService") diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index 0da816b03..1dcc72790 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -217,7 +217,19 @@ final class SparkPaymentSafetyTests: XCTestCase { XCTAssertFalse(SparkManager.fitsBalance(amountSats: UInt64.max, feeSats: 1, balanceSats: UInt64.max)) } - private func lightningPayment(type: PaymentType, status: BreezSdkSpark.PaymentStatus) -> Payment { + func testASendSparkFailedWhileTheAppWasClosedIsReleasedAtTheNextSync() { + let payments = [ + lightningPayment(type: .send, status: .failed, hash: "failed"), + lightningPayment(type: .send, status: .failed, hash: "retried"), + lightningPayment(type: .send, status: .pending, hash: "retried"), + lightningPayment(type: .receive, status: .failed, hash: "received"), + lightningPayment(type: .send, status: .completed, hash: "paid"), + ] + + XCTAssertEqual(SparkManager.failedSendHashes(payments), ["failed"]) + } + + private func lightningPayment(type: PaymentType, status: BreezSdkSpark.PaymentStatus, hash: String = "hash") -> Payment { Payment( id: "id", paymentType: type, @@ -230,7 +242,7 @@ final class SparkPaymentSafetyTests: XCTestCase { description: nil, invoice: "lnbcrt1", destinationPubkey: "pubkey", - htlcDetails: SparkHtlcDetails(paymentHash: "hash", preimage: "preimage", expiryTime: 0, status: .preimageShared), + htlcDetails: SparkHtlcDetails(paymentHash: hash, preimage: "preimage", expiryTime: 0, status: .preimageShared), lnurlPayInfo: nil, lnurlWithdrawInfo: nil, lnurlReceiveMetadata: nil, From 54c0e0dad9dcdb411d5ec3a6f98840c4ff0bec4e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:59:35 +0100 Subject: [PATCH 104/378] fix: price a spark-funded request at its on-chain endpoint --- Bitkit/ViewModels/AppViewModel.swift | 10 +++++++++- BitkitTests/SendConfirmationLifecycleTests.swift | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 739d96a47..8699d04dc 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -760,9 +760,17 @@ extension AppViewModel { return paykitBitcoinMethod(for: selectedWalletToPayFrom) } + /// The method a send prices with. Spending with no Lightning payment to make funds the Spark leg to the on-chain + /// endpoint, so it takes that endpoint's price, the one the leg and its proof pay. func paykitBitcoinMethod(for wallet: WalletType) -> PublicPaykitService.MethodId { if wallet == .lightning { - return lnurlPayData != nil ? .bitcoinLightningLnurl : .bitcoinLightningBolt11 + if lnurlPayData != nil { + return .bitcoinLightningLnurl + } + if scannedLightningInvoice == nil, let address = scannedOnchainInvoice?.address { + return PublicPaykitService.onchainMethodId(for: address) + } + return .bitcoinLightningBolt11 } return PublicPaykitService.onchainMethodId(for: scannedOnchainInvoice?.address ?? "") } diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index a6d960d90..dc6b645b0 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -29,6 +29,22 @@ final class SendConfirmationLifecycleTests: XCTestCase { XCTAssertEqual(wallet.sendAmountSats, 10000) } + func testASparkFundedOnchainRequestKeepsItsOnchainPriceOnSpending() throws { + snapshotAppDefaultsDomain() + let request = try onchainRequest(rates: [ + ConversionRate(asset: "btc", value: "0.000002188"), + ConversionRate(asset: "btc-lightning", value: "0.00002"), + ]) + let app = AppViewModel() + app.scannedOnchainInvoice = invoice + let onchainTerms = try request.payment(using: app.paykitPaymentMethod) + + // Spending funds the Spark leg, which still pays the request's on-chain endpoint. + app.selectedWalletToPayFrom = .lightning + + XCTAssertEqual(try request.payment(using: app.paykitPaymentMethod), onchainTerms) + } + func testQuotedWalletSwitchRebindsAmountOnConfirmation() async throws { snapshotAppDefaultsDomain() snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") From 4c735717e9aebf0490dd0256ccb5f934c3016f17 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:03:54 +0100 Subject: [PATCH 105/378] fix: settle a pending spark send from its journal --- Bitkit/Managers/SparkManager.swift | 36 +++++++++++++++++++ .../Wallets/Send/SendConfirmationView.swift | 6 ++-- .../Wallets/Send/SendPendingScreen.swift | 33 +++++++++++++++++ Bitkit/Views/Wallets/Send/SendSheet.swift | 7 ++++ BitkitTests/PaykitRouteTests.swift | 32 +++++++++++++++++ BitkitTests/SendConfirmationViewTests.swift | 14 +++++--- 6 files changed, 120 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 7d76b297e..2c57e502c 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -17,6 +17,14 @@ final class SparkManager: ChannelFreeLightningRail { nonisolated static let routeService = "spark" private nonisolated static let routeAtomicity = "trusted" private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject() + private nonisolated(unsafe) static let contactWithdrawalResolutionSubject = PassthroughSubject() + /// How each contact withdrawal's journal closes, which a pending send screen waits on. + static var contactWithdrawalResolutionPublisher: AnyPublisher { + contactWithdrawalResolutionSubject.eraseToAnyPublisher() + } + + /// The latest journal close in this process, for a pending screen that opens after it; a new journal clears it. + private(set) static var lastContactWithdrawalResolution: SparkContactWithdrawalResolution? /// Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. nonisolated static var exitStateChangedPublisher: AnyPublisher { exitStateChanged.eraseToAnyPublisher() @@ -533,6 +541,7 @@ final class SparkManager: ChannelFreeLightningRail { earlierPaymentIds: Self.withdrawalIds(to: address, amountSats: amountSats, in: earlierPayments) ) Self.pendingContactWithdrawal = pending + Self.lastContactWithdrawalResolution = nil isWithdrawingToContact = true defer { isWithdrawingToContact = false } let response: SendPaymentResponse @@ -658,6 +667,7 @@ final class SparkManager: ChannelFreeLightningRail { await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .succeeded, transactionId: txId) } Self.pendingContactWithdrawal = nil + Self.resolveContactWithdrawal(pending, txId: txId) } else { Logger.warn("Kept the Spark contact withdrawal journal: its local records are not all saved yet", context: "SparkManager") } @@ -679,6 +689,13 @@ final class SparkManager: ChannelFreeLightningRail { await failAllowance(request) } pendingContactWithdrawal = nil + resolveContactWithdrawal(pending, txId: nil) + } + + static func resolveContactWithdrawal(_ pending: SparkContactWithdrawal, txId: String?) { + let resolution = SparkContactWithdrawalResolution(attempt: pending.attempt, txId: txId) + lastContactWithdrawalResolution = resolution + contactWithdrawalResolutionSubject.send(resolution) } private static func releaseUnsentProof(_ request: SparkContactWithdrawal.PaykitRequest) async -> Bool { @@ -1225,6 +1242,25 @@ struct SparkContactWithdrawal: Codable, Equatable { var paymentId: String? } +/// One contact withdrawal, which a pending send screen follows until its journal closes. +struct SparkWithdrawalAttempt: Hashable { + let address: String + let amountSats: UInt64 + let requestId: PaykitPaymentRequest.ID? +} + +/// How a contact withdrawal's journal closed: sent with its txid, or never sent when `txId` is nil. +struct SparkContactWithdrawalResolution: Equatable { + let attempt: SparkWithdrawalAttempt + let txId: String? +} + +extension SparkContactWithdrawal { + var attempt: SparkWithdrawalAttempt { + SparkWithdrawalAttempt(address: address, amountSats: amountSats, requestId: request?.id) + } +} + struct SparkWithdrawal: Identifiable, Equatable { let txid: String let amountSats: UInt64 diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 60a270956..e6f4eb617 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1464,11 +1464,11 @@ struct SendConfirmationView: View { /// other failure may have sent it, so it stays pending until Spark's journal settles it. static func serviceLegFailureRoute( _ error: Error, - requestId: PaykitPaymentRequest.ID?, + attempt: SparkWithdrawalAttempt, routingCacheResetAttempted: Bool ) -> SendRoute { guard let refusal = error as? SparkWithdrawalNotSent else { - return .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId) + return .sparkWithdrawalPending(attempt) } return .failure(SendFailureContext( error: refusal.underlying, @@ -1564,7 +1564,7 @@ struct SendConfirmationView: View { Logger.error("Service route payment failed: \(error)", context: "SendConfirmation") navigationPath.append(Self.serviceLegFailureRoute( error, - requestId: request?.id, + attempt: SparkWithdrawalAttempt(address: leg.endpoint.value, amountSats: leg.deliveredSat, requestId: request?.id), routingCacheResetAttempted: routingCacheResetAttempted )) return diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 3e80b6374..1518692ec 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -38,6 +38,8 @@ struct SendPendingScreen: View { var hardwareWalletId: String? var hardwareTransactionId: String? var hardwarePaymentIdentity: String? + /// The Spark withdrawal this screen waits on, whose journal settles it. + var sparkWithdrawal: SparkWithdrawalAttempt? var proofService: PaykitPaymentProofService = .shared var requestPinCheck: () async -> Bool = { false } @Binding var navigationPath: [SendRoute] @@ -347,6 +349,37 @@ struct SendPendingScreen: View { .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in applyOnchainPaymentResolution(resolution) } + .onReceive(SparkManager.contactWithdrawalResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in + applySparkWithdrawalResolution(resolution) + } + .task { + // The journal may have closed before this screen subscribed. + guard let attempt = sparkWithdrawal, SparkManager.pendingContactWithdrawal?.attempt != attempt, + let resolution = SparkManager.lastContactWithdrawalResolution + else { return } + applySparkWithdrawalResolution(resolution) + } + } + + private func applySparkWithdrawalResolution(_ resolution: SparkContactWithdrawalResolution) { + guard !ordinarySendResolved, let attempt = sparkWithdrawal, + let route = Self.sparkWithdrawalRoute(for: resolution, attempt: attempt) + else { return } + ordinarySendResolved = true + navigationPath.append(route) + } + + /// Where the journal's close takes the screen waiting on `attempt`: a success once Spark sent it, a failure that can + /// be tried again once it never did, and nowhere for another withdrawal. + static func sparkWithdrawalRoute(for resolution: SparkContactWithdrawalResolution, attempt: SparkWithdrawalAttempt) -> SendRoute? { + guard resolution.attempt == attempt else { return nil } + if let txId = resolution.txId { + return .success(paymentId: txId) + } + return .failure(SendFailureContext( + error: AppError(message: "wallet__payment_failed_description", debugMessage: "Spark never sent the contact withdrawal"), + retryRoute: .confirm + )) } @MainActor diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 7eacef40f..4b73c7763 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -69,6 +69,7 @@ enum SendRoute: Hashable { paykitPaymentRequestId: PaykitPaymentRequest.ID? = nil ) case onchainPending(OnchainSendPendingContext) + case sparkWithdrawalPending(SparkWithdrawalAttempt) case onchainOperationPending(OnchainSendPendingContext, requestId: PaykitPaymentRequest.ID?) case hardwarePending(requestId: PaykitPaymentRequest.ID, walletId: String, transactionId: String, paymentIdentity: String?) case success(paymentId: String, walletId: String = WalletScope.default) @@ -1171,6 +1172,12 @@ struct SendSheet: View { routingCacheResetAttempted: flow.routingCacheResetAttempted, navigationPath: $flow.navigationPath ) + case let .sparkWithdrawalPending(attempt): + SendPendingScreen( + paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: attempt.requestId, + routingCacheResetAttempted: flow.routingCacheResetAttempted, sparkWithdrawal: attempt, + navigationPath: $flow.navigationPath + ) case let .onchainPending(context): SendPendingScreen( paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: nil, diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index aa49589c9..2958039c9 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -292,6 +292,38 @@ final class PaykitRouteTests: XCTestCase { } } + @MainActor + func testAClosedJournalTellsThePendingScreenHowItsWithdrawalEnded() async { + snapshotAppDefaultsDomain() + let request = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee"), + identity: "payer" + ) + let pending = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) + SparkManager.pendingContactWithdrawal = pending + var resolutions: [SparkContactWithdrawalResolution] = [] + let subscription = SparkManager.contactWithdrawalResolutionPublisher.sink { resolutions.append($0) } + defer { subscription.cancel() } + + await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in true }, failAllowance: { _ in }) + + let unsent = SparkContactWithdrawalResolution(attempt: pending.attempt, txId: nil) + XCTAssertEqual(resolutions, [unsent]) + XCTAssertEqual(SparkManager.lastContactWithdrawalResolution, unsent, "A screen that opens later still learns it") + guard case .failure = SendPendingScreen.sparkWithdrawalRoute(for: unsent, attempt: pending.attempt) else { + return XCTFail("A withdrawal that never went out can be tried again") + } + XCTAssertEqual( + SendPendingScreen.sparkWithdrawalRoute( + for: SparkContactWithdrawalResolution(attempt: pending.attempt, txId: "txid"), + attempt: pending.attempt + ), + .success(paymentId: "txid") + ) + let other = SparkWithdrawalAttempt(address: onchain.value, amountSats: 30000, requestId: request.id) + XCTAssertNil(SendPendingScreen.sparkWithdrawalRoute(for: unsent, attempt: other), "Another withdrawal's close leaves the screen") + } + func testLegPreparesTheRequestBeforeItsProofIsStarted() async throws { var steps: [String] = [] let identity = try await PaykitRouteService.startLegRequestProof( diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index a4e485692..8c938d5c5 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -193,18 +193,22 @@ final class SendConfirmationViewTests: XCTestCase { func testASparkLegThatMayHaveSentStaysPendingInsteadOfOfferingTryAgain() { struct Refused: Error {} struct Lost: Error {} - let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee") + let attempt = SparkWithdrawalAttempt( + address: "bcrt1qcontact", + amountSats: 20000, + requestId: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee") + ) - let unresolved = SendConfirmationView.serviceLegFailureRoute(Lost(), requestId: requestId, routingCacheResetAttempted: false) + let unresolved = SendConfirmationView.serviceLegFailureRoute(Lost(), attempt: attempt, routingCacheResetAttempted: false) XCTAssertEqual( unresolved, - .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId), - "Spark may have sent it, so the request stays pending until the journal settles" + .sparkWithdrawalPending(attempt), + "Spark may have sent it, so the screen follows its journal until it settles" ) let refused = SendConfirmationView.serviceLegFailureRoute( SparkWithdrawalNotSent(underlying: Refused()), - requestId: requestId, + attempt: attempt, routingCacheResetAttempted: false ) guard case .failure = refused else { return XCTFail("A leg Spark refused before sending can be tried again") } From 97280c2ac2e396d528c0894bad38bb319df0a5d9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:06:09 +0100 Subject: [PATCH 106/378] fix: keep a pending spark send off the node's on-chain attempts --- .../Wallets/Send/SendPendingScreen.swift | 22 +++++++++++++++---- BitkitTests/PaykitRouteTests.swift | 11 ++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 1518692ec..967f130b6 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -109,7 +109,10 @@ struct SendPendingScreen: View { } private var pendingAmountSats: UInt64? { - pendingHardwareWalletId == nil ? onchainAttempt?.amountSats ?? pendingOnchainProof?.onchainAmountSats ?? wallet + if let sparkWithdrawal { + return sparkWithdrawal.amountSats + } + return pendingHardwareWalletId == nil ? onchainAttempt?.amountSats ?? pendingOnchainProof?.onchainAmountSats ?? wallet .sendAmountSats : pendingOnchainProof?.onchainAmountSats } @@ -281,7 +284,7 @@ struct SendPendingScreen: View { { applyOnchainPaymentResolution(resolution) } - } else if !onchainStateUnavailable { + } else if !onchainStateUnavailable, Self.readsNodeOnchainAttempts(sparkWithdrawal: sparkWithdrawal) { do { let loaded = try await Self.loadOrdinaryPending( using: attemptService, context: ordinaryPendingContext, @@ -318,7 +321,7 @@ struct SendPendingScreen: View { await searchForActivity() // Completion events are one-shot. Keep the visible original operation in sync // with its durable result even when an event preceded subscription or initialization. - while paymentHash == nil, paykitPaymentRequestId == nil, pendingHardwareWalletId == nil, + while paymentHash == nil, paykitPaymentRequestId == nil, pendingHardwareWalletId == nil, sparkWithdrawal == nil, !ordinarySendResolved, !Task.isCancelled { do { try await Task.sleep(for: .seconds(2)) } @@ -328,7 +331,9 @@ struct SendPendingScreen: View { } .task { // The request can end, or a failed check recover, while this screen stays open. - while paymentHash == nil, abandonableAttempt == nil, !Task.isCancelled { + while paymentHash == nil, abandonableAttempt == nil, Self.readsNodeOnchainAttempts(sparkWithdrawal: sparkWithdrawal), + !Task.isCancelled + { if pendingHardwareWalletId == nil { guard await loadAbandonableAttempt() else { return } } @@ -369,6 +374,12 @@ struct SendPendingScreen: View { navigationPath.append(route) } + /// Whether the screen reads the node wallet's on-chain send attempts: never for a Spark withdrawal, which creates + /// none, so an unrelated unresolved send never shows as this one; its journal settles it instead. + static func readsNodeOnchainAttempts(sparkWithdrawal: SparkWithdrawalAttempt?) -> Bool { + sparkWithdrawal == nil + } + /// Where the journal's close takes the screen waiting on `attempt`: a success once Spark sent it, a failure that can /// be tried again once it never did, and nowhere for another withdrawal. static func sparkWithdrawalRoute(for resolution: SparkContactWithdrawalResolution, attempt: SparkWithdrawalAttempt) -> SendRoute? { @@ -575,6 +586,9 @@ struct SendPendingScreen: View { } private var onchainPendingMessage: String { + if sparkWithdrawal != nil { + return t("wallet__onchain_unknown_pending") + } if onchainStateUnavailable { return t("wallet__onchain_state_unavailable") } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 2958039c9..52b06e11e 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -324,6 +324,17 @@ final class PaykitRouteTests: XCTestCase { XCTAssertNil(SendPendingScreen.sparkWithdrawalRoute(for: unsent, attempt: other), "Another withdrawal's close leaves the screen") } + @MainActor + func testAPendingSparkSendNeverAdoptsTheNodesOnchainAttempt() { + let attempt = SparkWithdrawalAttempt(address: onchain.value, amountSats: 20000, requestId: nil) + + XCTAssertFalse( + SendPendingScreen.readsNodeOnchainAttempts(sparkWithdrawal: attempt), + "A Spark withdrawal never creates a node on-chain attempt, so an unrelated one must not show as this send" + ) + XCTAssertTrue(SendPendingScreen.readsNodeOnchainAttempts(sparkWithdrawal: nil)) + } + func testLegPreparesTheRequestBeforeItsProofIsStarted() async throws { var steps: [String] = [] let identity = try await PaykitRouteService.startLegRequestProof( From 68028ff73b165a458f014a83583624ea926f92cc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:14:21 +0100 Subject: [PATCH 107/378] fix: bound the move to spark payment wait --- Bitkit/Managers/MoveToSparkManager.swift | 10 ++++- .../Localization/ar.lproj/Localizable.strings | 1 + .../Localization/ca.lproj/Localizable.strings | 1 + .../Localization/cs.lproj/Localizable.strings | 1 + .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/el.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../es-419.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/nl.lproj/Localizable.strings | 1 + .../Localization/pl.lproj/Localizable.strings | 1 + .../pt-BR.lproj/Localizable.strings | 1 + .../Localization/pt.lproj/Localizable.strings | 1 + .../Localization/ru.lproj/Localizable.strings | 1 + Bitkit/ViewModels/WalletViewModel.swift | 42 +++++++++++++++++-- .../Settings/Advanced/MoveToSparkView.swift | 17 ++++++++ 18 files changed, 79 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 0a7c369ed..fbf2f309f 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -99,6 +99,8 @@ enum MoveToSparkState: Equatable { case confirm(MoveToSparkQuote, isMoving: Bool) /// `hasRest`: the channels can still send enough to move again, as after a smaller amount. case done(amountSats: UInt64, feeSats: UInt64, closeChannel: ChannelDetails?, hasRest: Bool) + /// The payment had no outcome within the wait; it may still settle or return to the channel. + case inFlight(amountSats: UInt64) /// `amountSats` did not route in one payment; `smallerSats` is the next amount to try, if any is left. case tooLarge(amountSats: UInt64, smallerSats: UInt64?) case failed(MoveToSparkFailure) @@ -108,6 +110,7 @@ enum MoveToSparkState: Equatable { case (.quoting, .quoting): true case let (.confirm(a, am), .confirm(b, bm)): a == b && am == bm case let (.done(a, af, ac, ar), .done(b, bf, bc, br)): a == b && af == bf && ac?.channelId == bc?.channelId && ar == br + case let (.inFlight(a), .inFlight(b)): a == b case let (.tooLarge(a, asm), .tooLarge(b, bsm)): a == b && asm == bsm case let (.failed(a), .failed(b)): a == b default: false @@ -189,11 +192,14 @@ final class MoveToSparkManager { do { let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: quote.invoice).paymentHash() MoveToSparkPayments.insert(String(describing: paymentHash)) - let settled = try await wallet.sendOverChannels( + guard let settled = try await wallet.sendOverChannels( bolt11: quote.invoice, sats: quote.amountSats, maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats) - ) + ) else { + state = .inFlight(amountSats: quote.amountSats) + return + } await wallet.syncStateAsync() let openChannels = (wallet.channels ?? []).filter(\.isChannelReady) state = .done( diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 78ec9dbcd..aae891022 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1670,3 +1670,4 @@ "wallet__balance_updating" = "جارٍ تحديث الرصيد…"; "other__error_keychain" = "مشكلة في الاتصال بالبيانات"; "other__error_keychain_msg" = "تعذر تحميل المفتاح الأساسي من سلسلة المفاتيح."; +"lightning__move_to_spark__in_flight_text" = "الدفعة في طريقها وستظهر في Spark بمجرد إتمامها. ثم أغلق الاتصال الفارغ لإعادة احتياطيه إلى المدخرات."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index c5948b9f2..54186d84e 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1854,3 +1854,4 @@ "wallet__balance_updating" = "S’està actualitzant el saldo…"; "other__error_keychain" = "Problema de connexió de dades"; "other__error_keychain_msg" = "No s'ha pogut carregar la clau principal del clauer."; +"lightning__move_to_spark__in_flight_text" = "El pagament està en camí i apareixerà a Spark quan s'hagi liquidat. Després tanca la connexió buida per tornar-ne la reserva als Estalvis."; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 63e9e5835..c35ca95b5 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1991,3 +1991,4 @@ "subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; "wallet__balance_updating" = "Aktualizace zůstatku…"; +"lightning__move_to_spark__in_flight_text" = "Platba je na cestě a ve Spark se zobrazí po vypořádání. Poté uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index d2853fd0e..5e49d82cf 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1988,3 +1988,4 @@ "subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; "wallet__balance_updating" = "Kontostand wird aktualisiert…"; +"lightning__move_to_spark__in_flight_text" = "Die Zahlung ist unterwegs und erscheint in Spark, sobald sie abgeschlossen ist. Schließen Sie dann die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 2b5cc2830..3db8f8eac 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1725,3 +1725,4 @@ "wallet__balance_updating" = "Ενημέρωση υπολοίπου…"; "other__error_keychain" = "Πρόβλημα Σύνδεσης Δεδομένων"; "other__error_keychain_msg" = "Δεν ήταν δυνατή η φόρτωση του κύριου κλειδιού από την κλειδοθήκη."; +"lightning__move_to_spark__in_flight_text" = "Η πληρωμή βρίσκεται σε εξέλιξη και θα εμφανιστεί στο Spark μόλις διακανονιστεί. Έπειτα κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις."; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 0bcb2c42e..80d489fdc 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -2080,3 +2080,4 @@ "usdt__receive_copied" = "Address copied to clipboard.\nMake sure USDT is only sent on the selected network."; "wallet__balance_updating" = "Updating balance…"; +"lightning__move_to_spark__in_flight_text" = "The payment is on its way and shows in Spark once it settles. Then close the emptied connection to return its reserve to Savings."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 7e6939bf7..2c6fae145 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1994,3 +1994,4 @@ "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; "wallet__balance_updating" = "Actualizando saldo…"; +"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 6523c66a5..d62752da1 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1873,3 +1873,4 @@ "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; "wallet__balance_updating" = "Actualizando saldo…"; +"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 6110c51a5..ac76e58e4 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -2002,3 +2002,4 @@ "subscriptions__allowance_auto_paid" = "Payé automatiquement"; "wallet__balance_updating" = "Mise à jour du solde…"; +"lightning__move_to_spark__in_flight_text" = "Le paiement est en route et apparaîtra sur Spark une fois réglé. Fermez ensuite la connexion vidée pour rendre sa réserve à Épargnes."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index b81118fbd..c1894f12d 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1961,3 +1961,4 @@ "subscriptions__allowance_auto_paid" = "Pagato automaticamente"; "wallet__balance_updating" = "Aggiornamento del saldo…"; +"lightning__move_to_spark__in_flight_text" = "Il pagamento è in arrivo e compare su Spark una volta completato. Poi chiudi la connessione svuotata per restituire la sua riserva ai Risparmi."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index d80830f24..0aa06eb94 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1996,3 +1996,4 @@ "subscriptions__allowance_auto_paid" = "Automatisch betaald"; "wallet__balance_updating" = "Saldo bijwerken…"; +"lightning__move_to_spark__in_flight_text" = "De betaling is onderweg en verschijnt in Spark zodra ze is afgerond. Sluit daarna de lege verbinding om de reserve terug te geven aan uw spaargeld."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 02a7fa75c..9ff152310 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -2001,3 +2001,4 @@ "subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; "wallet__balance_updating" = "Aktualizowanie salda…"; +"lightning__move_to_spark__in_flight_text" = "Płatność jest w drodze i pojawi się w Spark po rozliczeniu. Następnie zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index c8f4eeda9..683fe837f 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -2002,3 +2002,4 @@ "subscriptions__allowance_auto_paid" = "Pago automaticamente"; "wallet__balance_updating" = "Atualizando saldo…"; +"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark assim que for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index fed3ce976..1a0885e50 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1650,3 +1650,4 @@ "wallet__balance_updating" = "A atualizar o saldo…"; "other__error_keychain" = "Problema de Ligação de Dados"; "other__error_keychain_msg" = "Não foi possível carregar a chave principal do porta-chaves."; +"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark quando for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index c2ffe3e4e..ba41cd47c 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1982,3 +1982,4 @@ "subscriptions__allowance_auto_paid" = "Оплачено автоматически"; "wallet__balance_updating" = "Обновление баланса…"; +"lightning__move_to_spark__in_flight_text" = "Платёж в пути и появится в Spark после завершения. Затем закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения."; diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 6ecc1263f..06a32158c 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1157,7 +1157,15 @@ class WalletViewModel: ObservableObject { } /// Pays from the channels only, with the routing fee capped at `maxFeeMsat`, and waits for the payment to settle. - func sendOverChannels(bolt11: String, sats: UInt64, maxFeeMsat: UInt64) async throws -> SettledLightningPayment { + /// Pays `bolt11` from the channels only and waits up to `timeout` for its outcome; `nil` means it is still in flight. + /// It listens before sending, so an outcome LDK reports before the send returns is not missed, and it matches a + /// failure by payment id as well as by hash, since LDK can report one without its hash. + func sendOverChannels( + bolt11: String, + sats: UInt64, + maxFeeMsat: UInt64, + timeout: Duration = .seconds(120) + ) async throws -> SettledLightningPayment? { // LDK's default route parameters, with the routing fee capped. let params = RouteParametersConfig( maxTotalRoutingFeeMsat: maxFeeMsat, @@ -1165,8 +1173,36 @@ class WalletViewModel: ObservableObject { maxPathCount: 10, maxChannelSaturationPowerOfHalf: 2 ) - let hash = try await lightningService.send(bolt11: bolt11, sats: sats, params: params, channelsOnly: true) - return try await watchSend(hash: String(hash)) + let listenerId = "move-to-spark-\(UUID().uuidString)" + let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + addOnEvent(id: listenerId) { sink.yield($0) } + defer { + removeOnEvent(id: listenerId) + sink.finish() + } + let id = try await String(lightningService.send(bolt11: bolt11, sats: sats, params: params, channelsOnly: true)) + return try await withThrowingTaskGroup(of: SettledLightningPayment?.self) { group in + group.addTask { + for await event in events { + switch event { + case let .paymentSuccessful(_, paymentHash, _, feePaidMsat) where paymentHash == id: + return SettledLightningPayment(paymentHash: paymentHash, feePaidSats: (feePaidMsat ?? 0) / 1000) + case let .paymentFailed(paymentId, paymentHash, reason) where paymentHash == id || paymentId == id: + throw AppError(paymentFailureReason: reason) + default: + continue + } + } + return nil + } + group.addTask { + try await Task.sleep(for: timeout) + return nil + } + let outcome = try await group.next() ?? nil + group.cancelAll() + return outcome + } } /// Observes task cancellation so `waitForLightningPayment`'s task group can unwind after its diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift index e06db93f6..a8fdb43c8 100644 --- a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -20,6 +20,8 @@ struct MoveToSparkView: View { confirm(quote: quote, isMoving: isMoving) case let .done(amountSats, feeSats, closeChannel, hasRest): done(amountSats: amountSats, feeSats: feeSats, closeChannel: closeChannel, hasRest: hasRest) + case let .inFlight(amountSats): + inFlight(amountSats: amountSats) case let .tooLarge(amountSats, smallerSats): tooLarge(amountSats: amountSats, smallerSats: smallerSats) case let .failed(failure): @@ -121,6 +123,21 @@ struct MoveToSparkView: View { } } + /// While the payment is in flight its amount may still return to the channel, so closing is not offered yet. + private func inFlight(amountSats: UInt64) -> some View { + VStack(alignment: .leading, spacing: 0) { + header(title: t("lightning__move_to_spark__done_title"), text: t("lightning__move_to_spark__in_flight_text")) + .padding(.bottom, 32) + MoveToSparkAmountRow(label: t("lightning__move_to_spark__amount"), sats: amountSats) + .accessibilityIdentifier("MoveToSparkMoved") + illustration("check") + CustomButton(title: t("common__ok")) { + navigation.navigateBack() + } + .accessibilityIdentifier("MoveToSparkInFlightOk") + } + } + private func tooLarge(amountSats: UInt64, smallerSats: UInt64?) -> some View { let next = smallerSats == nil ? t("lightning__move_to_spark__too_large_none") : t("lightning__move_to_spark__too_large_smaller") let amount = t("lightning__move_to_spark__too_large_text", variables: ["amount": currency.primaryAmountText(sats: amountSats)]) From 118b5e1ed027b4e55638b59384942a51cd23ac75 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:24:19 +0100 Subject: [PATCH 108/378] fix: show the spark exit savings fee error without fee rates --- .../Settings/Advanced/SparkExitViews.swift | 51 ++++++++++++++++--- BitkitTests/SparkExitReviewTests.swift | 25 +++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index c8db815a8..5be5972e9 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -1,3 +1,4 @@ +import BitkitCore import BreezSdkSpark import SwiftUI @@ -264,18 +265,52 @@ struct SparkExitFundingView: View { } private func estimateSavingsFee() async { - guard let address = exit.fundingAddress, fundingSats > 0, - let feeRates = await feeEstimatesManager.getEstimates(refresh: true) - else { return } + let wallet = wallet + let feeEstimatesManager = feeEstimatesManager + let priced = await Self.priceSavingsFee( + address: exit.fundingAddress, + fundingSats: fundingSats, + estimates: { await feeEstimatesManager.getEstimates(refresh: true) }, + calculate: { try await wallet.calculateTotalFee(address: $0, amountSats: $1, satsPerVByte: $2) } + ) + switch priced { + case .unavailable: + break + case .failed: + hasSavingsFeeFailed = true + case let .priced(rate, feeSats): + exit.savingsFeeRate = rate + exit.savingsFeeSats = feeSats + savingsFeeSats = feeSats + } + } + + enum SavingsFee: Equatable { + /// Nothing to price: no funding address or amount yet. + case unavailable + case failed + case priced(rate: UInt32, feeSats: UInt64) + } + + /// What savings pays to send the fee funding at the fast rate. + nonisolated static func priceSavingsFee( + address: String?, + fundingSats: UInt64, + estimates: () async -> FeeRates?, + calculate: (String, UInt64, UInt32) async throws -> UInt64 + ) async -> SavingsFee { + guard let address, fundingSats > 0 else { return .unavailable } + // Without rates there is no fee to confirm: Pay from savings shows its error rather than loading for good. + guard let feeRates = await estimates() else { + Logger.warn("Failed to read fee rates for the Spark exit funding from savings", context: "SparkExitFundingView") + return .failed + } let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) do { - let fee = try await wallet.calculateTotalFee(address: address, amountSats: fundingSats, satsPerVByte: rate) - exit.savingsFeeRate = rate - exit.savingsFeeSats = fee - savingsFeeSats = fee + return try await .priced(rate: rate, feeSats: calculate(address, fundingSats, rate)) } catch { Logger.warn("Failed to price the Spark exit funding from savings: \(error)", context: "SparkExitFundingView") - hasSavingsFeeFailed = true + return .failed } } } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 0a886a0d3..9b06434ae 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import BreezSdkSpark import XCTest @@ -634,6 +635,30 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(settled, ["transfer-2", "transfer-1"]) } + /// Fee rates that cannot be read fail the savings price, so Pay from savings shows its error instead of loading. + func testSavingsFeeFailsWithoutFeeRates() async { + let rates = FeeRates(fast: 9, mid: 6, slow: 1) + func price(address: String? = "bcrt1qfunding", fundingSats: UInt64 = 20000, rates: FeeRates?) async + -> SparkExitFundingView.SavingsFee + { + await SparkExitFundingView.priceSavingsFee( + address: address, + fundingSats: fundingSats, + estimates: { rates }, + calculate: { _, _, _ in 300 } + ) + } + + let noRates = await price(rates: nil) + XCTAssertEqual(noRates, .failed) + let noAddress = await price(address: nil, rates: rates) + XCTAssertEqual(noAddress, .unavailable) + let noFunding = await price(fundingSats: 0, rates: rates) + XCTAssertEqual(noFunding, .unavailable) + let priced = await price(rates: rates) + XCTAssertEqual(priced, .priced(rate: TransactionSpeed.fast.getFeeRate(from: rates), feeSats: 300)) + } + /// Savings never pays more than the funding the user confirmed; the rest is asked for on Progress once the payment /// confirms. func testSavingsPaysAtMostTheConfirmedFundingAndProgressAsksForTheRest() throws { From 8e5a27328657f8492cb3519ae78d0630ac7d58e4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:32:48 +0100 Subject: [PATCH 109/378] fix: read a disconnected spark's history as unavailable While Spark runs here but isn't connected, its payment history read as no payments, so after a restart an older failed channel attempt could settle a Paykit proof or QuickPay's reserved budget over a Spark retry that was still pending or had paid. A disconnected Spark's history is now unavailable, distinct from empty, so both reconciliations keep the proof and the budget until Spark's outcome is known. Where Spark doesn't run, the history still reads as empty. --- Bitkit/Services/LightningService.swift | 19 +++++++++++++------ BitkitTests/ChannelFreeRailTests.swift | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index a33f410ee..3c9084043 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -42,6 +42,9 @@ enum ChannelFreeRailFees { /// The channel-free rail refused a payment before anything left the wallet: the rail was not connected, could not /// prepare the payment, or its fee was over the caller's limit. A Paykit request or allowance can then end as failed. +/// The rail runs here but isn't connected, so its payments are unknown rather than none. +struct ChannelFreeRailUnavailable: Error {} + struct ChannelFreeRailSendRefused: LocalizedError { let reason: String var underlying: Error? @@ -1149,10 +1152,7 @@ class LightningService: NodeLifecycleService { /// Nil while the rail's payments can't be read: listing them failed, or the rail runs here but isn't connected. private func readableRailPayments() async -> [ChannelFreeRailPayment]? { - if let rail = channelFreeRail { - return try? await rail.lightningPayments() - } - return await channelFreeRailSupported() ? nil : [] + try? await channelFreeRailPayments() } private static func pendingDuplicate(paymentHash: String?) -> NodeError { @@ -1733,9 +1733,16 @@ extension LightningService { return isStaleFailure(paymentHash: hash, from: rail) } - /// The rail's Lightning payments, none without a rail; throws when the rail could not list them. + /// The rail's Lightning payments, none where the rail doesn't run; throws when the rail could not list them, or runs + /// here but isn't connected, so a payment it may hold is never read as one it never made. func channelFreeRailPayments() async throws -> [ChannelFreeRailPayment] { - try await channelFreeRail?.lightningPayments() ?? [] + if let rail = channelFreeRail { + return try await rail.lightningPayments() + } + if await channelFreeRailSupported() { + throw ChannelFreeRailUnavailable() + } + return [] } func deliverEvent(_ event: Event, onEvent: ((Event) -> Void)?) async -> OnchainActivityCompletion? { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 30dea9703..83d3561bd 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -276,6 +276,29 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } + func testSparkHistoryReadsAsUnavailableWhileSparkIsDisconnected() async { + Bitkit.LightningService.shared.channelFreeRail = nil + + do { + _ = try await Bitkit.LightningService.shared.channelFreeRailPayments() + XCTFail("A disconnected Spark must not read as holding no payments") + } catch {} + let proof = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: "out") + guard case .unavailable = proof else { return XCTFail("Expected an unavailable proof, got \(proof)") } + // After a restart an older failed channel attempt must not settle a Spark retry Spark can't list yet. + XCTAssertEqual(PaykitLightningPaymentProofLookup.combined(ldk: .failed, rail: proof, latestAttempt: nil), .unavailable) + } + + func testSparkHistoryReadsAsEmptyWhereSparkDoesNotRun() async throws { + let service = Bitkit.LightningService.shared + service.channelFreeRail = nil + service.channelFreeRailSupported = { false } + + let payments = try await service.channelFreeRailPayments() + + XCTAssertTrue(payments.isEmpty) + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) From f4ca8e67a6e39f3d19b95a0f1e9cf03a7b19fb90 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:33:58 +0100 Subject: [PATCH 110/378] fix: hold spark exit ticks until its start settles --- Bitkit/Managers/SparkExitManager.swift | 14 ++++++++++++-- BitkitTests/SparkExitReviewTests.swift | 9 +++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 13dff8f00..8c4589f1c 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -53,6 +53,9 @@ final class SparkExitManager { @ObservationIgnored var savingsFeeSats: UInt64 = 0 @ObservationIgnored private var walletIndex = 0 @ObservationIgnored private var isTicking = false + /// The exit a start is still settling: no tick or background run drives it until its optimization wait, requote + /// and reconnect end, since optimization may still swap the leaves it exits. + @ObservationIgnored private var startingCreatedAt: Date? @ObservationIgnored private(set) var pendingDestination: String? @ObservationIgnored private var pendingFeeRate: UInt64? @ObservationIgnored private(set) var pendingFundingIndex: UInt32? @@ -331,7 +334,7 @@ final class SparkExitManager { Self.scheduleBackgroundRuns() } } - guard !isTicking, let current = record, current.needsDriving, let chain else { return } + guard !isTicking, let current = record, Self.canDrive(current, startingCreatedAt: startingCreatedAt), let chain else { return } isTicking = true defer { isTicking = false } let store = store @@ -648,6 +651,8 @@ final class SparkExitManager { UserDefaults.standard.set(walletIndex, forKey: Self.walletIndexKey) UserDefaults.standard.set(Int(fundingIndex) + 1, forKey: fundingIndexKey) pendingFundingIndex = nil + startingCreatedAt = record.createdAt + defer { startingCreatedAt = nil } self.record = record SparkManager.shared.detachForExit() // Reconnecting cuts an optimization short, which would leave its counter-swap unclaimed for a while. @@ -776,6 +781,11 @@ final class SparkExitManager { return requoted } + /// Whether a tick may drive the exit now: not while its start still settles it (`startingCreatedAt`). + nonisolated static func canDrive(_ record: SparkExitRecord, startingCreatedAt: Date?) -> Bool { + record.needsDriving && record.createdAt != startingCreatedAt + } + /// Savings pays at most the funding the user confirmed: a higher fresh amount goes through the shortfall flow on /// Progress, which asks for the rest. nonisolated static func savingsFundingSats(confirmed: SparkExitQuote, fresh: SparkExitQuote) -> UInt64 { @@ -886,7 +896,7 @@ final class SparkExitManager { while isTicking, !Task.isCancelled, Date() < deadline { try? await Task.sleep(nanoseconds: 250_000_000) } - guard !isTicking, let chain else { return .init(record: record) } + guard !isTicking, startingCreatedAt == nil, let chain else { return .init(record: record) } let wasActive = record?.isActive == true isTicking = true defer { isTicking = false } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 9b06434ae..5e7d52c8b 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -635,6 +635,15 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(settled, ["transfer-2", "transfer-1"]) } + /// No tick drives an exit while its start still settles its leaves: the optimization wait, the requote and the + /// reconnect. + func testNoTickDrivesAnExitWhileItsStartSettles() { + let record = SparkExitFixtures.record() + XCTAssertFalse(SparkExitManager.canDrive(record, startingCreatedAt: record.createdAt)) + XCTAssertTrue(SparkExitManager.canDrive(record, startingCreatedAt: nil)) + XCTAssertTrue(SparkExitManager.canDrive(record, startingCreatedAt: record.createdAt.addingTimeInterval(-60))) + } + /// Fee rates that cannot be read fail the savings price, so Pay from savings shows its error instead of loading. func testSavingsFeeFailsWithoutFeeRates() async { let rates = FeeRates(fast: 9, mid: 6, slow: 1) From f212534c1735612bb9d2c8c2724be796d43f058b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:34:08 +0100 Subject: [PATCH 111/378] fix: take lost spark exit leaves out of the transfer --- Bitkit/Services/SparkExit/SparkExitFunding.swift | 6 +++--- BitkitTests/SparkExitTests.swift | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index f740dba23..b1df81e5a 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -604,8 +604,8 @@ enum SparkExitMath { } /// What the exit still brings to savings: what it expects, less what already reached savings, less the first fee - /// funding nobody paid yet (the expected amount counts it as coming back). Only confirmed steps count as reached: - /// the sweep is in the plan from its first build. + /// funding nobody paid yet (the expected amount counts it as coming back), less leaves an older state claimed, + /// which never arrive. Only confirmed steps count as reached: the sweep is in the plan from its first build. static func inTransferSats(_ record: SparkExitRecord, fundingSeenSats: UInt64) -> UInt64 { let confirmed = (record.recovery?.response.transactions ?? []).filter { guard case .confirmed = $0.status else { return false } @@ -617,7 +617,7 @@ enum SparkExitMath { if case let .awaitingFunding(requiredSats) = record.phase, record.fundingPaymentTxid == nil { unpaid = requiredSats.saturatingSub(max(record.fundedSats, fundingSeenSats)) } - return record.expectedSats.saturatingSub(received).saturatingSub(unpaid) + return record.expectedSats.saturatingSub(received).saturatingSub(unpaid).saturatingSub(record.lostSats) } /// What the Spark balance holds beyond the exit's leaves: value that reached Spark after the quote, such as a diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 8c24d870a..d96223160 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1216,6 +1216,7 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.generation, 2, "the build that found the lost leaf is not adopted") XCTAssertEqual(record.lostLeaves, [.init(leafId: "leaf-1", valueSats: 100_000)]) XCTAssertEqual(record.lostSats, 100_000) + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 50000, "the lost leaf leaves the transfer") XCTAssertTrue(record.isRebuilding) XCTAssertEqual(record.phase, .running) XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).first?.id, "lost-1") From 753136ec9b38d59d3fa22077719662ef74bb3080 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:19:56 +0100 Subject: [PATCH 112/378] test: run spark funding recovery tests on the main actor --- BitkitTests/SparkFundingRecoveryTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/BitkitTests/SparkFundingRecoveryTests.swift b/BitkitTests/SparkFundingRecoveryTests.swift index 7db08162c..b36d35a46 100644 --- a/BitkitTests/SparkFundingRecoveryTests.swift +++ b/BitkitTests/SparkFundingRecoveryTests.swift @@ -2,6 +2,7 @@ import XCTest /// A Spark funding whose outcome was unknown, resolved from the Send sheet's pending screen. +@MainActor final class SparkFundingRecoveryTests: XCTestCase { private let walletId = OnchainSendAttemptService.walletId(index: 0) From c2305be96e3f8378a281a9c0d7afae58fb038b1e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:21:57 +0100 Subject: [PATCH 113/378] fix: show a move to spark that fails after the wait --- Bitkit/Managers/MoveToSparkManager.swift | 20 +++++++- .../Localization/ar.lproj/Localizable.strings | 3 +- .../Localization/ca.lproj/Localizable.strings | 3 +- .../Localization/cs.lproj/Localizable.strings | 3 +- .../Localization/de.lproj/Localizable.strings | 3 +- .../Localization/el.lproj/Localizable.strings | 3 +- .../Localization/en.lproj/Localizable.strings | 3 +- .../es-419.lproj/Localizable.strings | 3 +- .../Localization/es.lproj/Localizable.strings | 3 +- .../Localization/fr.lproj/Localizable.strings | 3 +- .../Localization/it.lproj/Localizable.strings | 3 +- .../Localization/nl.lproj/Localizable.strings | 3 +- .../Localization/pl.lproj/Localizable.strings | 3 +- .../pt-BR.lproj/Localizable.strings | 3 +- .../Localization/pt.lproj/Localizable.strings | 3 +- .../Localization/ru.lproj/Localizable.strings | 3 +- Bitkit/ViewModels/WalletViewModel.swift | 48 +++++++++++++------ .../Settings/Advanced/MoveToSparkView.swift | 4 +- 18 files changed, 84 insertions(+), 33 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index fbf2f309f..fe00a13e3 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -195,7 +195,8 @@ final class MoveToSparkManager { guard let settled = try await wallet.sendOverChannels( bolt11: quote.invoice, sats: quote.amountSats, - maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats) + maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats), + onLateFailure: { [weak self] in self?.lateFailure(amountSats: quote.amountSats) } ) else { state = .inFlight(amountSats: quote.amountSats) return @@ -259,6 +260,23 @@ final class MoveToSparkManager { throw MoveToSparkError.tooLarge(amountSats: amountSats) } + /// A move that was still in flight failed after the wait: its amount stays in the channel, so the screen, if it still + /// shows that move, says it could not move, and a toast tells the user wherever they are. + private func lateFailure(amountSats: UInt64) { + Logger.warn("Move to Spark of '\(amountSats)' sats failed after the wait", context: "MoveToSpark") + if state == .inFlight(amountSats: amountSats) { + state = .failed(.unknown) + } + guard !ToastWindowManager.areToastsDisabled() else { return } + Haptics.notify(.error) + ToastWindowManager.shared.showToast(Toast( + type: .error, + title: t("wallet__toast_payment_failed_title"), + description: t("wallet__payment_failed_description"), + accessibilityIdentifier: "PaymentFailedToast" + )) + } + /// Whether the payment failed for want of a route that carries the amount, so a smaller one may route. private static func isNoRoute(_ error: Error) -> Bool { let appError = error as? AppError diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index aae891022..6e0cc2ef8 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1670,4 +1670,5 @@ "wallet__balance_updating" = "جارٍ تحديث الرصيد…"; "other__error_keychain" = "مشكلة في الاتصال بالبيانات"; "other__error_keychain_msg" = "تعذر تحميل المفتاح الأساسي من سلسلة المفاتيح."; -"lightning__move_to_spark__in_flight_text" = "الدفعة في طريقها وستظهر في Spark بمجرد إتمامها. ثم أغلق الاتصال الفارغ لإعادة احتياطيه إلى المدخرات."; +"lightning__move_to_spark__in_flight_text" = "الدفعة في طريقها وستظهر في Spark عند اكتمالها. إذا فشلت، يبقى المبلغ في اتصالك."; +"lightning__move_to_spark__in_flight_title" = "جارٍ النقل إلى\nSpark"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 54186d84e..fd1b21d7f 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1854,4 +1854,5 @@ "wallet__balance_updating" = "S’està actualitzant el saldo…"; "other__error_keychain" = "Problema de connexió de dades"; "other__error_keychain_msg" = "No s'ha pogut carregar la clau principal del clauer."; -"lightning__move_to_spark__in_flight_text" = "El pagament està en camí i apareixerà a Spark quan s'hagi liquidat. Després tanca la connexió buida per tornar-ne la reserva als Estalvis."; +"lightning__move_to_spark__in_flight_text" = "El pagament està en camí i apareixerà a Spark quan es liquidi. Si falla, l'import es queda a la teva connexió."; +"lightning__move_to_spark__in_flight_title" = "Movent a\nSpark"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index c35ca95b5..9ffcb7f0c 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1991,4 +1991,5 @@ "subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; "wallet__balance_updating" = "Aktualizace zůstatku…"; -"lightning__move_to_spark__in_flight_text" = "Platba je na cestě a ve Spark se zobrazí po vypořádání. Poté uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor."; +"lightning__move_to_spark__in_flight_text" = "Platba je na cestě a ve Sparku se zobrazí po vypořádání. Pokud selže, částka zůstane ve vašem připojení."; +"lightning__move_to_spark__in_flight_title" = "Přesouvání do\nSpark"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 5e49d82cf..2b4d10132 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1988,4 +1988,5 @@ "subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; "wallet__balance_updating" = "Kontostand wird aktualisiert…"; -"lightning__move_to_spark__in_flight_text" = "Die Zahlung ist unterwegs und erscheint in Spark, sobald sie abgeschlossen ist. Schließen Sie dann die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben."; +"lightning__move_to_spark__in_flight_text" = "Die Zahlung ist unterwegs und erscheint in Spark, sobald sie abgeschlossen ist. Schlägt sie fehl, bleibt der Betrag in deiner Verbindung."; +"lightning__move_to_spark__in_flight_title" = "Wird verschoben zu\nSpark"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 3db8f8eac..993ba91ba 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1725,4 +1725,5 @@ "wallet__balance_updating" = "Ενημέρωση υπολοίπου…"; "other__error_keychain" = "Πρόβλημα Σύνδεσης Δεδομένων"; "other__error_keychain_msg" = "Δεν ήταν δυνατή η φόρτωση του κύριου κλειδιού από την κλειδοθήκη."; -"lightning__move_to_spark__in_flight_text" = "Η πληρωμή βρίσκεται σε εξέλιξη και θα εμφανιστεί στο Spark μόλις διακανονιστεί. Έπειτα κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις."; +"lightning__move_to_spark__in_flight_text" = "Η πληρωμή είναι καθ' οδόν και θα εμφανιστεί στο Spark μόλις ολοκληρωθεί. Αν αποτύχει, το ποσό παραμένει στη σύνδεσή σας."; +"lightning__move_to_spark__in_flight_title" = "Μεταφέρεται στο\nSpark"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 80d489fdc..df2b54b16 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -2080,4 +2080,5 @@ "usdt__receive_copied" = "Address copied to clipboard.\nMake sure USDT is only sent on the selected network."; "wallet__balance_updating" = "Updating balance…"; -"lightning__move_to_spark__in_flight_text" = "The payment is on its way and shows in Spark once it settles. Then close the emptied connection to return its reserve to Savings."; +"lightning__move_to_spark__in_flight_text" = "The payment is on its way and shows in Spark once it settles. If it fails, the amount stays in your connection."; +"lightning__move_to_spark__in_flight_title" = "Moving to\nSpark"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 2c6fae145..86c9b6dcd 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1994,4 +1994,5 @@ "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; "wallet__balance_updating" = "Actualizando saldo…"; -"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; +"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se liquide. Si falla, el importe se queda en tu conexión."; +"lightning__move_to_spark__in_flight_title" = "Moviendo a\nSpark"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index d62752da1..52bf63369 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1873,4 +1873,5 @@ "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; "wallet__balance_updating" = "Actualizando saldo…"; -"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro."; +"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se liquide. Si falla, el importe se queda en tu conexión."; +"lightning__move_to_spark__in_flight_title" = "Moviendo a\nSpark"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index ac76e58e4..1996093b8 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -2002,4 +2002,5 @@ "subscriptions__allowance_auto_paid" = "Payé automatiquement"; "wallet__balance_updating" = "Mise à jour du solde…"; -"lightning__move_to_spark__in_flight_text" = "Le paiement est en route et apparaîtra sur Spark une fois réglé. Fermez ensuite la connexion vidée pour rendre sa réserve à Épargnes."; +"lightning__move_to_spark__in_flight_text" = "Le paiement est en route et apparaîtra dans Spark une fois réglé. S'il échoue, le montant reste dans votre connexion."; +"lightning__move_to_spark__in_flight_title" = "Déplacement vers\nSpark"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index c1894f12d..fe8e4de00 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1961,4 +1961,5 @@ "subscriptions__allowance_auto_paid" = "Pagato automaticamente"; "wallet__balance_updating" = "Aggiornamento del saldo…"; -"lightning__move_to_spark__in_flight_text" = "Il pagamento è in arrivo e compare su Spark una volta completato. Poi chiudi la connessione svuotata per restituire la sua riserva ai Risparmi."; +"lightning__move_to_spark__in_flight_text" = "Il pagamento è in corso e apparirà in Spark una volta completato. Se non va a buon fine, l'importo resta nella tua connessione."; +"lightning__move_to_spark__in_flight_title" = "Spostamento su\nSpark"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 0aa06eb94..d9ea19e60 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1996,4 +1996,5 @@ "subscriptions__allowance_auto_paid" = "Automatisch betaald"; "wallet__balance_updating" = "Saldo bijwerken…"; -"lightning__move_to_spark__in_flight_text" = "De betaling is onderweg en verschijnt in Spark zodra ze is afgerond. Sluit daarna de lege verbinding om de reserve terug te geven aan uw spaargeld."; +"lightning__move_to_spark__in_flight_text" = "De betaling is onderweg en verschijnt in Spark zodra die is afgerond. Mislukt die, dan blijft het bedrag in je verbinding."; +"lightning__move_to_spark__in_flight_title" = "Verplaatsen naar\nSpark"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 9ff152310..b7ebfbad1 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -2001,4 +2001,5 @@ "subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; "wallet__balance_updating" = "Aktualizowanie salda…"; -"lightning__move_to_spark__in_flight_text" = "Płatność jest w drodze i pojawi się w Spark po rozliczeniu. Następnie zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności."; +"lightning__move_to_spark__in_flight_text" = "Płatność jest w drodze i pojawi się w Spark po rozliczeniu. Jeśli się nie powiedzie, kwota pozostanie w Twoim połączeniu."; +"lightning__move_to_spark__in_flight_title" = "Przenoszenie do\nSpark"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 683fe837f..72f035ef1 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -2002,4 +2002,5 @@ "subscriptions__allowance_auto_paid" = "Pago automaticamente"; "wallet__balance_updating" = "Atualizando saldo…"; -"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark assim que for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; +"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparecerá no Spark quando for liquidado. Se falhar, o valor fica na sua conexão."; +"lightning__move_to_spark__in_flight_title" = "Movendo para o\nSpark"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 1a0885e50..fd50b6ad4 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1650,4 +1650,5 @@ "wallet__balance_updating" = "A atualizar o saldo…"; "other__error_keychain" = "Problema de Ligação de Dados"; "other__error_keychain_msg" = "Não foi possível carregar a chave principal do porta-chaves."; -"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparece no Spark quando for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança."; +"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparecerá no Spark quando for liquidado. Se falhar, o valor fica na sua conexão."; +"lightning__move_to_spark__in_flight_title" = "Movendo para o\nSpark"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index ba41cd47c..e77cd050a 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1982,4 +1982,5 @@ "subscriptions__allowance_auto_paid" = "Оплачено автоматически"; "wallet__balance_updating" = "Обновление баланса…"; -"lightning__move_to_spark__in_flight_text" = "Платёж в пути и появится в Spark после завершения. Затем закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения."; +"lightning__move_to_spark__in_flight_text" = "Платеж в пути и появится в Spark после завершения. Если он не пройдет, сумма останется в вашем подключении."; +"lightning__move_to_spark__in_flight_title" = "Перенос в\nSpark"; diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 06a32158c..26ee6bb3f 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1159,12 +1159,14 @@ class WalletViewModel: ObservableObject { /// Pays from the channels only, with the routing fee capped at `maxFeeMsat`, and waits for the payment to settle. /// Pays `bolt11` from the channels only and waits up to `timeout` for its outcome; `nil` means it is still in flight. /// It listens before sending, so an outcome LDK reports before the send returns is not missed, and it matches a - /// failure by payment id as well as by hash, since LDK can report one without its hash. + /// failure by payment id as well as by hash, since LDK can report one without its hash. After a timeout it keeps + /// listening, and a failure that arrives later calls `onLateFailure`. func sendOverChannels( bolt11: String, sats: UInt64, maxFeeMsat: UInt64, - timeout: Duration = .seconds(120) + timeout: Duration = .seconds(120), + onLateFailure: @escaping @MainActor () -> Void = {} ) async throws -> SettledLightningPayment? { // LDK's default route parameters, with the routing fee capped. let params = RouteParametersConfig( @@ -1176,32 +1178,48 @@ class WalletViewModel: ObservableObject { let listenerId = "move-to-spark-\(UUID().uuidString)" let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) addOnEvent(id: listenerId) { sink.yield($0) } - defer { + let id: String + do { + id = try await String(lightningService.send(bolt11: bolt11, sats: sats, params: params, channelsOnly: true)) + } catch { removeOnEvent(id: listenerId) sink.finish() + throw error } - let id = try await String(lightningService.send(bolt11: bolt11, sats: sats, params: params, channelsOnly: true)) - return try await withThrowingTaskGroup(of: SettledLightningPayment?.self) { group in - group.addTask { + return try await withCheckedThrowingContinuation { continuation in + var isAnswered = false + Task { @MainActor [weak self] in + defer { + self?.removeOnEvent(id: listenerId) + sink.finish() + } for await event in events { + let outcome: Result switch event { case let .paymentSuccessful(_, paymentHash, _, feePaidMsat) where paymentHash == id: - return SettledLightningPayment(paymentHash: paymentHash, feePaidSats: (feePaidMsat ?? 0) / 1000) + outcome = .success(SettledLightningPayment(paymentHash: paymentHash, feePaidSats: (feePaidMsat ?? 0) / 1000)) case let .paymentFailed(paymentId, paymentHash, reason) where paymentHash == id || paymentId == id: - throw AppError(paymentFailureReason: reason) + outcome = .failure(AppError(paymentFailureReason: reason)) default: continue } + if isAnswered { + if case .failure = outcome { + onLateFailure() + } + } else { + isAnswered = true + continuation.resume(with: outcome) + } + return } - return nil } - group.addTask { - try await Task.sleep(for: timeout) - return nil + Task { @MainActor in + try? await Task.sleep(for: timeout) + guard !isAnswered else { return } + isAnswered = true + continuation.resume(returning: nil) } - let outcome = try await group.next() ?? nil - group.cancelAll() - return outcome } } diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift index a8fdb43c8..a680cfa91 100644 --- a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -126,11 +126,11 @@ struct MoveToSparkView: View { /// While the payment is in flight its amount may still return to the channel, so closing is not offered yet. private func inFlight(amountSats: UInt64) -> some View { VStack(alignment: .leading, spacing: 0) { - header(title: t("lightning__move_to_spark__done_title"), text: t("lightning__move_to_spark__in_flight_text")) + header(title: t("lightning__move_to_spark__in_flight_title"), text: t("lightning__move_to_spark__in_flight_text")) .padding(.bottom, 32) MoveToSparkAmountRow(label: t("lightning__move_to_spark__amount"), sats: amountSats) .accessibilityIdentifier("MoveToSparkMoved") - illustration("check") + illustration("lightning") CustomButton(title: t("common__ok")) { navigation.navigateBack() } From 3a310c4fa9ad38f4d0e0aa2f16d533966a498f47 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:22:28 +0100 Subject: [PATCH 114/378] fix: write the spark journal before its request proof starts --- Bitkit/Managers/SparkManager.swift | 31 +++++++++-- Bitkit/Services/PaykitRouteService.swift | 12 ++--- .../Wallets/Send/SendConfirmationView.swift | 53 +++++++++++-------- BitkitTests/PaykitRouteTests.swift | 36 ++++++++++--- 4 files changed, 93 insertions(+), 39 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 51f367de6..62e840514 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -537,7 +537,6 @@ final class SparkManager: ChannelFreeLightningRail { // Withdrawals Spark already lists for this address and amount belong to earlier attempts, so recovery never // takes one of them for this attempt. earlierPayments = try await sdk.listPayments(request: ListPaymentsRequest(sortAscending: false)).payments - try await beforeSend() } catch { throw SparkWithdrawalNotSent(underlying: error) } @@ -550,10 +549,19 @@ final class SparkManager: ChannelFreeLightningRail { tags: tags, earlierPaymentIds: Self.withdrawalIds(to: address, amountSats: amountSats, in: earlierPayments) ) - Self.pendingContactWithdrawal = pending - Self.lastContactWithdrawalResolution = nil isWithdrawingToContact = true defer { isWithdrawingToContact = false } + let journaled = pending + try await Self.startJournaledWithdrawal( + writeJournal: { + Self.pendingContactWithdrawal = journaled + Self.lastContactWithdrawalResolution = nil + }, + beforeSend: beforeSend, + closeUnsent: { + await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) + } + ) let response: SendPaymentResponse do { response = try await sdk.sendPayment(request: SendPaymentRequest( @@ -610,6 +618,23 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Writes the attempt's journal before `beforeSend` starts the request's proof, so a kill between the two leaves a + /// journal that recovery closes as unsent once it expires, releasing the proof; a refusal in `beforeSend` closes it + /// at once, releasing what it started. + static func startJournaledWithdrawal( + writeJournal: () -> Void, + beforeSend: () async throws -> Void, + closeUnsent: () async -> Void + ) async throws { + writeJournal() + do { + try await beforeSend() + } catch { + await closeUnsent() + throw SparkWithdrawalNotSent(underlying: error) + } + } + nonisolated static let contactWithdrawalExpirySecs: UInt64 = 600 /// An attempt Spark never returned nor listed is taken as unsent only after the expiry. diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index c8b33dfd1..aa05e71fd 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -149,14 +149,14 @@ enum PaykitRouteService { error is SparkWithdrawalNotSent && openJournalRequest != proofRequest } - /// Starts the on-chain proof of a request a Spark leg pays, in a direct send's order: the proof is prepared, then the - /// request (its checks and private list consumption), then the proof is marked started. Any failure releases the - /// private payment list as unsent, and one after the proof was prepared cancels it, so the request can be paid again. + /// Prepares the on-chain proof of a request a Spark leg pays, in a direct send's order: the proof is prepared, then + /// the request (its checks and private list consumption). Spark's withdrawal marks the proof started once its journal + /// is written. Any failure releases the private payment list as unsent, and one after the proof was prepared cancels + /// it, so the request can be paid again. static func startLegRequestProof( prepareProof: () async throws -> Void, prepareRequest: () async throws -> Void, paymentIdentity: () async throws -> String, - markStarted: (String) async throws -> Void, cancelProof: () async -> Void, releasePaymentList: () async -> Void ) async throws -> String { @@ -168,9 +168,7 @@ enum PaykitRouteService { } do { try await prepareRequest() - let identity = try await paymentIdentity() - try await markStarted(identity) - return identity + return try await paymentIdentity() } catch { await cancelProof() await releasePaymentList() diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index e6f4eb617..44d3df6bc 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1488,6 +1488,7 @@ struct SendConfirmationView: View { let context = app.contactPaymentContext let request = context?.incomingPaymentRequest var proofRequest: SparkContactWithdrawal.PaykitRequest? + var markProofStarted: (() async throws -> Void)? do { if let request { guard let privateContext = context?.privatePaymentContext else { throw PaykitPaymentRequestError.requestUnavailable } @@ -1503,24 +1504,25 @@ struct SendConfirmationView: View { }, prepareRequest: { try await prepareIncomingPaymentRequest() }, paymentIdentity: { try await PaykitPaymentProofService.shared.onchainPaymentIdentity(requestId: request.id) }, - markStarted: { identity in - // The proof keeps the list version this send consumed, so dropping it as unsent gives the list back. - let previousVersion = await PrivatePaykitService.shared.consumedPaymentListVersion( - publicKey: request.counterparty, - attemptId: context?.id - ) - try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( - request, - address: leg.endpoint.value, - paymentIdentity: identity, - privatePaymentListVersion: privateContext.paymentListVersion, - previousPrivatePaymentListVersion: previousVersion - ) - }, cancelProof: { await PaykitPaymentProofService.shared.cancelPreparation(request) }, releasePaymentList: { await context?.resolvePrivatePaymentListConsumption(.definitePreBroadcastFailure) } ) proofRequest = SparkContactWithdrawal.PaykitRequest(id: request.id, identity: identity) + // Spark's withdrawal runs this once its journal is written, so recovery owns the started proof. + markProofStarted = { + // The proof keeps the list version this send consumed, so dropping it as unsent gives the list back. + let previousVersion = await PrivatePaykitService.shared.consumedPaymentListVersion( + publicKey: request.counterparty, + attemptId: context?.id + ) + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( + request, + address: leg.endpoint.value, + paymentIdentity: identity, + privatePaymentListVersion: privateContext.paymentListVersion, + previousPrivatePaymentListVersion: previousVersion + ) + } } let txid: String do { @@ -1542,13 +1544,18 @@ struct SendConfirmationView: View { await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: $0, outcome: $1, transactionId: $2) } ) { beforeSend in - try await PaykitRouteService.pay( - leg, - contact: context?.publicKey, - request: proofRequest, - tags: tagManager.selectedTagsArray, - beforeSend: beforeSend - ) + try await withoutActuallyEscaping(beforeSend) { beforeSend in + try await PaykitRouteService.pay( + leg, + contact: context?.publicKey, + request: proofRequest, + tags: tagManager.selectedTagsArray, + beforeSend: { + try await markProofStarted?() + try await beforeSend() + } + ) + } } } catch { // Refused before Spark's send: nothing left, so this call's started proof goes. After it, the journal settles it. @@ -1560,6 +1567,10 @@ struct SendConfirmationView: View { ) { await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: proofRequest.id, identity: proofRequest.identity) } + if refusedBeforeSend, let request { + // A refusal before the proof was marked started leaves it prepared; started proofs stay as they are. + await PaykitPaymentProofService.shared.cancelPreparation(request) + } await context?.resolvePrivatePaymentListConsumption(refusedBeforeSend ? .definitePreBroadcastFailure : .uncertain) Logger.error("Service route payment failed: \(error)", context: "SendConfirmation") navigationPath.append(Self.serviceLegFailureRoute( diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 52b06e11e..1fc44b714 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -324,6 +324,32 @@ final class PaykitRouteTests: XCTestCase { XCTAssertNil(SendPendingScreen.sparkWithdrawalRoute(for: unsent, attempt: other), "Another withdrawal's close leaves the screen") } + @MainActor + func testAWithdrawalsJournalIsWrittenBeforeItsProofStarts() async { + struct Refused: Error {} + var steps: [String] = [] + + try? await SparkManager.startJournaledWithdrawal( + writeJournal: { steps.append("journal") }, + beforeSend: { steps.append("proof started") }, + closeUnsent: { steps.append("closed") } + ) + XCTAssertEqual(steps, ["journal", "proof started"], "A kill after the proof starts leaves a journal that releases it") + + steps = [] + do { + try await SparkManager.startJournaledWithdrawal( + writeJournal: { steps.append("journal") }, + beforeSend: { steps.append("proof started"); throw Refused() }, + closeUnsent: { steps.append("closed") } + ) + XCTFail("A refusal before the send must reach the caller") + } catch { + XCTAssertTrue(error is SparkWithdrawalNotSent) + } + XCTAssertEqual(steps, ["journal", "proof started", "closed"], "A refusal closes the journal, releasing what it started") + } + @MainActor func testAPendingSparkSendNeverAdoptsTheNodesOnchainAttempt() { let attempt = SparkWithdrawalAttempt(address: onchain.value, amountSats: 20000, requestId: nil) @@ -341,18 +367,17 @@ final class PaykitRouteTests: XCTestCase { prepareProof: { steps.append("proof") }, prepareRequest: { steps.append("request") }, paymentIdentity: { steps.append("identity"); return "payer" }, - markStarted: { steps.append("started \($0)") }, cancelProof: { steps.append("cancel") }, releasePaymentList: { steps.append("release") } ) XCTAssertEqual(identity, "payer") - XCTAssertEqual(steps, ["proof", "request", "identity", "started payer"], "The request is approved before Spark's payment check") + XCTAssertEqual(steps, ["proof", "request", "identity"], "The request is approved before Spark's payment check") } func testLegThatFailsBeforeSendingFreesTheRequest() async { struct Refused: Error {} - for failing in ["proof", "request", "started"] { + for failing in ["proof", "request"] { var steps: [String] = [] do { _ = try await PaykitRouteService.startLegRequestProof( @@ -367,11 +392,6 @@ final class PaykitRouteTests: XCTestCase { } }, paymentIdentity: { "payer" }, - markStarted: { - _ in if failing == "started" { - throw Refused() - } - }, cancelProof: { steps.append("cancel") }, releasePaymentList: { steps.append("release") } ) From 16c67699000aa26b17edb8da9b6f16edff0a71f5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:03:32 +0100 Subject: [PATCH 115/378] fix: give a recovered spark funding one owner A Spark funding that resolved while Spending Confirm was still open was settled by two paths, the confirm screen and the Send sheet's pending screen, which could disagree on the claim speed and where the user landed. An open Spending Confirm now owns it: it follows the deposit at the speed the user chose and moves to Setting Up, as a swipe would have, and the pending screen only closes. With no confirm screen open, the pending screen still returns home. --- Bitkit/ViewModels/SparkTransferFlow.swift | 9 +++++++++ Bitkit/Views/Transfer/SpendingConfirm.swift | 12 ++++++++---- Bitkit/Views/Wallets/Send/SendPendingScreen.swift | 6 ++++-- BitkitTests/SparkFundingRecoveryTests.swift | 13 +++++++++---- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 08ee5478d..07d2f1332 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -367,6 +367,15 @@ final class SparkTransferFlow { /// A transfer to the Spark deposit address whose outcome was unknown, resolved later from the Send sheet's pending screen. enum SparkFundingRecovery { + /// Set while Spending Confirm is open: it then owns a recovered funding, at the speed the user chose. + @MainActor static var confirmScreenOpen = false + + /// The Send sheet's pending screen goes home only when no open Spending Confirm owns the recovered funding, so one + /// path decides the claim speed and where the user lands. + static func pendingScreenGoesHome(confirmScreenOpen: Bool) -> Bool { + !confirmScreenOpen + } + /// Lists the deposit as a transfer and tells Spark about it, so it shows as pending and is claimed. static func handOff(txid: String, amountSats: UInt64) async { await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txid) diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 990b6f977..73dccce95 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -77,10 +77,14 @@ struct SpendingConfirm: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .offlineOverlay(title: t("lightning__transfer__nav_title")) + .onAppear { SparkFundingRecovery.confirmScreenOpen = true } + .onDisappear { SparkFundingRecovery.confirmScreenOpen = false } .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in - if Self.leavesAfter(resolution, isPaying: isPaying) { - navigation.reset() - } + guard Self.followsRecovered(resolution, isPaying: isPaying), !hideSwipeButton else { return } + // This screen owns the recovered funding: follow it at the chosen speed, as a swipe would have. + flow.onBroadcast(txid: resolution.txid) + hideSwipeButton = true + navigation.navigate(.settingUp) } .task(id: QuoteInputs(amountSats: flow.amountSats, isMaxAmount: flow.isMaxAmount, speed: flow.speed)) { guard !SparkTransferFlow.isRunningInPreview else { return } @@ -168,7 +172,7 @@ struct SpendingConfirm: View { extension SpendingConfirm { /// A Spark funding that resolved on its own while this screen stayed open, after an unknown outcome the user left /// pending, would let the swipe send the transfer again; the swipe's own funding resolves while it is paying. - static func leavesAfter(_ resolution: OnchainSendLocalResolution, isPaying: Bool) -> Bool { + static func followsRecovered(_ resolution: OnchainSendLocalResolution, isPaying: Bool) -> Bool { resolution.isSparkFunding && !isPaying } } diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index f6bc770a6..68438cb3b 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -617,9 +617,11 @@ struct SendPendingScreen: View { onchainAttempt?.status = .accepted onchainAttempt?.localFollowupComplete = true if resolution.isSparkFunding { - // Leave the funding flow, whose confirm screen would offer the sent transfer again. sheets.hideSheet(reason: "Recovered Spark funding") - navigation.reset() + // An open Spending Confirm follows it at the chosen speed; otherwise leave the funding flow for home. + if SparkFundingRecovery.pendingScreenGoesHome(confirmScreenOpen: SparkFundingRecovery.confirmScreenOpen) { + navigation.reset() + } } } diff --git a/BitkitTests/SparkFundingRecoveryTests.swift b/BitkitTests/SparkFundingRecoveryTests.swift index b36d35a46..21aae8bc0 100644 --- a/BitkitTests/SparkFundingRecoveryTests.swift +++ b/BitkitTests/SparkFundingRecoveryTests.swift @@ -42,12 +42,17 @@ final class SparkFundingRecoveryTests: XCTestCase { XCTAssertEqual(handedOff, 0) } - func testSpendingConfirmLeavesOnceItsFundingResolvesOnItsOwn() async throws { + func testSpendingConfirmFollowsItsFundingOnceItResolvesOnItsOwn() async throws { let spark = try await resolvedSend(isSparkFunding: true, txid: String(repeating: "7c", count: 32)) let ordinary = try await resolvedSend(isSparkFunding: false, txid: String(repeating: "8d", count: 32)) - XCTAssertTrue(SpendingConfirm.leavesAfter(spark, isPaying: false)) - XCTAssertFalse(SpendingConfirm.leavesAfter(spark, isPaying: true), "The swipe's own funding goes on to Setting Up") - XCTAssertFalse(SpendingConfirm.leavesAfter(ordinary, isPaying: false)) + XCTAssertTrue(SpendingConfirm.followsRecovered(spark, isPaying: false)) + XCTAssertFalse(SpendingConfirm.followsRecovered(spark, isPaying: true), "The swipe's own funding goes on to Setting Up") + XCTAssertFalse(SpendingConfirm.followsRecovered(ordinary, isPaying: false)) + } + + func testAnOpenSpendingConfirmOwnsTheRecoveredFunding() { + XCTAssertFalse(SparkFundingRecovery.pendingScreenGoesHome(confirmScreenOpen: true), "Spending Confirm follows it at the chosen speed") + XCTAssertTrue(SparkFundingRecovery.pendingScreenGoesHome(confirmScreenOpen: false)) } } From 2798e6eca1344205bd53c4f2c6b39b92ce40bb15 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:03:32 +0100 Subject: [PATCH 116/378] fix: show later on setting up when the transfer waits for confirmations When Instant wasn't available or Spark hadn't seen the deposit yet, Setting Up said the transfer is added after 3 confirmations while its caption still read "Speed: Instant". The caption now reads Later in both cases. --- Bitkit/Views/Transfer/SettingUpView.swift | 10 +++++++--- BitkitTests/SparkFundingRecoveryTests.swift | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift index ecc807d29..cba6e7430 100644 --- a/Bitkit/Views/Transfer/SettingUpView.swift +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -40,12 +40,16 @@ struct SettingUpView: View { /// The speed the user chose, or Later once they chose to wait. var speedText: String { - let isLater = flow.speed == .later || state == .later - return t("lightning__setting_up_speed", variables: [ - "speed": isLater ? t("lightning__spending_confirm__later") : t("fee__instant__title"), + t("lightning__setting_up_speed", variables: [ + "speed": Self.showsLater(speed: flow.speed, state: state) ? t("lightning__spending_confirm__later") : t("fee__instant__title"), ]) } + /// Later too whenever the text says the deposit waits for confirmations. + static func showsLater(speed: SparkTransferSpeed, state: SparkClaimState) -> Bool { + speed == .later || state == .later || state == .unavailable || state == .notSeen + } + var buttonTitle: String { isTransferring ? t("lightning__setting_up_button") : randomOkText } diff --git a/BitkitTests/SparkFundingRecoveryTests.swift b/BitkitTests/SparkFundingRecoveryTests.swift index 21aae8bc0..4a26ff14b 100644 --- a/BitkitTests/SparkFundingRecoveryTests.swift +++ b/BitkitTests/SparkFundingRecoveryTests.swift @@ -55,4 +55,14 @@ final class SparkFundingRecoveryTests: XCTestCase { XCTAssertFalse(SparkFundingRecovery.pendingScreenGoesHome(confirmScreenOpen: true), "Spending Confirm follows it at the chosen speed") XCTAssertTrue(SparkFundingRecovery.pendingScreenGoesHome(confirmScreenOpen: false)) } + + func testSettingUpShowsLaterWheneverTheTextSaysItWaitsForConfirmations() { + for state in [SparkClaimState.later, .unavailable, .notSeen] { + XCTAssertTrue(SettingUpView.showsLater(speed: .instant, state: state), "\(state)") + } + for state in [SparkClaimState.looking, .claiming, .adding, .repriced(feeSats: 1)] { + XCTAssertFalse(SettingUpView.showsLater(speed: .instant, state: state), "\(state)") + } + XCTAssertTrue(SettingUpView.showsLater(speed: .later, state: .looking)) + } } From 9052aa6480e35181e51f1c1e8b3d0aa82bb8dc46 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:22:28 +0100 Subject: [PATCH 117/378] fix: keep the exact amount of a withdrawal spark can't invoice Spark's invoices are whole sats, so an LNURL withdrawal with a part of a sat (a fixed 222 538 msat, say) was invoiced for less than asked, below the endpoint's minimum, and an amount under a sat became an amountless invoice. Such an amount now goes to the channels when they can receive it, at its exact amount, and is otherwise refused with the no-capacity error before the withdrawal is submitted. --- Bitkit/Services/LightningService.swift | 35 ++++++++++++++++++++++---- BitkitTests/ChannelFreeRailTests.swift | 16 ++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 3c9084043..f66bf463e 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -841,11 +841,20 @@ class LightningService: NodeLifecycleService { expirySecs: UInt32 = 3600, isPaykitEndpoint: Bool = false ) async throws -> String { - if let rail = channelFreeRail, - await shouldReceiveWithRail(rail, amountSats: amountMsats.map { $0 / 1000 }, isPaykitEndpoint: isPaykitEndpoint) - { - let amountSats = amountMsats.map { $0 / 1000 }.flatMap { $0 > 0 ? $0 : nil } - return try await rail.receive(amountSats: amountSats, description: description, expirySecs: expirySecs) + if let rail = channelFreeRail { + // Judged on the amount rounded up, so channels that can't take the last part of a sat don't get it. + let railTakes = await shouldReceiveWithRail(rail, amountSats: amountMsats.map { ($0 + 999) / 1000 }, isPaykitEndpoint: isPaykitEndpoint) + switch Self.receiveRoute(amountMsats: amountMsats, railTakes: railTakes) { + case let .rail(amountSats): + return try await rail.receive(amountSats: amountSats, description: description, expirySecs: expirySecs) + case .refused: + throw AppError( + message: t("other__lnurl_withdr_error_no_capacity"), + debugMessage: "Spark invoices are whole sats, and no channel can receive \(amountMsats ?? 0) msat" + ) + case .node: + break + } } guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -873,6 +882,22 @@ class LightningService: NodeLifecycleService { return bolt11.description } + enum ReceiveRoute: Equatable { + case rail(amountSats: UInt64?) + case node + /// Neither rail can receive the exact amount. + case refused + } + + /// Where an invoice for `amountMsats` is made, given whether the channel-free rail takes it. The rail's invoices + /// are whole sats, so an amount with a part of a sat goes to the channels, or is refused when they can't take it, + /// rather than invoicing less than asked. + static func receiveRoute(amountMsats: UInt64?, railTakes: Bool) -> ReceiveRoute { + guard railTakes else { return .node } + guard let amountMsats, amountMsats > 0 else { return .rail(amountSats: nil) } + return amountMsats % 1000 == 0 ? .rail(amountSats: amountMsats / 1000) : .refused + } + /// Receives on the channel-free rail when no ready channel has the inbound capacity for the amount. @MainActor private func shouldReceiveWithRail(_ rail: ChannelFreeLightningRail, amountSats: UInt64?, isPaykitEndpoint: Bool) -> Bool { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 83d3561bd..30fddc3da 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -299,6 +299,22 @@ extension ChannelFreeRailTests { XCTAssertTrue(payments.isEmpty) } + func testAFixedWithdrawalWithAPartOfASatKeepsItsExactAmount() { + typealias Route = Bitkit.LightningService.ReceiveRoute + // A fixed LNURL withdrawal of 222 538 msat: channels that can take it get the exact amount; Spark can't, so + // without such a channel it is refused rather than invoicing 222 000 msat, below the endpoint's minimum. + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 222_538, railTakes: false), Route.node) + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 222_538, railTakes: true), Route.refused) + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 222_000, railTakes: true), Route.rail(amountSats: 222)) + } + + func testAnAmountUnderASatIsNeverInvoicedAsAmountless() { + typealias Route = Bitkit.LightningService.ReceiveRoute + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 500, railTakes: true), Route.refused) + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: 500, railTakes: false), Route.node) + XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: nil, railTakes: true), Route.rail(amountSats: nil)) + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) From 5dfc2ca015bbe897d6a786473097c2584d084e3b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:25:21 +0100 Subject: [PATCH 118/378] fix: ask for fee funding a skipped spark exit leaf's retry needs --- .../Services/SparkExit/SparkExitDriver.swift | 41 ++++++++++++++----- .../Services/SparkExit/SparkExitRecord.swift | 14 ++++++- BitkitTests/SparkExitTests.swift | 29 +++++++++++++ 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index b290f002f..f352f8642 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -104,7 +104,16 @@ struct SparkExitDriver { return try saveIfChanged(record, from: before) } } - try await build(&record, recovering: recovering) + if record.isFundingWaitingLeaves { + // Funding for leaves a partial build skipped: only they are built, now that it arrived. + record.phase = .running + let tip = try? await chain.tipHeight() + if try await !retryWaitingLeaves(&record, recovering: recovering, tip: tip, isFunded: true) { + await finish(&record, phase: .done) + } + } else { + try await build(&record, recovering: recovering) + } } case .running where record.isRebuilding: // The diverged generation is never broadcast again; only a rebuild moves on from here. @@ -329,12 +338,7 @@ struct SparkExitDriver { record.lastBuildAt = nil return } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { - // The SDK names the total all inputs must hold, not what is missing. - var utxos: [EsploraUtxo]? - if let address = record.funding.first?.address { - utxos = try? await chain.utxos(address: address) - } - record.phase = .needsFunding(requiredSats: SparkExitMath.neededFunding(requiredSats: requiredSats, record: record, utxos: utxos)) + await askForFunding(&record, requiredSats: requiredSats) return } guard !built.transactions.isEmpty else { @@ -596,10 +600,25 @@ struct SparkExitDriver { return true } + /// The SDK names the total all inputs must hold, not what is missing. + private func askForFunding(_ record: inout SparkExitRecord, requiredSats: UInt64) async { + var utxos: [EsploraUtxo]? + if let address = record.funding.first?.address { + utxos = try? await chain.utxos(address: address) + } + record.phase = .needsFunding(requiredSats: SparkExitMath.neededFunding(requiredSats: requiredSats, record: record, utxos: utxos)) + } + /// Leaves a build skipped while they may still move (`waitingLeafIds`), prepared again on their own once the rest of /// the exit is done: at once, then at Glow's doubling gaps (`isRetryDue`), until they build or about a week passed - /// (`releaseBlocks`). True while the exit keeps waiting for them, false once it can end. - private func retryWaitingLeaves(_ record: inout SparkExitRecord, recovering: SparkExitRecovering, tip: UInt32?) async throws -> Bool { + /// (`releaseBlocks`). One that needs more fee funding asks for it and is built as soon as it arrives (`isFunded`). + /// True while the exit keeps waiting for them, false once it can end. + private func retryWaitingLeaves( + _ record: inout SparkExitRecord, + recovering: SparkExitRecovering, + tip: UInt32?, + isFunded: Bool = false + ) async throws -> Bool { let ids = (record.waitingLeafIds ?? []).filter(record.exitLeafIds.contains) guard !ids.isEmpty else { record.waitingLeafIds = nil @@ -612,7 +631,7 @@ struct SparkExitDriver { letGoOfWaitingLeaves(&record) return false } - if record.retrySince != nil, !Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) { + if !isFunded, record.retrySince != nil, !Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) { return true } record.retrySince = since @@ -644,6 +663,8 @@ struct SparkExitDriver { record.retriedAt = nil try save(record) try await broadcastReady(&record, tip: tip) + } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) { + await askForFunding(&record, requiredSats: requiredSats) } catch { Logger.warn("Spark exit could not recover the leaves it skipped yet: \(error)", context: "SparkExitDriver") } diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 5af6837d5..4cdf00f89 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -496,11 +496,23 @@ struct SparkExitRecord: Codable, Equatable { /// being checked, sent and reminded. var isDrivingWhileFunding: Bool { if case .needsFunding = phase { - return recovery != nil && !isRebuilding + return recovery != nil && !isRebuilding && !isFundingWaitingLeaves } return false } + /// Fee funding asked for `waitingLeafIds` once every step of the recovery confirmed: only those leaves are built + /// when it arrives. + var isFundingWaitingLeaves: Bool { + guard case .needsFunding = phase, !(waitingLeafIds ?? []).isEmpty, let recovery else { return false } + return recovery.response.transactions.allSatisfy { + if case .confirmed = $0.status { + return true + } + return false + } + } + /// Progress says the exit is underpriced: it runs at its rate, nothing rebuilds it and no higher fee is on its way. func showsFeeLow(tip: UInt32?) -> Bool { phase == .running && !isRebuilding && underpricedStep(tip: tip) != nil diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index d96223160..4a32789af 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1612,6 +1612,35 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.phase, .done) } + /// A skipped leaf whose retry needs more fee funding asks for it, and is built on its own once it arrives. + func testASkippedLeafWhoseRetryNeedsMoreFeesAsksForThem() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2) + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + XCTAssertEqual(record.waitingLeafIds, ["leaf-2"]) + + sdk.recoverError = nil + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f9", value: 3136)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])] + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running) + guard case let .specific(leafIds) = sdk.prepareRequests.last?.selection else { return XCTFail("Expected a specific selection") } + XCTAssertEqual(leafIds, ["leaf-2"]) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"]) + } + /// A skipped leaf still unverified after about a week is let go, and the exit ends. func testAPartialBuildLetsTheSkippedLeafGoAfterAWeek() async throws { var record = rebuildingRecord() From 609be769879d846f18eb9dcd3df7c46366909683 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:33:45 +0100 Subject: [PATCH 119/378] fix: drop a paykit route that returns after its payment changed --- Bitkit/Services/PaykitRouteService.swift | 19 +++++++----- .../Views/Wallets/Send/SendAmountView.swift | 2 +- .../Wallets/Send/SendConfirmationView.swift | 3 ++ BitkitTests/PaykitRouteTests.swift | 29 +++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index aa05e71fd..e37100db7 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -237,6 +237,8 @@ enum PaykitRouteOutcome: Equatable { case leg /// No route pays the contact; the app's copy says why. case refused + /// The payment changed while the router answered, so its answer was dropped. + case stale } @MainActor @@ -250,18 +252,18 @@ extension AppViewModel { source: PaykitRouteService.Source, lightningSpendableSats: UInt64, onchainSpendableSats: UInt64, - request: PaykitPaymentRequest? + request: PaykitPaymentRequest?, + router: (PaykitRouteUi, UInt64, PaykitRouteService.Source, UInt64, UInt64) async throws -> PaykitRoute = PaykitRouteService.route ) async -> PaykitRouteOutcome { var unrouted = route unrouted.serviceLeg = nil + let contactId = contactPaymentContext?.id do { - let picked = try await PaykitRouteService.route( - route, - amountSats: amountSats, - source: source, - lightningSpendableSats: lightningSpendableSats, - onchainSpendableSats: onchainSpendableSats - ) + let picked = try await router(route, amountSats, source, lightningSpendableSats, onchainSpendableSats) + // A dismissed or replaced payment's route must never land on the one that followed it. + guard contactPaymentContext?.id == contactId, paykitRoute.map({ $0.endpoints == route.endpoints }) ?? true else { + return .stale + } switch picked { case let .direct(endpoint): paykitRoute = unrouted @@ -288,6 +290,7 @@ extension AppViewModel { } } catch { Logger.error("Failed to route Paykit payment: \(error)", context: "PaykitRoute") + guard contactPaymentContext?.id == contactId else { return .stale } showNoPaykitRouteToast(isBalanceShort: false) return .refused } diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 9577b8d39..dddfa14d8 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -398,7 +398,7 @@ struct SendAmountView: View { case .leg: navigationPath.append(.confirm) return - case .refused: + case .refused, .stale: return case .direct: break diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 44d3df6bc..7e66f328c 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1446,6 +1446,9 @@ struct SendConfirmationView: View { if outcome == .refused { return walletType } + if outcome == .stale { + return previous + } return previous == walletType ? nil : previous } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 1fc44b714..85843d5ba 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -350,6 +350,35 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(steps, ["journal", "proof started", "closed"], "A refusal closes the journal, releasing what it started") } + @MainActor + func testARouteThatReturnsAfterItsPaymentChangedNeverLandsOnTheNextOne() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let contactA = ContactPaymentContext(publicKey: "contacta", privatePaymentContext: nil, endpoints: []) + let contactB = ContactPaymentContext(publicKey: "contactb", privatePaymentContext: nil, endpoints: []) + let routeA = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + let routeB = PaykitRouteUi(endpoints: [lightning], offersSparkLeg: false) + app.contactPaymentContext = contactA + app.paykitRoute = routeA + + let outcome = await app.routeContactPayment( + routeA, + amountSats: 20000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in + // Contact A's sheet is dismissed and contact B's opens while Spark quotes A's leg. + app.contactPaymentContext = contactB + app.paykitRoute = routeB + return .service(leg: self.sparkLeg(amountSats: 20000, feeSats: 300)) + } + + XCTAssertEqual(outcome, .stale) + XCTAssertEqual(app.paykitRoute, routeB, "contact A's leg must never pay contact B") + } + @MainActor func testAPendingSparkSendNeverAdoptsTheNodesOnchainAttempt() { let attempt = SparkWithdrawalAttempt(address: onchain.value, amountSats: 20000, requestId: nil) From 11f743369ec835537297d0ef3cdcd9605aa65813 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:33:47 +0100 Subject: [PATCH 120/378] test: run the spending confirm check on the main actor --- BitkitTests/SparkFundingRecoveryTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/BitkitTests/SparkFundingRecoveryTests.swift b/BitkitTests/SparkFundingRecoveryTests.swift index 7db08162c..45f6930f5 100644 --- a/BitkitTests/SparkFundingRecoveryTests.swift +++ b/BitkitTests/SparkFundingRecoveryTests.swift @@ -41,6 +41,7 @@ final class SparkFundingRecoveryTests: XCTestCase { XCTAssertEqual(handedOff, 0) } + @MainActor func testSpendingConfirmLeavesOnceItsFundingResolvesOnItsOwn() async throws { let spark = try await resolvedSend(isSparkFunding: true, txid: String(repeating: "7c", count: 32)) let ordinary = try await resolvedSend(isSparkFunding: false, txid: String(repeating: "8d", count: 32)) From c84393edc09c4e766f25708f98b3330dde2e1e8e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:46:22 +0100 Subject: [PATCH 121/378] fix: keep a savings transfer on the channels it was quoted on A transfer from spending to savings is quoted against the channels, but paid through the send that may fall back to Spark, so a channel that became unusable after the quote could let a deselected Spark balance pay it, with Spark's fees. It now pays on the channels only and fails if they can't. --- Bitkit/Services/LightningService.swift | 4 +++- Bitkit/ViewModels/TransferViewModel.swift | 3 ++- BitkitTests/ChannelFreeRailTests.swift | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index f66bf463e..b15fb50e3 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1025,14 +1025,16 @@ class LightningService: NodeLifecycleService { } } + /// `channelsOnly` keeps a payment quoted against the channels on them, never falling back to the channel-free rail. func send( bolt11: String, sats: UInt64? = nil, params: RouteParametersConfig? = nil, + channelsOnly: Bool = false, beforeSubmission: @escaping () throws -> Void = {} ) async throws -> PaymentHash { let paymentHash = (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).paymentHash()).map { String($0) } - if let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { + if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { // LDK refuses its own repeats; Spark doesn't, so a Spark send checks both rails. if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.ldk, .channelFree]) { return paid diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 566b5cfb9..23a00b9d7 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -727,7 +727,8 @@ class TransferViewModel: ObservableObject { // 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) + // Quoted against the channels; a deselected Spark balance must never pay it if the channels can't. + let paymentId = try await lightningService.send(bolt11: swap.invoice, channelsOnly: true) let result = await awaitSwapOutcome( swapId: swap.id, diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 30fddc3da..88a7140e6 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -315,6 +315,19 @@ extension ChannelFreeRailTests { XCTAssertEqual(Bitkit.LightningService.receiveRoute(amountMsats: nil, railTakes: true), Route.rail(amountSats: nil)) } + func testAChannelsOnlyPaymentNeverFallsBackToSpark() async { + // The channels can't pay it (there are none in tests) and Spark could, as after a quote whose channel went away. + rail.balanceSats = 100_000 + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice, channelsOnly: true) + XCTFail("Expected the payment to fail on the channels") + } catch { + XCTAssertTrue("\(error)".contains("nodeNotSetup"), "\(error)") + } + XCTAssertNil(rail.sentBolt11, "A deselected Spark balance paid a channel-funded payment") + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) From 26d4ad16a80ddb4609d71210d418e7ec13836dfe Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:47:17 +0100 Subject: [PATCH 122/378] fix: record a move to spark as its channel send --- Bitkit/Services/CoreService.swift | 9 ++- BitkitTests/MoveToSparkActivityTests.swift | 86 ++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 BitkitTests/MoveToSparkActivityTests.swift diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 7677c57e7..331120926 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -986,6 +986,13 @@ class ActivityService { nil } + // Paying an invoice of the wallet's own Spark (Move to Spark) gives an LDK send and a Spark receive under one hash: + // the send is the payment, so its row wins whichever leg arrives first. + if rail == .channelFree, payment.direction == .inbound, existingLightning?.txType == .sent { + return + } + let replacesSelfReceive = rail == .ldk && payment.direction == .outbound && existingLightning?.txType == .received + let state: BitkitCore.PaymentState = switch payment.status { case .failed: .failed case .pending: .pending @@ -1006,7 +1013,7 @@ class ActivityService { if let existing = existingLightning, let existingUpdatedAt = existing.updatedAt { let statusChanging = existing.status != state let needsPrivateContactAttribution = existing.contact == nil && payment.direction == .inbound - if existingUpdatedAt > paymentTimestamp && !statusChanging && !needsPrivateContactAttribution { + if existingUpdatedAt > paymentTimestamp && !statusChanging && !needsPrivateContactAttribution && !replacesSelfReceive { return } } diff --git a/BitkitTests/MoveToSparkActivityTests.swift b/BitkitTests/MoveToSparkActivityTests.swift new file mode 100644 index 000000000..790687f07 --- /dev/null +++ b/BitkitTests/MoveToSparkActivityTests.swift @@ -0,0 +1,86 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +/// A move pays the wallet's own Spark invoice from the channels: the LDK send and the Spark receive share one hash. +final class MoveToSparkActivityTests: XCTestCase { + private let database = FileManager.default.temporaryDirectory + .appendingPathComponent("MoveToSparkActivityTests-\(UUID().uuidString)", isDirectory: true) + private let paymentHash = String(repeating: "ab", count: 32) + private let invoice = "lnbcrt50u1movetospark" + private let movedSats: UInt64 = 5000 + private let routingFeeSats: UInt64 = 3 + + override func setUp() async throws { + try await super.setUp() + snapshotAppDefaults("activityDetachedContacts") + _ = Bitkit.CoreService.shared + await drainCoreServiceQueue() + try FileManager.default.createDirectory(at: database, withIntermediateDirectories: true) + _ = try initDb(basePath: database.path) + } + + override func tearDown() async throws { + await drainCoreServiceQueue() + await repointCoreToAppStorage() + try? FileManager.default.removeItem(at: database) + try await super.tearDown() + } + + func testSparkReceiveAfterTheLdkSendKeepsOneOutgoingRow() async throws { + let activity = Bitkit.CoreService.shared.activity + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1000)]) + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + func testLdkSendAfterTheSparkReceiveReplacesItWithOneOutgoingRow() async throws { + let activity = Bitkit.CoreService.shared.activity + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1000)]) + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1002)) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + private func assertOneOutgoingRowWithTheRoutingFee(file: StaticString = #filePath, line: UInt = #line) async throws { + let rows = try await Bitkit.CoreService.shared.activity.get(filter: .lightning, walletId: WalletScope.default) + XCTAssertEqual(rows.count, 1, file: file, line: line) + guard case let .lightning(row) = try XCTUnwrap(rows.first, file: file, line: line) else { + return XCTFail("Expected a lightning activity", file: file, line: line) + } + XCTAssertEqual(row.id, paymentHash, file: file, line: line) + XCTAssertEqual(row.txType, .sent, file: file, line: line) + XCTAssertEqual(row.status, .succeeded, file: file, line: line) + XCTAssertEqual(row.value, movedSats, file: file, line: line) + XCTAssertEqual(row.fee, routingFeeSats, file: file, line: line) + } + + private func ldkSend(timestamp: UInt64) -> LDKNode.PaymentDetails { + payment(direction: .outbound, feeSats: routingFeeSats, timestamp: timestamp) + } + + private func sparkReceive(timestamp: UInt64) -> LDKNode.PaymentDetails { + payment(direction: .inbound, feeSats: 0, timestamp: timestamp) + } + + private func payment(direction: LDKNode.PaymentDirection, feeSats: UInt64, timestamp: UInt64) -> LDKNode.PaymentDetails { + LDKNode.PaymentDetails( + id: paymentHash, + kind: .bolt11( + hash: paymentHash, + preimage: String(repeating: "cd", count: 32), + secret: nil, + description: "Move to Spark", + bolt11: invoice + ), + amountMsat: movedSats * 1000, + feePaidMsat: feeSats * 1000, + direction: direction, + status: .succeeded, + latestUpdateTimestamp: timestamp + ) + } +} From 66cbf307a6dcb8aee2ac20daee221bc4fad0f78e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 18:32:34 +0100 Subject: [PATCH 123/378] fix: end an unfunded spark exit leaf retry after its week --- .../Services/SparkExit/SparkExitDriver.swift | 10 +++++++ BitkitTests/SparkExitTests.swift | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index f352f8642..aa9a7c111 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -97,6 +97,16 @@ struct SparkExitDriver { // Still waiting for funding (a generation that finished meanwhile is back to running). if record.phase != .running { guard let recovering else { return try saveIfChanged(record, from: before) } + // A skipped leaf's fee funding that never came is let go after the same week as its retries. + if record.isFundingWaitingLeaves, let since = record.retrySince, let tip = try? await chain.tipHeight(), + tip.saturatingSub(since) >= Self.releaseBlocks + { + Logger.warn("Spark exit lets go of skipped leaves whose fee funding never came", context: "SparkExitDriver") + letGoOfWaitingLeaves(&record) + await finish(&record, phase: .done) + try save(record) + return record + } // Nothing to fund when every leaf is recovered cooperatively. if let shortfall = record.fundingShortfallSats { guard try await adoptFunding(&record, requiredSats: shortfall) else { diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 4a32789af..ba2a1d4c4 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1641,6 +1641,32 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"]) } + /// A skipped leaf's fee ask nobody pays is let go after the same week as its retries, and the exit ends. + func testASkippedLeafsFeeAskNobodyPaysIsLetGoAfterAWeek() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)] + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + + chain.tip = 999 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertNil(record.waitingLeafIds) + } + /// A skipped leaf still unverified after about a week is let go, and the exit ends. func testAPartialBuildLetsTheSkippedLeafGoAfterAWeek() async throws { var record = rebuildingRecord() From fa8372a425f6af01fc2200e3b0e6e69dcf5d5753 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 18:32:34 +0100 Subject: [PATCH 124/378] fix: keep spark exit leaves skipped beside failed cooperative ones --- .../Services/SparkExit/SparkExitDriver.swift | 6 +++ BitkitTests/SparkExitFeeBumpTests.swift | 37 +++++++++++++++++++ BitkitTests/SparkExitTests.swift | 15 ++++++++ 3 files changed, 58 insertions(+) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index aa9a7c111..81e7739dd 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -356,6 +356,7 @@ struct SparkExitDriver { // puts them through the bounded failed-leaf retry (about a week), with the operators-waiting line. if !built.failed.isEmpty { record.adopt(built, at: now()) + record.waitingLeafIds = waitingLeafIds.isEmpty ? nil : waitingLeafIds return } await finish(&record, phase: .failed(reason: "Nothing to recover")) @@ -595,6 +596,9 @@ struct SparkExitDriver { let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: failedIds))) noteFailedLeafValues(prepared, failedIds: failedIds, in: &record) guard !prepared.leaves.isEmpty else { return true } + // A failed leaf skipped while it may still move is kept and tried again on its own. + noteStoppedLeaves(prepared, tip: tip, in: &record) + let skipped = prepared.skipped.filter { isProvisional($0, prepared: prepared, record: record, tip: tip) }.map(\.leafId) let built = try await recovering.recover( RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs), signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex) @@ -602,6 +606,8 @@ struct SparkExitDriver { guard !built.transactions.isEmpty else { return true } Logger.info("Spark exit recovers \(built.leaves.count) leaves the operators moved on-chain", context: "SparkExitDriver") record.adopt(built, at: now()) + let waiting = Array(Set((record.waitingLeafIds ?? []) + skipped)).sorted() + record.waitingLeafIds = waiting.isEmpty ? nil : waiting try save(record) try await broadcastReady(&record, tip: tip) } catch { diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index 318da1b89..1e6d29cb0 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -285,6 +285,43 @@ final class SparkExitFeeBumpTests: XCTestCase { XCTAssertNil(record.isFailedLeavesReleased) } + /// A retry of failed leaves that builds one and skips another while it may still move keeps the skipped one, + /// and tries it again once the built one landed. + func testAFailedLeafRetryKeepsALeafItSkipped() async throws { + let other = CooperativeRecoveryFailure( + leafId: "coop-2", + outputTxid: "watchtower", + outputVout: 1, + error: .operatorsUnavailable(message: "down") + ) + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["coop-1", "coop-2"] + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)], failed: [failure, other])) + let done = SparkExitFixtures.recovery( + [SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 990))], + failed: [failure, other] + ) + sdk.checks = [CheckRecoverFundsResponse(recovery: done, verdict: .done)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: []) + prepared.leaves = [RecoverFundsLeaf(leafId: "coop-1", valueSats: 5000, method: .cooperative)] + prepared.skipped = [SkippedLeaf(leafId: "coop-2", valueSats: 4000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])] + + record = try await driver().tick(record) + XCTAssertEqual(record.waitingLeafIds, ["coop-2"]) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative, status: .confirmed(blockHeight: 1001))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)] + prepared.leaves = [] + sdk.preparedResponse = prepared + chain.tip = 1001 + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running, "coop-2 may still move") + XCTAssertEqual(sdk.prepareRequests.last?.selection, .specific(leafIds: ["coop-2"])) + } + /// After about a week (1,008 blocks) the exit lets the leaf go and ends, saying what is left. func testAFailedLeafIsReleasedAfterAWeek() async throws { var record = doneWithFailedLeaf() diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index ba2a1d4c4..9bf33dc1a 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1667,6 +1667,21 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertNil(record.waitingLeafIds) } + /// A partial build whose only built leaf fails cooperatively still keeps the leaf it skipped. + func testAPartialBuildOfOnlyFailedLeavesKeepsTheSkippedLeaf() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + let failure = CooperativeRecoveryFailure(leafId: "leaf-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down")) + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.waitingLeafIds, ["leaf-2"]) + } + /// A skipped leaf still unverified after about a week is let go, and the exit ends. func testAPartialBuildLetsTheSkippedLeafGoAfterAWeek() async throws { var record = rebuildingRecord() From 9b0622069ab2bec7d413513889ac70b92e19468e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 18:32:35 +0100 Subject: [PATCH 125/378] fix: count spark exit leaves let go after a week as left on spark --- .../Services/SparkExit/SparkExitDriver.swift | 3 ++- BitkitTests/SparkExitTests.swift | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 81e7739dd..cb2b8f28d 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -688,7 +688,8 @@ struct SparkExitDriver { } private func letGoOfWaitingLeaves(_ record: inout SparkExitRecord) { - record.skippedLeafCount = max(record.skippedLeafCount, record.waitingLeafIds?.count ?? 0) + // The quote's skips never were exit leaves: the leaves let go here add to them. + record.skippedLeafCount += record.waitingLeafIds?.count ?? 0 record.waitingLeafIds = nil record.retrySince = nil record.retriedAt = nil diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 9bf33dc1a..d153d103a 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1682,6 +1682,28 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.waitingLeafIds, ["leaf-2"]) } + /// A waiting leaf let go after a week adds to the leaves that stayed on Spark, the quote's skips included. + func testAWaitingLeafLetGoAddsToTheLeavesThatStayedOnSpark() async throws { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + record.skippedLeafCount = 13 + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .fundsNotFound)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2) + sdk.preparedResponse.leaves = [] + record = try await driver().tick(record) + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.skippedLeafCount, 14) + } + /// A skipped leaf still unverified after about a week is let go, and the exit ends. func testAPartialBuildLetsTheSkippedLeafGoAfterAWeek() async throws { var record = rebuildingRecord() From d20530ec05f3045cd8f9ce54f86e1faa56367ece Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 18:38:54 +0100 Subject: [PATCH 126/378] fix: keep an unselected lnurl's price and minimum off a spark leg --- Bitkit/ViewModels/AppViewModel.swift | 4 +++ .../Wallets/Send/SendConfirmationView.swift | 3 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 2 +- .../SendConfirmationLifecycleTests.swift | 28 +++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 8699d04dc..07fad3d56 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -764,6 +764,10 @@ extension AppViewModel { /// endpoint, so it takes that endpoint's price, the one the leg and its proof pay. func paykitBitcoinMethod(for wallet: WalletType) -> PublicPaykitService.MethodId { if wallet == .lightning { + // A routed Spark leg pays its on-chain endpoint, whatever Lightning endpoint the contact also has. + if let leg = paykitRoute?.serviceLeg, let method = PublicPaykitService.MethodId(rawValue: leg.endpoint.methodId) { + return method + } if lnurlPayData != nil { return .bitcoinLightningLnurl } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 7e66f328c..252f68a7b 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -2182,7 +2182,8 @@ struct SendConfirmationView: View { } guard request.pricing.conversion != nil else { return false } let terms = try? request.payment(using: app.paykitPaymentMethod, quoteId: app.paykitPaymentTerms?.quoteId) - let minimumAmount = app.selectedWalletToPayFrom == .lightning ? (app.lnurlPayData?.minSendableSat ?? 1) : UInt64(Env.dustLimit) + let paysLightning = app.selectedWalletToPayFrom == .lightning && app.paykitRoute?.serviceLeg == nil + let minimumAmount = paysLightning ? (app.lnurlPayData?.minSendableSat ?? 1) : UInt64(Env.dustLimit) let meetsMinimum = terms.map { $0.amount.atomic >= minimumAmount } == true if rebindMethod, !app.paykitUsesUsdt, !isSubmittingPayment, let previous = app.paykitPaymentTerms, previous.amount.asset == .btc, app.paykitReviewedAmount == previous.amount, diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 4b73c7763..a89e8925a 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -768,7 +768,7 @@ struct SendSheet: View { return .failed } - if let lnurlPayData = app.lnurlPayData, let requestedAmount { + if let lnurlPayData = app.lnurlPayData, app.paykitRoute?.serviceLeg == nil, let requestedAmount { let minimumAmount = max(1, lnurlPayData.minSendableSat) guard requestedAmount >= minimumAmount else { showPaymentSetupFailure( diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index dc6b645b0..bf736eeca 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -45,6 +45,34 @@ final class SendConfirmationLifecycleTests: XCTestCase { XCTAssertEqual(try request.payment(using: app.paykitPaymentMethod), onchainTerms) } + func testASparkLegIgnoresTheUnselectedLnurlsPrice() throws { + snapshotAppDefaultsDomain() + let request = try onchainRequest(acceptsLightning: true, rates: [ + ConversionRate(asset: "btc", value: "0.000002188"), + ConversionRate(asset: "btc-lightning", value: "0.00002"), + ]) + let app = AppViewModel() + app.scannedOnchainInvoice = invoice + let onchainTerms = try request.payment(using: app.paykitPaymentMethod) + let endpoint = PaykitEndpoint(methodId: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue, value: invoice.address) + app.lnurlPayData = LnurlPayData( + uri: "lnurl1contact", callback: "https://example.com/callback", minSendable: 5_000_000, maxSendable: 100_000_000, + metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil + ) + app.paykitRoute = PaykitRouteUi( + endpoints: [endpoint], + offersSparkLeg: true, + serviceLeg: ServiceRouteLeg( + service: SparkManager.routeService, endpoint: endpoint, deliveredSat: 5000, debitSat: 5300, + atomicity: "trusted", validUntilUnix: 0 + ) + ) + + app.selectedWalletToPayFrom = .lightning + + XCTAssertEqual(try request.payment(using: app.paykitPaymentMethod), onchainTerms, "The leg pays the on-chain endpoint") + } + func testQuotedWalletSwitchRebindsAmountOnConfirmation() async throws { snapshotAppDefaultsDomain() snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") From 3f0a7cd91175ec72e629f316ff9eca8c06a7eac9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 18:47:57 +0100 Subject: [PATCH 127/378] fix: keep a move's send row and screen against stale writes --- Bitkit/Managers/MoveToSparkManager.swift | 10 ++++--- Bitkit/Services/CoreService.swift | 34 +++++++++++++--------- BitkitTests/MoveToSparkActivityTests.swift | 17 +++++++++++ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index fe00a13e3..3f1fe0640 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -185,6 +185,7 @@ final class MoveToSparkManager { func move(wallet: WalletViewModel) async { guard case let .confirm(quote, isMoving) = state, !isMoving else { return } generation += 1 + let moveGeneration = generation keepsOutcome = true isMoveInFlight = true defer { isMoveInFlight = false } @@ -196,7 +197,7 @@ final class MoveToSparkManager { bolt11: quote.invoice, sats: quote.amountSats, maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats), - onLateFailure: { [weak self] in self?.lateFailure(amountSats: quote.amountSats) } + onLateFailure: { [weak self] in self?.lateFailure(amountSats: quote.amountSats, moveGeneration: moveGeneration) } ) else { state = .inFlight(amountSats: quote.amountSats) return @@ -261,10 +262,11 @@ final class MoveToSparkManager { } /// A move that was still in flight failed after the wait: its amount stays in the channel, so the screen, if it still - /// shows that move, says it could not move, and a toast tells the user wherever they are. - private func lateFailure(amountSats: UInt64) { + /// shows that same move, says it could not move, and a toast tells the user wherever they are. + private func lateFailure(amountSats: UInt64, moveGeneration: Int) { Logger.warn("Move to Spark of '\(amountSats)' sats failed after the wait", context: "MoveToSpark") - if state == .inFlight(amountSats: amountSats) { + // Only the move that owns the screen may change it: a later move counts a new generation. + if generation == moveGeneration, case .inFlight = state { state = .failed(.unknown) } guard !ToastWindowManager.areToastsDisabled() else { return } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 331120926..e0e21b4f6 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1044,22 +1044,28 @@ class ActivityService { seenAt: existingLightning?.seenAt ) - if existingActivity != nil { - try await ServiceQueue.background(.core) { - var updated = ln - if case let .lightning(latest)? = try getActivityById(walletId: ln.walletId, activityId: payment.id), - latest.contact != existingLightning?.contact - { - updated.contact = latest.contact - } - if self.isContactDetached(activityId: payment.id, walletId: ln.walletId) { - updated.contact = nil - } - try updateActivity(activityId: payment.id, activity: .lightning(updated)) + let isChannelFreeReceive = rail == .channelFree && payment.direction == .inbound + try await ServiceQueue.background(.core) { + let latestActivity = try getActivityById(walletId: ln.walletId, activityId: payment.id) + // The move's LDK send can be stored while this receive waited on its contact lookup; it still wins. + if isChannelFreeReceive, case let .lightning(latest)? = latestActivity, latest.txType == .sent { + return + } + guard let latestActivity else { + try upsertActivity(activity: .lightning(ln)) + self.updateBoostTxIdsCache(for: .lightning(ln)) self.activitiesChangedSubject.send() + return } - } else { - try await upsert(.lightning(ln)) + var updated = ln + if case let .lightning(latest) = latestActivity, latest.contact != existingLightning?.contact { + updated.contact = latest.contact + } + if self.isContactDetached(activityId: payment.id, walletId: ln.walletId) { + updated.contact = nil + } + try updateActivity(activityId: payment.id, activity: .lightning(updated)) + self.activitiesChangedSubject.send() } } diff --git a/BitkitTests/MoveToSparkActivityTests.swift b/BitkitTests/MoveToSparkActivityTests.swift index 790687f07..c8ab4a977 100644 --- a/BitkitTests/MoveToSparkActivityTests.swift +++ b/BitkitTests/MoveToSparkActivityTests.swift @@ -45,6 +45,23 @@ final class MoveToSparkActivityTests: XCTestCase { try await assertOneOutgoingRowWithTheRoutingFee() } + func testSparkReceiveWaitingOnItsContactLookupKeepsTheLdkSendStoredMeanwhile() async throws { + let activity = Bitkit.CoreService.shared.activity + let send = ldkSend(timestamp: 1000) + // The receive's contact lookup is where it waits; the send lands in that gap. + activity.setPrivatePaykitContactResolvers( + invoice: { _ in + try? await Bitkit.CoreService.shared.activity.syncLdkNodePayments([send]) + return nil + }, + onchainAddresses: nil + ) + defer { activity.setPrivatePaykitContactResolvers(invoice: nil, onchainAddresses: nil) } + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + private func assertOneOutgoingRowWithTheRoutingFee(file: StaticString = #filePath, line: UInt = #line) async throws { let rows = try await Bitkit.CoreService.shared.activity.get(filter: .lightning, walletId: WalletScope.default) XCTAssertEqual(rows.count, 1, file: file, line: line) From 08b3371024a5fddb424bb5af809c66fe77e0870f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 18:49:06 +0100 Subject: [PATCH 128/378] fix: keep the hold on an invoice whose spark retry is in flight A launch's Spark sync released the hold on invoices whose sends had failed from the list it read before recording the activities, so a retry the user started meanwhile could lose its hold. A release now skips an invoice with a Spark send in flight, and the sync reads Spark's list again before releasing. --- Bitkit/Managers/SparkManager.swift | 6 ++++-- Bitkit/Services/LightningService.swift | 3 ++- BitkitTests/ChannelFreeRailTests.swift | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 6acc94b26..a853fba97 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -694,8 +694,10 @@ final class SparkManager: ChannelFreeLightningRail { for payment in payments { await recordActivity(payment) } - // A send that failed while the app was closed released no hold on a channel retry. - for paymentHash in Self.failedSendHashes(payments) { + // A send that failed while the app was closed released no hold on a channel retry. Spark's list is read again, + // so a retry the user started while the activities were recorded keeps its hold. + guard let latest = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments else { return } + for paymentHash in Self.failedSendHashes(latest) { LightningService.shared.releaseRailAttempt(paymentHash: paymentHash) } } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index b15fb50e3..81b941378 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1121,10 +1121,11 @@ class LightningService: NodeLifecycleService { } /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. + /// Skipped while a rail send of the invoice is in flight, as that retry holds it again. func releaseRailAttempt(paymentHash: String) { let hash = paymentHash.lowercased() eventCallbackLock.withLock { - guard var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), hashes.contains(hash) else { return } + guard !railSendsInFlight.contains(hash), var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), hashes.contains(hash) else { return } hashes.removeAll { $0 == hash } railAttemptsStore.set(hashes, forKey: Self.railAttemptsKey) } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 88a7140e6..d2a174afe 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -328,6 +328,30 @@ extension ChannelFreeRailTests { XCTAssertNil(rail.sentBolt11, "A deselected Spark balance paid a channel-funded payment") } + func testAHeldInvoiceIsNotReleasedWhileItsSparkRetryIsInFlight() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + var release: CheckedContinuation? + rail.sendGate = { await withCheckedContinuation { release = $0 } } + rail.balanceSats = 100_000 + let retry = Task { try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) } + var waited = 0 + while release == nil, waited < 500 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertNotNil(release, "The Spark retry never started") + + // What a launch's sync does with an earlier failed send of the same invoice. + Bitkit.LightningService.shared.releaseRailAttempt(paymentHash: hash) + release?.resume() + _ = try await retry.value + rail.sentBolt11 = nil + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) From b4f65559bd723a20fec0b9f7841a515a0c6f7440 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:17:09 +0100 Subject: [PATCH 129/378] fix: show no cancelled spark exit's funding for the next exit --- Bitkit/Managers/SparkExitManager.swift | 35 ++++++++++++++++++++------ BitkitTests/SparkExitReviewTests.swift | 19 ++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 8c4589f1c..4b397da37 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -39,9 +39,19 @@ final class SparkExitManager { private(set) var isPricingFeeBump = false private(set) var feeBumpError: String? /// What the running exit shows about its fee funding while it lacks some. - private(set) var fundingState: SparkExitFundingState? + var fundingState: SparkExitFundingState? { + Self.isFundingOfExit(fundingExitCreatedAt, record: record) ? storedFundingState : nil + } + /// The full funding the Progress top-up asks for, every extra payment's input fee included. - private(set) var fundingAskTotal: UInt64? + var fundingAskTotal: UInt64? { + Self.isFundingOfExit(fundingExitCreatedAt, record: record) ? storedFundingAskTotal : nil + } + + private var storedFundingState: SparkExitFundingState? + private var storedFundingAskTotal: UInt64? + /// The exit `fundingState` and `fundingAskTotal` were read for. + private var fundingExitCreatedAt: Date? private(set) var fundingAddress: String? private(set) var lastError: String? /// Set when the user tapped an exit notification, until the app shows the exit. @@ -432,15 +442,26 @@ final class SparkExitManager { guard let address = record.funding.first?.address else { return } fundingAddress = address guard record.fundingShortfallSats != nil else { - fundingState = nil + setFunding(nil, for: record) return } let utxos = try? await chain.utxos(address: address) if let utxos { fundingSeenSats = SparkExitMath.newFunding(utxos, record: record).reduce(0) { $0 + $1.value } } - fundingState = SparkExitMath.fundingState(record, utxos: utxos) - fundingAskTotal = SparkExitMath.fundingAskTotal(record, utxos: utxos) + setFunding(SparkExitMath.fundingState(record, utxos: utxos), askTotal: SparkExitMath.fundingAskTotal(record, utxos: utxos), for: record) + } + + private func setFunding(_ state: SparkExitFundingState?, askTotal: UInt64? = nil, for record: SparkExitRecord) { + storedFundingState = state + storedFundingAskTotal = askTotal + fundingExitCreatedAt = record.createdAt + } + + /// Whether funding read for the exit created at `createdAt` belongs to `record`: a cancelled exit's savings + /// payment or funding ask never shows for the next one. + nonisolated static func isFundingOfExit(_ createdAt: Date?, record: SparkExitRecord?) -> Bool { + createdAt != nil && createdAt == record?.createdAt } private func refreshNetworkFees(chain: SparkExitChain) async { @@ -555,7 +576,7 @@ final class SparkExitManager { record.applyFeeBump(rate: option.rate, extraSats: extraSats, paymentTxid: txid) try saveRecord(record) self.record = record - fundingState = SparkExitMath.fundingState(record, utxos: nil) + setFunding(SparkExitMath.fundingState(record, utxos: nil), askTotal: storedFundingAskTotal, for: record) Logger.info("Spark exit moves to \(option.rate) sat/vB with \(extraSats) sats more funding", context: "SparkExitManager") Task { await tick() } } @@ -711,7 +732,7 @@ final class SparkExitManager { Logger.error("Failed to save the Spark exit funding payment: \(error)", context: "SparkExitManager") } self.record = started - fundingState = SparkExitMath.fundingState(started, utxos: nil) + setFunding(SparkExitMath.fundingState(started, utxos: nil), for: started) } Task { await tick() } } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 5e7d52c8b..2f8a0addb 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -644,6 +644,25 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertTrue(SparkExitManager.canDrive(record, startingCreatedAt: record.createdAt.addingTimeInterval(-60))) } + /// Funding read for a cancelled exit, such as its savings payment, never shows for the next one. + func testACancelledExitsFundingNeverShowsForTheNextExit() { + let cancelled = SparkExitFixtures.record() + var next = SparkExitFixtures.record() + next = SparkExitRecord( + network: next.network, + createdAt: cancelled.createdAt.addingTimeInterval(60), + phase: next.phase, + feeRateSatPerVbyte: next.feeRateSatPerVbyte, + destination: next.destination, + leafIds: next.leafIds, + funding: next.funding, + expectedSats: next.expectedSats + ) + XCTAssertTrue(SparkExitManager.isFundingOfExit(cancelled.createdAt, record: cancelled)) + XCTAssertFalse(SparkExitManager.isFundingOfExit(cancelled.createdAt, record: next)) + XCTAssertFalse(SparkExitManager.isFundingOfExit(nil, record: next)) + } + /// Fee rates that cannot be read fail the savings price, so Pay from savings shows its error instead of loading. func testSavingsFeeFailsWithoutFeeRates() async { let rates = FeeRates(fast: 9, mid: 6, slow: 1) From 1c40b077052d4389ea2be3a8e45f32139557bedf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:26:38 +0100 Subject: [PATCH 130/378] fix: settle move send from its spark receive A succeeded Spark receive marks its pending or failed LDK send succeeded; only a succeeded send replaces the Spark receive row. --- Bitkit/Services/CoreService.swift | 27 ++++++++++----- BitkitTests/MoveToSparkActivityTests.swift | 39 +++++++++++++++++++--- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index e0e21b4f6..dfe6e621f 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -986,19 +986,21 @@ class ActivityService { nil } - // Paying an invoice of the wallet's own Spark (Move to Spark) gives an LDK send and a Spark receive under one hash: - // the send is the payment, so its row wins whichever leg arrives first. - if rail == .channelFree, payment.direction == .inbound, existingLightning?.txType == .sent { - return - } - let replacesSelfReceive = rail == .ldk && payment.direction == .outbound && existingLightning?.txType == .received - let state: BitkitCore.PaymentState = switch payment.status { case .failed: .failed case .pending: .pending case .succeeded: .succeeded } + // Paying an invoice of the wallet's own Spark (Move to Spark) gives an LDK send and a Spark receive under one hash: + // the send is the payment, so its row wins whichever leg arrives first. + let isChannelFreeReceive = rail == .channelFree && payment.direction == .inbound + if isChannelFreeReceive, let sent = existingLightning, sent.txType == .sent { + try settleMove(sent, receiveState: state) + return + } + let replacesSelfReceive = rail == .ldk && payment.direction == .outbound && existingLightning?.txType == .received + if let existing = existingLightning, Self.keepsLightningStatus( existing: existing.status, @@ -1044,11 +1046,11 @@ class ActivityService { seenAt: existingLightning?.seenAt ) - let isChannelFreeReceive = rail == .channelFree && payment.direction == .inbound try await ServiceQueue.background(.core) { let latestActivity = try getActivityById(walletId: ln.walletId, activityId: payment.id) // The move's LDK send can be stored while this receive waited on its contact lookup; it still wins. if isChannelFreeReceive, case let .lightning(latest)? = latestActivity, latest.txType == .sent { + try self.settleMove(latest, receiveState: state) return } guard let latestActivity else { @@ -1069,6 +1071,15 @@ class ActivityService { } } + /// A succeeded Spark receive proves the move's LDK send settled, so the send's row becomes succeeded and keeps its amount and fee. + private func settleMove(_ sent: LightningActivity, receiveState: BitkitCore.PaymentState) throws { + guard receiveState == .succeeded, sent.status != .succeeded else { return } + var settled = sent + settled.status = .succeeded + try updateActivity(activityId: sent.id, activity: .lightning(settled)) + activitiesChangedSubject.send() + } + private func privatePaykitContactPublicKey(forReceivedInvoicePaymentHash paymentHash: String, direction: PaymentDirection) async -> String? { guard direction == .inbound, !isContactDetached(activityId: paymentHash, walletId: WalletScope.default) else { return nil } return await privateInvoiceContactResolver?(paymentHash) diff --git a/BitkitTests/MoveToSparkActivityTests.swift b/BitkitTests/MoveToSparkActivityTests.swift index c8ab4a977..268ba936b 100644 --- a/BitkitTests/MoveToSparkActivityTests.swift +++ b/BitkitTests/MoveToSparkActivityTests.swift @@ -62,6 +62,32 @@ final class MoveToSparkActivityTests: XCTestCase { try await assertOneOutgoingRowWithTheRoutingFee() } + func testSucceededSparkReceiveSettlesAPendingLdkSend() async throws { + let activity = Bitkit.CoreService.shared.activity + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1000, status: .pending)]) + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + + func testPendingLdkSendKeepsTheSucceededSparkReceiveUntilTheSendSucceeds() async throws { + let activity = Bitkit.CoreService.shared.activity + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1000)) + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1001, status: .pending)]) + + let rows = try await activity.get(filter: .lightning, walletId: WalletScope.default) + XCTAssertEqual(rows.count, 1) + guard case let .lightning(row) = try XCTUnwrap(rows.first) else { + return XCTFail("Expected a lightning activity") + } + XCTAssertEqual(row.txType, .received) + XCTAssertEqual(row.status, .succeeded) + + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1002)]) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + private func assertOneOutgoingRowWithTheRoutingFee(file: StaticString = #filePath, line: UInt = #line) async throws { let rows = try await Bitkit.CoreService.shared.activity.get(filter: .lightning, walletId: WalletScope.default) XCTAssertEqual(rows.count, 1, file: file, line: line) @@ -75,15 +101,20 @@ final class MoveToSparkActivityTests: XCTestCase { XCTAssertEqual(row.fee, routingFeeSats, file: file, line: line) } - private func ldkSend(timestamp: UInt64) -> LDKNode.PaymentDetails { - payment(direction: .outbound, feeSats: routingFeeSats, timestamp: timestamp) + private func ldkSend(timestamp: UInt64, status: LDKNode.PaymentStatus = .succeeded) -> LDKNode.PaymentDetails { + payment(direction: .outbound, feeSats: routingFeeSats, timestamp: timestamp, status: status) } private func sparkReceive(timestamp: UInt64) -> LDKNode.PaymentDetails { payment(direction: .inbound, feeSats: 0, timestamp: timestamp) } - private func payment(direction: LDKNode.PaymentDirection, feeSats: UInt64, timestamp: UInt64) -> LDKNode.PaymentDetails { + private func payment( + direction: LDKNode.PaymentDirection, + feeSats: UInt64, + timestamp: UInt64, + status: LDKNode.PaymentStatus = .succeeded + ) -> LDKNode.PaymentDetails { LDKNode.PaymentDetails( id: paymentHash, kind: .bolt11( @@ -96,7 +127,7 @@ final class MoveToSparkActivityTests: XCTestCase { amountMsat: movedSats * 1000, feePaidMsat: feeSats * 1000, direction: direction, - status: .succeeded, + status: status, latestUpdateTimestamp: timestamp ) } From 2dd722aa9cdcbfb67d04c4a2c5ead29351fa5a06 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:26:41 +0100 Subject: [PATCH 131/378] chore: format --- Bitkit/Services/LightningService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 81b941378..eaf3c3a4b 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1125,7 +1125,9 @@ class LightningService: NodeLifecycleService { func releaseRailAttempt(paymentHash: String) { let hash = paymentHash.lowercased() eventCallbackLock.withLock { - guard !railSendsInFlight.contains(hash), var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), hashes.contains(hash) else { return } + guard !railSendsInFlight.contains(hash), var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), + hashes.contains(hash) + else { return } hashes.removeAll { $0 == hash } railAttemptsStore.set(hashes, forKey: Self.railAttemptsKey) } From 611c9be5dfec972ce08e1ccbc197f4886d8f7bbf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:44:39 +0100 Subject: [PATCH 132/378] feat: offer to pay a refused spark exit savings payment again --- Bitkit/Managers/SparkExitManager.swift | 73 ++++++++++++++++++- .../Localization/en.lproj/Localizable.strings | 1 + .../Settings/Advanced/SparkExitViews.swift | 18 +++++ BitkitTests/SparkExitReviewTests.swift | 47 ++++++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 4b397da37..1d8e25662 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -223,6 +223,69 @@ final class SparkExitManager { /// The address of an unresolved savings send, read on every tick and before a cancel. private(set) var unresolvedSendAddress: String? + /// The savings payment of the start that a node refused, read on every tick: progress says so and offers to pay + /// again. + private(set) var refusedSavingsPayment: OnchainSendAttempt? + + var isSavingsPaymentRefused: Bool { + record.map { Self.isSavingsPaymentRefused($0, attempt: refusedSavingsPayment) } ?? false + } + + /// A node refused the savings payment of `record`'s fee funding, and none was adopted since: only before the exit + /// is funded, for the payment the start made. + nonisolated static func isSavingsPaymentRefused(_ record: SparkExitRecord, attempt: OnchainSendAttempt?) -> Bool { + guard record.isActive, case .awaitingFunding = record.phase, record.fundingPaymentTxid == nil, + let address = record.funding.first?.address, let attempt + else { return false } + return attempt.address == address && attempt.status == .rejected + } + + /// Pays a refused savings payment again: the same attempt, re-signed at the rate the user approved over the inputs + /// it already spends. Every version spends those inputs, so at most one ever confirms, and nothing waits for the + /// refused one to be known gone from the mempool first. The txid to wait for, or nil when it was refused again. + nonisolated static func payRefusedAgain( + _ refused: OnchainSendAttempt, + record: SparkExitRecord, + retrySamePayment: (OnchainSendPendingContext) async throws -> OnchainSendResult + ) async throws -> String? { + guard isSavingsPaymentRefused(record, attempt: refused), refused.canRetrySamePayment else { + throw OnchainSendAttemptError.unresolved + } + let result = try await retrySamePayment(OnchainSendPendingContext(attemptId: refused.id, walletId: refused.walletId, txid: refused.txid)) + return fundingTxid(result) + } + + func retrySavingsPayment() async throws { + guard let current = record, let refused = refusedSavingsPayment else { return } + let attempts = OnchainSendAttemptService.shared + let txid = try await Self.payRefusedAgain(refused, record: current) { context in + try await attempts.retrySamePayment(using: LightningService.shared, context: context) { attempt, _ in + guard attempt.id == refused.id, attempt.address == refused.address else { throw OnchainSendAttemptError.unresolved } + } + } + await refreshRefusedSavingsPayment() + guard let txid else { + throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark exit savings payment refused again") + } + _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: refused.walletId, observedTxid: txid) + // Written between ticks, so a tick in flight never saves over it. + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + guard var paid = record, paid.createdAt == current.createdAt, paid.isActive else { return } + paid.fundingPaymentTxid = txid + try saveRecord(paid) + record = paid + setFunding(SparkExitMath.fundingState(paid, utxos: nil), for: paid) + refusedSavingsPayment = nil + Logger.info("Paid the Spark exit funding from savings again in \(txid)", context: "SparkExitManager") + Task { await tick() } + } + + private func refreshRefusedSavingsPayment() async { + let attempt = try? await OnchainSendAttemptService.shared.blockingAttempt() + refusedSavingsPayment = attempt?.status == .rejected ? attempt : nil + } /// What the exit still brings to savings, for its own transfer's in-transfer amount; nil without one. var inTransfer: (transferId: String, sats: UInt64)? { @@ -358,6 +421,7 @@ final class SparkExitManager { do { tipHeight = try? await chain.tipHeight() unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress() + await refreshRefusedSavingsPayment() await refreshNetworkFees(chain: chain) await refreshFundingSeen(chain: chain, record: current) var updated = try await driver.tick(current) @@ -723,7 +787,14 @@ final class SparkExitManager { let savingsSats = Self.savingsFundingSats(confirmed: quote, fresh: exitQuote) if fundFromSavings, savingsSats > 0 { - let txid = try await payFunding(address: key.address, sats: savingsSats, satsPerVbyte: savingsFeeRate, feeSats: savingsFeeSats) + let txid: String + do { + txid = try await payFunding(address: key.address, sats: savingsSats, satsPerVbyte: savingsFeeRate, feeSats: savingsFeeSats) + } catch { + // A refused payment shows on progress at once, with the offer to pay again. + await refreshRefusedSavingsPayment() + throw error + } // Saved before the lock is released, so a cancel after the payment finds it and refuses. started.fundingPaymentTxid = txid do { diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index b88431bf8..7a202915b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -374,6 +374,7 @@ "lightning__spark_exit__fees" = "Network fees"; "lightning__spark_exit__receive" = "You receive about"; "lightning__spark_exit__savings_fee" = "Savings payment fee"; +"lightning__spark_exit__savings_refused" = "Your savings payment didn't go through. Pay again, or fund from another wallet."; "lightning__spark_exit__savings_total" = "Total from savings"; "lightning__spark_exit__funding" = "Fee funding needed"; "lightning__spark_exit__left_behind" = "Stays on Spark"; diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 5be5972e9..49e3f9f07 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -703,6 +703,12 @@ struct SparkExitProgressView: View { .accessibilityIdentifier("SparkExitOutsideExit") } + if exit.isSavingsPaymentRefused { + BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitSavingsRefused") + } + if record.isActive { SparkExitBackgroundNotes() .padding(.top, 16) @@ -716,6 +722,18 @@ struct SparkExitProgressView: View { } } + if exit.isSavingsPaymentRefused { + CustomButton(title: t("lightning__spark_exit__fund_savings"), variant: .secondary) { + do { + try await exit.retrySavingsPayment() + } catch { + app.toast(error) + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitRetrySavings") + } + if exit.canPayHigherFee { CustomButton(title: t("lightning__spark_exit__fee_bump_button"), variant: .secondary) { navigation.navigate(.sparkExitFeeBump) diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 2f8a0addb..368904e5a 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -663,6 +663,53 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertFalse(SparkExitManager.isFundingOfExit(nil, record: next)) } + private func refusedPayment(status: OnchainSendAttempt.Status = .rejected, address: String = "bcrt1qfunding") -> OnchainSendAttempt { + var attempt = OnchainSendAttempt( + id: UUID(), walletId: "wallet", requestId: nil, orderId: nil, address: address, amountSats: 9000, isMaxAmount: false, + status: status, txid: "refused" + ) + attempt.recoveryContext = OnchainSendRecoveryContext( + inputs: [OnchainSendInput(txid: String(repeating: "a", count: 64), vout: 0)], + satsPerVbyte: 9, + paymentIdentity: nil, + candidateTxids: ["refused"] + ) + return attempt + } + + /// Progress says a savings payment was refused only while the exit still waits for its first funding. + func testARefusedSavingsPaymentIsShownUntilAPaymentIsKept() { + let record = SparkExitFixtures.record() + XCTAssertTrue(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment())) + XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment(status: .unknown)), "it may still go out") + XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment(address: "bcrt1qother"))) + var paid = record + paid.fundingPaymentTxid = "paid" + XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(paid, attempt: refusedPayment())) + XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(SparkExitFixtures.record(phase: .running), attempt: refusedPayment())) + } + + /// Paying again re-signs the refused attempt itself, over the inputs it already spends, so at most one confirms. + func testPayingAgainRetriesOnlyTheRefusedAttempt() async throws { + let refused = refusedPayment() + var contexts: [OnchainSendPendingContext] = [] + let txid = try await SparkExitManager.payRefusedAgain(refused, record: SparkExitFixtures.record()) { context in + contexts.append(context) + return .accepted(txid: "again") + } + XCTAssertEqual(txid, "again") + XCTAssertEqual(contexts, [OnchainSendPendingContext(attemptId: refused.id, walletId: "wallet", txid: "refused")]) + + do { + _ = try await SparkExitManager.payRefusedAgain(refusedPayment(status: .unknown), record: SparkExitFixtures.record()) { context in + contexts.append(context) + return .accepted(txid: "again") + } + XCTFail("a payment that may still go out is never paid again") + } catch {} + XCTAssertEqual(contexts.count, 1) + } + /// Fee rates that cannot be read fail the savings price, so Pay from savings shows its error instead of loading. func testSavingsFeeFailsWithoutFeeRates() async { let rates = FeeRates(fast: 9, mid: 6, slow: 1) From 0654bc49d46bb5225db0fd63471a30c7ddc21981 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:53:51 +0100 Subject: [PATCH 133/378] fix: release the hold when spark reports its send failed in flight The guard that keeps a held invoice while a Spark retry of it is in flight also dropped the release when Spark reported that same send failed before its send call returned, so the hold outlived the failure. Only the launch sync's release is guarded now; a reported failure always releases. --- Bitkit/Services/LightningService.swift | 10 ++++++---- BitkitTests/ChannelFreeRailTests.swift | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index eaf3c3a4b..a96f0e72c 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1121,11 +1121,12 @@ class LightningService: NodeLifecycleService { } /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. - /// Skipped while a rail send of the invoice is in flight, as that retry holds it again. - func releaseRailAttempt(paymentHash: String) { + /// With `unlessInFlight`, skipped while a rail send of the invoice is in flight, as that retry holds it again. + func releaseRailAttempt(paymentHash: String, unlessInFlight: Bool = true) { let hash = paymentHash.lowercased() eventCallbackLock.withLock { - guard !railSendsInFlight.contains(hash), var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), + guard !unlessInFlight || !railSendsInFlight.contains(hash), + var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), hashes.contains(hash) else { return } hashes.removeAll { $0 == hash } @@ -1722,7 +1723,8 @@ extension LightningService { /// Spark runs before the node starts, so its events wait until the node's event callback is set rather than being lost. func emitChannelFreeRailEvent(_ event: Event) { if case let .paymentFailed(_, paymentHash?, _) = event { - releaseRailAttempt(paymentHash: paymentHash) + // The failure of the send itself, which may arrive while its send call is still returning. + releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) } if isStaleFailure(event, from: .channelFree) { Logger.info("Ignored a Spark failure for a payment retried over a channel: \(event)", context: "LightningService") diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index d2a174afe..652d5e8e1 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -352,6 +352,28 @@ extension ChannelFreeRailTests { await assertRefusedAsDuplicate() } + func testASparkFailureReportedWhileItsSendIsInFlightStillReleasesTheHold() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + var release: CheckedContinuation? + rail.sendGate = { await withCheckedContinuation { release = $0 } } + rail.balanceSats = 100_000 + let send = Task { try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) } + var waited = 0 + while release == nil, waited < 500 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertNotNil(release, "The Spark send never started") + + Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: nil)) + release?.resume() + _ = try await send.value + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) From af9b9001df1c1e9b0b15024d4a6d0d5753ea6780 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:54:03 +0100 Subject: [PATCH 134/378] fix: end a later transfer once spark claims its deposit With Later, or after choosing to wait, the transfer runs no early claim, and nothing moved Setting Up on when Spark claimed the deposit at maturity, so it could show the transfer in progress after it had reached spending. Setting Up now ends the transfer once Spark lists its deposit as claimed, whatever the speed. --- Bitkit/ViewModels/SparkTransferFlow.swift | 11 +++++++++++ Bitkit/Views/Transfer/SettingUpView.swift | 4 ++++ BitkitTests/SparkTransferFlowTests.swift | 13 +++++++++++++ 3 files changed, 28 insertions(+) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 07d2f1332..478ac79d0 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -282,6 +282,17 @@ final class SparkTransferFlow { } } + /// Ends the transfer on screen once Spark lists its deposit as claimed, whatever the speed, so Later or Wait + /// doesn't stay in progress after the deposit reached spending. + func noteSettled(_ deposits: [SparkDeposit]) { + guard let txid, !claimState.isDone, + deposits.contains(where: { $0.isClaimed && $0.txid.caseInsensitiveCompare(txid) == .orderedSame }) + else { return } + watchTasks[txid]?.cancel() + watchTasks[txid] = nil + claimState = .adding + } + /// Accepts the early claim at the repriced fee. func payRepriced() { guard case let .repriced(feeSats) = claimState, let txid else { return } diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift index cba6e7430..c47f21bcd 100644 --- a/Bitkit/Views/Transfer/SettingUpView.swift +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -125,6 +125,10 @@ struct SettingUpView: View { .allowSwipeBack(false) .padding(.horizontal, 16) .bottomSafeAreaPadding() + // Later and Wait run no claim of their own, so the deposit Spark claims at maturity ends the transfer. + .onChange(of: SparkManager.shared.deposits, initial: true) { _, deposits in + flow.noteSettled(deposits) + } } private func repricedButtons(feeSats: UInt64) -> some View { diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index c0923f10b..2d631b870 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -154,6 +154,19 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .later, "Its result stays off the new transfer's screen") } + @MainActor + func testALaterTransferEndsOnceSparkClaimsItsDeposit() { + let flow = SparkTransferFlow(amountSats: 100_000, speed: .later, quote: quote) + flow.onBroadcast(txid: "txid") + + flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: false)]) + XCTAssertEqual(flow.claimState, .later) + flow.noteSettled([SparkDeposit(txid: "other", amountSats: 100_000, isClaimed: true)]) + XCTAssertEqual(flow.claimState, .later) + flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: true)]) + XCTAssertEqual(flow.claimState, .adding, "Later stayed in progress after the deposit reached spending") + } + /// A flow whose poll answers with `offers` in turn (nil once they run out) and whose claims answer with `claims`. @MainActor private func pollingFlow(offers: [SparkClaimOffer.Kind?], claims: [SparkEarlyClaim] = [], pollAttempts: Int? = nil) -> SparkTransferFlow { From 5a72971279424f248c696f0cbaf497b721bf79d6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:54:03 +0100 Subject: [PATCH 135/378] fix: claim a recovered funding at the swiped ceiling A funding whose outcome was unknown at the swipe took its early-claim ceiling and speed from the quote on screen when it resolved, and Spending Confirm kept requoting under the pending sheet, so a fee-rate rise could raise the ceiling past what the user approved and pay a higher early claim without asking. The ceiling and speed are now recorded at the swipe and used when the funding resolves; with nothing recorded, a higher early claim asks first. --- Bitkit/ViewModels/SparkTransferFlow.swift | 19 +++++++++++++++++-- Bitkit/Views/Transfer/SpendingConfirm.swift | 3 ++- BitkitTests/SparkTransferFlowTests.swift | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 478ac79d0..2fb902ba3 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -159,6 +159,7 @@ final class SparkTransferFlow { /// its results, which only the current transfer's screen shows. @ObservationIgnored private var offers: [String: SparkClaimOffer] = [:] @ObservationIgnored private var ceilings: [String: UInt64] = [:] + @ObservationIgnored private var approved: (ceilingSats: UInt64, speed: SparkTransferSpeed)? @ObservationIgnored private var watchTasks: [String: Task] = [:] @ObservationIgnored private var quoteRequestId = 0 @ObservationIgnored private let operations: SparkClaimOperations @@ -270,9 +271,9 @@ final class SparkTransferFlow { // MARK: Claim /// Records the broadcast deposit and starts its claim for the chosen speed. - func onBroadcast(txid: String) { + func onBroadcast(txid: String, ceilingSats: UInt64? = nil) { self.txid = txid - ceilings[txid] = quote?.claimFees.instantMaxSats ?? 0 + ceilings[txid] = ceilingSats ?? quote?.claimFees.instantMaxSats ?? 0 offers[txid] = nil claimState = speed == .instant ? .looking : .later watchTasks[txid]?.cancel() @@ -282,6 +283,20 @@ final class SparkTransferFlow { } } + /// Records the early-claim ceiling and speed the user swiped, as the confirm screen keeps requoting while a payment + /// whose outcome is unknown waits on the pending screen. + func approveSwipe() { + approved = (ceilingSats: quote?.claimFees.instantMaxSats ?? 0, speed: speed) + } + + /// Starts the claim of a funding whose outcome was unknown at the swipe, at the ceiling and speed then approved; with + /// nothing approved, an early claim asks first. + func onRecoveredBroadcast(txid: String) { + let approved = approved ?? (ceilingSats: 0, speed: speed) + speed = approved.speed + onBroadcast(txid: txid, ceilingSats: approved.ceilingSats) + } + /// Ends the transfer on screen once Spark lists its deposit as claimed, whatever the speed, so Later or Wait /// doesn't stay in progress after the deposit reached spending. func noteSettled(_ deposits: [SparkDeposit]) { diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 73dccce95..ab0064bae 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -82,7 +82,7 @@ struct SpendingConfirm: View { .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in guard Self.followsRecovered(resolution, isPaying: isPaying), !hideSwipeButton else { return } // This screen owns the recovered funding: follow it at the chosen speed, as a swipe would have. - flow.onBroadcast(txid: resolution.txid) + flow.onRecoveredBroadcast(txid: resolution.txid) hideSwipeButton = true navigation.navigate(.settingUp) } @@ -143,6 +143,7 @@ struct SpendingConfirm: View { throw SpendingFeesIncreasedError() } + flow.approveSwipe() do { let txid = try await transfer.fundSpark( address: fresh.address, diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 2d631b870..5ba02a184 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -167,6 +167,27 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .adding, "Later stayed in progress after the deposit reached spending") } + @MainActor + func testARecoveredFundingClaimsEarlyOnlyUpToTheSwipedCeiling() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + flow.quote = quote(instantMax: 337) + flow.approveSwipe() + // The confirm screen requoted under the pending sheet while fee rates rose. + flow.quote = quote(instantMax: 3010) + + flow.onRecoveredBroadcast(txid: "txid") + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "Paid an early claim fee the user never approved") + } + + private func quote(instantMax: UInt64) -> SparkTransferQuote { + SparkTransferQuote( + address: "bcrt1qtest", satsPerVbyte: 6, networkFeeSats: 577, depositSats: 100_000, + claimFees: SparkClaimFees(instantMaxSats: instantMax, laterSats: 198) + ) + } + /// A flow whose poll answers with `offers` in turn (nil once they run out) and whose claims answer with `claims`. @MainActor private func pollingFlow(offers: [SparkClaimOffer.Kind?], claims: [SparkEarlyClaim] = [], pollAttempts: Int? = nil) -> SparkTransferFlow { From 921c3f8c00865f227224e084fce6e6c2393618c6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:55:58 +0100 Subject: [PATCH 136/378] fix: close only a spark journal's own proof and allowance attempt --- Bitkit/Managers/SparkManager.swift | 13 ++++-- .../Services/PaykitPaymentProofService.swift | 42 +++++++++++++------ .../Wallets/Send/SendConfirmationView.swift | 3 +- .../PaykitPaymentProofServiceTests.swift | 30 +++++++++---- BitkitTests/PaykitRouteTests.swift | 13 ++++-- 5 files changed, 74 insertions(+), 27 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 1d4dc415f..80a754cc2 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -713,15 +713,20 @@ final class SparkManager: ChannelFreeLightningRail { /// allowance attempt, left unknown when the send's outcome was, then fails, so the ledger lets it be paid again. static func closeUnsentWithdrawal( _ pending: SparkContactWithdrawal, - releaseProof: (SparkContactWithdrawal.PaykitRequest) async -> Bool, + releaseProof: (SparkContactWithdrawal.PaykitRequest) async -> SparkProofRelease, failAllowance: (SparkContactWithdrawal.PaykitRequest) async -> Void ) async { if let request = pending.request { - guard await releaseProof(request) else { + switch await releaseProof(request) { + case .failed: Logger.warn("Kept the Spark contact withdrawal journal: its unsent proof is not released yet", context: "SparkManager") return + case .released: + // The allowance attempt begins only once the proof started, so only then is there one to fail. + await failAllowance(request) + case .nothingStarted: + break } - await failAllowance(request) } pendingContactWithdrawal = nil resolveContactWithdrawal(pending, txId: nil) @@ -733,7 +738,7 @@ final class SparkManager: ChannelFreeLightningRail { contactWithdrawalResolutionSubject.send(resolution) } - private static func releaseUnsentProof(_ request: SparkContactWithdrawal.PaykitRequest) async -> Bool { + private static func releaseUnsentProof(_ request: SparkContactWithdrawal.PaykitRequest) async -> SparkProofRelease { await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index d816e93e4..99f04a287 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -76,6 +76,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { var onchainLocalFollowupComplete: Bool? /// Retain the bounded original proof until its payer consumes the local resolution. var onchainResolutionAwaitingConsumption: Bool? = false + /// A Spark withdrawal pays this proof, so only that withdrawal's journal may complete or drop it. + var sparkWithdrawal: Bool? var hasUnsupportedOnchainWallet: Bool { kind == .onchain && onchainWalletId != nil && onchainWalletId != WalletScope.default @@ -818,7 +820,8 @@ actor PaykitPaymentProofService { paymentIdentity: String? = nil, signedTx: HwFundingSignedTx? = nil, privatePaymentListVersion: UInt64? = nil, - previousPrivatePaymentListVersion: UInt64? = nil + previousPrivatePaymentListVersion: UInt64? = nil, + sparkWithdrawal: Bool = false ) async throws { let identity = try await currentIdentity() try await mutationLock.withLock { @@ -831,7 +834,8 @@ actor PaykitPaymentProofService { serializedTx: signedTx?.serializedTx, signedTx: signedTx, privatePaymentListVersion: privatePaymentListVersion, - previousPrivatePaymentListVersion: previousPrivatePaymentListVersion + previousPrivatePaymentListVersion: previousPrivatePaymentListVersion, + sparkWithdrawal: sparkWithdrawal ) } } @@ -845,7 +849,8 @@ actor PaykitPaymentProofService { serializedTx: String?, signedTx: HwFundingSignedTx?, privatePaymentListVersion: UInt64?, - previousPrivatePaymentListVersion: UInt64? + previousPrivatePaymentListVersion: UInt64?, + sparkWithdrawal: Bool = false ) async throws { if let hardwareWalletId { guard hardwareWalletId != WalletScope.default, hardwareTransactionLookup.hasWallet(walletId: hardwareWalletId) @@ -887,6 +892,7 @@ actor PaykitPaymentProofService { pendingProofs[index].hardwareDispatchAttempted = signedTx.map { _ in false } pendingProofs[index].privatePaymentListVersion = privatePaymentListVersion pendingProofs[index].previousPrivatePaymentListVersion = privatePaymentListVersion == nil ? nil : previousPrivatePaymentListVersion + pendingProofs[index].sparkWithdrawal = sparkWithdrawal ? true : nil try await persist(pendingProofs) } @@ -1338,7 +1344,7 @@ actor PaykitPaymentProofService { var proofs = try await loadProofs() guard let index = proofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && - $0.paymentStarted && $0.onchainWalletId == nil && + $0.paymentStarted && $0.onchainWalletId == nil && $0.sparkWithdrawal == true && ($0.paymentIdentifier == nil || $0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame) }) else { return nil } proofs[index].paymentIdentifier = txid.lowercased() @@ -1360,22 +1366,24 @@ actor PaykitPaymentProofService { } /// Drops the started on-chain proof of a request whose Spark withdrawal never went out, giving back the private list - /// version it consumed when the contact's list is still at exactly that version. False when the proof could not be - /// dropped, so the withdrawal's journal stays open for a retry. + /// version it consumed when the contact's list is still at exactly that version. Only a proof the withdrawal started + /// goes, never a direct on-chain send's for the same request. `.failed` when it could not be dropped, so the + /// withdrawal's journal stays open for a retry. @discardableResult func failSparkOnchainPayment( requestId: PaykitPaymentRequest.ID, identity: String, privatePaykitService: PrivatePaykitService = .shared - ) async -> Bool { + ) async -> SparkProofRelease { do { - try await mutationLock.withLock { + return try await mutationLock.withLock { let proofs = try await loadProofs() let unsent = proofs.filter { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && - $0.paymentStarted && $0.onchainWalletId == nil && $0.paymentIdentifier == nil && $0.proofData == nil + $0.paymentStarted && $0.onchainWalletId == nil && $0.sparkWithdrawal == true && + $0.paymentIdentifier == nil && $0.proofData == nil } - guard !unsent.isEmpty else { return } + guard !unsent.isEmpty else { return .nothingStarted } for proof in unsent { guard let version = proof.privatePaymentListVersion else { continue } try await privatePaykitService.releaseUnsentPaymentListVersion( @@ -1393,11 +1401,11 @@ actor PaykitPaymentProofService { } throw error } + return .released } - return true } catch { logWarning("Failed to clear an unsent Spark on-chain proof: \(error)") - return false + return .failed } } @@ -2032,3 +2040,13 @@ actor PaykitPaymentProofService { } } } + +/// What dropping a Spark withdrawal's started proof did. +enum SparkProofRelease: Equatable { + /// The withdrawal's started proof is gone. + case released + /// The withdrawal never started a proof: a kill came before it, or a direct send owns the request's proof. + case nothingStarted + /// The proof could not be dropped, so it stays for a retry. + case failed +} diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 252f68a7b..7172ae410 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1523,7 +1523,8 @@ struct SendConfirmationView: View { address: leg.endpoint.value, paymentIdentity: identity, privatePaymentListVersion: privateContext.paymentListVersion, - previousPrivatePaymentListVersion: previousVersion + previousPrivatePaymentListVersion: previousVersion, + sparkWithdrawal: true ) } } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 9c170c3e7..f4ad72c03 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1894,7 +1894,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let txid = String(repeating: "cd", count: 32) try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) - try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, sparkWithdrawal: true) let completed = await service.completeSparkOnchainPayment(requestId: request.id, identity: identity, txid: txid) await sdk.waitForSubmissionStart() await fulfillment(of: [proofRemoved], timeout: 1) @@ -1919,30 +1919,46 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) - try await service.markOnchainPaymentStarted(request, address: onchainAddress) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, sparkWithdrawal: true) await service.failSparkOnchainPayment(requestId: request.id, identity: identity) let remaining = await store.snapshot() XCTAssertTrue(remaining.isEmpty) } - func testAnUnsentSparkWithdrawalProofThatCantBeDroppedSaysSo() async throws { + func testClosingASparkJournalLeavesADirectOnchainProofForTheSameRequest() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) let store = PaymentProofMemoryStore() let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + // A kill left the Spark journal open with only a prepared proof, and the request was then paid directly. try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.failSparkOnchainPayment(requestId: request.id, identity: identity) + + let remaining = await store.snapshot() + XCTAssertEqual(remaining.count, 1, "The direct send's proof is not the Spark withdrawal's to drop") + } + + func testAnUnsentSparkWithdrawalProofThatCantBeDroppedSaysSo() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, sparkWithdrawal: true) await store.failNextSave() let failed = await service.failSparkOnchainPayment(requestId: request.id, identity: identity) - XCTAssertFalse(failed, "The journal stays open only when the caller learns the proof is still there") + XCTAssertEqual(failed, .failed, "The journal stays open only when the caller learns the proof is still there") let remaining = await store.snapshot() XCTAssertEqual(remaining.count, 1) let retried = await service.failSparkOnchainPayment(requestId: request.id, identity: identity) - XCTAssertTrue(retried) + XCTAssertEqual(retried, .released) let afterRetry = await store.snapshot() XCTAssertTrue(afterRetry.isEmpty) } @@ -1962,7 +1978,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted( - request, address: onchainAddress, privatePaymentListVersion: 7, previousPrivatePaymentListVersion: 6 + request, address: onchainAddress, privatePaymentListVersion: 7, previousPrivatePaymentListVersion: 6, sparkWithdrawal: true ) // Spark's journal closes the withdrawal as never sent after the app restarted. let relaunched = paymentProofService(sdk: sdk, store: store) @@ -1988,7 +2004,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted( - request, address: onchainAddress, privatePaymentListVersion: 7, previousPrivatePaymentListVersion: nil + request, address: onchainAddress, privatePaymentListVersion: 7, previousPrivatePaymentListVersion: nil, sparkWithdrawal: true ) try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 8), attemptId: UUID()) await service.failSparkOnchainPayment(requestId: request.id, identity: identity, privatePaykitService: privatePaykit) diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 85843d5ba..7b8a49701 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -218,19 +218,26 @@ final class PaykitRouteTests: XCTestCase { SparkManager.pendingContactWithdrawal = pending var failedAllowances: [SparkContactWithdrawal.PaykitRequest] = [] - await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in false }, failAllowance: { failedAllowances.append($0) }) + await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in .failed }, failAllowance: { failedAllowances.append($0) }) XCTAssertEqual(SparkManager.pendingContactWithdrawal, pending, "A proof that wasn't released keeps the journal, so the next sync retries it") XCTAssertEqual(failedAllowances, [], "The attempt stays unknown while the journal is still open") var released: SparkContactWithdrawal.PaykitRequest? await SparkManager.closeUnsentWithdrawal( pending, - releaseProof: { released = $0; return true }, + releaseProof: { released = $0; return .released }, failAllowance: { failedAllowances.append($0) } ) XCTAssertEqual(released, request) XCTAssertEqual(failedAllowances, [request], "An unsent withdrawal fails its allowance attempt, so the ledger lets the request be paid again") XCTAssertNil(SparkManager.pendingContactWithdrawal) + + // A kill before the proof started leaves no Spark attempt, so a direct send's attempt for the request stays. + SparkManager.pendingContactWithdrawal = pending + failedAllowances = [] + await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in .nothingStarted }, failAllowance: { failedAllowances.append($0) }) + XCTAssertEqual(failedAllowances, [], "Only the Spark withdrawal's own allowance attempt fails") + XCTAssertNil(SparkManager.pendingContactWithdrawal) } func testLegReportsItsAllowanceAttemptWithTheSendsOutcome() async { @@ -305,7 +312,7 @@ final class PaykitRouteTests: XCTestCase { let subscription = SparkManager.contactWithdrawalResolutionPublisher.sink { resolutions.append($0) } defer { subscription.cancel() } - await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in true }, failAllowance: { _ in }) + await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in .released }, failAllowance: { _ in }) let unsent = SparkContactWithdrawalResolution(attempt: pending.attempt, txId: nil) XCTAssertEqual(resolutions, [unsent]) From b62ed541d040d94ded456de30687dbabd6b92973 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:56:19 +0100 Subject: [PATCH 137/378] fix: let go of an unpaid spark exit leaf ask without the sdk --- .../Services/SparkExit/SparkExitDriver.swift | 15 ++++--- BitkitTests/SparkExitTests.swift | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index cb2b8f28d..ff95c3500 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -96,17 +96,22 @@ struct SparkExitDriver { } // Still waiting for funding (a generation that finished meanwhile is back to running). if record.phase != .running { - guard let recovering else { return try saveIfChanged(record, from: before) } - // A skipped leaf's fee funding that never came is let go after the same week as its retries. + // A skipped leaf's fee funding that never came is let go after the same week as its retries. It needs + // only the chain, and lands whole: while the address can't be read nothing changes, and a later tick + // lets go. if record.isFundingWaitingLeaves, let since = record.retrySince, let tip = try? await chain.tipHeight(), tip.saturatingSub(since) >= Self.releaseBlocks { - Logger.warn("Spark exit lets go of skipped leaves whose fee funding never came", context: "SparkExitDriver") - letGoOfWaitingLeaves(&record) - await finish(&record, phase: .done) + var released = record + letGoOfWaitingLeaves(&released) + await finish(&released, phase: .done) + guard !released.isActive else { return try saveIfChanged(record, from: before) } + Logger.warn("Spark exit let go of skipped leaves whose fee funding never came", context: "SparkExitDriver") + record = released try save(record) return record } + guard let recovering else { return try saveIfChanged(record, from: before) } // Nothing to fund when every leaf is recovered cooperatively. if let shortfall = record.fundingShortfallSats { guard try await adoptFunding(&record, requiredSats: shortfall) else { diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index d153d103a..625b6c443 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1704,6 +1704,48 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.skippedLeafCount, 14) } + private func fundingAskForASkippedLeaf() async throws -> SparkExitRecord { + var record = rebuildingRecord() + record.leafIds = ["leaf-1", "leaf-2"] + record.skippedLeafCount = 3 + var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + record = try await driver().tick(record) + let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))]) + sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)] + sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"]) + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + return record + } + + /// The week's let-go of an unpaid fee ask needs only the chain: it comes while the SDK can't connect too. + func testAnUnpaidFeeAskIsLetGoWithoutTheSdk() async throws { + var record = try await fundingAskForASkippedLeaf() + chain.tip = 1000 + SparkExitDriver.releaseBlocks + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(record.phase, .done) + } + + /// A let-go tick that can't read the funding address changes nothing; the next one lets go once. + func testAnUnpaidFeeAskLetGoIsAllOrNothing() async throws { + var record = try await fundingAskForASkippedLeaf() + chain.tip = 1000 + SparkExitDriver.releaseBlocks + chain.isUtxosUnreadable = true + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136)) + XCTAssertEqual(record.waitingLeafIds, ["leaf-2"]) + XCTAssertEqual(record.skippedLeafCount, 3) + + chain.isUtxosUnreadable = false + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .done) + XCTAssertEqual(record.skippedLeafCount, 4) + } + /// A skipped leaf still unverified after about a week is let go, and the exit ends. func testAPartialBuildLetsTheSkippedLeafGoAfterAWeek() async throws { var record = rebuildingRecord() From 23627f47cd2419c6ec92d69ecd60e306ecde304f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:56:19 +0100 Subject: [PATCH 138/378] fix: allow a higher spark exit fee once a failed-leaf retry built --- Bitkit/Managers/SparkExitManager.swift | 18 +++++++++++++++--- BitkitTests/SparkExitFeeBumpTests.swift | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 1d8e25662..080566e7d 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -542,10 +542,22 @@ final class SparkExitManager { /// accepted and unconfirmed for more than `unconfirmedBlocksToSurface` blocks), or the network's next-block rate /// is above its own. Not while it rebuilds or waits for funding, and not once only leaves the operators hold are left. var canPayHigherFee: Bool { - guard let record, record.phase == .running, !record.isRebuilding, record.recovery != nil, !record.isRetryingFailedLeaves else { - return false + guard let record else { return false } + return Self.canPayHigherFee(record, tip: tipHeight, isNetworkAboveExitRate: isNetworkAboveExitRate) + } + + /// A recovery of the exit's own still has a step to confirm: a failed-leaf retry that built one counts too, its + /// timing kept for the leaves still outstanding. One waiting only on the operators has nothing to speed up. + nonisolated static func canPayHigherFee(_ record: SparkExitRecord, tip: UInt32?, isNetworkAboveExitRate: Bool) -> Bool { + guard record.phase == .running, !record.isRebuilding, let recovery = record.recovery?.response else { return false } + let hasStepToConfirm = recovery.transactions.contains { + if case .confirmed = $0.status { + return false + } + return true } - return record.underpricedStep(tip: tipHeight) != nil || isNetworkAboveExitRate + guard !record.isRetryingFailedLeaves || hasStepToConfirm else { return false } + return record.underpricedStep(tip: tip) != nil || isNetworkAboveExitRate } /// The network's next-block rate is above the exit's. diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index 1e6d29cb0..ee3327a65 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -322,6 +322,21 @@ final class SparkExitFeeBumpTests: XCTestCase { XCTAssertEqual(sdk.prepareRequests.last?.selection, .specific(leafIds: ["coop-2"])) } + /// A failed-leaf retry that built a recovery of its own can be sped up like any live recovery; its timing stays. + func testAFailedLeafRetryThatBuiltCanPayAHigherFee() async throws { + var record = doneWithFailedLeaf() + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])] + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 2) + XCTAssertNotNil(record.failedLeafRetrySince) + + XCTAssertTrue(SparkExitManager.canPayHigherFee(record, tip: 1000, isNetworkAboveExitRate: true)) + var waiting = doneWithFailedLeaf() + waiting.failedLeafRetrySince = 1000 + waiting.recovery = StoredRecovery(SparkExitFixtures.recovery([], failed: [failure])) + XCTAssertFalse(SparkExitManager.canPayHigherFee(waiting, tip: 1000, isNetworkAboveExitRate: true), "nothing of its own is left to speed up") + } + /// After about a week (1,008 blocks) the exit lets the leaf go and ends, saying what is left. func testAFailedLeafIsReleasedAfterAWeek() async throws { var record = doneWithFailedLeaf() From 9803432480532824c475ae355a8f36c319a6aa4e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 20:17:37 +0100 Subject: [PATCH 139/378] fix: keep a spark exit cancel out of its savings payment again --- Bitkit/Managers/SparkExitManager.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 080566e7d..6e9b61d9f 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -256,6 +256,16 @@ final class SparkExitManager { } func retrySavingsPayment() async throws { + // Under the ticks' lock from the check to the saved txid, as the start's payment: a cancel either ends the exit + // before savings pays again or finds the payment and refuses. + while isTicking { + try? await Task.sleep(nanoseconds: 100_000_000) + } + isTicking = true + defer { isTicking = false } + if let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) { + record = stored + } guard let current = record, let refused = refusedSavingsPayment else { return } let attempts = OnchainSendAttemptService.shared let txid = try await Self.payRefusedAgain(refused, record: current) { context in @@ -268,10 +278,6 @@ final class SparkExitManager { throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark exit savings payment refused again") } _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: refused.walletId, observedTxid: txid) - // Written between ticks, so a tick in flight never saves over it. - while isTicking { - try? await Task.sleep(nanoseconds: 100_000_000) - } guard var paid = record, paid.createdAt == current.createdAt, paid.isActive else { return } paid.fundingPaymentTxid = txid try saveRecord(paid) From 2b4e07e2ae4bc4cfe697288a863e3a1f823b7b47 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 20:17:37 +0100 Subject: [PATCH 140/378] fix: translate the refused spark exit savings payment line --- Bitkit/Resources/Localization/ar.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/ca.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/cs.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/de.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/el.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/es-419.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/es.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/fr.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/it.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/nl.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/pl.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/pt.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/ru.lproj/Localizable.strings | 1 + 14 files changed, 14 insertions(+) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 23ec4c3a3..45c6fb3db 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -328,6 +328,7 @@ "lightning__spark_exit__fees" = "رسوم الشبكة"; "lightning__spark_exit__receive" = "ستستلم نحو"; "lightning__spark_exit__savings_fee" = "رسوم الدفع من المدخرات"; +"lightning__spark_exit__savings_refused" = "لم يكتمل دفعك من المدخرات. ادفع مرة أخرى، أو موّل من محفظة أخرى."; "lightning__spark_exit__savings_total" = "الإجمالي من المدخرات"; "lightning__spark_exit__funding" = "تمويل الرسوم المطلوب"; "lightning__spark_exit__left_behind" = "يبقى على Spark"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index d554efef9..542567f07 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -365,6 +365,7 @@ "lightning__spark_exit__fees" = "Comissions de xarxa"; "lightning__spark_exit__receive" = "Rebràs aproximadament"; "lightning__spark_exit__savings_fee" = "Comissió del pagament des d'estalvis"; +"lightning__spark_exit__savings_refused" = "El teu pagament dels estalvis no s'ha completat. Torna a pagar o finança des d'un altre moneder."; "lightning__spark_exit__savings_total" = "Total des d'estalvis"; "lightning__spark_exit__funding" = "Finançament de comissions necessari"; "lightning__spark_exit__left_behind" = "Es queda a Spark"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 8012e8398..aabb844ee 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -381,6 +381,7 @@ "lightning__spark_exit__fees" = "Síťové poplatky"; "lightning__spark_exit__receive" = "Obdržíte přibližně"; "lightning__spark_exit__savings_fee" = "Poplatek za platbu z úspor"; +"lightning__spark_exit__savings_refused" = "Vaše platba z úspor neprošla. Zaplaťte znovu, nebo financujte z jiné peněženky."; "lightning__spark_exit__savings_total" = "Celkem z úspor"; "lightning__spark_exit__funding" = "Potřebné krytí poplatků"; "lightning__spark_exit__left_behind" = "Zůstává na Spark"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index bc2db9084..66eb5e7f5 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -379,6 +379,7 @@ "lightning__spark_exit__fees" = "Netzwerkgebühren"; "lightning__spark_exit__receive" = "Du erhältst etwa"; "lightning__spark_exit__savings_fee" = "Gebühr für Zahlung aus dem Sparkonto"; +"lightning__spark_exit__savings_refused" = "Deine Zahlung vom Sparkonto ist nicht durchgegangen. Zahle erneut oder finanziere aus einer anderen Wallet."; "lightning__spark_exit__savings_total" = "Gesamt aus dem Sparkonto"; "lightning__spark_exit__funding" = "Benötigte Gebührendeckung"; "lightning__spark_exit__left_behind" = "Bleibt auf Spark"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index f063cd7fb..0cd14ccd2 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -352,6 +352,7 @@ "lightning__spark_exit__fees" = "Τέλη δικτύου"; "lightning__spark_exit__receive" = "Λαμβάνετε περίπου"; "lightning__spark_exit__savings_fee" = "Τέλος πληρωμής από αποταμιεύσεις"; +"lightning__spark_exit__savings_refused" = "Η πληρωμή σας από αποταμιεύσεις δεν ολοκληρώθηκε. Πληρώστε ξανά ή χρηματοδοτήστε από άλλο πορτοφόλι."; "lightning__spark_exit__savings_total" = "Σύνολο από αποταμιεύσεις"; "lightning__spark_exit__funding" = "Απαιτούμενη χρηματοδότηση τελών"; "lightning__spark_exit__left_behind" = "Παραμένει στο Spark"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index ff3103406..eafcd3f78 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -382,6 +382,7 @@ "lightning__spark_exit__fees" = "Comisiones de red"; "lightning__spark_exit__receive" = "Recibes aproximadamente"; "lightning__spark_exit__savings_fee" = "Comisión del pago desde ahorros"; +"lightning__spark_exit__savings_refused" = "Tu pago desde ahorros no se completó. Paga de nuevo o financia desde otra billetera."; "lightning__spark_exit__savings_total" = "Total desde ahorros"; "lightning__spark_exit__funding" = "Fondos para comisiones necesarios"; "lightning__spark_exit__left_behind" = "Se queda en Spark"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index f9d0999a1..dfdd3acde 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -366,6 +366,7 @@ "lightning__spark_exit__fees" = "Comisiones de red"; "lightning__spark_exit__receive" = "Recibes aproximadamente"; "lightning__spark_exit__savings_fee" = "Comisión del pago desde ahorros"; +"lightning__spark_exit__savings_refused" = "Tu pago desde ahorros no se completó. Paga de nuevo o financia desde otro monedero."; "lightning__spark_exit__savings_total" = "Total desde ahorros"; "lightning__spark_exit__funding" = "Fondos para comisiones necesarios"; "lightning__spark_exit__left_behind" = "Se queda en Spark"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index a6b2a31e9..2cc67a62d 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -382,6 +382,7 @@ "lightning__spark_exit__fees" = "Frais de réseau"; "lightning__spark_exit__receive" = "Vous recevez environ"; "lightning__spark_exit__savings_fee" = "Frais de paiement depuis l'épargne"; +"lightning__spark_exit__savings_refused" = "Votre paiement depuis l'épargne n'a pas abouti. Payez à nouveau ou financez depuis un autre portefeuille."; "lightning__spark_exit__savings_total" = "Total depuis l'épargne"; "lightning__spark_exit__funding" = "Financement des frais requis"; "lightning__spark_exit__left_behind" = "Reste sur Spark"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index abf954889..2671dd02d 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -375,6 +375,7 @@ "lightning__spark_exit__fees" = "Commissioni di rete"; "lightning__spark_exit__receive" = "Ricevi circa"; "lightning__spark_exit__savings_fee" = "Commissione del pagamento dai risparmi"; +"lightning__spark_exit__savings_refused" = "Il tuo pagamento dai risparmi non è andato a buon fine. Paga di nuovo o finanzia da un altro portafoglio."; "lightning__spark_exit__savings_total" = "Totale dai risparmi"; "lightning__spark_exit__funding" = "Finanziamento commissioni necessario"; "lightning__spark_exit__left_behind" = "Resta su Spark"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index c25d7fce8..7d97db84a 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -379,6 +379,7 @@ "lightning__spark_exit__fees" = "Netwerkkosten"; "lightning__spark_exit__receive" = "Je ontvangt ongeveer"; "lightning__spark_exit__savings_fee" = "Vergoeding betaling uit spaargeld"; +"lightning__spark_exit__savings_refused" = "Je betaling uit spaargeld is niet gelukt. Betaal opnieuw of financier vanuit een andere wallet."; "lightning__spark_exit__savings_total" = "Totaal uit spaargeld"; "lightning__spark_exit__funding" = "Benodigde vergoedingsdekking"; "lightning__spark_exit__left_behind" = "Blijft op Spark"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index bf3d032c0..6d7efdb28 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -381,6 +381,7 @@ "lightning__spark_exit__fees" = "Opłaty sieciowe"; "lightning__spark_exit__receive" = "Otrzymasz około"; "lightning__spark_exit__savings_fee" = "Opłata za płatność z oszczędności"; +"lightning__spark_exit__savings_refused" = "Twoja płatność z Oszczędności nie przeszła. Zapłać ponownie lub sfinansuj z innego portfela."; "lightning__spark_exit__savings_total" = "Łącznie z oszczędności"; "lightning__spark_exit__funding" = "Potrzebne środki na opłaty"; "lightning__spark_exit__left_behind" = "Zostaje na Spark"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index d697d7b10..8a7753434 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -381,6 +381,7 @@ "lightning__spark_exit__fees" = "Taxas de rede"; "lightning__spark_exit__receive" = "Você recebe cerca de"; "lightning__spark_exit__savings_fee" = "Taxa do pagamento da poupança"; +"lightning__spark_exit__savings_refused" = "Seu pagamento da poupança não foi concluído. Pague novamente ou financie de outra carteira."; "lightning__spark_exit__savings_total" = "Total da poupança"; "lightning__spark_exit__funding" = "Fundos para taxas necessários"; "lightning__spark_exit__left_behind" = "Fica na Spark"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 480139804..569376120 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -379,6 +379,7 @@ "lightning__spark_exit__fees" = "Taxas de rede"; "lightning__spark_exit__receive" = "Recebe cerca de"; "lightning__spark_exit__savings_fee" = "Taxa do pagamento da poupança"; +"lightning__spark_exit__savings_refused" = "O seu pagamento da poupança não foi concluído. Pague novamente ou financie a partir de outra carteira."; "lightning__spark_exit__savings_total" = "Total da poupança"; "lightning__spark_exit__funding" = "Fundos para taxas necessários"; "lightning__spark_exit__left_behind" = "Fica na Spark"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index c8a97c4e0..1060c07b5 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -376,6 +376,7 @@ "lightning__spark_exit__fees" = "Сетевые комиссии"; "lightning__spark_exit__receive" = "Вы получите примерно"; "lightning__spark_exit__savings_fee" = "Комиссия платежа из сбережений"; +"lightning__spark_exit__savings_refused" = "Платёж из сбережений не прошёл. Оплатите снова или пополните с другого кошелька."; "lightning__spark_exit__savings_total" = "Всего из сбережений"; "lightning__spark_exit__funding" = "Нужно средств на комиссии"; "lightning__spark_exit__left_behind" = "Остаётся в Spark"; From 2555a90d85b3323cc798191c3d751c8c868fed9c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 20:34:20 +0100 Subject: [PATCH 141/378] fix: give back a request's private list when its spark proof never started --- Bitkit/Managers/SparkManager.swift | 14 +++++++++- .../Services/PaykitPaymentProofService.swift | 13 ++++++++- .../Wallets/Send/SendConfirmationView.swift | 19 +++++++++---- .../PaykitPaymentProofServiceTests.swift | 28 +++++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 80a754cc2..3c546d18c 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -739,7 +739,11 @@ final class SparkManager: ChannelFreeLightningRail { } private static func releaseUnsentProof(_ request: SparkContactWithdrawal.PaykitRequest) async -> SparkProofRelease { - await PaykitPaymentProofService.shared.failSparkOnchainPayment(requestId: request.id, identity: request.identity) + await PaykitPaymentProofService.shared.failSparkOnchainPayment( + requestId: request.id, + identity: request.identity, + unstartedPaymentList: request.paymentList + ) } private static func failAllowance(_ request: SparkContactWithdrawal.PaykitRequest) async { @@ -1288,6 +1292,14 @@ struct SparkContactWithdrawal: Codable, Equatable { struct PaykitRequest: Codable, Equatable { let id: PaykitPaymentRequest.ID let identity: String + /// The contact's private list the request consumed, given back when the withdrawal never started its proof. + var paymentList: PaymentList? + } + + /// A private payment list version a send consumed, and the one before it. + struct PaymentList: Codable, Equatable { + let version: UInt64 + let previousVersion: UInt64? } let address: String diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 99f04a287..f58b515ed 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1373,6 +1373,7 @@ actor PaykitPaymentProofService { func failSparkOnchainPayment( requestId: PaykitPaymentRequest.ID, identity: String, + unstartedPaymentList: SparkContactWithdrawal.PaymentList? = nil, privatePaykitService: PrivatePaykitService = .shared ) async -> SparkProofRelease { do { @@ -1383,7 +1384,17 @@ actor PaykitPaymentProofService { $0.paymentStarted && $0.onchainWalletId == nil && $0.sparkWithdrawal == true && $0.paymentIdentifier == nil && $0.proofData == nil } - guard !unsent.isEmpty else { return .nothingStarted } + guard !unsent.isEmpty else { + // A kill before the proof started leaves the list the request consumed only in the journal. + if let list = unstartedPaymentList { + try await privatePaykitService.releaseUnsentPaymentListVersion( + publicKey: requestId.counterparty, + version: list.version, + previousVersion: list.previousVersion + ) + } + return .nothingStarted + } for proof in unsent { guard let version = proof.privatePaymentListVersion else { continue } try await privatePaykitService.releaseUnsentPaymentListVersion( diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 7172ae410..441384f0b 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1510,14 +1510,21 @@ struct SendConfirmationView: View { cancelProof: { await PaykitPaymentProofService.shared.cancelPreparation(request) }, releasePaymentList: { await context?.resolvePrivatePaymentListConsumption(.definitePreBroadcastFailure) } ) - proofRequest = SparkContactWithdrawal.PaykitRequest(id: request.id, identity: identity) + // The proof and the journal keep the list version this send consumed, so dropping the withdrawal as + // unsent gives the list back, also after a kill before the proof started. + let previousVersion = await PrivatePaykitService.shared.consumedPaymentListVersion( + publicKey: request.counterparty, + attemptId: context?.id + ) + proofRequest = SparkContactWithdrawal.PaykitRequest( + id: request.id, + identity: identity, + paymentList: privateContext.paymentListVersion.map { + SparkContactWithdrawal.PaymentList(version: $0, previousVersion: previousVersion) + } + ) // Spark's withdrawal runs this once its journal is written, so recovery owns the started proof. markProofStarted = { - // The proof keeps the list version this send consumed, so dropping it as unsent gives the list back. - let previousVersion = await PrivatePaykitService.shared.consumedPaymentListVersion( - publicKey: request.counterparty, - attemptId: context?.id - ) try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( request, address: leg.endpoint.value, diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index f4ad72c03..0a847fb51 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1991,6 +1991,34 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) } + func testAKillBeforeTheSparkProofStartsGivesBackTheListAfterARelaunch() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let privatePaykit = PrivatePaykitService() + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: sdk, store: store) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 6), attemptId: UUID()) + try await privatePaykit.consumePrivatePaymentList(publicKey: request.counterparty, context: listContext(version: 7), attemptId: UUID()) + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + + // Killed after the journal was written, before the proof was marked started: only the journal knows the list. + let relaunched = paymentProofService(sdk: sdk, store: store) + let release = await relaunched.failSparkOnchainPayment( + requestId: request.id, + identity: identity, + unstartedPaymentList: SparkContactWithdrawal.PaymentList(version: 7, previousVersion: 6), + privatePaykitService: privatePaykit + ) + + XCTAssertEqual(release, .nothingStarted) + let released = await privatePaykit.consumedPaymentListVersion(publicKey: request.counterparty) + XCTAssertEqual(released, 6, "The unsent request can be paid from its private list again") + } + func testAnUnsentSparkWithdrawalLeavesAListAnotherPaymentConsumedSince() async throws { snapshotAppDefaultsDomain() UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) From c1ecd55358a4875447433b18b2f527bcecac415e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 21:45:44 +0100 Subject: [PATCH 142/378] fix: show a move that settles after the wait as moved A success after the two-minute wait now shows Moved to Spark with the fee paid and Close or Move The Rest, while the screen still shows that move. --- Bitkit/Managers/MoveToSparkManager.swift | 50 +++++++++++++++---- Bitkit/ViewModels/WalletViewModel.swift | 11 ++-- BitkitTests/MoveToSparkLateOutcomeTests.swift | 25 ++++++++++ 3 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 BitkitTests/MoveToSparkLateOutcomeTests.swift diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 3f1fe0640..92123c18e 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -197,19 +197,24 @@ final class MoveToSparkManager { bolt11: quote.invoice, sats: quote.amountSats, maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats), - onLateFailure: { [weak self] in self?.lateFailure(amountSats: quote.amountSats, moveGeneration: moveGeneration) } + onLateFailure: { [weak self] in self?.lateFailure(amountSats: quote.amountSats, moveGeneration: moveGeneration) }, + onLateSuccess: { [weak self, weak wallet] settled in + guard let self, let wallet else { return } + Task { + await self.lateSuccess( + amountSats: quote.amountSats, + feeSats: settled.feePaidSats, + moveGeneration: moveGeneration, + wallet: wallet + ) + } + } ) else { state = .inFlight(amountSats: quote.amountSats) return } await wallet.syncStateAsync() - let openChannels = (wallet.channels ?? []).filter(\.isChannelReady) - state = .done( - amountSats: quote.amountSats, - feeSats: settled.feePaidSats, - closeChannel: openChannels.count == 1 ? openChannels.first : nil, - hasRest: MoveToSparkMath.hasMovableBalance(limitSats: MoveToSparkMath.movableSats(channels: wallet.channels)) - ) + state = Self.doneState(amountSats: quote.amountSats, feeSats: settled.feePaidSats, channels: wallet.channels) } catch { Logger.error("Failed to move '\(quote.amountSats)' sats to Spark: \(error)", context: "MoveToSpark") state = Self.isNoRoute(error) || Self.isSendWithoutRoute(error, invoice: quote.invoice) @@ -261,12 +266,37 @@ final class MoveToSparkManager { throw MoveToSparkError.tooLarge(amountSats: amountSats) } + /// The outcome of a move that paid `feeSats`: Close when one channel is left, Move The Rest when it can still send. + nonisolated static func doneState(amountSats: UInt64, feeSats: UInt64, channels: [ChannelDetails]?) -> MoveToSparkState { + let openChannels = (channels ?? []).filter(\.isChannelReady) + return .done( + amountSats: amountSats, + feeSats: feeSats, + closeChannel: openChannels.count == 1 ? openChannels.first : nil, + hasRest: MoveToSparkMath.hasMovableBalance(limitSats: MoveToSparkMath.movableSats(channels: channels)) + ) + } + + /// Whether the screen still shows the in-flight move of `moveGeneration`: a later move counts a new generation. + nonisolated static func showsInFlightMove(_ state: MoveToSparkState, generation: Int, moveGeneration: Int) -> Bool { + guard generation == moveGeneration, case .inFlight = state else { return false } + return true + } + + /// A move that was still in flight paid after the wait: the screen, if it still shows that same move, shows it moved. + private func lateSuccess(amountSats: UInt64, feeSats: UInt64, moveGeneration: Int, wallet: WalletViewModel) async { + Logger.info("Move to Spark of '\(amountSats)' sats succeeded after the wait", context: "MoveToSpark") + guard Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) else { return } + await wallet.syncStateAsync() + guard Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) else { return } + state = Self.doneState(amountSats: amountSats, feeSats: feeSats, channels: wallet.channels) + } + /// A move that was still in flight failed after the wait: its amount stays in the channel, so the screen, if it still /// shows that same move, says it could not move, and a toast tells the user wherever they are. private func lateFailure(amountSats: UInt64, moveGeneration: Int) { Logger.warn("Move to Spark of '\(amountSats)' sats failed after the wait", context: "MoveToSpark") - // Only the move that owns the screen may change it: a later move counts a new generation. - if generation == moveGeneration, case .inFlight = state { + if Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) { state = .failed(.unknown) } guard !ToastWindowManager.areToastsDisabled() else { return } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 54b2539c9..0c801fc73 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1169,13 +1169,14 @@ class WalletViewModel: ObservableObject { /// Pays `bolt11` from the channels only and waits up to `timeout` for its outcome; `nil` means it is still in flight. /// It listens before sending, so an outcome LDK reports before the send returns is not missed, and it matches a /// failure by payment id as well as by hash, since LDK can report one without its hash. After a timeout it keeps - /// listening, and a failure that arrives later calls `onLateFailure`. + /// listening: a failure that arrives later calls `onLateFailure`, and a success `onLateSuccess`. func sendOverChannels( bolt11: String, sats: UInt64, maxFeeMsat: UInt64, timeout: Duration = .seconds(120), - onLateFailure: @escaping @MainActor () -> Void = {} + onLateFailure: @escaping @MainActor () -> Void = {}, + onLateSuccess: @escaping @MainActor (SettledLightningPayment) -> Void = { _ in } ) async throws -> SettledLightningPayment? { // LDK's default route parameters, with the routing fee capped. let params = RouteParametersConfig( @@ -1213,8 +1214,10 @@ class WalletViewModel: ObservableObject { continue } if isAnswered { - if case .failure = outcome { - onLateFailure() + switch outcome { + case let .success(settled?): onLateSuccess(settled) + case .success: break + case .failure: onLateFailure() } } else { isAnswered = true diff --git a/BitkitTests/MoveToSparkLateOutcomeTests.swift b/BitkitTests/MoveToSparkLateOutcomeTests.swift new file mode 100644 index 000000000..3e3d14645 --- /dev/null +++ b/BitkitTests/MoveToSparkLateOutcomeTests.swift @@ -0,0 +1,25 @@ +@testable import Bitkit +import LDKNode +import XCTest + +final class MoveToSparkLateOutcomeTests: XCTestCase { + func testLateSuccessOfTheInFlightMoveShowsItMovedWithItsFee() { + let state = MoveToSparkState.inFlight(amountSats: 40000) + XCTAssertTrue(MoveToSparkManager.showsInFlightMove(state, generation: 3, moveGeneration: 3)) + + let emptied = ChannelDetails.mock(outboundCapacityMsat: 500_000) + let done = MoveToSparkManager.doneState(amountSats: 40000, feeSats: 12, channels: [emptied]) + XCTAssertEqual(done, .done(amountSats: 40000, feeSats: 12, closeChannel: emptied, hasRest: false)) + } + + func testLateSuccessOfAPartialMoveOffersTheRest() { + let channel = ChannelDetails.mock(outboundCapacityMsat: 50_000_000) + let done = MoveToSparkManager.doneState(amountSats: 25000, feeSats: 7, channels: [channel]) + XCTAssertEqual(done, .done(amountSats: 25000, feeSats: 7, closeChannel: channel, hasRest: true)) + } + + func testLateOutcomeOfAnOlderMoveLeavesTheScreen() { + XCTAssertFalse(MoveToSparkManager.showsInFlightMove(.inFlight(amountSats: 40000), generation: 4, moveGeneration: 3)) + XCTAssertFalse(MoveToSparkManager.showsInFlightMove(.quoting, generation: 3, moveGeneration: 3)) + } +} From b3006724a0d573f8b21bad0ae6db0db85eec6470 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 21:47:46 +0100 Subject: [PATCH 143/378] fix: let no spark connect bring a wiped wallet back --- Bitkit/Managers/SparkManager.swift | 50 +++++++++++++++++++++----- BitkitTests/SparkExitReviewTests.swift | 14 ++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index f04ce7d6d..1d71471c5 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -60,6 +60,9 @@ final class SparkManager: ChannelFreeLightningRail { private static let optimizationWaitSeconds: TimeInterval = 120 /// Bumped by `wipe`, so a connect that started before it disconnects instead of keeping the wiped wallet. @ObservationIgnored private var connectGeneration = 0 + /// Set for the whole wipe: no connect, start or exit reconnect is admitted, so nothing brings the wiped wallet's + /// Spark identity back while its mnemonic still exists. + @ObservationIgnored private var isWiping = false /// The refresh in flight; callers during it wait for it, and it reads once more for them. @ObservationIgnored private var refreshTask: Task? @ObservationIgnored private var refreshAgain = false @@ -92,7 +95,7 @@ final class SparkManager: ChannelFreeLightningRail { /// Connects Spark again so its leaf optimization follows whether an exit runs: off when one starts, back on /// once it ends. func reconnectForExit() async { - guard let network = Self.sparkNetwork else { return } + guard let network = Self.sparkNetwork, !isWiping else { return } noteOptimizationCutShort() if let sdk { if let listenerId { @@ -265,7 +268,7 @@ final class SparkManager: ChannelFreeLightningRail { } func start(walletIndex: Int) async { - guard let network = Self.sparkNetwork else { return } + guard let network = Self.sparkNetwork, !isWiping else { return } self.walletIndex = walletIndex SparkExitManager.shared.load(walletIndex: walletIndex) do { @@ -309,6 +312,7 @@ final class SparkManager: ChannelFreeLightningRail { } private func connect(walletIndex: Int, network: BreezSdkSpark.Network, generation: Int) async throws { + guard !isWiping else { throw CancellationError() } guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { throw CustomServiceError.mnemonicNotFound } @@ -323,7 +327,7 @@ final class SparkManager: ChannelFreeLightningRail { await builder.withRestChainService(url: chainServiceUrl, apiType: .esplora, credentials: nil) } let connected = try await builder.build() - guard generation == connectGeneration else { + guard generation == connectGeneration, !isWiping else { try? await connected.disconnect() throw CancellationError() } @@ -359,7 +363,37 @@ final class SparkManager: ChannelFreeLightningRail { /// Disconnects and deletes this wallet's Spark data. A connect still running is waited for first, so it can't /// keep the wiped wallet; throws when the data could not be deleted, so the wipe is retried. func wipe(walletIndex: Int) async throws { - connectGeneration += 1 + defer { isWiping = false } + var disconnectError: Error? + try await Self.wipeInOrder( + closeAdmission: { + isWiping = true + connectGeneration += 1 + }, + drainExitWork: { await SparkExitManager.shared.wipe() }, + disconnect: { disconnectError = await disconnectForWipe() }, + deleteData: { try deleteWipedData(walletIndex: walletIndex) } + ) + if let disconnectError { + throw disconnectError + } + } + + /// The wipe's steps, in the order that keeps the wiped wallet's identity from coming back. + static func wipeInOrder( + closeAdmission: () -> Void, + drainExitWork: () async -> Void, + disconnect: () async -> Void, + deleteData: () throws -> Void + ) async throws { + closeAdmission() + // An exit tick finishing now may reconnect; with admission closed it can't, and it is done before the SDK goes. + await drainExitWork() + await disconnect() + try deleteData() + } + + private func disconnectForWipe() async -> Error? { if let connectTask { _ = try? await connectTask.value } @@ -390,7 +424,10 @@ final class SparkManager: ChannelFreeLightningRail { refreshAgain = false activitiesSynced = false isReady = false - await SparkExitManager.shared.wipe() + return disconnectError + } + + private func deleteWipedData(walletIndex: Int) throws { balanceSats = 0 pendingDepositSats = 0 recoverableFundsSats = 0 @@ -403,9 +440,6 @@ final class SparkManager: ChannelFreeLightningRail { onStateChanged?() try Self.removeIfPresent(Env.sparkStorage(walletIndex: walletIndex)) try Self.removeIfPresent(Env.sparkExitRestore(walletIndex: walletIndex)) - if let disconnectError { - throw disconnectError - } } nonisolated static func removeIfPresent(_ url: URL) throws { diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 368904e5a..0b4318083 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -710,6 +710,20 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(contexts.count, 1) } + /// A wipe closes Spark's connects first, then lets exit work (a tick that may reconnect) finish, and only then + /// disconnects and deletes: nothing can reconnect the wiped wallet after its SDK is cleared. + @MainActor + func testAWipeClosesConnectsAndDrainsExitWorkBeforeItDisconnects() async throws { + var steps: [String] = [] + try await SparkManager.wipeInOrder( + closeAdmission: { steps.append("close") }, + drainExitWork: { steps.append("drain") }, + disconnect: { steps.append("disconnect") }, + deleteData: { steps.append("delete") } + ) + XCTAssertEqual(steps, ["close", "drain", "disconnect", "delete"]) + } + /// Fee rates that cannot be read fail the savings price, so Pay from savings shows its error instead of loading. func testSavingsFeeFailsWithoutFeeRates() async { let rates = FeeRates(fast: 9, mid: 6, slow: 1) From 448860488b3a82ea69521912808cffcf0ac918e8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 21:47:47 +0100 Subject: [PATCH 144/378] fix: offer no spark exit fee rate a leaf can't pay its exit at --- Bitkit/Services/SparkExit/SparkExitDriver.swift | 8 ++++++++ BitkitTests/SparkExitFeeBumpTests.swift | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index ff95c3500..d7f651f94 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -732,6 +732,14 @@ struct SparkExitDriver { let dropped = leavesBefore.subtracting(record.exitLeafIds).reduce(UInt64(0)) { $0 + (values[$1] ?? 0) } throw SparkExitFeeBumpError.leavesBehind(sats: dropped) } + // A prepare that succeeds can still skip an exiting leaf whose exit costs more than it holds at this rate. + let unpaid = prepared.skipped.filter { + guard case .feeExceedsValue = $0.reason else { return false } + return record.exitLeafIds.contains($0.leafId) + } + if !unpaid.isEmpty { + throw SparkExitFeeBumpError.leavesBehind(sats: unpaid.reduce(0) { $0 + $1.valueSats }) + } guard prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 } do { _ = try await recovering.recover( diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index ee3327a65..3d6b5b19f 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -153,6 +153,22 @@ final class SparkExitFeeBumpTests: XCTestCase { XCTAssertTrue(chain.broadcasts.isEmpty, "pricing broadcasts nothing") } + /// A rate at which the prepare succeeds but skips an exiting leaf for costing more than it holds is never offered. + func testFeeBumpRefusesARateAtWhichThePrepareSkipsAnExitingLeafForItsFee() async throws { + var record = runningRecord() + record.leafIds = ["leaf-1", "leaf-2"] + var prepared = SparkExitFixtures.prepared(funding: nil) + prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 2000, reason: .feeExceedsValue)] + sdk.preparedResponse = prepared + + do { + _ = try await driver().feeBumpShortfall(record, rate: 10) + XCTFail("leaf-2 would stay on Spark at this rate") + } catch let error as SparkExitFeeBumpError { + XCTAssertEqual(error, .leavesBehind(sats: 2000)) + } + } + /// Short at the higher rate: what the inputs lack, plus the input fee of the payment to come at the new rate /// (68 vB at 10 sat/vB). func testFeeBumpShortfallIsWhatTheInputsLackPlusTheNextInput() async throws { From 733d9a59e68acbc0e2bf14f41ba728c849907fa6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 21:47:47 +0100 Subject: [PATCH 145/378] fix: keep spark exit reminders while a retry's steps confirm --- Bitkit/Services/SparkExit/SparkExitRecord.swift | 11 +++++++++-- BitkitTests/SparkExitFeeBumpTests.swift | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 4cdf00f89..185d47e19 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -436,9 +436,16 @@ struct SparkExitRecord: Codable, Equatable { isRetryingFailedLeaves || (isActive && (!(recovery?.response.failed.isEmpty ?? true) || !(waitingLeafIds ?? []).isEmpty)) } - /// Waiting for the operators to co-sign leaves they moved on-chain: nothing of the exit's own is left to send. + /// Waiting for the operators to co-sign leaves they moved on-chain: nothing of the exit's own is left to send or + /// confirm, so a retry whose steps still confirm keeps their reminders. var isWaitingForOperators: Bool { - isRetryingFailedLeaves && !(recovery?.response.failed.isEmpty ?? true) + guard isRetryingFailedLeaves, let recovery = recovery?.response, !recovery.failed.isEmpty else { return false } + return recovery.transactions.allSatisfy { + if case .confirmed = $0.status { + return true + } + return false + } } var failedLeavesSats: UInt64 { diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index 3d6b5b19f..64401c87d 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -169,6 +169,21 @@ final class SparkExitFeeBumpTests: XCTestCase { } } + /// A failed-leaf retry with steps of its own still to confirm keeps their reminders: it is not waiting only on the + /// operators. + func testAFailedLeafRetryWithStepsToConfirmIsNotWaitingOnlyOnTheOperators() { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)], failed: [failure])) + record.failedLeafRetrySince = 1000 + XCTAssertFalse(record.isWaitingForOperators, "c1 still has to confirm") + + record.recovery = StoredRecovery(SparkExitFixtures.recovery( + [SparkExitFixtures.tx("c1", kind: .cooperative, status: .confirmed(blockHeight: 1001))], + failed: [failure] + )) + XCTAssertTrue(record.isWaitingForOperators) + } + /// Short at the higher rate: what the inputs lack, plus the input fee of the payment to come at the new rate /// (68 vB at 10 sat/vB). func testFeeBumpShortfallIsWhatTheInputsLackPlusTheNextInput() async throws { From 514a6add5cd2bb4971d48190164aeb6ffc0f0c31 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 22:10:28 +0100 Subject: [PATCH 146/378] fix: back up spark exit data changed while the node was stopped Backup observing stops while the node does, but Spark stays connected and can report exit data changes meanwhile; those were lost, and the once-per-connection mark then skipped the backup on restart, leaving it stale. Every connect and exit data change now bumps a generation kept outside the observers, and observing marks the backup on restart when that generation moved since it last did. --- Bitkit/Managers/SparkManager.swift | 27 ++++++++++++++++++-------- Bitkit/Services/BackupService.swift | 26 ++++++++++++------------- BitkitTests/SparkExitBackupTests.swift | 18 +++++++++++++---- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index a853fba97..e4ab965c6 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -15,6 +15,20 @@ import Observation final class SparkManager: ChannelFreeLightningRail { static let shared = SparkManager() private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject() + private nonisolated static let exitStateLock = NSLock() + private nonisolated(unsafe) static var exitStateGenerationValue = 0 + + /// Bumped on every connect and exit data change, kept outside the backup observers, which stop while the node does, + /// so a change they missed is still backed up once they start again. + nonisolated static var exitStateGeneration: Int { + exitStateLock.withLock { exitStateGenerationValue } + } + + nonisolated static func notifyExitStateChanged() { + exitStateLock.withLock { exitStateGenerationValue += 1 } + exitStateChanged.send() + } + /// Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. nonisolated static var exitStateChangedPublisher: AnyPublisher { exitStateChanged.eraseToAnyPublisher() @@ -62,14 +76,12 @@ final class SparkManager: ChannelFreeLightningRail { sdk != nil } - /// Counts connects in this process, nil while disconnected, so a backup can tell a new connection from observing + /// The exit data's generation while connected, nil while disconnected, so a backup can tell a change from observing /// that restarted while Spark stayed connected. - var connectionId: Int? { - sdk == nil ? nil : connectionCount + var exitStateVersion: Int? { + sdk == nil ? nil : Self.exitStateGeneration } - @ObservationIgnored private var connectionCount = 0 - /// Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a /// Spark transfer, which lets two dev builds pay each other's invoices. nonisolated static var prefersSpark: Bool { @@ -152,10 +164,9 @@ final class SparkManager: ChannelFreeLightningRail { await self?.onSdkEvent(event) }) sdk = connected - connectionCount += 1 Logger.info("Connected Spark wallet on '\(network)'", context: "SparkManager") await importRestoredExitState(walletIndex: walletIndex) - Self.exitStateChanged.send() + Self.notifyExitStateChanged() } /// The SDK config Bitkit connects with. @@ -718,7 +729,7 @@ final class SparkManager: ChannelFreeLightningRail { case let .paymentPending(payment): await recordActivity(payment) case .unilateralExitStateChanged: - Self.exitStateChanged.send() + Self.notifyExitStateChanged() default: break } diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index be89f62c2..4c84411dd 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -312,10 +312,10 @@ class BackupService { Logger.debug("Start observing backup statuses and data store changes", context: "BackupService") await installBackupObservers(generation: observation.generation, foregroundGeneration: observation.foregroundGeneration) - // Spark's connect signal is missed when it connected before observing started. Spark stays connected while - // the node stops, so observing restarts on each foreground: mark it once per connection. - let connection = await SparkManager.shared.connectionId - if stateQueue.sync(execute: { sparkExitMark.claim(connection: connection) }) { + // Spark's exit data signals are missed while observing is stopped, as it is while the node stops and Spark + // stays connected: mark it when the exit data changed since it was last marked, not on every restart. + let version = await SparkManager.shared.exitStateVersion + if stateQueue.sync(execute: { sparkExitMark.claim(version: version) }) { markBackupRequired(category: .sparkExit) } } @@ -883,8 +883,8 @@ class BackupService { guard let self, !self.shouldSkipBackup() else { return } markBackupRequired(category: .sparkExit) Task { @MainActor in - let connection = SparkManager.shared.connectionId - _ = self.stateQueue.sync { self.sparkExitMark.claim(connection: connection) } + let version = SparkManager.shared.exitStateVersion + _ = self.stateQueue.sync { self.sparkExitMark.claim(version: version) } } } .store(in: cancellables) @@ -1646,15 +1646,15 @@ struct BackupFailureNotificationGate { } } -/// Which Spark connection the exit data backup was last marked for, so observing restarting on a foreground doesn't -/// mark it again while Spark stayed connected. +/// Which Spark exit data generation the backup was last marked for, so observing restarting on a foreground marks it +/// only when the exit data changed meanwhile. struct SparkExitBackupMark { - private var markedConnection: Int? + private var markedVersion: Int? - /// True when `connection` is a Spark connection not marked yet, and records it. - mutating func claim(connection: Int?) -> Bool { - guard let connection, connection != markedConnection else { return false } - markedConnection = connection + /// True when `version` is an exit data generation not marked yet, and records it; nil while Spark is disconnected. + mutating func claim(version: Int?) -> Bool { + guard let version, version != markedVersion else { return false } + markedVersion = version return true } } diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift index 73144a465..7a9d2f485 100644 --- a/BitkitTests/SparkExitBackupTests.swift +++ b/BitkitTests/SparkExitBackupTests.swift @@ -52,9 +52,19 @@ final class SparkExitBackupTests: XCTestCase { func testObservingRestartedOnAForegroundDoesNotMarkTheSameConnectionAgain() { var mark = SparkExitBackupMark() - XCTAssertFalse(mark.claim(connection: nil), "Nothing to back up while Spark is disconnected") - XCTAssertTrue(mark.claim(connection: 1)) - XCTAssertFalse(mark.claim(connection: 1), "Spark stayed connected while observing restarted") - XCTAssertTrue(mark.claim(connection: 2), "A new connection is backed up again") + XCTAssertFalse(mark.claim(version: nil), "Nothing to back up while Spark is disconnected") + XCTAssertTrue(mark.claim(version: 1)) + XCTAssertFalse(mark.claim(version: 1), "Spark stayed connected while observing restarted") + XCTAssertTrue(mark.claim(version: 2), "A changed exit state is backed up again") + } + + func testAnExitStateChangeMissedWhileObservingWasStoppedIsBackedUpOnRestart() { + var mark = SparkExitBackupMark() + _ = mark.claim(version: SparkManager.exitStateGeneration) + + // Spark reports a change while the node, and so backup observing, is stopped. + SparkManager.notifyExitStateChanged() + + XCTAssertTrue(mark.claim(version: SparkManager.exitStateGeneration), "The restart kept the stale exit data backup") } } From 2b011764d103bc5fbbecd83ff714f20a00fee8f3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 22:10:29 +0100 Subject: [PATCH 147/378] fix: keep the remote spark exit backup until its restore reads it A restore that couldn't fetch or decode the Spark exit data backup was treated as done, so the next backup could upload a fresh export over the remote one before the recovery data was read. The restore now stays pending, kept across launches, until it succeeds or finds no backup there; until then each backup attempt tries the restore again and uploads nothing. --- Bitkit/Services/BackupService.swift | 45 +++++++++++++++++++++++--- BitkitTests/SparkExitBackupTests.swift | 28 ++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 4c84411dd..e43bd8ba1 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -130,6 +130,10 @@ class BackupService { private var foregroundObservationGeneration: UInt64 = 0 private let defaults: UserDefaults + private let fetchBackup: (String) async throws -> Data? + /// Set while the Spark exit data restore hasn't succeeded or found no older backup, kept across launches, so a fresh + /// export never replaces the remote backup it couldn't read yet. + private static let sparkExitRestorePendingKey = "sparkExitRestorePending" private let backupData: ((BackupCategory) async throws -> Data)? private let uploadBackup: (String, Data) async throws -> Void private let waitForBackupDelay: () async throws -> Void @@ -173,9 +177,13 @@ class BackupService { }, waitForForegroundRecoveryTimeout: @escaping () async throws -> Void = { try await Task.sleep(for: .seconds(BackupService.backupFailureCheckInterval)) + }, + fetchBackup: @escaping (String) async throws -> Data? = { key in + try await VssBackupClient.shared.getObject(key: key)?.value } ) { self.defaults = defaults + self.fetchBackup = fetchBackup self.backupData = backupData self.usdtRecoveryBackup = usdtRecoveryBackup self.uploadBackup = uploadBackup @@ -418,6 +426,11 @@ class BackupService { try await persistWalletBackup() } } else if category == .sparkExit { + // A restore that couldn't read the remote backup is tried again first, and blocks replacing it until then. + if defaults.bool(forKey: Self.sparkExitRestorePendingKey) { + await restoreSparkExit() + } + guard !defaults.bool(forKey: Self.sparkExitRestorePendingKey) else { throw SparkExitBackupError.restorePending } // Nothing to back up until Spark connects, which marks this category required again. if let data = try await sparkExitBackupData() { try await uploadBackup(category.rawValue, data) @@ -461,6 +474,9 @@ class BackupService { if case MetadataBackupError.deferred = error { outcome = .busy Logger.debug("Metadata backup deferred until wallet restoration and backup complete", context: "BackupService") + } else if error is SparkExitBackupError { + outcome = .busy + Logger.warn("Spark exit backup deferred until its restore reads the remote backup", context: "BackupService") } else if error is WalletBackupSnapshotError { outcome = .snapshotChanged Logger.warn("Wallet backup deferred after three changing snapshots", context: "BackupService") @@ -797,10 +813,8 @@ class BackupService { Logger.debug("Restored \(payload.orders.count) orders, \(payload.cjitEntries.count) CJITs", context: "BackupService") } - try await performRestore(category: .sparkExit) { dataBytes in - let backup = try JSONDecoder().decode(SparkExitBackupV1.self, from: dataBytes) - try await SparkManager.shared.restoreExitBackup(backup, walletIndex: LightningService.shared.currentWalletIndex) - } + await restoreSparkExit() + markRestoreComplete(category: .sparkExit) // Always reset PIN settings after restore (PIN is never backed up for security) await SettingsViewModel.shared.resetPinSettings() @@ -1408,6 +1422,24 @@ class BackupService { } } + /// Restores the Spark exit data backup. Until that succeeds, or the backup is found absent (not just unreadable), the + /// restore stays pending and the next backup attempt tries it again before uploading. + func restoreSparkExit() async { + defaults.set(true, forKey: Self.sparkExitRestorePendingKey) + do { + if let data = try await fetchBackup(BackupCategory.sparkExit.rawValue) { + let backup = try JSONDecoder().decode(SparkExitBackupV1.self, from: data) + try await SparkManager.shared.restoreExitBackup(backup, walletIndex: LightningService.shared.currentWalletIndex) + Logger.info("Restore success for: '\(BackupCategory.sparkExit.rawValue)'", context: "BackupService") + } else { + Logger.warn("Restore null for: '\(BackupCategory.sparkExit.rawValue)'", context: "BackupService") + } + defaults.removeObject(forKey: Self.sparkExitRestorePendingKey) + } catch { + Logger.warn("Restore error for: '\(BackupCategory.sparkExit.rawValue)': \(error)", context: "BackupService") + } + } + private func sparkExitBackupData() async throws -> Data? { if let backupData { return try await backupData(.sparkExit) @@ -1646,6 +1678,11 @@ struct BackupFailureNotificationGate { } } +enum SparkExitBackupError: Error { + /// The restore couldn't read the remote backup yet, so a fresh export must not replace it. + case restorePending +} + /// Which Spark exit data generation the backup was last marked for, so observing restarting on a foreground marks it /// only when the exit data changed meanwhile. struct SparkExitBackupMark { diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift index 7a9d2f485..cc682177b 100644 --- a/BitkitTests/SparkExitBackupTests.swift +++ b/BitkitTests/SparkExitBackupTests.swift @@ -31,6 +31,34 @@ final class SparkExitBackupTests: XCTestCase { XCTAssertEqual(restored.network, backup.network) } + func testAFailedExitRestoreBlocksReplacingTheRemoteBackupUntilItRecovers() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let export = try JSONEncoder().encode(SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "fresh")) + var remoteIsReadable = false + var uploads: [String] = [] + let service = BackupService( + defaults: defaults, + backupData: { _ in export }, + uploadBackup: { key, _ in uploads.append(key) }, + waitForTemporaryFailureDelay: { _ in }, + fetchBackup: { _ in + guard remoteIsReadable else { throw URLError(.notConnectedToInternet) } + return nil + } + ) + + await service.restoreSparkExit() + _ = await service.triggerBackup(category: .sparkExit) + XCTAssertTrue(uploads.isEmpty, "A fresh export replaced a remote exit backup the restore couldn't read") + + // The next attempt finds no older backup, so the fresh export may go up. + remoteIsReadable = true + _ = await service.triggerBackup(category: .sparkExit) + XCTAssertEqual(uploads, ["SPARK_EXIT"]) + } + func testStagesARestoredExitStateUntilSparkConnects() async throws { guard Env.network == .regtest else { throw XCTSkip("Spark's test network is regtest") } let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "state") From 1fffd79f3ae45b0019971cdab19cee3d9ddacaa6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 22:10:29 +0100 Subject: [PATCH 148/378] fix: offer chain funding of spark only where spark reads that chain Spark's default regtest is its own chain, so a regtest transfer from savings or a hardware wallet spent test funds on Bitkit's chain without ever crediting Spark. Funding Spark from the chain is now offered only where Spark reads Bitkit's chain: mainnet, or a regtest debug or E2E build launched with SPARK_CHAIN_SERVICE_URL; elsewhere it shows Transfer Unavailable. Receiving over Lightning is unchanged. --- Bitkit/Constants/Env.swift | 5 +++-- Bitkit/Managers/SparkManager.swift | 11 +++++++++++ Bitkit/Views/Transfer/FundingOptions.swift | 7 +++++-- Bitkit/Views/Wallets/HardwareWalletScreen.swift | 2 +- Bitkit/Views/Wallets/SavingsWalletScreen.swift | 2 +- Bitkit/Views/Wallets/SpendingWalletScreen.swift | 2 +- BitkitTests/SparkPaymentSafetyTests.swift | 6 ++++++ journeys/transfer/spark-transfer-to-spending.xml | 6 ++++-- journeys/transfer/spending-confirm-amount-change.xml | 5 +++-- 9 files changed, 35 insertions(+), 11 deletions(-) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 475fc8da1..e52608561 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -222,11 +222,12 @@ enum Env { } /// Esplora REST API Spark reads the chain from and broadcasts through, in place of the SDK's default - /// (mempool.space with blockstream as fallback on mainnet); nil keeps the default. + /// (mempool.space with blockstream as fallback on mainnet); nil keeps the default. On regtest a debug or E2E build + /// can point Spark at Bitkit's own chain with `SPARK_CHAIN_SERVICE_URL`, so a Savings deposit reaches it. static func sparkChainServiceUrl(for network: LDKNode.Network) -> String? { switch network { case .bitcoin: "https://bitkit.to/electrs" - default: nil + default: configValue("SPARK_CHAIN_SERVICE_URL") } } diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index e4ab965c6..2b9566b78 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -72,6 +72,17 @@ final class SparkManager: ChannelFreeLightningRail { Self.sparkNetwork != nil } + /// Whether savings or a hardware wallet can fund Spark: its deposit goes out on Bitkit's chain, which Spark must read. + var canFundFromChain: Bool { + isReady && Self.readsBitkitChain(network: Env.network, chainServiceUrl: Env.sparkChainServiceUrl(for: Env.network)) + } + + /// On mainnet both read Bitcoin; Spark's default regtest is its own chain, so a regtest deposit reaches Spark only + /// when Spark reads Bitkit's chain through `chainServiceUrl`. + nonisolated static func readsBitkitChain(network: LDKNode.Network, chainServiceUrl: String?) -> Bool { + network == .bitcoin || chainServiceUrl != nil + } + var isConnected: Bool { sdk != nil } diff --git a/Bitkit/Views/Transfer/FundingOptions.swift b/Bitkit/Views/Transfer/FundingOptions.swift index 8c710cd97..f6a3558b4 100644 --- a/Bitkit/Views/Transfer/FundingOptions.swift +++ b/Bitkit/Views/Transfer/FundingOptions.swift @@ -46,7 +46,9 @@ struct FundingOptions: View { VStack(spacing: 8) { if canFundWithSpark { RectangleButton(icon: "transfer", title: t("lightning__funding__button1"), testID: "FundTransfer") { - if wallet.totalOnchainSats == 0 { + if !SparkManager.shared.canFundFromChain { + sheets.showSheet(.transferUnavailable) + } else if wallet.totalOnchainSats == 0 { showNoFundsAlert = true } else if app.hasSeenTransferToSpendingIntro { navigation.navigate(.spendingAmount) @@ -74,7 +76,8 @@ struct FundingOptions: View { RectangleButton( icon: "device-tablet-speaker", title: t("lightning__funding__button_hw"), - isDisabled: !canFundWithSpark, + // A hardware wallet's deposit goes out on Bitkit's chain, which Spark must read. + isDisabled: !SparkManager.shared.canFundFromChain, testID: "FundHardware" ) { fundWithHardware() diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift index 35c23d2c7..750c54a96 100644 --- a/Bitkit/Views/Wallets/HardwareWalletScreen.swift +++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift @@ -127,7 +127,7 @@ struct HardwareWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - if !SparkManager.shared.isReady { + if !SparkManager.shared.canFundFromChain { sheets.showSheet(.transferUnavailable) } else if app.hasSeenTransferToSpendingIntro { navigation.navigate(.spendingAmountHw(walletId: walletId)) diff --git a/Bitkit/Views/Wallets/SavingsWalletScreen.swift b/Bitkit/Views/Wallets/SavingsWalletScreen.swift index 5b3b92632..13370e2e2 100644 --- a/Bitkit/Views/Wallets/SavingsWalletScreen.swift +++ b/Bitkit/Views/Wallets/SavingsWalletScreen.swift @@ -122,7 +122,7 @@ struct SavingsWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - if !SparkManager.shared.isReady { + if !SparkManager.shared.canFundFromChain { sheets.showSheet(.transferUnavailable) } else if app.hasSeenTransferToSpendingIntro { navigation.navigate(.spendingAmount) diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index 3e8522614..925e7f65c 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -115,7 +115,7 @@ struct SpendingWalletScreen: View { .frame(width: 16, height: 16) .foregroundColor(.white80) ) { - if !SparkManager.shared.isReady { + if !SparkManager.shared.canFundFromChain { sheets.showSheet(.transferUnavailable) } else if app.hasSeenTransferToSpendingIntro { navigation.navigate(.spendingAmount) diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index 1dcc72790..d400be777 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -217,6 +217,12 @@ final class SparkPaymentSafetyTests: XCTestCase { XCTAssertFalse(SparkManager.fitsBalance(amountSats: UInt64.max, feeSats: 1, balanceSats: UInt64.max)) } + func testChainFundingNeedsSparkToReadBitkitsChain() { + XCTAssertTrue(SparkManager.readsBitkitChain(network: .bitcoin, chainServiceUrl: nil)) + XCTAssertFalse(SparkManager.readsBitkitChain(network: .regtest, chainServiceUrl: nil), "Spark's regtest never sees the deposit") + XCTAssertTrue(SparkManager.readsBitkitChain(network: .regtest, chainServiceUrl: "http://127.0.0.1:3002")) + } + func testASendSparkFailedWhileTheAppWasClosedIsReleasedAtTheNextSync() { let payments = [ lightningPayment(type: .send, status: .failed, hash: "failed"), diff --git a/journeys/transfer/spark-transfer-to-spending.xml b/journeys/transfer/spark-transfer-to-spending.xml index 39ca4c6eb..a740aa4f9 100644 --- a/journeys/transfer/spark-transfer-to-spending.xml +++ b/journeys/transfer/spark-transfer-to-spending.xml @@ -10,8 +10,10 @@ the swipe depends on the deposit Spark sees and the provider's quote; the setting-up states are covered by the previews and unit tests, so this journey stops before the swipe. - Precondition: dev build with Spark connected on its regtest (no key needed), a freshly onboarded wallet with a - positive on-chain Savings balance and no channels. Start on the wallet home screen. + Precondition: a build whose Spark reads Bitkit's chain (mainnet with a Breez key, or a regtest build launched with + SPARK_CHAIN_SERVICE_URL set to the regtest's Esplora URL; Spark's default regtest is its own chain, so without it + the Savings transfer shows the Transfer Unavailable sheet), a freshly onboarded wallet with a positive on-chain Savings balance and no + channels. Start on the wallet home screen. Tap the Savings balance card (testTag "ActivitySavings"), tap "Transfer To Spending" (testTag "TransferToSpending") and verify the spending intro (testTag "SpendingIntro") opens, not the Transfer Unavailable sheet diff --git a/journeys/transfer/spending-confirm-amount-change.xml b/journeys/transfer/spending-confirm-amount-change.xml index 18f6a4971..7a47aca0f 100644 --- a/journeys/transfer/spending-confirm-amount-change.xml +++ b/journeys/transfer/spending-confirm-amount-change.xml @@ -13,8 +13,9 @@ swipe pays at once. The higher-total path (the "Fees changed" toast) needs fee rates to rise between the quote and the swipe, which is not a listed capability. - Precondition: a freshly onboarded Debug build on the staging regtest backend with Spark connected, - no spending balance and exactly two confirmed on-chain UTXOs of 150 000 sats each (two + Precondition: a freshly onboarded Debug build on the staging regtest backend with Spark connected, launched with + SPARK_CHAIN_SERVICE_URL set to that regtest's Esplora URL (otherwise the transfer shows the Transfer Unavailable + sheet), no spending balance and exactly two confirmed on-chain UTXOs of 150 000 sats each (two ../bitkit-android/lsp deposits to the wallet's savings address, each followed by a mine). Start on the wallet home screen. From 2a91662a07fdff53117fd654183f0a1285fb9b1b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 22:16:33 +0100 Subject: [PATCH 149/378] fix: clear a spark withdrawal journal only while it is still open --- Bitkit/Managers/SparkManager.swift | 11 +++++++++-- BitkitTests/PaykitRouteTests.swift | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 3c546d18c..dec5b97a2 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -701,7 +701,7 @@ final class SparkManager: ChannelFreeLightningRail { if let request = pending.request { await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .succeeded, transactionId: txId) } - Self.pendingContactWithdrawal = nil + Self.clearJournal(pending) Self.resolveContactWithdrawal(pending, txId: txId) } else { Logger.warn("Kept the Spark contact withdrawal journal: its local records are not all saved yet", context: "SparkManager") @@ -728,10 +728,17 @@ final class SparkManager: ChannelFreeLightningRail { break } } - pendingContactWithdrawal = nil + clearJournal(pending) resolveContactWithdrawal(pending, txId: nil) } + /// Clears `pending`'s journal only while it is still the open one, so a stale recovery never drops a newer + /// withdrawal's journal, which could then be paid twice. + nonisolated static func clearJournal(_ pending: SparkContactWithdrawal) { + guard pendingContactWithdrawal == pending else { return } + pendingContactWithdrawal = nil + } + static func resolveContactWithdrawal(_ pending: SparkContactWithdrawal, txId: String?) { let resolution = SparkContactWithdrawalResolution(attempt: pending.attempt, txId: txId) lastContactWithdrawalResolution = resolution diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 7b8a49701..2a7ab0222 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -386,6 +386,19 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(app.paykitRoute, routeB, "contact A's leg must never pay contact B") } + @MainActor + func testAStaleRecoveryNeverClearsANewerWithdrawalsJournal() async { + snapshotAppDefaultsDomain() + let older = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000) + let newer = SparkContactWithdrawal(address: onchain.value, amountSats: 30000, contact: "contact", startedAt: 2000) + // A recovery captured the older withdrawal; another one closed it and the newer withdrawal wrote its journal. + SparkManager.pendingContactWithdrawal = newer + + await SparkManager.closeUnsentWithdrawal(older, releaseProof: { _ in .nothingStarted }, failAllowance: { _ in }) + + XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer, "Losing the newer journal could pay it twice") + } + @MainActor func testAPendingSparkSendNeverAdoptsTheNodesOnchainAttempt() { let attempt = SparkWithdrawalAttempt(address: onchain.value, amountSats: 20000, requestId: nil) From 724ec712066086f26641c0427b3a1dc7c45f36be Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 22:31:04 +0100 Subject: [PATCH 150/378] chore: log private payment list publication --- Bitkit/Services/PrivatePaykitService+Contacts.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 3056c4b0e..6c5433eed 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -894,10 +894,12 @@ extension PrivatePaykitService { updates.removeAll { !knownSavedContactKeys.contains($0.counterparty) } guard generation == preparationGeneration else { throw PrivatePaykitError.privateUnavailable } guard !updates.isEmpty else { + Logger.debug("Skipped private payment list publication during \(reason): no list changed", context: "PrivatePaykit") schedulePendingPrivateMessageDrainRetries(reason: reason, retryKeys: linkRetryKeys) return } let report = try await operations.syncPaymentLists(updates) + Logger.debug("Published private payment lists to \(updates.count) contacts during \(reason)", context: "PrivatePaykit") let deliveryError = applyPrivatePaymentListDeliveryReport(report, reason: reason) firstError = firstError ?? deliveryError let retryKeys = linkRetryKeys + privatePaymentListDeliveryRetryKeys(from: report) From 4af37c2556359ab8720017f4105237577952a9dc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 23:02:57 +0100 Subject: [PATCH 151/378] fix: record a send killed mid-payment with its contact on relaunch A Spark send still in flight when the app was killed was missed by the next launch's activity sync, which read Spark's payments before Spark's own sync had listed it, and it showed up only a launch later without the contact it paid, since the send flow that would set it was gone. The first activity sync of a launch now waits for a Spark sync, and a contact send remembers its contact by payment hash before it goes out, so the activity recorded later still names it. --- Bitkit/Managers/SparkManager.swift | 9 ++ Bitkit/Services/CoreService.swift | 32 +++++++ .../Wallets/Send/SendConfirmationView.swift | 4 + BitkitTests/SparkRelaunchActivityTests.swift | 85 +++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 BitkitTests/SparkRelaunchActivityTests.swift diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 2b9566b78..f3d054f3b 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -130,6 +130,7 @@ final class SparkManager: ChannelFreeLightningRail { activitiesSynced = try await Self.startConnected( activitiesSynced: activitiesSynced, refresh: { try await refresh() }, + syncWallet: { _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) }, syncActivities: { try await syncActivities() } ) } catch { @@ -141,13 +142,21 @@ final class SparkManager: ChannelFreeLightningRail { /// A start reads the balance every time, but rewrites every Spark activity only until that has succeeded once, so /// each foreground doesn't redo it; a start whose refresh failed leaves the sync to the next one. Returns whether /// the activities are synced. + /// Spark lists a send that was still in flight when the app was killed only once its own sync reaches it, so the + /// first activity sync waits for one. static func startConnected( activitiesSynced: Bool, refresh: () async throws -> Void, + syncWallet: () async throws -> Void = {}, syncActivities: () async throws -> Void ) async throws -> Bool { try await refresh() guard !activitiesSynced else { return true } + do { + try await syncWallet() + } catch { + Logger.warn("Failed to sync Spark before recording its activities: \(error)", context: "SparkManager") + } try await syncActivities() return true } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 7677c57e7..7ac1e5679 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -963,6 +963,10 @@ class ActivityService { } } + /// Contacts of Lightning sends, by payment hash, kept from before the payment goes out until its activity is saved, + /// so a send recorded after the app was killed mid-payment still names its contact. + nonisolated(unsafe) static var sentPaymentContacts = SentPaymentContacts(defaults: .standard) + /// Whether a Lightning activity keeps its status against an update: a settled payment stays settled, and a failure /// from the rail that didn't carry the latest attempt is stale. static func keepsLightningStatus(existing: BitkitCore.PaymentState, update: BitkitCore.PaymentState, isStaleRailFailure: Bool) -> Bool { @@ -1013,6 +1017,8 @@ class ActivityService { let contact = if let existingContact = existingLightning?.contact { existingContact + } else if payment.direction == .outbound, let sentContact = Self.sentPaymentContacts.contact(forPaymentHash: payment.id) { + sentContact } else { await privatePaykitContactPublicKey( forReceivedInvoicePaymentHash: payment.id, @@ -1054,6 +1060,9 @@ class ActivityService { } else { try await upsert(.lightning(ln)) } + if payment.direction == .outbound, ln.contact != nil, payment.status != .pending { + Self.sentPaymentContacts.forget(paymentHash: payment.id) + } } private func privatePaykitContactPublicKey(forReceivedInvoicePaymentHash paymentHash: String, direction: PaymentDirection) async -> String? { @@ -2609,3 +2618,26 @@ class CoreService { } } } + +/// The contact a Lightning send pays, remembered by payment hash before it goes out. +struct SentPaymentContacts { + private static let key = "sentPaymentContacts" + let defaults: UserDefaults + + func remember(_ contact: String, forPaymentHash paymentHash: String) { + var contacts = defaults.dictionary(forKey: Self.key) as? [String: String] ?? [:] + contacts[paymentHash.lowercased()] = contact + defaults.set(contacts, forKey: Self.key) + } + + func contact(forPaymentHash paymentHash: String) -> String? { + (defaults.dictionary(forKey: Self.key) as? [String: String])?[paymentHash.lowercased()] + } + + func forget(paymentHash: String) { + guard var contacts = defaults.dictionary(forKey: Self.key) as? [String: String], + contacts.removeValue(forKey: paymentHash.lowercased()) != nil + else { return } + defaults.set(contacts, forKey: Self.key) + } +} diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index dfd78bd1e..e8dc697e4 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1369,6 +1369,10 @@ struct SendConfirmationView: View { } createdMetadataPaymentId = paymentHash await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash) + // Kept before the payment goes out, so a send recorded after the app was killed still names its contact. + if let contactPublicKey { + ActivityService.sentPaymentContacts.remember(contactPublicKey, forPaymentHash: paymentHash) + } // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) // For invoices with a built-in amount, pass sats: nil so LDK uses the invoice's diff --git a/BitkitTests/SparkRelaunchActivityTests.swift b/BitkitTests/SparkRelaunchActivityTests.swift new file mode 100644 index 000000000..3bef1aeae --- /dev/null +++ b/BitkitTests/SparkRelaunchActivityTests.swift @@ -0,0 +1,85 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +/// A send over Spark that was still in flight when the app was killed, recorded on the next launch. +@MainActor +final class SparkRelaunchActivityTests: XCTestCase { + func testTheFirstActivitySyncOfALaunchWaitsForSparksOwnSync() async throws { + var calls: [String] = [] + + _ = try await SparkManager.startConnected( + activitiesSynced: false, + refresh: { calls.append("refresh") }, + syncWallet: { calls.append("syncWallet") }, + syncActivities: { calls.append("syncActivities") } + ) + + XCTAssertEqual(calls, ["refresh", "syncWallet", "syncActivities"], "The activities were read before Spark listed the send") + } + + func testAFailedSparkSyncStillRecordsTheActivitiesItHas() async throws { + var synced = false + + _ = try await SparkManager.startConnected( + activitiesSynced: false, + refresh: {}, + syncWallet: { throw URLError(.timedOut) }, + syncActivities: { synced = true } + ) + + XCTAssertTrue(synced) + } + + func testASendRecordedAfterARelaunchKeepsTheContactItPaid() async throws { + let suite = "SparkRelaunchActivityTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let previous = ActivityService.sentPaymentContacts + ActivityService.sentPaymentContacts = SentPaymentContacts(defaults: defaults) + defer { ActivityService.sentPaymentContacts = previous } + // A fresh hash per run, as the activity store outlives a test run. + let hash = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + String(repeating: "c4", count: 16) + let contact = "pk:contact" + ActivityService.sentPaymentContacts.remember(contact, forPaymentHash: hash) + + // The relaunch records the send from Spark's history; the send flow that would set its contact is gone. + try await CoreService.shared.activity.recordChannelFreeRailPayment(PaymentDetails( + id: hash, + kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: "lnbcrt1"), + amountMsat: 300_000, feePaidMsat: 1000, direction: .outbound, status: .succeeded, latestUpdateTimestamp: 100 + )) + + let activity = try await CoreService.shared.activity.getActivity(id: hash) + guard case let .lightning(lightning)? = activity else { return XCTFail("The send wasn't recorded") } + XCTAssertEqual(lightning.contact, contact, "The relaunch recorded the send without its contact") + XCTAssertNil(ActivityService.sentPaymentContacts.contact(forPaymentHash: hash), "The remembered contact outlived its activity") + } + + func testASendRecordedPendingBeforeTheKillGetsItsContactWhenItSettles() async throws { + let suite = "SparkRelaunchActivityTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let previous = ActivityService.sentPaymentContacts + ActivityService.sentPaymentContacts = SentPaymentContacts(defaults: defaults) + defer { ActivityService.sentPaymentContacts = previous } + let hash = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + String(repeating: "d5", count: 16) + func send(_ status: LDKNode.PaymentStatus, at timestamp: UInt64) -> PaymentDetails { + PaymentDetails( + id: hash, + kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: "lnbcrt1"), + amountMsat: 300_000, feePaidMsat: 1000, direction: .outbound, status: status, latestUpdateTimestamp: timestamp + ) + } + // Spark recorded the send as pending before the app was killed, with no contact yet. + try await CoreService.shared.activity.recordChannelFreeRailPayment(send(.pending, at: 100)) + ActivityService.sentPaymentContacts.remember("pk:contact", forPaymentHash: hash) + + try await CoreService.shared.activity.recordChannelFreeRailPayment(send(.succeeded, at: 200)) + + let activity = try await CoreService.shared.activity.getActivity(id: hash) + guard case let .lightning(lightning)? = activity else { return XCTFail("The send wasn't recorded") } + XCTAssertEqual(lightning.contact, "pk:contact") + } +} From 2909786f809dc2a47de2576f678bb60ce3449a7d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 23:02:57 +0100 Subject: [PATCH 152/378] fix: turn off spark's default external input parsers Breez SDK parses input with third-party lookups unless they are turned off, which Bitkit's privacy setup rules out, as on Android. Spark now connects with them off. --- Bitkit/Managers/SparkManager.swift | 3 ++- BitkitTests/SparkConfigTests.swift | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index f3d054f3b..0410352de 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -193,9 +193,10 @@ final class SparkManager: ChannelFreeLightningRail { nonisolated static func sdkConfig(network: BreezSdkSpark.Network, apiKey: String?) -> BreezSdkSpark.Config { var config = defaultConfig(network: network) config.apiKey = apiKey - // No Breez data sync server and no Lightning address server: Bitkit uses neither. + // No Breez data sync server, no Lightning address server and no third-party input lookups: Bitkit uses none. config.realTimeSyncServerUrl = nil config.lnurlDomain = nil + config.useDefaultExternalInputParsers = false // Lowering it below the spread keeps the deposit from being claimed early, and does not hold back its claim at // maturity. An early claim is made only when the user chose Instant (`claimEarly`). config.maxDepositClaimFee = MaxFee( diff --git a/BitkitTests/SparkConfigTests.swift b/BitkitTests/SparkConfigTests.swift index f61ef0466..426673a4d 100644 --- a/BitkitTests/SparkConfigTests.swift +++ b/BitkitTests/SparkConfigTests.swift @@ -9,6 +9,7 @@ final class SparkConfigTests: XCTestCase { XCTAssertNil(config.realTimeSyncServerUrl, "\(network)") XCTAssertNil(config.lnurlDomain, "\(network)") + XCTAssertFalse(config.useDefaultExternalInputParsers, "Parsing input must not look it up with a third party") XCTAssertEqual(config.apiKey, "test-key") } } From 0233147e9a5686a8d3e77388130952af1c853c3a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 23:35:14 +0100 Subject: [PATCH 153/378] test: read a contact list invoice paid on spark as settled --- BitkitTests/ChannelFreeRailTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 652d5e8e1..76c446873 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -76,6 +76,19 @@ final class ChannelFreeRailTests: XCTestCase { XCTAssertEqual(attempted.intersection(["out", "failed"]), ["out"]) } + func testAContactListInvoicePaidOnSparkReadsAsSettled() async { + // The private payment list's invoice was created on Spark, so only Spark's list shows it paid. + let hash = String(repeating: "ab", count: 32) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .inbound, status: .pending, preimage: nil)] + let pending = await PrivatePaykitService.shared.isReceivedInvoiceSettled(paymentHash: hash) + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .inbound, status: .succeeded, preimage: nil)] + + let paid = await PrivatePaykitService.shared.isReceivedInvoiceSettled(paymentHash: hash) + + XCTAssertFalse(pending) + XCTAssertTrue(paid, "A paid Spark invoice would leave the private list unrotated") + } + func testLightningWaitFinishesWhenTheRailSettledBeforeWatching() async throws { rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "ff", direction: .outbound, status: .succeeded, preimage: nil)] From 169659a44088666aa61a30fb67d751f6da39442e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 23:56:57 +0100 Subject: [PATCH 154/378] feat: spark in app status --- .../Localization/en.lproj/Localizable.strings | 5 +++ Bitkit/Utilities/AppStatusHelper.swift | 44 +++++++++++++++++-- Bitkit/Views/Settings/AppStatusView.swift | 20 ++++++++- BitkitTests/AppStatusHelperTests.swift | 38 ++++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 BitkitTests/AppStatusHelperTests.swift diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 5accc924d..87958b518 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -937,6 +937,11 @@ "settings__status__lightning_connection__ready" = "Open"; "settings__status__lightning_connection__pending" = "Opening..."; "settings__status__lightning_connection__error" = "No open connections"; +"settings__status__lightning_connection__spark" = "Using Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Connected"; +"settings__status__spark__pending" = "Connecting..."; +"settings__status__spark__error" = "Offline"; "settings__status__backup__title" = "Latest Full Data Backup"; "settings__status__backup__ready" = "Backed up"; "settings__status__backup__pending" = "Backing up..."; diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index fe54e823d..f8f0151ea 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -63,15 +63,40 @@ struct AppStatusHelper { } static func channelsStatus(from wallet: WalletViewModel, network: NetworkMonitor) -> HealthStatus { - let isOnline = network.isConnected - let hasChannels = wallet.channelCount > 0 + channelsStatus( + isOnline: network.isConnected, + hasUsableChannels: wallet.hasUsableChannels, + hasChannels: wallet.channelCount > 0, + isSparkReady: SparkManager.shared.isReady + ) + } + + static func isLightningViaSpark(from wallet: WalletViewModel, network: NetworkMonitor) -> Bool { + isLightningViaSpark( + isOnline: network.isConnected, + hasUsableChannels: wallet.hasUsableChannels, + isSparkReady: SparkManager.shared.isReady + ) + } + + /// Nil where Spark is unsupported. + static func sparkStatus(network: NetworkMonitor) -> HealthStatus? { + let spark = SparkManager.shared + return sparkStatus(isOnline: network.isConnected, isSupported: spark.isSupported, isReady: spark.isReady) + } + /// Spark carries Lightning when no channel can, so the connection is ready through it; red only when neither can pay. + nonisolated static func channelsStatus( + isOnline: Bool, + hasUsableChannels: Bool, + hasChannels: Bool, + isSparkReady: Bool + ) -> HealthStatus { if !isOnline { return .error } - // Spark carries Lightning without channels, so a Spark wallet's connection is ready. - if wallet.hasUsableChannels || SparkManager.shared.isReady { + if hasUsableChannels || isSparkReady { return .ready } else if !hasChannels { return .error @@ -80,6 +105,17 @@ struct AppStatusHelper { } } + /// No channel can carry a payment, so Spark carries Lightning. + nonisolated static func isLightningViaSpark(isOnline: Bool, hasUsableChannels: Bool, isSparkReady: Bool) -> Bool { + isOnline && !hasUsableChannels && isSparkReady + } + + nonisolated static func sparkStatus(isOnline: Bool, isSupported: Bool, isReady: Bool) -> HealthStatus? { + guard isSupported else { return nil } + guard isOnline else { return .error } + return isReady ? .ready : .pending + } + static func combinedAppStatus(from wallet: WalletViewModel, network: NetworkMonitor) -> HealthStatus { let internetState = internetStatus(network: network) let bitcoinNodeState = bitcoinNodeStatus(from: wallet, network: network) diff --git a/Bitkit/Views/Settings/AppStatusView.swift b/Bitkit/Views/Settings/AppStatusView.swift index d7ea925bd..763fc03b4 100644 --- a/Bitkit/Views/Settings/AppStatusView.swift +++ b/Bitkit/Views/Settings/AppStatusView.swift @@ -19,6 +19,7 @@ struct AppStatusView: View { bitcoinNodeStatusRow nodeStatusRow channelsStatusRow + sparkStatusRow backupStatusRow } } @@ -122,7 +123,8 @@ struct AppStatusView: View { private var channelsStatusRow: some View { let status = AppStatusHelper.channelsStatus(from: wallet, network: network) - let description = t("settings__status__lightning_connection__\(status.rawValue)") + let isViaSpark = AppStatusHelper.isLightningViaSpark(from: wallet, network: network) + let description = t("settings__status__lightning_connection__\(isViaSpark ? "spark" : status.rawValue)") return StatusRow( imageName: "status-lightning", @@ -136,6 +138,22 @@ struct AppStatusView: View { .accessibilityIdentifier("Status-lightning_connection") } + @ViewBuilder + private var sparkStatusRow: some View { + if let status = AppStatusHelper.sparkStatus(network: network) { + StatusRow( + imageName: "asterisk", + title: t("settings__status__spark__title"), + description: t("settings__status__spark__\(status.rawValue)"), + status: status, + onTap: { + navigation.navigate(.sparkConnectionDetail) + } + ) + .accessibilityIdentifier("Status-spark") + } + } + private var backupStatusRow: some View { let description: String let status: HealthStatus diff --git a/BitkitTests/AppStatusHelperTests.swift b/BitkitTests/AppStatusHelperTests.swift new file mode 100644 index 000000000..57fc887c7 --- /dev/null +++ b/BitkitTests/AppStatusHelperTests.swift @@ -0,0 +1,38 @@ +@testable import Bitkit +import XCTest + +final class AppStatusHelperTests: XCTestCase { + func testSparkStatusIsNilWhenSparkIsUnsupported() { + XCTAssertNil(AppStatusHelper.sparkStatus(isOnline: true, isSupported: false, isReady: true)) + } + + func testSparkStatusMapsToReadyPendingAndError() { + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: true), .ready) + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: false), .pending) + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: false, isSupported: true, isReady: true), .error) + } + + func testChannelsStatusIsReadyThroughSparkWhenNoChannelIsUsable() { + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: false, hasChannels: true, isSparkReady: true) + XCTAssertEqual(status, .ready) + XCTAssertTrue(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: false, isSparkReady: true)) + } + + func testChannelsStatusIsOpenWhenAChannelIsUsableBesideSpark() { + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: true, hasChannels: true, isSparkReady: true) + XCTAssertEqual(status, .ready) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: true, isSparkReady: true)) + } + + func testChannelsStatusIsErrorWhenNeitherAChannelNorSparkCanPay() { + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: false, hasChannels: false, isSparkReady: false) + XCTAssertEqual(status, .error) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: false, isSparkReady: false)) + } + + func testChannelsStatusIsErrorOfflineEvenWithSpark() { + let status = AppStatusHelper.channelsStatus(isOnline: false, hasUsableChannels: false, hasChannels: false, isSparkReady: true) + XCTAssertEqual(status, .error) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: false, hasUsableChannels: false, isSparkReady: true)) + } +} From 94e3d57e81b22e37f147f7269488552f2d200cf5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 23:56:58 +0100 Subject: [PATCH 155/378] feat: emergency exit in app status --- .../Localization/en.lproj/Localizable.strings | 3 ++ Bitkit/Utilities/AppStatusHelper.swift | 16 ++++++++++ Bitkit/Views/Settings/AppStatusView.swift | 17 +++++++++++ BitkitTests/AppStatusExitTests.swift | 29 +++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 BitkitTests/AppStatusExitTests.swift diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 7a202915b..1d98fa67a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1025,6 +1025,9 @@ "settings__status__lightning_connection__ready" = "Open"; "settings__status__lightning_connection__pending" = "Opening..."; "settings__status__lightning_connection__error" = "No open connections"; +"settings__status__exit__title" = "Emergency Exit"; +"settings__status__exit__pending" = "In progress"; +"settings__status__exit__funding" = "Fee funding needed"; "settings__status__backup__title" = "Latest Full Data Backup"; "settings__status__backup__ready" = "Backed up"; "settings__status__backup__pending" = "Backing up..."; diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index fe54e823d..8a18f1167 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -22,8 +22,24 @@ enum HealthStatus: String { } } +/// The step of a running emergency exit, named like its App Status subtitle key. +enum ExitStatusStep: String { + case funding + case pending +} + @MainActor struct AppStatusHelper { + /// Nil while no emergency exit runs. + nonisolated static func exitStep(record: SparkExitRecord?) -> ExitStatusStep? { + guard let record, record.isActive else { return nil } + switch record.phase { + case .awaitingFunding, .needsFunding: return .funding + case .running: return .pending + case .done, .failed: return nil + } + } + static func internetStatus(network: NetworkMonitor) -> HealthStatus { return network.isConnected ? .ready : .error } diff --git a/Bitkit/Views/Settings/AppStatusView.swift b/Bitkit/Views/Settings/AppStatusView.swift index d7ea925bd..61c7b5472 100644 --- a/Bitkit/Views/Settings/AppStatusView.swift +++ b/Bitkit/Views/Settings/AppStatusView.swift @@ -19,6 +19,7 @@ struct AppStatusView: View { bitcoinNodeStatusRow nodeStatusRow channelsStatusRow + exitStatusRow backupStatusRow } } @@ -136,6 +137,22 @@ struct AppStatusView: View { .accessibilityIdentifier("Status-lightning_connection") } + @ViewBuilder + private var exitStatusRow: some View { + if let step = AppStatusHelper.exitStep(record: SparkExitManager.shared.record) { + StatusRow( + imageName: "warning", + title: t("settings__status__exit__title"), + description: t("settings__status__exit__\(step.rawValue)"), + status: .pending, + onTap: { + navigation.navigate(.sparkExitProgress) + } + ) + .accessibilityIdentifier("Status-spark_exit") + } + } + private var backupStatusRow: some View { let description: String let status: HealthStatus diff --git a/BitkitTests/AppStatusExitTests.swift b/BitkitTests/AppStatusExitTests.swift new file mode 100644 index 000000000..efb434aee --- /dev/null +++ b/BitkitTests/AppStatusExitTests.swift @@ -0,0 +1,29 @@ +@testable import Bitkit +import XCTest + +final class AppStatusExitTests: XCTestCase { + func testExitStepIsNilWithoutARunningExit() { + XCTAssertNil(AppStatusHelper.exitStep(record: nil)) + XCTAssertNil(AppStatusHelper.exitStep(record: record(.done))) + XCTAssertNil(AppStatusHelper.exitStep(record: record(.failed(reason: "reason")))) + } + + func testExitStepMapsRunningAndFundingPhases() { + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.running)), .pending) + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.awaitingFunding(requiredSats: 1000))), .funding) + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.needsFunding(requiredSats: 1000))), .funding) + } + + private func record(_ phase: SparkExitRecord.Phase) -> SparkExitRecord { + SparkExitRecord( + network: "regtest", + createdAt: Date(), + phase: phase, + feeRateSatPerVbyte: 1, + destination: "bcrt1qdestination", + leafIds: ["leaf-1"], + funding: [.init(index: 0, address: "bcrt1qfunding", pubkey: "02")], + expectedSats: 1000 + ) + } +} From dc4b20234f44900a456819e350d942d537a2a991 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 00:13:23 +0100 Subject: [PATCH 156/378] fix: keep an unsettled move after its screen is left A payment still unsettled after the wait keeps the move guard until its late outcome, so leaving and reopening Move to Spark shows that move instead of a new quote. --- Bitkit/Managers/MoveToSparkManager.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 92123c18e..987d4708c 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -188,7 +188,14 @@ final class MoveToSparkManager { let moveGeneration = generation keepsOutcome = true isMoveInFlight = true - defer { isMoveInFlight = false } + // A payment still unsettled after the wait keeps the move until its late outcome, so leaving and reopening the + // screen shows it again instead of a new quote. + var isSettled = true + defer { + if isSettled { + isMoveInFlight = false + } + } state = .confirm(quote, isMoving: true) do { let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: quote.invoice).paymentHash() @@ -210,6 +217,7 @@ final class MoveToSparkManager { } } ) else { + isSettled = false state = .inFlight(amountSats: quote.amountSats) return } @@ -286,6 +294,11 @@ final class MoveToSparkManager { /// A move that was still in flight paid after the wait: the screen, if it still shows that same move, shows it moved. private func lateSuccess(amountSats: UInt64, feeSats: UInt64, moveGeneration: Int, wallet: WalletViewModel) async { Logger.info("Move to Spark of '\(amountSats)' sats succeeded after the wait", context: "MoveToSpark") + defer { + if generation == moveGeneration { + isMoveInFlight = false + } + } guard Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) else { return } await wallet.syncStateAsync() guard Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) else { return } @@ -296,6 +309,9 @@ final class MoveToSparkManager { /// shows that same move, says it could not move, and a toast tells the user wherever they are. private func lateFailure(amountSats: UInt64, moveGeneration: Int) { Logger.warn("Move to Spark of '\(amountSats)' sats failed after the wait", context: "MoveToSpark") + if generation == moveGeneration { + isMoveInFlight = false + } if Self.showsInFlightMove(state, generation: generation, moveGeneration: moveGeneration) { state = .failed(.unknown) } From 1be8467c52bf0e56366055a59a632f5ae55413fe Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 00:28:27 +0100 Subject: [PATCH 157/378] fix: open savings for a unified invoice spending can't pay A unified invoice scanned while the channels' peer was offline opened Spending even when neither the channels nor Spark could pay it, selecting a balance of zero. It now waits for an offline peer's channels only when they could pay the amount once it is back, and otherwise opens Savings, as Android does; Spark covering the amount still opens Spending. --- Bitkit/ViewModels/AppViewModel.swift | 24 +++++---- BitkitTests/SparkLightningSendTests.swift | 59 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index c3a6da2c0..c5b48e537 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -108,6 +108,9 @@ struct ScanPaymentState { let hasUsableChannels: Bool /// Spark sends Lightning without channels. var hasChannelFreeRail = false + /// What the channels can send, usable or not, so a channel whose peer is briefly offline is waited for only when it + /// could pay. + var channelOutboundSats: UInt64 = 0 } struct ScanPaymentOperations { @@ -127,7 +130,8 @@ struct ScanPaymentOperations { + (lightningService.channelFreeRail?.balanceSats ?? 0), hasChannels: channels?.isEmpty == false, hasUsableChannels: channels?.contains(where: \.isUsable) == true, - hasChannelFreeRail: lightningService.channelFreeRail?.isReady == true + hasChannelFreeRail: lightningService.channelFreeRail?.isReady == true, + channelOutboundSats: (channels ?? []).map(\.outboundCapacityMsat).reduce(0, +) / 1000 ) }, canSendLightning: lightningService.canSend @@ -987,21 +991,21 @@ extension AppViewModel { if nodeIsRunning { // Node is running → we have fresh balances; validate immediately. // Prefer lightning; if insufficient or no channels/capacity, fall back to onchain. - let canSendLightning = try scanPaymentOperations.canSendLightning( - contactPaymentContext?.incomingPaymentRequest?.payment(using: .bitcoinLightningBolt11).amount.atomic ?? - lightningInvoice.amountSatoshis - ) + let lightningAmount = try contactPaymentContext?.incomingPaymentRequest? + .payment(using: .bitcoinLightningBolt11).amount.atomic ?? lightningInvoice.amountSatoshis + let canSendLightning = scanPaymentOperations.canSendLightning(lightningAmount) if canSendLightning { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } - // Channels exist but none are usable (e.g. peer offline): still prefer - // lightning. The send sheet shows the sync overlay and either proceeds - // over lightning when the peer reconnects or falls back to onchain - // after its timeout. - if paymentState.hasChannels, !paymentState.hasUsableChannels { + // Channels exist but none are usable (e.g. peer offline) and they could pay once it is + // back: still prefer lightning. The send sheet shows the sync overlay and either proceeds + // over lightning when the peer reconnects or falls back to onchain after its timeout. + if paymentState.hasChannels, !paymentState.hasUsableChannels, + paymentState.channelOutboundSats >= max(1, lightningAmount) + { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } diff --git a/BitkitTests/SparkLightningSendTests.swift b/BitkitTests/SparkLightningSendTests.swift index fa9ade9e6..2fc6e7a9c 100644 --- a/BitkitTests/SparkLightningSendTests.swift +++ b/BitkitTests/SparkLightningSendTests.swift @@ -42,6 +42,65 @@ final class SparkLightningSendTests: XCTestCase { XCTAssertTrue(app.didRejectScannedPayment) } + func testAUnifiedInvoiceNothingOnSpendingCanPayOpensSavings() async throws { + // A channel whose peer is offline, with nothing to send even once it is back, and no Spark balance. + let app = makeUnifiedApp(channelOutboundSats: 0) { _ in false } + + try await app.handleScannedData(Self.unifiedUri) + + XCTAssertEqual(app.selectedWalletToPayFrom, .onchain, "Spending was picked with nothing to pay from") + XCTAssertNil(app.scannedLightningInvoice) + XCTAssertNotNil(app.scannedOnchainInvoice) + } + + func testAUnifiedInvoiceSparkCanPayOpensSpending() async throws { + let app = makeUnifiedApp(channelOutboundSats: 0) { _ in true } + + try await app.handleScannedData(Self.unifiedUri) + + XCTAssertEqual(app.selectedWalletToPayFrom, .lightning) + XCTAssertNotNil(app.scannedLightningInvoice) + } + + func testAUnifiedInvoiceAChannelWithAnOfflinePeerCouldPayStillOpensSpending() async throws { + let app = makeUnifiedApp(channelOutboundSats: 5000) { _ in false } + + try await app.handleScannedData(Self.unifiedUri) + + XCTAssertEqual(app.selectedWalletToPayFrom, .lightning) + } + + private static let unifiedUri = "bitcoin:bcrt1q95ukqhq8q3ymg74zvzpy0n9f8ec6xq26as60u3?lightning=lnbcrt1sparkonly" + + private func makeUnifiedApp(channelOutboundSats: UInt64, canSendLightning: @escaping (UInt64) -> Bool) -> AppViewModel { + AppViewModel( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 50000, + totalLightningBalanceSats: 0, + hasChannels: true, + hasUsableChannels: false, + hasChannelFreeRail: true, + channelOutboundSats: channelOutboundSats + ) + }, + canSendLightning: canSendLightning, + decodeInvoice: { uri in + uri.hasPrefix("bitcoin:") + ? .onChain(invoice: OnChainInvoice( + address: "bcrt1q95ukqhq8q3ymg74zvzpy0n9f8ec6xq26as60u3", amountSatoshis: 0, label: nil, message: nil, + params: ["lightning": "lnbcrt1sparkonly"] + )) + : .lightning(invoice: self.invoice) + } + ) + ) + } + private func makeApp(hasChannelFreeRail: Bool, canSendLightning: @escaping (UInt64) -> Bool) -> AppViewModel { AppViewModel( sheetViewModel: SheetViewModel(), From 36410003490027555349bf6bf6f3fb12d665ab77 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 00:31:17 +0100 Subject: [PATCH 158/378] chore: add changelog fragment --- changelog.d/next/1256.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1256.added.md diff --git a/changelog.d/next/1256.added.md b/changelog.d/next/1256.added.md new file mode 100644 index 000000000..5e84c2054 --- /dev/null +++ b/changelog.d/next/1256.added.md @@ -0,0 +1 @@ +App Status shows Spark's connection, and Lightning Connection no longer reports an error while Spark carries Lightning payments. From 28c5e89b716f49890ba4233a9e51dca67395bf97 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 00:31:27 +0100 Subject: [PATCH 159/378] chore: add changelog fragment --- changelog.d/next/1257.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1257.added.md diff --git a/changelog.d/next/1257.added.md b/changelog.d/next/1257.added.md new file mode 100644 index 000000000..37aff52e5 --- /dev/null +++ b/changelog.d/next/1257.added.md @@ -0,0 +1 @@ +App Status shows an emergency exit while it runs. From b368304f4a7926ffa9a5e74fd7e68421e1f39b1b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 01:02:59 +0100 Subject: [PATCH 160/378] fix: spark status for start failures and translations --- Bitkit/Managers/SparkManager.swift | 5 +++++ .../Localization/ar.lproj/Localizable.strings | 5 +++++ .../Localization/ca.lproj/Localizable.strings | 5 +++++ .../Localization/cs.lproj/Localizable.strings | 5 +++++ .../Localization/de.lproj/Localizable.strings | 5 +++++ .../Localization/el.lproj/Localizable.strings | 5 +++++ .../es-419.lproj/Localizable.strings | 5 +++++ .../Localization/es.lproj/Localizable.strings | 5 +++++ .../Localization/fr.lproj/Localizable.strings | 5 +++++ .../Localization/it.lproj/Localizable.strings | 5 +++++ .../Localization/nl.lproj/Localizable.strings | 5 +++++ .../Localization/pl.lproj/Localizable.strings | 5 +++++ .../Localization/pt-BR.lproj/Localizable.strings | 5 +++++ .../Localization/pt.lproj/Localizable.strings | 5 +++++ .../Localization/ru.lproj/Localizable.strings | 5 +++++ Bitkit/Utilities/AppStatusHelper.swift | 16 ++++++++++++---- BitkitTests/AppStatusHelperTests.swift | 12 ++++++++---- journeys/settings/app-status-spark-row.xml | 16 ++++++++++++++++ 18 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 journeys/settings/app-status-spark-row.xml diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 0410352de..4469f7c31 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -38,6 +38,8 @@ final class SparkManager: ChannelFreeLightningRail { private nonisolated static let depositClaimFeePpm: UInt32 = 2000 private(set) var isReady = false + /// The last start failed before Spark could read its state; the next start clears it. + private(set) var hasStartFailed = false private(set) var balanceSats: UInt64 = 0 /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. private(set) var pendingDepositSats: UInt64 = 0 @@ -133,8 +135,10 @@ final class SparkManager: ChannelFreeLightningRail { syncWallet: { _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) }, syncActivities: { try await syncActivities() } ) + hasStartFailed = false } catch { connectTask = nil + hasStartFailed = true Logger.error("Failed to start Spark wallet: \(error)", context: "SparkManager") } } @@ -235,6 +239,7 @@ final class SparkManager: ChannelFreeLightningRail { refreshAgain = false activitiesSynced = false isReady = false + hasStartFailed = false balanceSats = 0 pendingDepositSats = 0 deposits = [] diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index a69ab2606..2f882dc65 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -824,6 +824,11 @@ "settings__status__lightning_connection__ready" = "مفتوح"; "settings__status__lightning_connection__pending" = "جارٍ الفتح..."; "settings__status__lightning_connection__error" = "لا توجد اتصالات مفتوحة"; +"settings__status__lightning_connection__spark" = "يستخدم Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "متصل"; +"settings__status__spark__pending" = "جارٍ الاتصال..."; +"settings__status__spark__error" = "غير متصل"; "settings__status__backup__title" = "آخر نسخة احتياطية كاملة للبيانات"; "settings__status__backup__error" = "فشل إكمال نسخة احتياطية كاملة"; "settings__adv__section_debug" = "تصحيح"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 1f71464a9..2b89c4bd9 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -891,6 +891,11 @@ "settings__status__lightning_connection__ready" = "Obrir"; "settings__status__lightning_connection__pending" = "Obrint ..."; "settings__status__lightning_connection__error" = "No hi ha connexions obertes"; +"settings__status__lightning_connection__spark" = "Utilitzant Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Connectat"; +"settings__status__spark__pending" = "Conectant ..."; +"settings__status__spark__error" = "Fora de línia"; "settings__status__backup__title" = "Última còpia de seguretat completa de dades"; "settings__status__backup__error" = "No s\'ha pogut completar una còpia de seguretat completa"; "settings__adv__section_debug" = "Debug"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 55a0d77e4..4524c65f7 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -954,6 +954,11 @@ "settings__status__lightning_connection__ready" = "Otevřít"; "settings__status__lightning_connection__pending" = "Otevírání..."; "settings__status__lightning_connection__error" = "Žádná otevřená spojení"; +"settings__status__lightning_connection__spark" = "Používá Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Připojeno"; +"settings__status__spark__pending" = "Připojování..."; +"settings__status__spark__error" = "Offline"; "settings__status__backup__title" = "Nejnovější úplná záloha dat"; "settings__status__backup__ready" = "Zálohováno"; "settings__status__backup__pending" = "Zálohování..."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 8f0b9354d..ccec78c8a 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -952,6 +952,11 @@ "settings__status__lightning_connection__ready" = "Geöffnet"; "settings__status__lightning_connection__pending" = "In Vorbereitung..."; "settings__status__lightning_connection__error" = "Keine offenen Verbindungen"; +"settings__status__lightning_connection__spark" = "Nutzt Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Verbunden"; +"settings__status__spark__pending" = "Verbinden..."; +"settings__status__spark__error" = "Offline"; "settings__status__backup__title" = "Vollständiges Backup"; "settings__status__backup__ready" = "Abgeschlossen"; "settings__status__backup__pending" = "In Bearbeitung..."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index a8b85970e..9ed08066e 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -835,6 +835,11 @@ "settings__status__lightning_connection__ready" = "Ανοιχτή"; "settings__status__lightning_connection__pending" = "Άνοιγμα..."; "settings__status__lightning_connection__error" = "Δεν υπάρχουν ανοιχτές συνδέσεις"; +"settings__status__lightning_connection__spark" = "Χρήση Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Συνδεδεμένο"; +"settings__status__spark__pending" = "Σύνδεση..."; +"settings__status__spark__error" = "Εκτός σύνδεσης"; "settings__status__backup__title" = "Τελευταίο Πλήρες Αντίγραφο Δεδομένων"; "settings__status__backup__error" = "Αποτυχία ολοκλήρωσης πλήρους αντιγράφου"; "settings__adv__section_debug" = "Debug"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 15afeaeec..ae09f5e0d 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -955,6 +955,11 @@ "settings__status__lightning_connection__ready" = "Abierta"; "settings__status__lightning_connection__pending" = "Abriendo..."; "settings__status__lightning_connection__error" = "No hay conexiones abiertas"; +"settings__status__lightning_connection__spark" = "Usando Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Conectado"; +"settings__status__spark__pending" = "Conectando..."; +"settings__status__spark__error" = "Sin conexión"; "settings__status__backup__title" = "Última copia de seguridad completa de los datos"; "settings__status__backup__ready" = "Copia de seguridad"; "settings__status__backup__pending" = "Respaldando..."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index c24724933..f8f3b70cd 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -902,6 +902,11 @@ "settings__status__lightning_connection__ready" = "Abierta"; "settings__status__lightning_connection__pending" = "Abriendo..."; "settings__status__lightning_connection__error" = "Sin conexiones abiertas"; +"settings__status__lightning_connection__spark" = "Usando Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Conectado"; +"settings__status__spark__pending" = "Conectando..."; +"settings__status__spark__error" = "Sin conexión"; "settings__status__backup__title" = "Último Backup de Datos Completo"; "settings__status__backup__error" = "Falló al hacer una copia de seguridad completa"; "settings__adv__section_debug" = "Debug"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 46a8f765c..47d1084e1 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -955,6 +955,11 @@ "settings__status__lightning_connection__ready" = "Ouvrir"; "settings__status__lightning_connection__pending" = "Ouverture..."; "settings__status__lightning_connection__error" = "Pas de connexions ouvertes"; +"settings__status__lightning_connection__spark" = "Via Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Connecté"; +"settings__status__spark__pending" = "Connexion..."; +"settings__status__spark__error" = "Hors ligne"; "settings__status__backup__title" = "Dernière sauvegarde complète"; "settings__status__backup__ready" = "Sauvegardé"; "settings__status__backup__pending" = "Sauvegarde..."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index d93231509..94b012056 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -935,6 +935,11 @@ "settings__status__lightning_connection__ready" = "Aperta"; "settings__status__lightning_connection__pending" = "In apertura..."; "settings__status__lightning_connection__error" = "Nessuna connessione aperta"; +"settings__status__lightning_connection__spark" = "Tramite Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Connesso"; +"settings__status__spark__pending" = "In connessione..."; +"settings__status__spark__error" = "Offline"; "settings__status__backup__title" = "Ultimo backup completo dei dati"; "settings__status__backup__ready" = "Backup eseguito"; "settings__status__backup__pending" = "Backup in esecuzione..."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 2b989592c..6de388d2e 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -952,6 +952,11 @@ "settings__status__lightning_connection__ready" = "Open"; "settings__status__lightning_connection__pending" = "Openen..."; "settings__status__lightning_connection__error" = "Geen open verbindingen"; +"settings__status__lightning_connection__spark" = "Via Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Aangesloten"; +"settings__status__spark__pending" = "Aansluiten..."; +"settings__status__spark__error" = "Offline"; "settings__status__backup__title" = "Laatste volledige gegevensback-up"; "settings__status__backup__ready" = "Back-up klaar"; "settings__status__backup__pending" = "Een back-up maken..."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 98c5813a2..91f16b8d8 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -954,6 +954,11 @@ "settings__status__lightning_connection__ready" = "Otwarte"; "settings__status__lightning_connection__pending" = "Otwieranie..."; "settings__status__lightning_connection__error" = "Brak otwartych połączeń"; +"settings__status__lightning_connection__spark" = "Przez Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Połączono"; +"settings__status__spark__pending" = "Łączenie..."; +"settings__status__spark__error" = "Offline"; "settings__status__backup__title" = "Ostatnia kopia zapasowa"; "settings__status__backup__ready" = "Zapisano"; "settings__status__backup__pending" = "Zapisywanie..."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 18a5543a0..84a171814 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -954,6 +954,11 @@ "settings__status__lightning_connection__ready" = "Ativa"; "settings__status__lightning_connection__pending" = "Ativando..."; "settings__status__lightning_connection__error" = "Nenhuma conexão ativa"; +"settings__status__lightning_connection__spark" = "Usando o Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Conectado"; +"settings__status__spark__pending" = "Conectando..."; +"settings__status__spark__error" = "Offline"; "settings__status__backup__title" = "Último Backup Completo"; "settings__status__backup__ready" = "Backup Feito"; "settings__status__backup__pending" = "Fazendo o backup..."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 573f1250f..d9e9744d1 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -839,6 +839,11 @@ "settings__status__lightning_connection__ready" = "Ativa"; "settings__status__lightning_connection__pending" = "Ativando..."; "settings__status__lightning_connection__error" = "Nenhuma conexão ativa"; +"settings__status__lightning_connection__spark" = "Usando o Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Conectado"; +"settings__status__spark__pending" = "Conectando..."; +"settings__status__spark__error" = "Offline"; "settings__status__backup__title" = "Último Backup Completo"; "settings__status__backup__error" = "Falha ao concluir backup"; "settings__adv__section_debug" = "Debug"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 2feb410f3..e1c1fd501 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -949,6 +949,11 @@ "settings__status__lightning_connection__ready" = "Открыто"; "settings__status__lightning_connection__pending" = "Открытие..."; "settings__status__lightning_connection__error" = "Нет открытых соединений"; +"settings__status__lightning_connection__spark" = "Через Spark"; +"settings__status__spark__title" = "Spark"; +"settings__status__spark__ready" = "Подключено"; +"settings__status__spark__pending" = "Подключение..."; +"settings__status__spark__error" = "Не в сети"; "settings__status__backup__title" = "Последнее Полное Резервное Копирование Данных"; "settings__status__backup__ready" = "Скопировано"; "settings__status__backup__pending" = "Резервное копирование..."; diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index f8f0151ea..f32bb9953 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -82,7 +82,12 @@ struct AppStatusHelper { /// Nil where Spark is unsupported. static func sparkStatus(network: NetworkMonitor) -> HealthStatus? { let spark = SparkManager.shared - return sparkStatus(isOnline: network.isConnected, isSupported: spark.isSupported, isReady: spark.isReady) + return sparkStatus( + isOnline: network.isConnected, + isSupported: spark.isSupported, + isReady: spark.isReady, + hasStartFailed: spark.hasStartFailed + ) } /// Spark carries Lightning when no channel can, so the connection is ready through it; red only when neither can pay. @@ -105,15 +110,18 @@ struct AppStatusHelper { } } - /// No channel can carry a payment, so Spark carries Lightning. + /// No channel is usable, so Spark carries Lightning payments. nonisolated static func isLightningViaSpark(isOnline: Bool, hasUsableChannels: Bool, isSparkReady: Bool) -> Bool { isOnline && !hasUsableChannels && isSparkReady } - nonisolated static func sparkStatus(isOnline: Bool, isSupported: Bool, isReady: Bool) -> HealthStatus? { + nonisolated static func sparkStatus(isOnline: Bool, isSupported: Bool, isReady: Bool, hasStartFailed: Bool) -> HealthStatus? { guard isSupported else { return nil } guard isOnline else { return .error } - return isReady ? .ready : .pending + if isReady { + return .ready + } + return hasStartFailed ? .error : .pending } static func combinedAppStatus(from wallet: WalletViewModel, network: NetworkMonitor) -> HealthStatus { diff --git a/BitkitTests/AppStatusHelperTests.swift b/BitkitTests/AppStatusHelperTests.swift index 57fc887c7..e3c9a62c9 100644 --- a/BitkitTests/AppStatusHelperTests.swift +++ b/BitkitTests/AppStatusHelperTests.swift @@ -3,13 +3,17 @@ import XCTest final class AppStatusHelperTests: XCTestCase { func testSparkStatusIsNilWhenSparkIsUnsupported() { - XCTAssertNil(AppStatusHelper.sparkStatus(isOnline: true, isSupported: false, isReady: true)) + XCTAssertNil(AppStatusHelper.sparkStatus(isOnline: true, isSupported: false, isReady: true, hasStartFailed: false)) } func testSparkStatusMapsToReadyPendingAndError() { - XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: true), .ready) - XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: false), .pending) - XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: false, isSupported: true, isReady: true), .error) + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: true, hasStartFailed: false), .ready) + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: false, hasStartFailed: false), .pending) + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: false, isSupported: true, isReady: true, hasStartFailed: false), .error) + } + + func testSparkStatusIsErrorWhenItsStartFailed() { + XCTAssertEqual(AppStatusHelper.sparkStatus(isOnline: true, isSupported: true, isReady: false, hasStartFailed: true), .error) } func testChannelsStatusIsReadyThroughSparkWhenNoChannelIsUsable() { diff --git a/journeys/settings/app-status-spark-row.xml b/journeys/settings/app-status-spark-row.xml new file mode 100644 index 000000000..c89dbd590 --- /dev/null +++ b/journeys/settings/app-status-spark-row.xml @@ -0,0 +1,16 @@ + + + App Status shows a Spark row under Lightning Connection on builds where Spark is supported, and + the row opens Spark's detail screen. A dev build on regtest always supports Spark; the row reads + Connected once Spark reaches Lightspark's hosted regtest, Connecting before that, and Offline + without internet or after a failed start. Reaching the hosted regtest is not a journey + capability, so the checks accept any of the three. + + Precondition: onboarded dev wallet with no channel, start on the wallet home screen. + + + Tap the menu button (id "HeaderMenu") and tap App Status (id "DrawerAppStatus") + Verify a row titled "Spark" (id "Status-spark") sits directly under Lightning Connection (id "Status-lightning_connection") and reads "Connected", "Connecting..." or "Offline" + Tap the Spark row and verify Spark's detail screen opens + + From 2d256f1195968bf5497ef4c7f7e1d12ca1eceb22 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 01:03:08 +0100 Subject: [PATCH 161/378] chore: translate emergency exit status --- Bitkit/Resources/Localization/ar.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/ca.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/cs.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/de.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/el.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/es-419.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/es.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/fr.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/it.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/nl.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/pl.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/pt.lproj/Localizable.strings | 3 +++ Bitkit/Resources/Localization/ru.lproj/Localizable.strings | 3 +++ 14 files changed, 42 insertions(+) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 45c6fb3db..844cfd51d 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -912,6 +912,9 @@ "settings__status__lightning_connection__ready" = "مفتوح"; "settings__status__lightning_connection__pending" = "جارٍ الفتح..."; "settings__status__lightning_connection__error" = "لا توجد اتصالات مفتوحة"; +"settings__status__exit__title" = "الخروج الطارئ"; +"settings__status__exit__pending" = "قيد التنفيذ"; +"settings__status__exit__funding" = "تمويل الرسوم المطلوب"; "settings__status__backup__title" = "آخر نسخة احتياطية كاملة للبيانات"; "settings__status__backup__error" = "فشل إكمال نسخة احتياطية كاملة"; "settings__adv__section_debug" = "تصحيح"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 542567f07..fc98eec14 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -979,6 +979,9 @@ "settings__status__lightning_connection__ready" = "Obrir"; "settings__status__lightning_connection__pending" = "Obrint ..."; "settings__status__lightning_connection__error" = "No hi ha connexions obertes"; +"settings__status__exit__title" = "Sortida d'emergència"; +"settings__status__exit__pending" = "En curs"; +"settings__status__exit__funding" = "Finançament de comissions necessari"; "settings__status__backup__title" = "Última còpia de seguretat completa de dades"; "settings__status__backup__error" = "No s\'ha pogut completar una còpia de seguretat completa"; "settings__adv__section_debug" = "Debug"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index aabb844ee..bcc018bc3 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1042,6 +1042,9 @@ "settings__status__lightning_connection__ready" = "Otevřít"; "settings__status__lightning_connection__pending" = "Otevírání..."; "settings__status__lightning_connection__error" = "Žádná otevřená spojení"; +"settings__status__exit__title" = "Nouzový výstup"; +"settings__status__exit__pending" = "Probíhá"; +"settings__status__exit__funding" = "Potřebné krytí poplatků"; "settings__status__backup__title" = "Nejnovější úplná záloha dat"; "settings__status__backup__ready" = "Zálohováno"; "settings__status__backup__pending" = "Zálohování..."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 66eb5e7f5..e4ca9e38c 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1040,6 +1040,9 @@ "settings__status__lightning_connection__ready" = "Geöffnet"; "settings__status__lightning_connection__pending" = "In Vorbereitung..."; "settings__status__lightning_connection__error" = "Keine offenen Verbindungen"; +"settings__status__exit__title" = "Notausstieg"; +"settings__status__exit__pending" = "Läuft"; +"settings__status__exit__funding" = "Benötigte Gebührendeckung"; "settings__status__backup__title" = "Vollständiges Backup"; "settings__status__backup__ready" = "Abgeschlossen"; "settings__status__backup__pending" = "In Bearbeitung..."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 0cd14ccd2..e1ef25463 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -923,6 +923,9 @@ "settings__status__lightning_connection__ready" = "Ανοιχτή"; "settings__status__lightning_connection__pending" = "Άνοιγμα..."; "settings__status__lightning_connection__error" = "Δεν υπάρχουν ανοιχτές συνδέσεις"; +"settings__status__exit__title" = "Έξοδος Έκτακτης Ανάγκης"; +"settings__status__exit__pending" = "Σε εξέλιξη"; +"settings__status__exit__funding" = "Απαιτούμενη χρηματοδότηση τελών"; "settings__status__backup__title" = "Τελευταίο Πλήρες Αντίγραφο Δεδομένων"; "settings__status__backup__error" = "Αποτυχία ολοκλήρωσης πλήρους αντιγράφου"; "settings__adv__section_debug" = "Debug"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index eafcd3f78..4bab8dcb9 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1043,6 +1043,9 @@ "settings__status__lightning_connection__ready" = "Abierta"; "settings__status__lightning_connection__pending" = "Abriendo..."; "settings__status__lightning_connection__error" = "No hay conexiones abiertas"; +"settings__status__exit__title" = "Salida de emergencia"; +"settings__status__exit__pending" = "En curso"; +"settings__status__exit__funding" = "Fondos para comisiones necesarios"; "settings__status__backup__title" = "Última copia de seguridad completa de los datos"; "settings__status__backup__ready" = "Copia de seguridad"; "settings__status__backup__pending" = "Respaldando..."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index dfdd3acde..36318549f 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -990,6 +990,9 @@ "settings__status__lightning_connection__ready" = "Abierta"; "settings__status__lightning_connection__pending" = "Abriendo..."; "settings__status__lightning_connection__error" = "Sin conexiones abiertas"; +"settings__status__exit__title" = "Salida de emergencia"; +"settings__status__exit__pending" = "En curso"; +"settings__status__exit__funding" = "Fondos para comisiones necesarios"; "settings__status__backup__title" = "Último Backup de Datos Completo"; "settings__status__backup__error" = "Falló al hacer una copia de seguridad completa"; "settings__adv__section_debug" = "Debug"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 2cc67a62d..6ffc0ce4f 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1043,6 +1043,9 @@ "settings__status__lightning_connection__ready" = "Ouvrir"; "settings__status__lightning_connection__pending" = "Ouverture..."; "settings__status__lightning_connection__error" = "Pas de connexions ouvertes"; +"settings__status__exit__title" = "Sortie d'urgence"; +"settings__status__exit__pending" = "En cours"; +"settings__status__exit__funding" = "Financement des frais requis"; "settings__status__backup__title" = "Dernière sauvegarde complète"; "settings__status__backup__ready" = "Sauvegardé"; "settings__status__backup__pending" = "Sauvegarde..."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 2671dd02d..d1548eabc 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1023,6 +1023,9 @@ "settings__status__lightning_connection__ready" = "Aperta"; "settings__status__lightning_connection__pending" = "In apertura..."; "settings__status__lightning_connection__error" = "Nessuna connessione aperta"; +"settings__status__exit__title" = "Uscita di emergenza"; +"settings__status__exit__pending" = "In corso"; +"settings__status__exit__funding" = "Finanziamento commissioni necessario"; "settings__status__backup__title" = "Ultimo backup completo dei dati"; "settings__status__backup__ready" = "Backup eseguito"; "settings__status__backup__pending" = "Backup in esecuzione..."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 7d97db84a..e629283e5 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1040,6 +1040,9 @@ "settings__status__lightning_connection__ready" = "Open"; "settings__status__lightning_connection__pending" = "Openen..."; "settings__status__lightning_connection__error" = "Geen open verbindingen"; +"settings__status__exit__title" = "Nooduitgang"; +"settings__status__exit__pending" = "Bezig"; +"settings__status__exit__funding" = "Benodigde vergoedingsdekking"; "settings__status__backup__title" = "Laatste volledige gegevensback-up"; "settings__status__backup__ready" = "Back-up klaar"; "settings__status__backup__pending" = "Een back-up maken..."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 6d7efdb28..f23e0c982 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1042,6 +1042,9 @@ "settings__status__lightning_connection__ready" = "Otwarte"; "settings__status__lightning_connection__pending" = "Otwieranie..."; "settings__status__lightning_connection__error" = "Brak otwartych połączeń"; +"settings__status__exit__title" = "Wyjście awaryjne"; +"settings__status__exit__pending" = "W toku"; +"settings__status__exit__funding" = "Potrzebne środki na opłaty"; "settings__status__backup__title" = "Ostatnia kopia zapasowa"; "settings__status__backup__ready" = "Zapisano"; "settings__status__backup__pending" = "Zapisywanie..."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 8a7753434..e60053f33 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1042,6 +1042,9 @@ "settings__status__lightning_connection__ready" = "Ativa"; "settings__status__lightning_connection__pending" = "Ativando..."; "settings__status__lightning_connection__error" = "Nenhuma conexão ativa"; +"settings__status__exit__title" = "Saída de Emergência"; +"settings__status__exit__pending" = "Em andamento"; +"settings__status__exit__funding" = "Fundos para taxas necessários"; "settings__status__backup__title" = "Último Backup Completo"; "settings__status__backup__ready" = "Backup Feito"; "settings__status__backup__pending" = "Fazendo o backup..."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 569376120..ee944cda7 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -927,6 +927,9 @@ "settings__status__lightning_connection__ready" = "Ativa"; "settings__status__lightning_connection__pending" = "Ativando..."; "settings__status__lightning_connection__error" = "Nenhuma conexão ativa"; +"settings__status__exit__title" = "Saída de Emergência"; +"settings__status__exit__pending" = "Em curso"; +"settings__status__exit__funding" = "Fundos para taxas necessários"; "settings__status__backup__title" = "Último Backup Completo"; "settings__status__backup__error" = "Falha ao concluir backup"; "settings__adv__section_debug" = "Debug"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 1060c07b5..a42e1afae 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1037,6 +1037,9 @@ "settings__status__lightning_connection__ready" = "Открыто"; "settings__status__lightning_connection__pending" = "Открытие..."; "settings__status__lightning_connection__error" = "Нет открытых соединений"; +"settings__status__exit__title" = "Экстренный Выход"; +"settings__status__exit__pending" = "Выполняется"; +"settings__status__exit__funding" = "Нужно средств на комиссии"; "settings__status__backup__title" = "Последнее Полное Резервное Копирование Данных"; "settings__status__backup__ready" = "Скопировано"; "settings__status__backup__pending" = "Резервное копирование..."; From 3861d34bb8c2400732c430f35d3d2a374b48d97a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 02:11:30 +0100 Subject: [PATCH 162/378] fix: keep each recovered transfer's own fee approval --- .../Services/OnchainSendAttemptService.swift | 6 ++++ Bitkit/ViewModels/SparkTransferFlow.swift | 14 ++++++-- Bitkit/Views/Transfer/SpendingConfirm.swift | 5 ++- BitkitTests/SparkTransferFlowTests.swift | 35 ++++++++++++++++++- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index 4949f1dab..f33dd69e5 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -941,6 +941,12 @@ actor OnchainSendAttemptService { try store.save([attempt]) } + /// The Spark funding still waiting on its outcome, so the confirm screen can keep the approval its swipe gave. + func unresolvedSparkFundingAttemptId() -> UUID? { + guard let attempt = try? currentAttempt(), attempt.isSparkFunding, attempt.status != .accepted else { return nil } + return attempt.id + } + private func currentAdmissionAttempt() throws -> OnchainSendAttempt? { do { return try currentAttempt() } catch { throw OnchainSendAttemptError.unresolved } diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 2fb902ba3..f4a20c950 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -160,6 +160,7 @@ final class SparkTransferFlow { @ObservationIgnored private var offers: [String: SparkClaimOffer] = [:] @ObservationIgnored private var ceilings: [String: UInt64] = [:] @ObservationIgnored private var approved: (ceilingSats: UInt64, speed: SparkTransferSpeed)? + @ObservationIgnored private var approvals: [UUID: (ceilingSats: UInt64, speed: SparkTransferSpeed)] = [:] @ObservationIgnored private var watchTasks: [String: Task] = [:] @ObservationIgnored private var quoteRequestId = 0 @ObservationIgnored private let operations: SparkClaimOperations @@ -289,10 +290,17 @@ final class SparkTransferFlow { approved = (ceilingSats: quote?.claimFees.instantMaxSats ?? 0, speed: speed) } + /// Ties the last swipe's approval to the funding it left unresolved. A later swipe rejected while that funding is + /// still unresolved finds it already tied, so it can't change the ceiling or speed the user approved for it. + func bindApproval(to attemptId: UUID) { + guard let approved, approvals[attemptId] == nil else { return } + approvals[attemptId] = approved + } + /// Starts the claim of a funding whose outcome was unknown at the swipe, at the ceiling and speed then approved; with - /// nothing approved, an early claim asks first. - func onRecoveredBroadcast(txid: String) { - let approved = approved ?? (ceilingSats: 0, speed: speed) + /// nothing approved for that funding, an early claim asks first. + func onRecoveredBroadcast(txid: String, attemptId: UUID) { + let approved = approvals.removeValue(forKey: attemptId) ?? (ceilingSats: 0, speed: speed) speed = approved.speed onBroadcast(txid: txid, ceilingSats: approved.ceilingSats) } diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index ab0064bae..9d86f583b 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -82,7 +82,7 @@ struct SpendingConfirm: View { .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in guard Self.followsRecovered(resolution, isPaying: isPaying), !hideSwipeButton else { return } // This screen owns the recovered funding: follow it at the chosen speed, as a swipe would have. - flow.onRecoveredBroadcast(txid: resolution.txid) + flow.onRecoveredBroadcast(txid: resolution.txid, attemptId: resolution.attemptId) hideSwipeButton = true navigation.navigate(.settingUp) } @@ -159,6 +159,9 @@ struct SpendingConfirm: View { hideSwipeButton = true } } catch { + if let attemptId = await OnchainSendAttemptService.shared.unresolvedSparkFundingAttemptId() { + flow.bindApproval(to: attemptId) + } Logger.error("Failed to transfer to spending: \(error)", context: "SpendingConfirm") app.toast( type: .error, diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 5ba02a184..5b1099a21 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -171,16 +171,49 @@ final class SparkTransferFlowTests: XCTestCase { func testARecoveredFundingClaimsEarlyOnlyUpToTheSwipedCeiling() async { let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) flow.quote = quote(instantMax: 337) + let attemptId = UUID() flow.approveSwipe() + flow.bindApproval(to: attemptId) // The confirm screen requoted under the pending sheet while fee rates rose. flow.quote = quote(instantMax: 3010) - flow.onRecoveredBroadcast(txid: "txid") + flow.onRecoveredBroadcast(txid: "txid", attemptId: attemptId) await flow.waitForClaim() XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "Paid an early claim fee the user never approved") } + @MainActor + func testARecoveredFundingKeepsItsOwnApprovalWhenALaterSwipeIsRejected() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let first = UUID() + flow.quote = quote(instantMax: 337) + flow.approveSwipe() + flow.bindApproval(to: first) + // A second swipe at a higher ceiling is rejected while the first funding is still unresolved, so the attempt + // the screen sees is still the first one. + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: first) + + flow.onRecoveredBroadcast(txid: "txid", attemptId: first) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "The first funding claimed early at the rejected swipe's ceiling") + } + + @MainActor + func testARecoveredFundingWithNoApprovalAsksBeforeAnEarlyClaim() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + + flow.onRecoveredBroadcast(txid: "txid", attemptId: UUID()) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "Claimed early on an approval made for another attempt") + } + private func quote(instantMax: UInt64) -> SparkTransferQuote { SparkTransferQuote( address: "bcrt1qtest", satsPerVbyte: 6, networkFeeSats: 577, depositSats: 100_000, From eed27d8281d60c5a7a70c99475d15cc2e8ec933c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 02:12:03 +0100 Subject: [PATCH 163/378] fix: spark row connecting during retries and tappable once ready --- Bitkit/Managers/SparkManager.swift | 5 +++-- Bitkit/Views/Settings/AppStatusView.swift | 6 +++--- journeys/settings/app-status-spark-row.xml | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 4469f7c31..ab064bc0d 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -38,7 +38,7 @@ final class SparkManager: ChannelFreeLightningRail { private nonisolated static let depositClaimFeePpm: UInt32 = 2000 private(set) var isReady = false - /// The last start failed before Spark could read its state; the next start clears it. + /// The last start failed; the next start clears it when it begins. private(set) var hasStartFailed = false private(set) var balanceSats: UInt64 = 0 /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. @@ -117,6 +117,8 @@ final class SparkManager: ChannelFreeLightningRail { func start(walletIndex: Int) async { guard let network = Self.sparkNetwork else { return } + // A retry reads as connecting until it fails again. + hasStartFailed = false do { if sdk == nil { if connectTask == nil { @@ -135,7 +137,6 @@ final class SparkManager: ChannelFreeLightningRail { syncWallet: { _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) }, syncActivities: { try await syncActivities() } ) - hasStartFailed = false } catch { connectTask = nil hasStartFailed = true diff --git a/Bitkit/Views/Settings/AppStatusView.swift b/Bitkit/Views/Settings/AppStatusView.swift index 763fc03b4..71940f6ce 100644 --- a/Bitkit/Views/Settings/AppStatusView.swift +++ b/Bitkit/Views/Settings/AppStatusView.swift @@ -146,10 +146,10 @@ struct AppStatusView: View { title: t("settings__status__spark__title"), description: t("settings__status__spark__\(status.rawValue)"), status: status, - onTap: { - navigation.navigate(.sparkConnectionDetail) - } + // Spark's detail shows a zero balance until Spark has read its state. + onTap: status == .ready ? { navigation.navigate(.sparkConnectionDetail) } : nil ) + .accessibilityElement(children: .contain) .accessibilityIdentifier("Status-spark") } } diff --git a/journeys/settings/app-status-spark-row.xml b/journeys/settings/app-status-spark-row.xml index c89dbd590..c883d3346 100644 --- a/journeys/settings/app-status-spark-row.xml +++ b/journeys/settings/app-status-spark-row.xml @@ -1,7 +1,7 @@ App Status shows a Spark row under Lightning Connection on builds where Spark is supported, and - the row opens Spark's detail screen. A dev build on regtest always supports Spark; the row reads + once Spark reads Connected the row opens Spark's detail screen. A dev build on regtest always supports Spark; the row reads Connected once Spark reaches Lightspark's hosted regtest, Connecting before that, and Offline without internet or after a failed start. Reaching the hosted regtest is not a journey capability, so the checks accept any of the three. @@ -11,6 +11,6 @@ Tap the menu button (id "HeaderMenu") and tap App Status (id "DrawerAppStatus") Verify a row titled "Spark" (id "Status-spark") sits directly under Lightning Connection (id "Status-lightning_connection") and reads "Connected", "Connecting..." or "Offline" - Tap the Spark row and verify Spark's detail screen opens + Wait for the Spark row to read "Connected", then tap it and verify Spark's detail screen opens From eec1041307e287db06e2ac0e185eef4fee1135ad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 02:12:11 +0100 Subject: [PATCH 164/378] fix: exit row funding step and emergency exit journey --- Bitkit/Utilities/AppStatusHelper.swift | 12 +++++----- Bitkit/Views/Settings/AppStatusView.swift | 3 ++- BitkitTests/AppStatusExitTests.swift | 6 +++++ .../settings/app-status-emergency-exit.xml | 23 +++++++++++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 journeys/settings/app-status-emergency-exit.xml diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index 8a18f1167..79d5cabf1 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -30,13 +30,13 @@ enum ExitStatusStep: String { @MainActor struct AppStatusHelper { - /// Nil while no emergency exit runs. - nonisolated static func exitStep(record: SparkExitRecord?) -> ExitStatusStep? { + /// Nil while no emergency exit runs. Asks for fee funding only while no payment covers it, as the progress screen does. + nonisolated static func exitStep(record: SparkExitRecord?, fundingState: SparkExitFundingState? = nil) -> ExitStatusStep? { guard let record, record.isActive else { return nil } - switch record.phase { - case .awaitingFunding, .needsFunding: return .funding - case .running: return .pending - case .done, .failed: return nil + guard record.fundingShortfallSats != nil else { return .pending } + switch fundingState ?? SparkExitMath.fundingState(record, utxos: nil) { + case .paid, .seen: return .pending + default: return .funding } } diff --git a/Bitkit/Views/Settings/AppStatusView.swift b/Bitkit/Views/Settings/AppStatusView.swift index 61c7b5472..60e89f4ba 100644 --- a/Bitkit/Views/Settings/AppStatusView.swift +++ b/Bitkit/Views/Settings/AppStatusView.swift @@ -139,7 +139,7 @@ struct AppStatusView: View { @ViewBuilder private var exitStatusRow: some View { - if let step = AppStatusHelper.exitStep(record: SparkExitManager.shared.record) { + if let step = AppStatusHelper.exitStep(record: SparkExitManager.shared.record, fundingState: SparkExitManager.shared.fundingState) { StatusRow( imageName: "warning", title: t("settings__status__exit__title"), @@ -149,6 +149,7 @@ struct AppStatusView: View { navigation.navigate(.sparkExitProgress) } ) + .accessibilityElement(children: .contain) .accessibilityIdentifier("Status-spark_exit") } } diff --git a/BitkitTests/AppStatusExitTests.swift b/BitkitTests/AppStatusExitTests.swift index efb434aee..95f92def9 100644 --- a/BitkitTests/AppStatusExitTests.swift +++ b/BitkitTests/AppStatusExitTests.swift @@ -12,6 +12,12 @@ final class AppStatusExitTests: XCTestCase { XCTAssertEqual(AppStatusHelper.exitStep(record: record(.running)), .pending) XCTAssertEqual(AppStatusHelper.exitStep(record: record(.awaitingFunding(requiredSats: 1000))), .funding) XCTAssertEqual(AppStatusHelper.exitStep(record: record(.needsFunding(requiredSats: 1000))), .funding) + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.needsFunding(requiredSats: 1000)), fundingState: .ask(sats: 1000)), .funding) + } + + func testExitStepIsPendingOnceAPaymentCoversTheFunding() { + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.awaitingFunding(requiredSats: 1000)), fundingState: .paid(txid: "txid")), .pending) + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.needsFunding(requiredSats: 1000)), fundingState: .seen(txid: "txid")), .pending) } private func record(_ phase: SparkExitRecord.Phase) -> SparkExitRecord { diff --git a/journeys/settings/app-status-emergency-exit.xml b/journeys/settings/app-status-emergency-exit.xml new file mode 100644 index 000000000..801da28c1 --- /dev/null +++ b/journeys/settings/app-status-emergency-exit.xml @@ -0,0 +1,23 @@ + + + While an emergency exit is active, App Status shows an "Emergency Exit" row under Lightning Connection. + It reads "Fee funding needed" while the exit asks for fee funding and "In progress" otherwise, including + while a payment that covers the funding waits to confirm. The row opens the exit's progress screen. With + no active exit the row is absent. On iOS, OK on the progress screen returns to the wallet home screen + from any entry point, App Status included. + + Precondition: Debug build on regtest with Spark connected, a Spark balance and savings that can pay the + exit's fee funding. Start an emergency exit from Spark (spark emergency exit journey), pick "Pay from + savings" (id "SparkExitFundSavings") and swipe to start it. Starting an exit takes the Spark balance out of + Spending for days, so use a throwaway wallet. Start on the wallet home screen. + + + Tap the menu button (id "HeaderMenu") and tap App Status (id "DrawerAppStatus") + Verify a row titled "Emergency Exit" (id "Status-spark_exit") sits under Lightning Connection (id "Status-lightning_connection") and reads "In progress", since savings paid the funding + Tap the row and verify the exit's progress screen opens + Tap "OK" (id "SparkExitProgressOk") and verify the wallet home screen shows + Open App Status again from the menu and verify the "Emergency Exit" row is still there + Tap the row, tap "Cancel exit" (id "SparkExitCancel") and confirm; if "Exit cancelled" shows, tap "OK" (id "SparkExitProgressOk") + Open App Status from the menu and verify the "Emergency Exit" row is gone + + From 4647ebe9e5b3bb4df449edc5a9046dc9e2236d41 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 02:49:13 +0100 Subject: [PATCH 165/378] fix: open savings for an amountless invoice an empty spark can't pay --- Bitkit/Services/LightningService.swift | 3 ++- BitkitTests/ChannelFreeRailTests.swift | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index a96f0e72c..e61fbe307 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -944,7 +944,8 @@ class LightningService: NodeLifecycleService { /// Note: Uses cached channels for fast, non-blocking checks @MainActor func canSend(amountSats: UInt64) -> Bool { - if let rail = channelFreeRail, rail.isReady, rail.sendableSats >= amountSats, !canSendWithChannels(amountSats: amountSats) { + // An amountless invoice still needs funds on Spark, as the channels need capacity above the amount. + if let rail = channelFreeRail, rail.isReady, rail.sendableSats >= max(1, amountSats), !canSendWithChannels(amountSats: amountSats) { return true } return canSendWithChannels(amountSats: amountSats) diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 76c446873..443b01fc4 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -424,6 +424,14 @@ extension ChannelFreeRailTests { XCTAssertFalse(Bitkit.LightningService.shared.canSend(amountSats: 100_000)) } + func testAnAmountlessInvoiceNeedsSparkToHoldFunds() { + rail.balanceSats = 0 + XCTAssertFalse(Bitkit.LightningService.shared.canSend(amountSats: 0), "An empty Spark wallet offered Spending for an amountless invoice") + + rail.balanceSats = 10000 + XCTAssertTrue(Bitkit.LightningService.shared.canSend(amountSats: 0)) + } + func testSendableSatsFitsTheFeeBesideTheAmount() { for balance: UInt64 in [0, 1, 400, 401, 9572, 100_000, 21_000_000] { let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: balance) From 7680638301d3d8ef762b632e7a54fac085b1adea Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 03:01:10 +0100 Subject: [PATCH 166/378] docs: spark row journey relies on regtest spark --- journeys/settings/app-status-spark-row.xml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/journeys/settings/app-status-spark-row.xml b/journeys/settings/app-status-spark-row.xml index c883d3346..4e3f721b2 100644 --- a/journeys/settings/app-status-spark-row.xml +++ b/journeys/settings/app-status-spark-row.xml @@ -1,16 +1,18 @@ - App Status shows a Spark row under Lightning Connection on builds where Spark is supported, and - once Spark reads Connected the row opens Spark's detail screen. A dev build on regtest always supports Spark; the row reads - Connected once Spark reaches Lightspark's hosted regtest, Connecting before that, and Offline - without internet or after a failed start. Reaching the hosted regtest is not a journey - capability, so the checks accept any of the three. + App Status shows a Spark row under Lightning Connection on builds where Spark is supported. A Debug build + on regtest connects Spark to its hosted regtest with no key (journeys/README.md capabilities), so with + no channel the row reads Connected and Lightning Connection reads "Using Spark". Once Connected, the + row opens Spark's detail screen. Connecting and Offline are covered by unit tests and the PR's + screenshots. If the row never reads Connected within 2 minutes, the run is blocked on the hosted + regtest, not failed. - Precondition: onboarded dev wallet with no channel, start on the wallet home screen. + Precondition: onboarded Debug build wallet with no channel, start on the wallet home screen. Tap the menu button (id "HeaderMenu") and tap App Status (id "DrawerAppStatus") - Verify a row titled "Spark" (id "Status-spark") sits directly under Lightning Connection (id "Status-lightning_connection") and reads "Connected", "Connecting..." or "Offline" - Wait for the Spark row to read "Connected", then tap it and verify Spark's detail screen opens + Verify a row titled "Spark" (id "Status-spark") sits directly under Lightning Connection (id "Status-lightning_connection") and reads "Connected" within 2 minutes + Verify Lightning Connection reads "Using Spark" + Tap the Spark row and verify Spark's detail screen opens From 6fe58d2b14071826ba008531bb7cfc69a98b32a9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 03:01:11 +0100 Subject: [PATCH 167/378] docs: exit journey without unreachable cancel --- journeys/settings/app-status-emergency-exit.xml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/journeys/settings/app-status-emergency-exit.xml b/journeys/settings/app-status-emergency-exit.xml index 801da28c1..49716f192 100644 --- a/journeys/settings/app-status-emergency-exit.xml +++ b/journeys/settings/app-status-emergency-exit.xml @@ -3,8 +3,9 @@ While an emergency exit is active, App Status shows an "Emergency Exit" row under Lightning Connection. It reads "Fee funding needed" while the exit asks for fee funding and "In progress" otherwise, including while a payment that covers the funding waits to confirm. The row opens the exit's progress screen. With - no active exit the row is absent. On iOS, OK on the progress screen returns to the wallet home screen - from any entry point, App Status included. + no active exit the row is absent. An exit paid from savings cannot be cancelled and runs for days, + so the row's removal once an exit ends is covered by unit tests, not here. On iOS, OK on the progress + screen returns to the wallet home screen from any entry point, App Status included. Precondition: Debug build on regtest with Spark connected, a Spark balance and savings that can pay the exit's fee funding. Start an emergency exit from Spark (spark emergency exit journey), pick "Pay from @@ -17,7 +18,5 @@ Tap the row and verify the exit's progress screen opens Tap "OK" (id "SparkExitProgressOk") and verify the wallet home screen shows Open App Status again from the menu and verify the "Emergency Exit" row is still there - Tap the row, tap "Cancel exit" (id "SparkExitCancel") and confirm; if "Exit cancelled" shows, tap "OK" (id "SparkExitProgressOk") - Open App Status from the menu and verify the "Emergency Exit" row is gone From 0313046db770722967d18c1054b7dc03768970e4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 03:03:39 +0100 Subject: [PATCH 168/378] test: exit row with nothing to fund or a savings payment confirming --- BitkitTests/AppStatusExitTests.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/BitkitTests/AppStatusExitTests.swift b/BitkitTests/AppStatusExitTests.swift index 95f92def9..56ddf2beb 100644 --- a/BitkitTests/AppStatusExitTests.swift +++ b/BitkitTests/AppStatusExitTests.swift @@ -20,6 +20,16 @@ final class AppStatusExitTests: XCTestCase { XCTAssertEqual(AppStatusHelper.exitStep(record: record(.needsFunding(requiredSats: 1000)), fundingState: .seen(txid: "txid")), .pending) } + func testExitStepIsPendingWithNothingToFund() { + XCTAssertEqual(AppStatusHelper.exitStep(record: record(.awaitingFunding(requiredSats: 0))), .pending) + } + + func testExitStepIsPendingWhileASavingsPaymentConfirms() { + var paid = record(.awaitingFunding(requiredSats: 1000)) + paid.fundingPaymentTxid = "txid" + XCTAssertEqual(AppStatusHelper.exitStep(record: paid), .pending) + } + private func record(_ phase: SparkExitRecord.Phase) -> SparkExitRecord { SparkExitRecord( network: "regtest", From e7a2492aba5b1f8013925d8b90a08c45c5fe3a42 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 03:14:58 +0100 Subject: [PATCH 169/378] fix: keep a dismissed payment request from popping up again A rejected or canceled request left the presented set as soon as it left pending, so the sheet opened again whenever a later SDK read listed it as proposed. Declined requests now stay marked while their history entry remains. --- .../PaykitPaymentRequestService.swift | 20 ++++++++++++++++--- .../PaykitPaymentRequestServiceTests.swift | 18 +++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 27d5f5996..0834ed1e9 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -2053,6 +2053,16 @@ final class PaykitPaymentRequestManager { } } + private static let declinedLifecycleStates: [Paykit.PaymentRequestLifecycleState] = [.rejected, .canceled] + + /// Requests the user declined stay marked as presented while the SDK still lists them, so one never pops up again + /// if a later read reports it as proposed. + private var declinedRequestIds: Set { + Set(historyRequests.filter { + $0.direction == .incoming && Self.declinedLifecycleStates.contains($0.lifecycleState) + }.map(\.id)) + } + func canDismiss(_ request: PaykitPaymentRequest) -> Bool { retainedHardwareRetries[request.id] == nil } @@ -2693,7 +2703,7 @@ final class PaykitPaymentRequestManager { clockOffset: subscriptionClockOffset ) let currentRequestIds = Set(pendingRequests.map(\.id)) - presentedRequestIds.formIntersection(currentRequestIds) + presentedRequestIds.formIntersection(currentRequestIds.union(declinedRequestIds)) presentationRetryAttempts = presentationRetryAttempts.filter { currentRequestIds.contains($0.key) } presentationRetryDeadlines = presentationRetryDeadlines.filter { currentRequestIds.contains($0.key) } automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { currentRequestIds.contains($0.key) } @@ -2804,7 +2814,11 @@ final class PaykitPaymentRequestManager { historyRequests.removeAll { $0.id == request.id } historyRequests.insert(updatedRequest, at: 0) pendingRequests.removeAll { $0.id == request.id } - presentedRequestIds.remove(request.id) + if Self.declinedLifecycleStates.contains(resultingState) { + presentedRequestIds.insert(request.id) + } else { + presentedRequestIds.remove(request.id) + } presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDeadlines.removeValue(forKey: request.id) automaticPresentationDiagnosticReasons.removeValue(forKey: request.id) @@ -2877,7 +2891,7 @@ final class PaykitPaymentRequestManager { requestedSubscriptionProposalId = nil } let requestIds = Set(pendingRequests.map(\.id)) - presentedRequestIds.formIntersection(requestIds) + presentedRequestIds.formIntersection(requestIds.union(declinedRequestIds)) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDeadlines = presentationRetryDeadlines.filter { requestIds.contains($0.key) } automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { requestIds.contains($0.key) } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index c1c7f0661..818a020b0 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -7110,6 +7110,24 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertFalse(manager.isApprovedForPayment(request)) } + func testDismissedRequestIsNotPresentedAgainWhenTheSdkListsItAsProposedAgain() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + XCTAssertTrue(manager.markPresentedIfPending(request)) + + try await manager.dismiss(request) + try await sdk.setRecords([paymentRequestRecord(state: .rejected)]) + await manager.refresh() + XCTAssertTrue(manager.pendingRequests.isEmpty) + + try await sdk.setRecords([paymentRequestRecord()]) + await manager.refresh() + XCTAssertEqual(manager.pendingRequests.map(\.id), [request.id]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + func testFailedRejectionDropsRequestRemovedFromAuthoritativeQueue() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) From 6d7c6f9b4f36c168e5478789bba28280ef3befb1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 03:24:33 +0100 Subject: [PATCH 170/378] fix: spark transfer, send and exit backup edge cases - Transfer to Savings starts from a fresh pick; Close instead waits for the Spark fee and a running re-price. - The Spark withdrawal fee timeout returns at its deadline although the SDK call ignores cancellation. - A Spark send is refused while the node can't list an invoice it sent; Paykit and QuickPay read an unlistable node as unknown. - Insufficient funds inside the Spark send is a refusal; the send sheet checks Spark's sendable amount. - Restored exit data not yet imported, or a remote exit backup not yet read, blocks the upload and backs off. - Wait after a claim-time reprice clears the early-claim fee cap; the latest backup time skips SPARK_EXIT. - SentPaymentContacts updates hold a lock. --- Bitkit/Managers/SparkManager.swift | 36 ++++++- Bitkit/Services/BackupService.swift | 17 +++- Bitkit/Services/CoreService.swift | 20 ++-- Bitkit/Services/LightningService.swift | 73 ++++++++++++--- .../PrivatePaykitService+Invoices.swift | 7 +- .../QuickPayPaymentCoordinator.swift | 21 +++-- Bitkit/ViewModels/SparkTransferFlow.swift | 16 +++- Bitkit/ViewModels/TransferViewModel.swift | 88 ++++++++++++++---- .../Transfer/SavingsAvailabilityView.swift | 2 + .../Views/Transfer/SavingsConfirmView.swift | 10 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 7 +- BitkitTests/ChannelFreeRailTests.swift | 93 +++++++++++++++++++ BitkitTests/SavingsSelectionTests.swift | 12 +++ BitkitTests/SparkExitBackupTests.swift | 40 ++++++++ BitkitTests/SparkRelaunchActivityTests.swift | 15 +++ BitkitTests/SparkTransferFlowTests.swift | 49 ++++++++++ BitkitTests/SparkWithdrawalTests.swift | 68 ++++++++++++++ 17 files changed, 515 insertions(+), 59 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 0410352de..3b6eff57b 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -271,7 +271,8 @@ final class SparkManager: ChannelFreeLightningRail { } /// Errors before `sendPayment` are `ChannelFreeRailSendRefused`, since nothing left the wallet; a failure inside it - /// stays uncertain, as the payment may have been dispatched. + /// stays uncertain, as the payment may have been dispatched, unless it is one raised before dispatch + /// (`sendPaymentFailure`). nonisolated func send( bolt11: String, sats: UInt64?, @@ -312,8 +313,7 @@ final class SparkManager: ChannelFreeLightningRail { options: Self.bolt11SendOptions )) } catch { - // The SDK checks the expiry again before sending, so an invoice that expired on the confirm screen left nothing. - throw Self.expiredInvoiceRefusal(error) ?? error + throw Self.sendPaymentFailure(error) } Logger.info("Sent Spark payment '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") return invoiceDetails.paymentHash @@ -333,6 +333,20 @@ final class SparkManager: ChannelFreeLightningRail { } /// The SDK refuses an expired Lightning invoice before anything leaves the wallet, with the scanner's expiry copy. + /// What a failure inside `sendPayment` means for the caller. + nonisolated static func sendPaymentFailure(_ error: Error) -> Error { + // The SDK checks the expiry again before sending, so an invoice that expired on the confirm screen left nothing. + if let refusal = expiredInvoiceRefusal(error) { + return refusal + } + // Spark selects the leaves to spend before anything goes to the operators, so a balance short of the payment + // left nothing, for instance when an earlier send spent it after the cached balance was read. + if case SdkError.InsufficientFunds = error { + return ChannelFreeRailSendRefused(reason: t("other__pay_insufficient_spending_description"), underlying: error) + } + return error + } + nonisolated static func expiredInvoiceRefusal(_ error: Error) -> ChannelFreeRailSendRefused? { guard case let SdkError.InvalidInput(message) = error, message.localizedCaseInsensitiveContains("invoice has expired") else { return nil } return ChannelFreeRailSendRefused(reason: t("other__scan__error__expired")) @@ -404,6 +418,16 @@ final class SparkManager: ChannelFreeLightningRail { return SparkClaimOffer(txid: deposit.txid, vout: deposit.vout, quote: quote) } + /// Clears the fee cap an early claim of `offer` left on its deposit, back to the configured one, which never claims + /// early (`sdkConfig`): a claim that names no cap clears the stored one before it is attempted. + func releaseEarlyClaim(_ offer: SparkClaimOffer) async { + do { + _ = try await requireSdk().claimDeposit(request: ClaimDepositRequest(txid: offer.txid, vout: offer.vout, maxFee: nil)) + } catch { + Logger.warn("Failed to clear the early claim cap of Spark deposit \(offer.txid): \(error)", context: "SparkManager") + } + } + /// Accepts the early claim of `offer` at its quoted fee. The fee ceiling stays on the deposit, so an early claim /// not offered at this depth is still made once the provider offers it. func claimEarly(_ offer: SparkClaimOffer) async throws -> SparkEarlyClaim { @@ -525,6 +549,12 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Whether restored exit data is still waiting for its import, after trying that again while connected. + func hasStagedExitState(walletIndex: Int) async -> Bool { + await importRestoredExitState(walletIndex: walletIndex) + return FileManager.default.fileExists(atPath: Env.sparkExitRestore(walletIndex: walletIndex).path) + } + private func importRestoredExitState(walletIndex: Int) async { let file = Env.sparkExitRestore(walletIndex: walletIndex) guard let sdk, let data = FileManager.default.contents(atPath: file.path) else { return } diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index e43bd8ba1..999e7e96e 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -431,6 +431,10 @@ class BackupService { await restoreSparkExit() } guard !defaults.bool(forKey: Self.sparkExitRestorePendingKey) else { throw SparkExitBackupError.restorePending } + // Restored exit data Spark couldn't import yet lives only in the remote backup, so nothing replaces it. + guard await !SparkManager.shared.hasStagedExitState(walletIndex: LightningService.shared.currentWalletIndex) else { + throw SparkExitBackupError.restorePending + } // Nothing to back up until Spark connects, which marks this category required again. if let data = try await sparkExitBackupData() { try await uploadBackup(category.rawValue, data) @@ -475,7 +479,8 @@ class BackupService { outcome = .busy Logger.debug("Metadata backup deferred until wallet restoration and backup complete", context: "BackupService") } else if error is SparkExitBackupError { - outcome = .busy + // Backs off like an unreachable server: retrying every few seconds refetches several MB each time. + outcome = .temporarilyUnavailable Logger.warn("Spark exit backup deferred until its restore reads the remote backup", context: "BackupService") } else if error is WalletBackupSnapshotError { outcome = .snapshotChanged @@ -1147,9 +1152,15 @@ class BackupService { return statuses[category] ?? BackupItemStatus() } + /// The categories whose remote timestamp tells when the wallet was last backed up. + /// The Spark exit data is left out: reading its time downloads data that can reach several MB. + static let latestBackupTimeCategories = BackupCategory.allCases.filter { + $0 != .lightningConnections && $0 != .usdt && $0 != .sparkExit + } + func getLatestBackupTime() async -> UInt64? { let timestamps = await withTaskGroup(of: UInt64?.self) { group in - for category in BackupCategory.allCases where category != .lightningConnections && category != .usdt { + for category in Self.latestBackupTimeCategories { group.addTask { await self.getRemoteBackupTimestamp(category: category) } @@ -1679,7 +1690,7 @@ struct BackupFailureNotificationGate { } enum SparkExitBackupError: Error { - /// The restore couldn't read the remote backup yet, so a fresh export must not replace it. + /// The restore couldn't read the remote backup or import it yet, so a fresh export must not replace it. case restorePending } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 7ac1e5679..18a92a64c 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -2622,12 +2622,16 @@ class CoreService { /// The contact a Lightning send pays, remembered by payment hash before it goes out. struct SentPaymentContacts { private static let key = "sentPaymentContacts" + /// `remember` runs on the main actor and `forget` on the core queue, so each read-modify-write holds this. + private static let lock = NSLock() let defaults: UserDefaults func remember(_ contact: String, forPaymentHash paymentHash: String) { - var contacts = defaults.dictionary(forKey: Self.key) as? [String: String] ?? [:] - contacts[paymentHash.lowercased()] = contact - defaults.set(contacts, forKey: Self.key) + Self.lock.withLock { + var contacts = defaults.dictionary(forKey: Self.key) as? [String: String] ?? [:] + contacts[paymentHash.lowercased()] = contact + defaults.set(contacts, forKey: Self.key) + } } func contact(forPaymentHash paymentHash: String) -> String? { @@ -2635,9 +2639,11 @@ struct SentPaymentContacts { } func forget(paymentHash: String) { - guard var contacts = defaults.dictionary(forKey: Self.key) as? [String: String], - contacts.removeValue(forKey: paymentHash.lowercased()) != nil - else { return } - defaults.set(contacts, forKey: Self.key) + Self.lock.withLock { + guard var contacts = defaults.dictionary(forKey: Self.key) as? [String: String], + contacts.removeValue(forKey: paymentHash.lowercased()) != nil + else { return } + defaults.set(contacts, forKey: Self.key) + } } } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index e61fbe307..458e436d6 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -446,6 +446,7 @@ class LightningService: NodeLifecycleService { Logger.warn("Wiping on lighting wallet...") try FileManager.default.removeItem(at: directory) + forgetLdkAttempts() await MainActor.run { clearCache() @@ -1082,7 +1083,10 @@ class LightningService: NodeLifecycleService { noteAttempt(on: .ldk, bolt11: bolt11) do { - return try await Self.submitPayment(beforeSubmission: beforeSubmission) { + return try await Self.submitPayment(beforeSubmission: { [weak self] in + try beforeSubmission() + self?.recordLdkAttempt(paymentHash: paymentHash) + }) { if let sats { try node.bolt11Payment().sendUsingAmount( invoice: .fromStr(invoiceStr: bolt11), amountMsat: sats * 1000, routeParameters: params @@ -1111,13 +1115,25 @@ class LightningService: NodeLifecycleService { private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" private static let railAttemptsLimit = 500 + /// Payment hashes sent over the node, kept across launches: while the node can't list its payments (stopped or not + /// started yet), only these may have an attempt there that a Spark payment would repeat. + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" + private func recordRailAttempt(paymentHash: String?) { + recordAttempt(paymentHash: paymentHash, key: Self.railAttemptsKey) + } + + func recordLdkAttempt(paymentHash: String?) { + recordAttempt(paymentHash: paymentHash, key: Self.ldkAttemptsKey) + } + + private func recordAttempt(paymentHash: String?, key: String) { guard let paymentHash = paymentHash?.lowercased() else { return } eventCallbackLock.withLock { - var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + var hashes = railAttemptsStore.stringArray(forKey: key) ?? [] guard !hashes.contains(paymentHash) else { return } hashes.append(paymentHash) - railAttemptsStore.set(Array(hashes.suffix(Self.railAttemptsLimit)), forKey: Self.railAttemptsKey) + railAttemptsStore.set(Array(hashes.suffix(Self.railAttemptsLimit)), forKey: key) } } @@ -1126,18 +1142,29 @@ class LightningService: NodeLifecycleService { func releaseRailAttempt(paymentHash: String, unlessInFlight: Bool = true) { let hash = paymentHash.lowercased() eventCallbackLock.withLock { - guard !unlessInFlight || !railSendsInFlight.contains(hash), - var hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey), - hashes.contains(hash) - else { return } - hashes.removeAll { $0 == hash } - railAttemptsStore.set(hashes, forKey: Self.railAttemptsKey) + guard !unlessInFlight || !railSendsInFlight.contains(hash) else { return } + removeAttempt(paymentHash: hash, key: Self.railAttemptsKey) } } + private func releaseLdkAttempt(paymentHash: String) { + eventCallbackLock.withLock { removeAttempt(paymentHash: paymentHash.lowercased(), key: Self.ldkAttemptsKey) } + } + + /// Callers hold `eventCallbackLock`. + private func removeAttempt(paymentHash hash: String, key: String) { + guard var hashes = railAttemptsStore.stringArray(forKey: key), hashes.contains(hash) else { return } + hashes.removeAll { $0 == hash } + railAttemptsStore.set(hashes, forKey: key) + } + private func hasRailAttempt(paymentHash: String) -> Bool { + hasAttempt(paymentHash: paymentHash, key: Self.railAttemptsKey) + } + + private func hasAttempt(paymentHash: String, key: String) -> Bool { eventCallbackLock.withLock { - railAttemptsStore.stringArray(forKey: Self.railAttemptsKey)?.contains(paymentHash.lowercased()) ?? false + railAttemptsStore.stringArray(forKey: key)?.contains(paymentHash.lowercased()) ?? false } } @@ -1146,16 +1173,32 @@ class LightningService: NodeLifecycleService { eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.railAttemptsKey) } } + /// Called when the node's wallet is wiped, as its payments go with it. + func forgetLdkAttempts() { + eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.ldkAttemptsKey) } + } + /// An invoice must never go out on both rails: an earlier attempt still pending on `rails` refuses the payment as a /// duplicate, as LDK does its own, and a settled one returns its hash as paid. A failed or unknown one lets the - /// payment go. While the rail can't list its payments, an invoice it was sent over is refused as pending too. + /// payment go. While either rail can't list its payments, an invoice it was sent over is refused as pending too. private func settledEarlierAttempt(paymentHash: String?, on rails: [LightningPaymentRail]) async throws -> PaymentHash? { guard let paymentHash else { return nil } var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] - if rails.contains(.ldk), let payment = await nodePayments()?.first(where: { - $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame - }) { - statuses.append(payment.status) + if rails.contains(.ldk) { + if let payments = await nodePayments() { + let payment = payments.first { + $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame + } + if let payment { + statuses.append(payment.status) + } + if payment == nil || payment?.status == .failed { + releaseLdkAttempt(paymentHash: paymentHash) + } + } else if hasAttempt(paymentHash: paymentHash, key: Self.ldkAttemptsKey) { + Logger.warn("Refused paying '\(paymentHash)', the node can't list the earlier attempt now", context: "LightningService") + throw Self.pendingDuplicate(paymentHash: paymentHash) + } } if rails.contains(.channelFree) { if let payments = await readableRailPayments() { diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 40e70c32e..e21931eae 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -72,7 +72,7 @@ extension PrivatePaykitService { return decodedInvoice.paymentHash.hex } - /// Whether this wallet already tried to pay `paymentHash`, or nil when Spark could not list its payments. + /// Whether this wallet already tried to pay `paymentHash`, or nil when the node or Spark could not list its payments. func hasAttemptedOutboundBolt11Payment(paymentHash: String) async -> Bool? { guard let attempted = try? await attemptedOutboundBolt11PaymentHashes() else { return nil } return attempted.contains(paymentHash) @@ -144,9 +144,10 @@ extension PrivatePaykitService { return Set(ldkHashes + railHashes) } - /// Throws when Spark could not list its payments, so a payment it may have attempted isn't read as never tried. + /// Throws when the node or Spark could not list its payments, so a payment either may have attempted isn't read as + /// never tried. func attemptedOutboundBolt11PaymentHashes() async throws -> Set { - let payments = await LightningService.shared.listPayments() ?? [] + guard let payments = await LightningService.shared.nodePayments() else { throw AppError(serviceError: .nodeNotSetup) } let ldkHashes = payments.compactMap { payment -> String? in guard payment.direction == .outbound, payment.status != .failed, diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index 4d99631ab..88c292344 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -69,15 +69,24 @@ final class QuickPayPaymentCoordinator { try await String(LightningService.shared.send(bolt11: bolt11)) } self.listRows = listRows ?? { - // A Spark list failure throws, so reconciling waits rather than reading Spark's payments as none. - let railRows = try await LightningService.shared.channelFreeRailPayments().map(QuickPayReconcileRow.init(railPayment:)) - guard let ldkRows = await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) else { - return railRows.isEmpty ? nil : railRows - } - return ldkRows + railRows + try await Self.reconcileRows( + railPayments: { try await LightningService.shared.channelFreeRailPayments() }, + nodePayments: { await LightningService.shared.listPayments() } + ) } } + /// Both rails' payments, nil while the node can't list its own, so reconciling waits rather than reading them as none. + static func reconcileRows( + railPayments: () async throws -> [ChannelFreeRailPayment], + nodePayments: () async -> [PaymentDetails]? + ) async throws -> [QuickPayReconcileRow]? { + // A Spark list failure throws, so reconciling waits rather than reading Spark's payments as none. + let railRows = try await railPayments().map(QuickPayReconcileRow.init(railPayment:)) + guard let ldkRows = await nodePayments()?.map(QuickPayReconcileRow.init) else { return nil } + return ldkRows + railRows + } + func hasOpen(_ paymentHash: String) -> Bool { guard !paymentHash.isEmpty else { return false } return operations[paymentHash] != nil || store.record(matching: paymentHash) != nil diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index f4a20c950..f284964c8 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -114,13 +114,16 @@ struct SparkClaimOperations { var claimEarly: (SparkClaimOffer) async throws -> SparkEarlyClaim var refresh: () async -> Void var sleep: (Duration) async -> Void + /// Clears the early-claim fee cap `claimEarly` left on the deposit, so only the claim at maturity is made. + var releaseEarlyClaim: (SparkClaimOffer) async -> Void = { _ in } static var live: SparkClaimOperations { SparkClaimOperations( offer: { try await SparkManager.shared.depositClaimOffer(txid: $0) }, claimEarly: { try await SparkManager.shared.claimEarly($0) }, refresh: { await SparkManager.shared.refreshState() }, - sleep: { try? await Task.sleep(for: $0) } + sleep: { try? await Task.sleep(for: $0) }, + releaseEarlyClaim: { await SparkManager.shared.releaseEarlyClaim($0) } ) } } @@ -158,6 +161,8 @@ final class SparkTransferFlow { /// Each broadcast deposit keeps its own claim: a later transfer neither stops an earlier one's early claim nor takes /// its results, which only the current transfer's screen shows. @ObservationIgnored private var offers: [String: SparkClaimOffer] = [:] + /// Offers whose early claim was attempted, so the fee cap it left on the deposit can be cleared on Wait. + @ObservationIgnored private var claimedOffers: [String: SparkClaimOffer] = [:] @ObservationIgnored private var ceilings: [String: UInt64] = [:] @ObservationIgnored private var approved: (ceilingSats: UInt64, speed: SparkTransferSpeed)? @ObservationIgnored private var approvals: [UUID: (ceilingSats: UInt64, speed: SparkTransferSpeed)] = [:] @@ -324,11 +329,17 @@ final class SparkTransferFlow { watch(txid: txid) } - /// Leaves the deposit to the claim after 3 confirmations. + /// Leaves the deposit to the claim after 3 confirmations, clearing the fee cap an early claim attempt left on it, so + /// Spark doesn't still claim it early. func waitForConfirmations() { if let txid { watchTasks[txid]?.cancel() watchTasks[txid] = nil + if let offer = claimedOffers.removeValue(forKey: txid) { + let operations = operations + // Not awaited: the SDK call ignores cancellation and can take a while. + Task { await operations.releaseEarlyClaim(offer) } + } } claimState = .later } @@ -374,6 +385,7 @@ final class SparkTransferFlow { return } setClaimState(.claiming, for: txid) + claimedOffers[txid] = found let claim = try await operations.claimEarly(found) guard !Task.isCancelled else { return } if let state = SparkClaimDecision.onClaim(claim) { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 55220e132..9d247c79d 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -12,6 +12,8 @@ class TransferViewModel: ObservableObject { /// Spark's fee to withdraw its balance to savings, for the confirm screen; nil until priced. @Published var sparkWithdrawFeeSats: UInt64? @Published var isPricingSparkWithdrawal = false + /// While the swipe prices Spark's withdrawal again, nothing else may commit the transfer. + @Published private(set) var isRecheckingSparkFee = false /// Why the last transfer's Spark withdrawal failed, so the progress screen shows it instead of success. @Published private(set) var sparkWithdrawError: String? @Published var channelsToClose: [ChannelDetails] = [] @@ -250,6 +252,11 @@ class TransferViewModel: ObservableObject { // MARK: - Savings Transfer Methods + /// Opens Transfer to Savings with nothing left from an earlier visit that ended before the swipe. + func beginSavingsTransfer() { + selectAllForSavings() + } + /// Back to transferring everything, the default. func selectAllForSavings() { selectedChannelIds = nil @@ -315,6 +322,8 @@ class TransferViewModel: ObservableObject { throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced") } let price = priceSparkWithdrawal + isRecheckingSparkFee = true + defer { isRecheckingSparkFee = false } guard let feeSats = await Self.withTimeout(seconds: sparkFeeTimeout, { try await price(address) }) else { throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal re-quote failed") } @@ -326,39 +335,50 @@ class TransferViewModel: ObservableObject { } } - /// The result of `operation`, or nil when it throws or takes longer than `seconds`. + /// The result of `operation`, or nil when it throws, takes longer than `seconds` or the caller is cancelled. Returns + /// at the deadline even while `operation` ignores cancellation, as the Breez SDK calls do. private nonisolated static func withTimeout( seconds: TimeInterval, _ operation: @escaping @Sendable () async throws -> T ) async -> T? { - await withTaskGroup(of: T?.self) { group in - group.addTask { - do { - return try await operation() - } catch { - Logger.error("Failed to price the Spark withdrawal", context: error.localizedDescription) - return nil - } - } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil + let result = FirstResult() + let work = Task { + do { + try await result.resume(operation()) + } catch { + Logger.error("Failed to price the Spark withdrawal", context: error.localizedDescription) + result.resume(nil) } - defer { group.cancelAll() } - return await group.next() ?? nil + } + let deadline = Task { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + result.resume(nil) + } + defer { + work.cancel() + deadline.cancel() + } + return await withTaskCancellationHandler { + await result.value() + } onCancel: { + result.resume(nil) } } - /// Commit the transfer and pick how it runs. A swap needs a priced quote, so without one + /// Commit the transfer and pick how it runs. A Spark withdrawal commits only at a fee shown and not being priced again, + /// returning false otherwise. 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], withdrawsSpark: Bool, mode: SavingsTransferMode? = nil) { + @discardableResult + func onTransferToSavingsConfirm(channels: [ChannelDetails], withdrawsSpark: Bool, mode: SavingsTransferMode? = nil) -> Bool { + guard !withdrawsSpark || (isSparkWithdrawalPriced(withdrawsSpark: true) && !isRecheckingSparkFee) else { return false } savingsTransferMode = mode ?? resolvedSavingsTransferMode selectAllForSavings() channelsToClose = channels self.withdrawsSpark = withdrawsSpark // Each swipe commit is a new transfer; the previous run's memo must not satisfy it. savingsSwapRun = nil + return true } private var resolvedSavingsTransferMode: SavingsTransferMode { @@ -958,3 +978,37 @@ actor ChannelPendingCapture { return channelData } } + +/// The first value any of several racing tasks resumes with; later ones are dropped. +private final class FirstResult: @unchecked Sendable { + private let lock = NSLock() + private var result: T? + private var isResolved = false + private var continuation: CheckedContinuation? + + func resume(_ value: T) { + lock.lock() + guard !isResolved else { return lock.unlock() } + isResolved = true + let waiting = continuation + continuation = nil + if waiting == nil { + result = value + } + lock.unlock() + waiting?.resume(returning: value) + } + + func value() async -> T { + await withCheckedContinuation { continuation in + lock.lock() + if isResolved, let result { + lock.unlock() + continuation.resume(returning: result) + } else { + self.continuation = continuation + lock.unlock() + } + } + } +} diff --git a/Bitkit/Views/Transfer/SavingsAvailabilityView.swift b/Bitkit/Views/Transfer/SavingsAvailabilityView.swift index 45d460109..ee6576749 100644 --- a/Bitkit/Views/Transfer/SavingsAvailabilityView.swift +++ b/Bitkit/Views/Transfer/SavingsAvailabilityView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SavingsAvailabilityView: View { @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var transfer: TransferViewModel var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -31,6 +32,7 @@ struct SavingsAvailabilityView: View { } CustomButton(title: t("common__continue")) { + transfer.beginSavingsTransfer() navigation.navigate(.savingsConfirm) } .accessibilityIdentifier("AvailabilityContinue") diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index 9fe3930ea..d3e932266 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -150,8 +150,14 @@ struct SavingsConfirmView: View { 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.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark, mode: .close) + CustomButton( + title: t("lightning__savings_confirm__close_instead"), + variant: .tertiary, + isDisabled: !transfer.isSparkWithdrawalPriced(withdrawsSpark: selection.withdrawsSpark) || transfer.isRecheckingSparkFee + ) { + guard transfer.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark, mode: .close) else { + return + } navigation.navigate(.savingsProgress) } .padding(.top, 12) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 6741ff88f..ab25990ea 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -330,7 +330,12 @@ struct SendSheet: View { private var sparkCanPayScannedInvoice: Bool { let spark = SparkManager.shared let amountSats = wallet.sendAmountSats ?? app.scannedLightningInvoice?.amountSatoshis ?? 0 - return spark.isReady && amountSats > 0 && spark.balanceSats >= amountSats + return Self.sparkCanPay(amountSats: amountSats, isReady: spark.isReady, balanceSats: spark.balanceSats) + } + + /// Leaves room for Spark's fee, as `LightningService.canSend` does. + static func sparkCanPay(amountSats: UInt64, isReady: Bool, balanceSats: UInt64) -> Bool { + isReady && amountSats > 0 && ChannelFreeRailFees.sendableSats(balanceSats: balanceSats) >= amountSats } /// Identity for the sync timeout task. The timer only counts down while the overlay is visible diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 443b01fc4..fbf281757 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BreezSdkSpark import LDKNode import XCTest @@ -22,6 +23,7 @@ final class ChannelFreeRailTests: XCTestCase { previousAttemptsStore = service.railAttemptsStore service.railAttemptsStore = UserDefaults(suiteName: "ChannelFreeRailTests")! service.forgetRailAttempts() + service.forgetLdkAttempts() previousRailSupported = service.channelFreeRailSupported service.channelFreeRailSupported = { true } } @@ -29,6 +31,7 @@ final class ChannelFreeRailTests: XCTestCase { override func tearDown() { let service = Bitkit.LightningService.shared service.forgetRailAttempts() + service.forgetLdkAttempts() service.railAttemptsStore = previousAttemptsStore service.channelFreeRailSupported = previousRailSupported service.clearEventCallback() @@ -68,6 +71,10 @@ final class ChannelFreeRailTests: XCTestCase { Bitkit.ChannelFreeRailPayment(paymentHash: "out", direction: .outbound, status: .pending, preimage: nil), Bitkit.ChannelFreeRailPayment(paymentHash: "failed", direction: .outbound, status: .failed, preimage: nil), ] + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { [] } + defer { service.nodePayments = nodePayments } let received = await PrivatePaykitService.shared.receivedSettledPaymentHashes() let attempted = try await PrivatePaykitService.shared.attemptedOutboundBolt11PaymentHashes() @@ -169,6 +176,76 @@ extension ChannelFreeRailTests { XCTAssertNil(rail.sentBolt11) } + func testAnInvoiceSentOverTheNodeIsNotSentThroughSparkWhileTheNodeCantListIt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { nil } + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: hash) + rail.balanceSats = 100_000 + + do { + _ = try await service.send(bolt11: Self.invoice) + XCTFail("Expected the node attempt it can't list to refuse the Spark send") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + + func testSparkStillPaysWhileTheNodeCantListWhenTheNodeNeverTriedTheInvoice() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { nil } + defer { service.nodePayments = nodePayments } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + XCTAssertEqual(rail.sentBolt11, Self.invoice) + } + + func testANodeAttemptTheNodeListsAsFailedNoLongerHoldsSpark() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let failed = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: nil, direction: .outbound, status: .failed, latestUpdateTimestamp: 0 + ) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: hash) + rail.balanceSats = 100_000 + + service.nodePayments = { [failed] } + _ = try await service.send(bolt11: Self.invoice) + rail.sentBolt11 = nil + service.nodePayments = { nil } + _ = try await service.send(bolt11: Self.invoice) + + XCTAssertEqual(rail.sentBolt11, Self.invoice, "A failed node attempt kept holding the invoice") + } + + func testPaykitReadsAnUnlistableNodeAsUnknownNotAsNeverPaid() async { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { nil } + defer { service.nodePayments = nodePayments } + + let attempted = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "out") + + XCTAssertNil(attempted) + } + + func testQuickPayWaitsWhileTheNodeCantListItsPayments() async throws { + let sparkFailure = Bitkit.ChannelFreeRailPayment(paymentHash: "aa", direction: .outbound, status: .failed, preimage: nil) + + let rows = try await QuickPayPaymentCoordinator.reconcileRows(railPayments: { [sparkFailure] }, nodePayments: { nil }) + + XCTAssertNil(rows, "Spark's rows alone can release a record the node is still paying") + } + func testAnInvoiceSparkIsStillPayingIsNotSentAgainThroughTheNode() async { let hash = try? String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash ?? "", direction: .outbound, status: .pending, preimage: nil)] @@ -456,6 +533,10 @@ extension ChannelFreeRailTests { func testDuplicatePaymentCheckSeesARailAttempt() async { rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: "out", direction: .outbound, status: .pending, preimage: nil)] + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { [] } + defer { service.nodePayments = nodePayments } let attempted = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "out") let fresh = await PrivatePaykitService.shared.hasAttemptedOutboundBolt11Payment(paymentHash: "new") @@ -477,6 +558,18 @@ extension ChannelFreeRailTests { XCTAssertEqual(Bitkit.ChannelFreeRailPayment.latestOutboundAttempt([failed], paymentHash: "aa"), failed) } + func testTheSendSheetWaitsForTheChannelWhenSparkCanCoverOnlyTheAmountNotItsFee() { + XCTAssertFalse(SendSheet.sparkCanPay(amountSats: 100_000, isReady: true, balanceSats: 100_000)) + XCTAssertTrue(SendSheet.sparkCanPay(amountSats: 99750, isReady: true, balanceSats: 100_000)) + } + + func testInsufficientFundsInsideTheSparkSendIsARefusal() { + let failure = Bitkit.SparkManager.sendPaymentFailure(SdkError.InsufficientFunds(tokenIdentifier: nil)) + + XCTAssertTrue(failure is Bitkit.ChannelFreeRailSendRefused, "\(failure)") + XCTAssertFalse(Bitkit.SparkManager.sendPaymentFailure(SdkError.SparkError("operators")) is Bitkit.ChannelFreeRailSendRefused) + } + func testSendableSatsDoesNotOverflowOnAHugeBalance() { let sendable = Bitkit.ChannelFreeRailFees.sendableSats(balanceSats: UInt64.max) diff --git a/BitkitTests/SavingsSelectionTests.swift b/BitkitTests/SavingsSelectionTests.swift index 6db3bd2f0..b7a4b5f02 100644 --- a/BitkitTests/SavingsSelectionTests.swift +++ b/BitkitTests/SavingsSelectionTests.swift @@ -63,6 +63,18 @@ final class SavingsSelectionTests: XCTestCase { XCTAssertTrue(transfer.isSparkSelected) } + @MainActor + func testEnteringTheFlowDropsAPickLeftFromAnAbandonedVisit() { + let transfer = TransferViewModel() + transfer.selectedChannelIds = ["closed-since"] + transfer.isSparkSelected = false + + transfer.beginSavingsTransfer() + + XCTAssertNil(transfer.selectedChannelIds) + XCTAssertTrue(transfer.isSparkSelected) + } + private func resolve(selected: Set?, spark: Bool, sparkSats: UInt64) -> SavingsSelection { SavingsSelection.resolve(readyChannelIds: channels, selectedChannelIds: selected, isSparkSelected: spark, sparkSats: sparkSats) } diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift index cc682177b..13de88791 100644 --- a/BitkitTests/SparkExitBackupTests.swift +++ b/BitkitTests/SparkExitBackupTests.swift @@ -59,6 +59,46 @@ final class SparkExitBackupTests: XCTestCase { XCTAssertEqual(uploads, ["SPARK_EXIT"]) } + func testAnExitStateSparkHasNotImportedYetBlocksReplacingTheRemoteBackup() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let staged = Env.sparkExitRestore(walletIndex: LightningService.shared.currentWalletIndex) + try FileManager.default.createDirectory(at: staged.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("restored".utf8).write(to: staged) + defer { try? FileManager.default.removeItem(at: staged) } + let export = try JSONEncoder().encode(SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "fresh")) + var uploads: [String] = [] + let service = BackupService(defaults: defaults, backupData: { _ in export }, uploadBackup: { key, _ in uploads.append(key) }) + + _ = await service.triggerBackup(category: .sparkExit) + + XCTAssertTrue(uploads.isEmpty, "A fresh export replaced the remote backup whose exit data Spark hasn't imported") + } + + func testAnUnreadableRemoteExitBackupBacksOffInsteadOfRetryingEveryFewSeconds() async throws { + let suite = "SparkExitBackupTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let export = try JSONEncoder().encode(SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "fresh")) + let service = BackupService( + defaults: defaults, + backupData: { _ in export }, + uploadBackup: { _, _ in }, + fetchBackup: { _ in Data("not an exit backup".utf8) } + ) + + await service.restoreSparkExit() + let outcome = await service.triggerBackup(category: .sparkExit) + + XCTAssertEqual(outcome, .temporarilyUnavailable) + } + + func testTheLatestBackupTimeSkipsTheExitData() { + XCTAssertFalse(BackupService.latestBackupTimeCategories.contains(.sparkExit), "Reading the time downloads the exit data") + XCTAssertTrue(BackupService.latestBackupTimeCategories.contains(.wallet)) + } + func testStagesARestoredExitStateUntilSparkConnects() async throws { guard Env.network == .regtest else { throw XCTSkip("Spark's test network is regtest") } let backup = SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "state") diff --git a/BitkitTests/SparkRelaunchActivityTests.swift b/BitkitTests/SparkRelaunchActivityTests.swift index 3bef1aeae..15bab32b6 100644 --- a/BitkitTests/SparkRelaunchActivityTests.swift +++ b/BitkitTests/SparkRelaunchActivityTests.swift @@ -32,6 +32,21 @@ final class SparkRelaunchActivityTests: XCTestCase { XCTAssertTrue(synced) } + func testContactsRememberedAndForgottenAtOnceAreNotLost() throws { + let suite = "SparkRelaunchActivityTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let contacts = SentPaymentContacts(defaults: defaults) + + DispatchQueue.concurrentPerform(iterations: 500) { index in + contacts.remember("pk:\(index)", forPaymentHash: "hash\(index)") + contacts.forget(paymentHash: "gone\(index)") + } + + let lost = (0 ..< 500).filter { contacts.contact(forPaymentHash: "hash\($0)") == nil } + XCTAssertEqual(lost, [], "Concurrent updates dropped remembered contacts") + } + func testASendRecordedAfterARelaunchKeepsTheContactItPaid() async throws { let suite = "SparkRelaunchActivityTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 5b1099a21..3813a71e8 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -63,6 +63,55 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .later) } + @MainActor + func testWaitAfterAClaimTimeRepriceClearsTheEarlyClaimCap() async throws { + var released: [String] = [] + let operations = SparkClaimOperations( + offer: { txid in SparkClaimOffer(txid: txid, vout: 0, kind: .choice( + earlyFeeSats: 900, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3 + )) }, + claimEarly: { _ in .repriced(requiredFeeSats: 1500) }, + refresh: {}, + sleep: { _ in }, + releaseEarlyClaim: { released.append($0.txid) } + ) + let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .repriced(feeSats: 1500)) + + flow.waitForConfirmations() + for _ in 0 ..< 100 where released.isEmpty { + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertEqual(flow.claimState, .later) + XCTAssertEqual(released, ["txid"], "Spark may still claim early at the cap Wait declined") + } + + @MainActor + func testWaitBeforeAnyClaimAttemptLeavesTheDepositAlone() async throws { + var released: [String] = [] + let operations = SparkClaimOperations( + offer: { txid in SparkClaimOffer(txid: txid, vout: 0, kind: .choice( + earlyFeeSats: 1500, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3 + )) }, + claimEarly: { _ in .claimed }, + refresh: {}, + sleep: { _ in }, + releaseEarlyClaim: { released.append($0.txid) } + ) + let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + XCTAssertEqual(flow.claimState, .repriced(feeSats: 1500)) + + flow.waitForConfirmations() + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(released, []) + } + @MainActor func testTimeoutWhenSparkNeverListsTheDeposit() async { let flow = pollingFlow(offers: [nil, nil, nil]) diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index dd23d7bec..3c9ce7e97 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -68,6 +68,58 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2190) } + @MainActor + func testTheFeeTimeoutEndsTheWaitWhileTheSdkCallIgnoresCancellation() async { + let transfer = TransferViewModel() + transfer.sparkFeeTimeout = 0.05 + // Like the Breez bridge, this call doesn't observe task cancellation. + transfer.priceSparkWithdrawal = { _ in + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 3) { continuation.resume(returning: 2190) } + } + } + let start = Date() + + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertFalse(transfer.isPricingSparkWithdrawal) + XCTAssertNil(transfer.sparkWithdrawFeeSats) + XCTAssertLessThan(Date().timeIntervalSince(start), 1.5) + } + + @MainActor + func testCloseInsteadWaitsForTheSparkFee() { + let transfer = TransferViewModel() + + let committed = transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close) + + XCTAssertFalse(committed, "A Spark withdrawal was committed with no fee shown") + XCTAssertFalse(transfer.hasChannelsToClose) + } + + @MainActor + func testNoSecondCommitWhileTheSwipeRepricesTheSparkFee() async throws { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + let gate = PriceGate() + transfer.priceSparkWithdrawal = { _ in + await gate.wait() + return 2190 + } + + let recheck = Task { try await transfer.recheckSparkWithdrawFee() } + try await Task.sleep(nanoseconds: 50_000_000) + let committedDuringRecheck = transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close) + await gate.open() + try await recheck.value + + XCTAssertFalse(committedDuringRecheck, "Close instead committed while the swipe was repricing") + XCTAssertTrue(transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true)) + } + // MARK: - Failed withdrawal func testFailedSparkWithdrawalIsShownAndNamesWhetherChannelsMoved() { @@ -114,3 +166,19 @@ private actor PriceCalls { addresses.append(address) } } + +private actor PriceGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + waiters.forEach { $0.resume() } + waiters = [] + } +} From 0161e1eda08d30d18025a88d775ce1333088ff6c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 03:24:33 +0100 Subject: [PATCH 171/378] docs: spark chain service url in transfer journeys --- journeys/README.md | 2 +- journeys/amount-limits/transfer-spending-over-max.xml | 7 ++++--- journeys/hardware-wallet/README.md | 3 ++- .../hardware-wallet/passphrase-transfer-to-spending.xml | 4 +++- journeys/hardware-wallet/transfer-to-spending-over-max.xml | 3 ++- journeys/hardware-wallet/transfer-to-spending.xml | 3 ++- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index 53b04b44c..a6dec62f8 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -215,7 +215,7 @@ journey PR, which is what made this file conflict on every merge. | USDT wallet navigation without funding | an onboarded build with USDT configured; wallet syncing remains silent while offline; payment-action errors remain available — [USDT QA](../Docs/USDT-QA.md) | | Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../Docs/USDT-QA.md) | | On-chain funds and blocks on regtest | `../bitkit-android/lsp` deposit and mine, borrowed from the sibling Android checkout until #694 lands an iOS copy — [Backend preconditions](#backend-preconditions) | -| Spending through Spark (transfer to spending, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key; transfers fund it from regtest Savings. Spark's claim of a deposit (Instant or after 3 confirmations) depends on the provider's quote, so the setting-up states are covered by previews and unit tests — [transfer](transfer/spark-transfer-to-spending.xml) | +| Spending through Spark (transfer to spending, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key. Transfers from regtest Savings also need the build launched with `SPARK_CHAIN_SERVICE_URL` set to the regtest's Esplora URL, as Spark's default regtest is its own chain; without it they show the Transfer Unavailable sheet. Spark's claim of a deposit (Instant or after 3 confirmations) depends on the provider's quote, so the setting-up states are covered by previews and unit tests — [transfer](transfer/spark-transfer-to-spending.xml) | | Lightning channels | a channel opened through Manual Setup to the node of the LSP the `E2E_BUILD` app targets, as an external LN peer — [Backend preconditions](#backend-preconditions) | | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker`, reached through Trezor Bridge on the host; the simulator has no Bluetooth LE and iOS cannot do WebUSB, so BLE pairing and USB are not covered — [hardware-wallet](hardware-wallet/README.md) | | The OS notification-permission dialog | the one-shot `UNUserNotificationCenter` alert, reset with `xcrun simctl uninstall to.bitkit` and a rebuild | diff --git a/journeys/amount-limits/transfer-spending-over-max.xml b/journeys/amount-limits/transfer-spending-over-max.xml index 6763f6ca8..785228591 100644 --- a/journeys/amount-limits/transfer-spending-over-max.xml +++ b/journeys/amount-limits/transfer-spending-over-max.xml @@ -4,9 +4,10 @@ transfer amount, shows the "Spending Balance Maximum" warning toast, and still allows deleting digits while at the cap. - Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and Spark connected (a - dev build reaches Spark's regtest with no key), so the transfer opens the amount screen rather than - the Transfer Unavailable sheet. The maximum is the Savings balance less the fast-rate network fee for + Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and Spark connected, on a + regtest build launched with SPARK_CHAIN_SERVICE_URL set to the regtest's Esplora URL (Spark's default + regtest is its own chain, so without it the transfer shows the Transfer Unavailable sheet), so the + transfer opens the amount screen. The maximum is the Savings balance less the fast-rate network fee for sending all of it to the Spark deposit address; it loads behind a spinner — wait for it to populate. Start on the wallet home screen. diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 55c29fa0d..343896f07 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -2,7 +2,8 @@ Cover pairing a Trezor, the home tile and detail screen, the Hardware Wallets settings surface, passphrase (hidden) wallets, wallet-scoped activity, and the watch-only Transfer To Spending flow, -which pays the Spark deposit address from the device (the Transfer Unavailable sheet while Spark is not ready). +which pays the Spark deposit address from the device. It needs Spark connected on a regtest build launched with +`SPARK_CHAIN_SERVICE_URL` set to the regtest's Esplora URL; otherwise it shows the Transfer Unavailable sheet. ## Transport: Bridge, not USB diff --git a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml index baf8f5491..a77e738b5 100644 --- a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml +++ b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml @@ -5,7 +5,9 @@ dropped Bitkit asks for the passphrase again, and a wrong one is refused instead of signing from whichever wallet the device happens to have open. Requires the emulator started with passphrase protection enabled, the hidden wallet from passphrase-pairing.xml paired, and its - native-segwit account holding spendable regtest funds, and Spark connected. + native-segwit account holding spendable regtest funds, and Spark connected on a regtest build launched + with SPARK_CHAIN_SERVICE_URL set to the regtest's Esplora URL (otherwise the Transfer Unavailable sheet + opens). Launch the Bitkit app and go to the wallet home screen diff --git a/journeys/hardware-wallet/transfer-to-spending-over-max.xml b/journeys/hardware-wallet/transfer-to-spending-over-max.xml index a91baa878..49feb667f 100644 --- a/journeys/hardware-wallet/transfer-to-spending-over-max.xml +++ b/journeys/hardware-wallet/transfer-to-spending-over-max.xml @@ -13,7 +13,8 @@ 999 999 999 against an AVAILABLE of 610 015, with no toast and no snap. Continue stayed disabled, so nothing unaffordable could be ordered — the gap was in the input, not in the order. - Precondition: Spark connected (a dev build reaches Spark's regtest with no key) and a paired Bridge + Precondition: Spark connected on a regtest build launched with SPARK_CHAIN_SERVICE_URL set to the + regtest's Esplora URL (otherwise the Transfer Unavailable sheet opens), and a paired Bridge emulator whose native-segwit account holds spendable regtest funds. Start on the wallet home screen. The screen is entered twice on purpose. A first entry that computes the limits from zero fires diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml index a8b31eb8a..0229ba78f 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -3,7 +3,8 @@ Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With Your Device -> Transaction Signed -> Funds In Transfer, then verifies the new transfer is represented once in the hardware wallet's Core activity scope. The device pays the Spark deposit - address. Requires Spark connected (a dev build reaches Spark's regtest with no key), a paired + address. Requires Spark connected on a regtest build launched with SPARK_CHAIN_SERVICE_URL set to the + regtest's Esplora URL (otherwise the Transfer Unavailable sheet opens), a paired Bridge emulator whose native-segwit account holds spendable regtest funds, and at least one older hardware receive row with a distinct amount. From f776a168376b591c62dceb5381ea7b54d673b8b5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 03:38:18 +0100 Subject: [PATCH 172/378] fix: sent payment contacts sendable for the concurrent test --- Bitkit/Services/CoreService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 18a92a64c..7f30aed58 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -2620,7 +2620,8 @@ class CoreService { } /// The contact a Lightning send pays, remembered by payment hash before it goes out. -struct SentPaymentContacts { +/// Shared across actors: every read-modify-write holds `lock`. +struct SentPaymentContacts: @unchecked Sendable { private static let key = "sentPaymentContacts" /// `remember` runs on the main actor and `forget` on the core queue, so each read-modify-write holds this. private static let lock = NSLock() From 9f251e6cdb5efdaa15e89afa778a9491b458b1ac Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 04:40:19 +0100 Subject: [PATCH 173/378] fix: refused savings swipe, earlier node sends and claim cap clearing - The Transfer to Savings swipe resets instead of opening progress when its commit is refused, as when Spark's balance changes during the fee recheck. - The node's first payment listing keeps its pending and paid sends by hash, so node payments made before hashes were kept hold Spark too; until that first listing an unlistable node refuses Spark sends. - Wait retries clearing the early-claim fee cap while another claim of the deposit holds it. --- Bitkit/Managers/SparkManager.swift | 7 ++- Bitkit/Services/LightningService.swift | 35 ++++++++++++- Bitkit/ViewModels/SparkTransferFlow.swift | 21 ++++++-- Bitkit/ViewModels/TransferViewModel.swift | 15 ++++++ .../Views/Transfer/SavingsConfirmView.swift | 15 +++--- BitkitTests/ChannelFreeRailTests.swift | 50 +++++++++++++++++++ BitkitTests/SparkTransferFlowTests.swift | 37 +++++++++++++- BitkitTests/SparkWithdrawalTests.swift | 34 +++++++++++++ 8 files changed, 197 insertions(+), 17 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 3b6eff57b..1f97d5cf8 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -419,12 +419,15 @@ final class SparkManager: ChannelFreeLightningRail { } /// Clears the fee cap an early claim of `offer` left on its deposit, back to the configured one, which never claims - /// early (`sdkConfig`): a claim that names no cap clears the stored one before it is attempted. - func releaseEarlyClaim(_ offer: SparkClaimOffer) async { + /// early (`sdkConfig`): a claim that names no cap clears the stored one before it is attempted. False when the cap + /// may still be there: the call failed, or another claim of the deposit held it, which leaves the cap unchanged. + func releaseEarlyClaim(_ offer: SparkClaimOffer) async -> Bool { do { _ = try await requireSdk().claimDeposit(request: ClaimDepositRequest(txid: offer.txid, vout: offer.vout, maxFee: nil)) + return true } catch { Logger.warn("Failed to clear the early claim cap of Spark deposit \(offer.txid): \(error)", context: "SparkManager") + return false } } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 458e436d6..b42a6ba6f 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -370,6 +370,9 @@ class LightningService: NodeLifecycleService { await refreshChannelCache() await refreshCache() + if let payments = await nodePayments() { + seedLdkAttempts(from: payments) + } Logger.info("Node started") } @@ -1118,6 +1121,9 @@ class LightningService: NodeLifecycleService { /// Payment hashes sent over the node, kept across launches: while the node can't list its payments (stopped or not /// started yet), only these may have an attempt there that a Spark payment would repeat. private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" + /// Set once the node's own list has filled `ldkAttemptsKey`, as node payments made before it was kept carry no hash + /// there. Until then an unlistable node refuses every Spark payment. + static let ldkAttemptsSeededKey = "ldkAttemptedPaymentHashesSeeded" private func recordRailAttempt(paymentHash: String?) { recordAttempt(paymentHash: paymentHash, key: Self.railAttemptsKey) @@ -1175,7 +1181,30 @@ class LightningService: NodeLifecycleService { /// Called when the node's wallet is wiped, as its payments go with it. func forgetLdkAttempts() { - eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.ldkAttemptsKey) } + eventCallbackLock.withLock { + railAttemptsStore.removeObject(forKey: Self.ldkAttemptsKey) + railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) + } + } + + /// Keeps the hashes of the node's pending and paid outbound payments, once, so those made before hashes were kept + /// hold Spark while the node can't list them. + private func seedLdkAttempts(from payments: [PaymentDetails]) { + eventCallbackLock.withLock { + guard !railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) else { return } + let seeded = payments + .filter { $0.direction == .outbound && $0.status != .failed } + .sorted { $0.latestUpdateTimestamp < $1.latestUpdateTimestamp } + .map { $0.id.lowercased() } + let kept = railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + let hashes = seeded.filter { !kept.contains($0) } + kept + railAttemptsStore.set(Array(hashes.suffix(Self.railAttemptsLimit)), forKey: Self.ldkAttemptsKey) + railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) + } + } + + private var hasSeededLdkAttempts: Bool { + eventCallbackLock.withLock { railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) } } /// An invoice must never go out on both rails: an earlier attempt still pending on `rails` refuses the payment as a @@ -1186,6 +1215,7 @@ class LightningService: NodeLifecycleService { var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] if rails.contains(.ldk) { if let payments = await nodePayments() { + seedLdkAttempts(from: payments) let payment = payments.first { $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame } @@ -1198,6 +1228,9 @@ class LightningService: NodeLifecycleService { } else if hasAttempt(paymentHash: paymentHash, key: Self.ldkAttemptsKey) { Logger.warn("Refused paying '\(paymentHash)', the node can't list the earlier attempt now", context: "LightningService") throw Self.pendingDuplicate(paymentHash: paymentHash) + } else if !hasSeededLdkAttempts { + Logger.warn("Refused paying '\(paymentHash)' until the node lists its earlier payments once", context: "LightningService") + throw AppError(serviceError: .nodeNotStarted) } } if rails.contains(.channelFree) { diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index f284964c8..1d4da3860 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -114,8 +114,9 @@ struct SparkClaimOperations { var claimEarly: (SparkClaimOffer) async throws -> SparkEarlyClaim var refresh: () async -> Void var sleep: (Duration) async -> Void - /// Clears the early-claim fee cap `claimEarly` left on the deposit, so only the claim at maturity is made. - var releaseEarlyClaim: (SparkClaimOffer) async -> Void = { _ in } + /// Clears the early-claim fee cap `claimEarly` left on the deposit, so only the claim at maturity is made. False when + /// the cap may still be there, as while another claim of the deposit is running. + var releaseEarlyClaim: (SparkClaimOffer) async -> Bool = { _ in true } static var live: SparkClaimOperations { SparkClaimOperations( @@ -337,8 +338,20 @@ final class SparkTransferFlow { watchTasks[txid] = nil if let offer = claimedOffers.removeValue(forKey: txid) { let operations = operations - // Not awaited: the SDK call ignores cancellation and can take a while. - Task { await operations.releaseEarlyClaim(offer) } + let attempts = max(pollAttempts, 1) + // Not awaited: the SDK call ignores cancellation and can take a while. Tried again while another claim of + // the deposit, such as Spark's own sync, leaves the cap in place. + Task { + for attempt in 1 ... attempts { + if await operations.releaseEarlyClaim(offer) { + return + } + if attempt < attempts { + await operations.sleep(Self.pollInterval) + } + } + Logger.warn("Gave up clearing the early claim cap of Spark deposit \(offer.txid)", context: "SparkTransferFlow") + } } } claimState = .later diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 9d247c79d..57cca8e7b 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -365,6 +365,18 @@ class TransferViewModel: ObservableObject { } } + /// The swipe: prices a Spark withdrawal again, then commits the transfer. Throws `SavingsCommitRefused` when the + /// commit is refused, as when Spark's balance changed during the recheck, so the swipe resets instead of opening + /// progress. + func confirmSavingsSwipe(channels: [ChannelDetails], withdrawsSpark: Bool) async throws { + if withdrawsSpark { + try await recheckSparkWithdrawFee() + } + guard onTransferToSavingsConfirm(channels: channels, withdrawsSpark: withdrawsSpark) else { + throw SavingsCommitRefused() + } + } + /// Commit the transfer and pick how it runs. A Spark withdrawal commits only at a fee shown and not being priced again, /// returning false otherwise. A swap needs a priced quote, so without one /// (swaps unsupported on this network, Boltz unreachable, or an amount below the swap @@ -923,6 +935,9 @@ enum SavingsTransferOutcome: Equatable { } /// Whether a transfer to savings swaps funds out or closes a channel (default). +/// The swipe's commit was refused, as Spark's withdrawal fee is being priced again; nothing was committed. +struct SavingsCommitRefused: Error {} + enum SavingsTransferMode { case swap case close diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index d3e932266..34b767463 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -127,12 +127,8 @@ struct SavingsConfirmView: View { // 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. - if selection.withdrawsSpark { - try await recheckSparkFee() - } + try await confirmSwipe() do { - transfer.onTransferToSavingsConfirm(channels: channels, withdrawsSpark: selection.withdrawsSpark) - try await Task.sleep(nanoseconds: 300_000_000) navigation.navigate(.savingsProgress) @@ -198,10 +194,13 @@ struct SavingsConfirmView: View { } } - /// Asks for another swipe when Spark's withdrawal fee rose since it was shown. - private func recheckSparkFee() async throws { + /// Commits the swipe. Asks for another swipe when Spark's withdrawal fee rose since it was shown, or is being priced + /// again for a new balance, which the swipe shows as loading. + private func confirmSwipe() async throws { do { - try await transfer.recheckSparkWithdrawFee() + try await transfer.confirmSavingsSwipe(channels: channels, withdrawsSpark: selection.withdrawsSpark) + } catch is SavingsCommitRefused { + throw SavingsCommitRefused() } catch let increase as SparkWithdrawalFeeIncreased { app.toast( type: .info, diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index fbf281757..22619be10 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -206,6 +206,56 @@ extension ChannelFreeRailTests { XCTAssertEqual(rail.sentBolt11, Self.invoice) } + func testSparkWaitsForTheNodesFirstListingWhenItsEarlierPaymentsWereNeverKept() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + // Node payments made before their hashes were kept: none is held until the node lists them. + service.railAttemptsStore.removeObject(forKey: Bitkit.LightningService.ldkAttemptsSeededKey) + rail.balanceSats = 100_000 + + service.nodePayments = { nil } + do { + _ = try await service.send(bolt11: Self.invoice) + XCTFail("Spark paid while the node's earlier payments were unknown") + } catch {} + XCTAssertNil(rail.sentBolt11) + + service.nodePayments = { [] } + _ = try await service.send(bolt11: Self.invoice) + rail.sentBolt11 = nil + service.nodePayments = { nil } + _ = try await service.send(bolt11: Self.invoice) + + XCTAssertEqual(rail.sentBolt11, Self.invoice, "Spark stayed held after the node listed its payments") + } + + func testANodePaymentMadeBeforeHashesWereKeptHoldsSparkOnceTheNodeListedIt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let pending = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: nil, direction: .outbound, status: .pending, latestUpdateTimestamp: 0 + ) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.railAttemptsStore.removeObject(forKey: Bitkit.LightningService.ldkAttemptsSeededKey) + rail.balanceSats = 100_000 + + service.nodePayments = { [pending] } + do { + _ = try await service.send(bolt11: Self.invoice) + } catch {} + service.nodePayments = { nil } + do { + _ = try await service.send(bolt11: Self.invoice) + XCTFail("Expected the node's pending payment to hold Spark while the node can't list it") + } catch { + XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)") + } + XCTAssertNil(rail.sentBolt11) + } + func testANodeAttemptTheNodeListsAsFailedNoLongerHoldsSpark() async throws { let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) let failed = LDKNode.PaymentDetails( diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 3813a71e8..02823bbec 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -73,7 +73,9 @@ final class SparkTransferFlowTests: XCTestCase { claimEarly: { _ in .repriced(requiredFeeSats: 1500) }, refresh: {}, sleep: { _ in }, - releaseEarlyClaim: { released.append($0.txid) } + releaseEarlyClaim: { released.append($0.txid) + return true + } ) let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) flow.onBroadcast(txid: "txid") @@ -89,6 +91,35 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(released, ["txid"], "Spark may still claim early at the cap Wait declined") } + @MainActor + func testWaitClearsTheEarlyClaimCapAgainWhenAnotherClaimHeldTheDeposit() async throws { + var releaseCalls = 0 + let operations = SparkClaimOperations( + offer: { txid in SparkClaimOffer(txid: txid, vout: 0, kind: .choice( + earlyFeeSats: 900, earlyConfirmations: 0, standardFeeSats: 198, standardConfirmations: 3 + )) }, + claimEarly: { _ in .repriced(requiredFeeSats: 1500) }, + refresh: {}, + sleep: { _ in }, + releaseEarlyClaim: { _ in + releaseCalls += 1 + // The first call meets Spark's own sync claiming the deposit, which leaves the cap in place. + return releaseCalls > 1 + } + ) + let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + + flow.waitForConfirmations() + for _ in 0 ..< 100 where releaseCalls < 2 { + try await Task.sleep(nanoseconds: 10_000_000) + } + try await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(releaseCalls, 2, "Wait gave up on the early claim cap after Spark was busy with the deposit") + } + @MainActor func testWaitBeforeAnyClaimAttemptLeavesTheDepositAlone() async throws { var released: [String] = [] @@ -99,7 +130,9 @@ final class SparkTransferFlowTests: XCTestCase { claimEarly: { _ in .claimed }, refresh: {}, sleep: { _ in }, - releaseEarlyClaim: { released.append($0.txid) } + releaseEarlyClaim: { released.append($0.txid) + return true + } ) let flow = SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: 2) flow.onBroadcast(txid: "txid") diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index 3c9ce7e97..1223d4064 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -120,6 +120,40 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertTrue(transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true)) } + @MainActor + func testTheSwipeResetsWhenSparksBalanceChangesDuringItsRecheck() async throws { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + let recheckGate = PriceGate() + transfer.priceSparkWithdrawal = { _ in + await recheckGate.wait() + return 2190 + } + + let swipe = Task { try await transfer.confirmSavingsSwipe(channels: [], withdrawsSpark: true) } + try await Task.sleep(nanoseconds: 50_000_000) + let repriceGate = PriceGate() + transfer.priceSparkWithdrawal = { _ in + await repriceGate.wait() + return 2300 + } + // A receive lands during the recheck, so the confirm screen prices the new balance. + transfer.loadSparkWithdrawFee(balanceSats: 120_000, savingsAddress: "bcrt1q-savings") + await recheckGate.open() + + do { + try await swipe.value + XCTFail("The swipe committed while Spark's new balance was being priced") + } catch { + XCTAssertTrue(error is SavingsCommitRefused, "Unexpected error: \(error)") + } + await repriceGate.open() + await waitUntilPriced(transfer) + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2300) + } + // MARK: - Failed withdrawal func testFailedSparkWithdrawalIsShownAndNamesWhetherChannelsMoved() { From 8da4289b42f3ab4f4c640c36c46f1b56c032b4be Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 05:04:02 +0100 Subject: [PATCH 174/378] docs: savings transfer mode doc comment --- Bitkit/ViewModels/TransferViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 57cca8e7b..f13a70924 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -934,10 +934,10 @@ enum SavingsTransferOutcome: Equatable { } } -/// Whether a transfer to savings swaps funds out or closes a channel (default). /// The swipe's commit was refused, as Spark's withdrawal fee is being priced again; nothing was committed. struct SavingsCommitRefused: Error {} +/// Whether a transfer to savings swaps funds out or closes a channel (default). enum SavingsTransferMode { case swap case close From ed5912d7daf2a1672d75fbde079c7c452eabe224 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 05:25:28 +0100 Subject: [PATCH 175/378] fix: keep pending node payment hashes past the cap - Recording a node or Spark attempt no longer trims the kept hashes, so later sends can't evict one still pending. - A payment listing trims past the cap only hashes it shows as paid, so a pending payment behind 500 paid ones keeps holding the other rail while its own can't list. --- Bitkit/Services/LightningService.swift | 58 ++++++++++++++++++------ BitkitTests/ChannelFreeRailTests.swift | 63 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index b42a6ba6f..2481febe8 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -371,7 +371,7 @@ class LightningService: NodeLifecycleService { await refreshChannelCache() await refreshCache() if let payments = await nodePayments() { - seedLdkAttempts(from: payments) + keepLdkAttempts(from: payments) } Logger.info("Node started") @@ -1116,6 +1116,7 @@ class LightningService: NodeLifecycleService { /// have an attempt there that a node payment would repeat. var railAttemptsStore: UserDefaults = .standard private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" + /// How many paid hashes each key keeps; one whose payment may still be pending is kept past it too. private static let railAttemptsLimit = 500 /// Payment hashes sent over the node, kept across launches: while the node can't list its payments (stopped or not @@ -1125,7 +1126,7 @@ class LightningService: NodeLifecycleService { /// there. Until then an unlistable node refuses every Spark payment. static let ldkAttemptsSeededKey = "ldkAttemptedPaymentHashesSeeded" - private func recordRailAttempt(paymentHash: String?) { + func recordRailAttempt(paymentHash: String?) { recordAttempt(paymentHash: paymentHash, key: Self.railAttemptsKey) } @@ -1139,10 +1140,26 @@ class LightningService: NodeLifecycleService { var hashes = railAttemptsStore.stringArray(forKey: key) ?? [] guard !hashes.contains(paymentHash) else { return } hashes.append(paymentHash) - railAttemptsStore.set(Array(hashes.suffix(Self.railAttemptsLimit)), forKey: key) + railAttemptsStore.set(hashes, forKey: key) } } + /// Past the limit, drops the oldest hashes `paid` lists as paid, so a payment that may still be pending keeps + /// holding the other rail. Callers hold `eventCallbackLock`. + private func trimPaidAttempts(_ paid: Set, key: String) { + guard let hashes = railAttemptsStore.stringArray(forKey: key), hashes.count > Self.railAttemptsLimit else { return } + var excess = hashes.count - Self.railAttemptsLimit + var kept: [String] = [] + for hash in hashes { + if excess > 0, paid.contains(hash) { + excess -= 1 + } else { + kept.append(hash) + } + } + railAttemptsStore.set(kept, forKey: key) + } + /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. /// With `unlessInFlight`, skipped while a rail send of the invoice is in flight, as that retry holds it again. func releaseRailAttempt(paymentHash: String, unlessInFlight: Bool = true) { @@ -1188,21 +1205,31 @@ class LightningService: NodeLifecycleService { } /// Keeps the hashes of the node's pending and paid outbound payments, once, so those made before hashes were kept - /// hold Spark while the node can't list them. - private func seedLdkAttempts(from payments: [PaymentDetails]) { + /// hold Spark while the node can't list them, then trims only paid ones past the limit. + private func keepLdkAttempts(from payments: [PaymentDetails]) { + let sends = payments + .filter { $0.direction == .outbound && $0.status != .failed } + .sorted { $0.latestUpdateTimestamp < $1.latestUpdateTimestamp } + let paid = Set(sends.filter { $0.status == .succeeded }.map { $0.id.lowercased() }) eventCallbackLock.withLock { - guard !railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) else { return } - let seeded = payments - .filter { $0.direction == .outbound && $0.status != .failed } - .sorted { $0.latestUpdateTimestamp < $1.latestUpdateTimestamp } - .map { $0.id.lowercased() } - let kept = railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] - let hashes = seeded.filter { !kept.contains($0) } + kept - railAttemptsStore.set(Array(hashes.suffix(Self.railAttemptsLimit)), forKey: Self.ldkAttemptsKey) - railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) + if !railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) { + let kept = railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + let seeded = sends.map { $0.id.lowercased() }.filter { !kept.contains($0) } + railAttemptsStore.set(seeded + kept, forKey: Self.ldkAttemptsKey) + railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) + } + trimPaidAttempts(paid, key: Self.ldkAttemptsKey) } } + /// Trims only the rail's paid hashes past the limit, so a payment that may still be pending keeps holding the node. + private func keepRailAttempts(from payments: [ChannelFreeRailPayment]) { + let outbound = payments.filter { $0.direction == .outbound } + let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.paymentHash.lowercased() }) } + let paid = hashes(.succeeded).subtracting(hashes(.pending)) + eventCallbackLock.withLock { trimPaidAttempts(paid, key: Self.railAttemptsKey) } + } + private var hasSeededLdkAttempts: Bool { eventCallbackLock.withLock { railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) } } @@ -1215,7 +1242,7 @@ class LightningService: NodeLifecycleService { var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] if rails.contains(.ldk) { if let payments = await nodePayments() { - seedLdkAttempts(from: payments) + keepLdkAttempts(from: payments) let payment = payments.first { $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame } @@ -1235,6 +1262,7 @@ class LightningService: NodeLifecycleService { } if rails.contains(.channelFree) { if let payments = await readableRailPayments() { + keepRailAttempts(from: payments) let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) if let payment { statuses.append(payment.status) diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 22619be10..95f6e5e01 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -277,6 +277,69 @@ extension ChannelFreeRailTests { XCTAssertEqual(rail.sentBolt11, Self.invoice, "A failed node attempt kept holding the invoice") } + func testAPendingNodePaymentBehindTheCapOfSettledOnesStillHoldsSparkWhileTheNodeCantList() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let pending = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: nil, direction: .outbound, status: .pending, latestUpdateTimestamp: 0 + ) + let settled = (1 ... 500).map { index in + LDKNode.PaymentDetails( + id: Self.otherHash(index), kind: .bolt11(hash: Self.otherHash(index), preimage: nil, secret: nil, description: nil, bolt11: ""), + amountMsat: 1000, feePaidMsat: nil, direction: .outbound, status: .succeeded, latestUpdateTimestamp: UInt64(index) + ) + } + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.railAttemptsStore.removeObject(forKey: Bitkit.LightningService.ldkAttemptsSeededKey) + rail.balanceSats = 100_000 + + service.nodePayments = { [pending] + settled } + _ = try? await service.send(bolt11: Self.invoice) + service.nodePayments = { nil } + + await assertRefusedAsDuplicate() + } + + func testANodeSendStillPendingBehindTheCapOfLaterSendsStillHoldsSparkWhileTheNodeCantList() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { nil } + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: hash) + for index in 1 ... 500 { + service.recordLdkAttempt(paymentHash: Self.otherHash(index)) + } + rail.balanceSats = 100_000 + + await assertRefusedAsDuplicate() + } + + func testASparkSendStillPendingBehindTheCapOfLaterSendsStillHoldsTheNode() async throws { + try await sendOverSpark() + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + for index in 1 ... 500 { + service.recordRailAttempt(paymentHash: Self.otherHash(index)) + } + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .pending, preimage: nil)] + + (1 ... 500).map { + Bitkit.ChannelFreeRailPayment(paymentHash: Self.otherHash($0), direction: .outbound, status: .succeeded, preimage: nil) + } + rail.balanceSats = 0 + await assertRefusedAsDuplicate() + + rail.listError = NSError(domain: "spark", code: 1) + + await assertRefusedAsDuplicate() + } + + private static func otherHash(_ index: Int) -> String { + String(format: "%064x", index) + } + func testPaykitReadsAnUnlistableNodeAsUnknownNotAsNeverPaid() async { let service = Bitkit.LightningService.shared let nodePayments = service.nodePayments From f59bd84f55d12605e900f8233e4b8091b7b53c6a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 06:30:07 +0100 Subject: [PATCH 176/378] fix: bound stored send hashes - A payment listing past the cap now trims the oldest sends it shows as paid or failed, so failed sends no longer pile up; a send that may still be pending stays. - A retry moves its hash to the newest end, and Spark's trim keeps a send in flight, so a trim from an older listing can't drop it. - A Spark send refused before anything left the wallet no longer holds the node. --- Bitkit/Services/LightningService.swift | 61 ++++++++++------ BitkitTests/ChannelFreeRailTests.swift | 96 ++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 23 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 2481febe8..a7d74fac8 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1064,16 +1064,24 @@ class LightningService: NodeLifecycleService { } } noteAttempt(on: .channelFree, bolt11: bolt11) - return try await rail.send( - bolt11: bolt11, - sats: sats, - maxFeeSats: params?.maxTotalRoutingFeeMsat.map { $0 / 1000 } - ?? Self.paymentAmountSats(bolt11: bolt11, sats: sats).map(Self.defaultMaxFeeSats), - beforeSubmission: { [weak self] in - try beforeSubmission() - self?.recordRailAttempt(paymentHash: paymentHash) + do { + return try await rail.send( + bolt11: bolt11, + sats: sats, + maxFeeSats: params?.maxTotalRoutingFeeMsat.map { $0 / 1000 } + ?? Self.paymentAmountSats(bolt11: bolt11, sats: sats).map(Self.defaultMaxFeeSats), + beforeSubmission: { [weak self] in + try beforeSubmission() + self?.recordRailAttempt(paymentHash: paymentHash) + } + ) + } catch let refusal as ChannelFreeRailSendRefused { + // Nothing left the wallet, and the checks above leave no earlier live attempt under this hash. + if let paymentHash { + releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) } - ) + throw refusal + } } if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.channelFree]) { return paid @@ -1116,7 +1124,8 @@ class LightningService: NodeLifecycleService { /// have an attempt there that a node payment would repeat. var railAttemptsStore: UserDefaults = .standard private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" - /// How many paid hashes each key keeps; one whose payment may still be pending is kept past it too. + /// Past this many hashes per key, a listing drops the oldest it shows as paid or failed. A hash whose payment may + /// still be pending, or that no listing has shown yet, is kept past it. private static let railAttemptsLimit = 500 /// Payment hashes sent over the node, kept across launches: while the node can't list its payments (stopped or not @@ -1134,24 +1143,26 @@ class LightningService: NodeLifecycleService { recordAttempt(paymentHash: paymentHash, key: Self.ldkAttemptsKey) } + /// A retry of a kept hash moves it to the newest end, so a trim working from a listing read before the retry + /// drops older hashes first. private func recordAttempt(paymentHash: String?, key: String) { guard let paymentHash = paymentHash?.lowercased() else { return } eventCallbackLock.withLock { var hashes = railAttemptsStore.stringArray(forKey: key) ?? [] - guard !hashes.contains(paymentHash) else { return } + hashes.removeAll { $0 == paymentHash } hashes.append(paymentHash) railAttemptsStore.set(hashes, forKey: key) } } - /// Past the limit, drops the oldest hashes `paid` lists as paid, so a payment that may still be pending keeps - /// holding the other rail. Callers hold `eventCallbackLock`. - private func trimPaidAttempts(_ paid: Set, key: String) { + /// Past the limit, drops the oldest hashes a listing showed as paid or failed (`settled`), so a payment that may + /// still be pending keeps holding the other rail. Callers hold `eventCallbackLock`. + private func trimSettledAttempts(_ settled: Set, key: String) { guard let hashes = railAttemptsStore.stringArray(forKey: key), hashes.count > Self.railAttemptsLimit else { return } var excess = hashes.count - Self.railAttemptsLimit var kept: [String] = [] for hash in hashes { - if excess > 0, paid.contains(hash) { + if excess > 0, settled.contains(hash) { excess -= 1 } else { kept.append(hash) @@ -1205,12 +1216,13 @@ class LightningService: NodeLifecycleService { } /// Keeps the hashes of the node's pending and paid outbound payments, once, so those made before hashes were kept - /// hold Spark while the node can't list them, then trims only paid ones past the limit. + /// hold Spark while the node can't list them, then trims only paid and failed ones past the limit. private func keepLdkAttempts(from payments: [PaymentDetails]) { - let sends = payments - .filter { $0.direction == .outbound && $0.status != .failed } + let outbound = payments.filter { $0.direction == .outbound } + let sends = outbound + .filter { $0.status != .failed } .sorted { $0.latestUpdateTimestamp < $1.latestUpdateTimestamp } - let paid = Set(sends.filter { $0.status == .succeeded }.map { $0.id.lowercased() }) + let settled = Set(outbound.filter { $0.status != .pending }.map { $0.id.lowercased() }) eventCallbackLock.withLock { if !railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) { let kept = railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] @@ -1218,16 +1230,19 @@ class LightningService: NodeLifecycleService { railAttemptsStore.set(seeded + kept, forKey: Self.ldkAttemptsKey) railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } - trimPaidAttempts(paid, key: Self.ldkAttemptsKey) + trimSettledAttempts(settled, key: Self.ldkAttemptsKey) } } - /// Trims only the rail's paid hashes past the limit, so a payment that may still be pending keeps holding the node. + /// Trims only the rail's paid and failed hashes past the limit, so a payment that may still be pending, or a + /// send in flight that the listing predates, keeps holding the node. private func keepRailAttempts(from payments: [ChannelFreeRailPayment]) { let outbound = payments.filter { $0.direction == .outbound } let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.paymentHash.lowercased() }) } - let paid = hashes(.succeeded).subtracting(hashes(.pending)) - eventCallbackLock.withLock { trimPaidAttempts(paid, key: Self.railAttemptsKey) } + let settled = hashes(.succeeded).union(hashes(.failed)).subtracting(hashes(.pending)) + eventCallbackLock.withLock { + trimSettledAttempts(settled.subtracting(railSendsInFlight), key: Self.railAttemptsKey) + } } private var hasSeededLdkAttempts: Bool { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 95f6e5e01..cd26b6edf 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -336,6 +336,98 @@ extension ChannelFreeRailTests { await assertRefusedAsDuplicate() } + func testNodeHashesListedAsFailedAreTrimmedPastTheCap() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let pending = Self.nodePayment(Self.otherHash(0), status: .pending) + let failed = (1 ... 501).map { Self.nodePayment(Self.otherHash($0), status: .failed) } + ([pending] + failed).forEach { service.recordLdkAttempt(paymentHash: $0.id) } + service.nodePayments = { [pending] + failed } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertEqual(kept.count, 500, "Failed node hashes stayed past the cap") + XCTAssertEqual(kept.first, Self.otherHash(0), "The pending node hash was dropped") + XCTAssertFalse(kept.contains(Self.otherHash(1)), "The oldest failed node hash was kept") + } + + func testANodeRetryRecordedDuringAnOlderListingSurvivesItsTrim() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let retried = Self.otherHash(0) + let failed = (0 ... 500).map { Self.nodePayment(Self.otherHash($0), status: .failed) } + failed.forEach { service.recordLdkAttempt(paymentHash: $0.id) } + // The node retries the oldest failed invoice while this listing, which still shows it failed, is read. + service.nodePayments = { + service.recordLdkAttempt(paymentHash: retried) + return failed + } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertEqual(kept.count, 500, "Failed node hashes stayed past the cap") + XCTAssertEqual(kept.last, retried, "The retry in flight was dropped by a trim from an older listing") + } + + func testAPendingNodePaymentBehindTheCapOfFailedOnesStillHoldsSparkWhileTheNodeCantList() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let failed = (1 ... 501).map { Self.nodePayment(Self.otherHash($0), status: .failed) } + service.recordLdkAttempt(paymentHash: hash) + failed.forEach { service.recordLdkAttempt(paymentHash: $0.id) } + rail.balanceSats = 100_000 + + service.nodePayments = { [Self.nodePayment(hash, status: .pending)] + failed } + await assertRefusedAsDuplicate() + service.nodePayments = { nil } + + await assertRefusedAsDuplicate() + } + + func testSparkHashesListedAsFailedAreTrimmedPastTheCap() async { + let service = Bitkit.LightningService.shared + (1 ... 501).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } + rail.payments = (1 ... 501).map { + Bitkit.ChannelFreeRailPayment(paymentHash: Self.otherHash($0), direction: .outbound, status: .failed, preimage: nil) + } + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept.count, 500, "Failed Spark hashes stayed past the cap") + XCTAssertFalse(kept.contains(Self.otherHash(1)), "The oldest failed Spark hash was kept") + } + + func testASendSparkRefusedAfterItWasRecordedIsNotHeldWhileSparkCannotList() async { + rail.balanceSats = 100_000 + rail.refusalAfterSubmission = Bitkit.ChannelFreeRailSendRefused(reason: "insufficient funds") + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.refusalAfterSubmission = nil + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" + private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" + + private static func nodePayment(_ hash: String, status: LDKNode.PaymentStatus) -> LDKNode.PaymentDetails { + LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: ""), + amountMsat: 1000, feePaidMsat: nil, direction: .outbound, status: status, latestUpdateTimestamp: 0 + ) + } + private static func otherHash(_ index: Int) -> String { String(format: "%064x", index) } @@ -823,6 +915,7 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var sentFeeLimitSats: UInt64? var sendGate: (() async -> Void)? var refusal: Bitkit.ChannelFreeRailSendRefused? + var refusalAfterSubmission: Bitkit.ChannelFreeRailSendRefused? func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { "lnbcrt1fake" @@ -833,6 +926,9 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { throw refusal } try beforeSubmission() + if let refusalAfterSubmission { + throw refusalAfterSubmission + } await sendGate?() sentFeeLimitSats = maxFeeSats sentBolt11 = bolt11 From 5e0159fb3250846a18c110685837f3a78d39777e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 10:35:52 +0100 Subject: [PATCH 177/378] fix: show the move in flight as its wait times out --- Bitkit/Managers/MoveToSparkManager.swift | 10 ++++- Bitkit/ViewModels/WalletViewModel.swift | 37 +++++++++++++--- BitkitTests/MoveToSparkLateOutcomeTests.swift | 44 +++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 987d4708c..50384a5e6 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -204,6 +204,8 @@ final class MoveToSparkManager { bolt11: quote.invoice, sats: quote.amountSats, maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats), + // Shown in the step that stops waiting, so a late outcome right after it finds this move in flight. + onTimeout: { [weak self] in self?.showInFlight(amountSats: quote.amountSats, moveGeneration: moveGeneration) }, onLateFailure: { [weak self] in self?.lateFailure(amountSats: quote.amountSats, moveGeneration: moveGeneration) }, onLateSuccess: { [weak self, weak wallet] settled in guard let self, let wallet else { return } @@ -218,7 +220,6 @@ final class MoveToSparkManager { } ) else { isSettled = false - state = .inFlight(amountSats: quote.amountSats) return } await wallet.syncStateAsync() @@ -291,6 +292,13 @@ final class MoveToSparkManager { return true } + /// The wait for the move of `moveGeneration` ended unsettled: the screen, if it still shows that move, shows it in + /// flight. + private func showInFlight(amountSats: UInt64, moveGeneration: Int) { + guard generation == moveGeneration else { return } + state = .inFlight(amountSats: amountSats) + } + /// A move that was still in flight paid after the wait: the screen, if it still shows that same move, shows it moved. private func lateSuccess(amountSats: UInt64, feeSats: UInt64, moveGeneration: Int, wallet: WalletViewModel) async { Logger.info("Move to Spark of '\(amountSats)' sats succeeded after the wait", context: "MoveToSpark") diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 0c801fc73..1cb28332a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1175,6 +1175,7 @@ class WalletViewModel: ObservableObject { sats: UInt64, maxFeeMsat: UInt64, timeout: Duration = .seconds(120), + onTimeout: @escaping @MainActor () -> Void = {}, onLateFailure: @escaping @MainActor () -> Void = {}, onLateSuccess: @escaping @MainActor (SettledLightningPayment) -> Void = { _ in } ) async throws -> SettledLightningPayment? { @@ -1196,13 +1197,36 @@ class WalletViewModel: ObservableObject { sink.finish() throw error } - return try await withCheckedThrowingContinuation { continuation in + return try await Self.settle( + paymentId: id, + events: events, + timeout: timeout, + onTimeout: onTimeout, + onLateFailure: onLateFailure, + onLateSuccess: onLateSuccess, + onEnd: { [weak self] in + self?.removeOnEvent(id: listenerId) + sink.finish() + } + ) + } + + /// Waits for the payment `paymentId`'s outcome in `events`, or `nil` after `timeout`. `onTimeout` runs in the same + /// step that gives up waiting, so an outcome that arrives right after already finds the caller's in-flight state + /// and goes to `onLateFailure` or `onLateSuccess`. + static func settle( + paymentId id: String, + events: AsyncStream, + timeout: Duration, + onTimeout: @escaping @MainActor () -> Void, + onLateFailure: @escaping @MainActor () -> Void, + onLateSuccess: @escaping @MainActor (SettledLightningPayment) -> Void, + onEnd: @escaping @MainActor () -> Void = {} + ) async throws -> SettledLightningPayment? { + try await withCheckedThrowingContinuation { continuation in var isAnswered = false - Task { @MainActor [weak self] in - defer { - self?.removeOnEvent(id: listenerId) - sink.finish() - } + Task { @MainActor in + defer { onEnd() } for await event in events { let outcome: Result switch event { @@ -1230,6 +1254,7 @@ class WalletViewModel: ObservableObject { try? await Task.sleep(for: timeout) guard !isAnswered else { return } isAnswered = true + onTimeout() continuation.resume(returning: nil) } } diff --git a/BitkitTests/MoveToSparkLateOutcomeTests.swift b/BitkitTests/MoveToSparkLateOutcomeTests.swift index 3e3d14645..1e8d9df90 100644 --- a/BitkitTests/MoveToSparkLateOutcomeTests.swift +++ b/BitkitTests/MoveToSparkLateOutcomeTests.swift @@ -22,4 +22,48 @@ final class MoveToSparkLateOutcomeTests: XCTestCase { XCTAssertFalse(MoveToSparkManager.showsInFlightMove(.inFlight(amountSats: 40000), generation: 4, moveGeneration: 3)) XCTAssertFalse(MoveToSparkManager.showsInFlightMove(.quoting, generation: 3, moveGeneration: 3)) } + + @MainActor + func testFailureRightAfterTheTimeoutFindsTheMoveInFlight() async throws { + let hash = "move-hash" + let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + var steps: [String] = [] + let failed = expectation(description: "the late failure is reported") + let settled = try await WalletViewModel.settle( + paymentId: hash, + events: events, + timeout: .milliseconds(10), + onTimeout: { + steps.append("inFlight") + // The failure arrives in the gap before the caller resumes. + sink.yield(.paymentFailed(paymentId: hash, paymentHash: hash, reason: nil)) + }, + onLateFailure: { + steps.append("failed") + failed.fulfill() + }, + onLateSuccess: { _ in steps.append("moved") } + ) + XCTAssertNil(settled) + await fulfillment(of: [failed], timeout: 2) + XCTAssertEqual(steps, ["inFlight", "failed"]) + } + + @MainActor + func testSuccessBeforeTheTimeoutSettlesTheMove() async throws { + let hash = "move-hash" + let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + sink.yield(.paymentSuccessful(paymentId: hash, paymentHash: hash, paymentPreimage: "pp", feePaidMsat: 12000)) + var timedOut = false + let settled = try await WalletViewModel.settle( + paymentId: hash, + events: events, + timeout: .seconds(5), + onTimeout: { timedOut = true }, + onLateFailure: {}, + onLateSuccess: { _ in } + ) + XCTAssertEqual(settled?.feePaidSats, 12) + XCTAssertFalse(timedOut) + } } From 0abf0abc20e7018098ef218fadc467c03c070d6b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 10:56:52 +0100 Subject: [PATCH 178/378] fix: keep an interrupted exit start in its transfer --- Bitkit/Managers/SparkExitManager.swift | 35 +++++++++++++++--- BitkitTests/SparkExitReviewTests.swift | 49 ++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 6e9b61d9f..27b3d4f6e 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -747,10 +747,15 @@ final class SparkExitManager { skippedLeafCount: quote.prepared.skipped.count ) record.fundingStrategy = SparkExitRecord.perNodeStrategy - // Saved before the transfer exists, so a failed save never leaves a transfer counted with no exit behind it. - try store.save(record) - record.transferId = try await TransferService(lightningService: .shared).createTransfer(type: .sparkExit, amountSats: quote.incomingSats) - try store.save(record) + let transfers = TransferService(lightningService: .shared) + let store = store + record = try await Self.writeStart( + record, + incomingSats: quote.incomingSats, + createTransfer: { try await transfers.createTransfer(type: .sparkExit, amountSats: $0) }, + save: { try store.save($0) }, + markSettled: { try? await transfers.markSettled(id: $0) } + ) UserDefaults.standard.set(walletIndex, forKey: Self.walletIndexKey) UserDefaults.standard.set(Int(fundingIndex) + 1, forKey: fundingIndexKey) pendingFundingIndex = nil @@ -858,6 +863,28 @@ final class SparkExitManager { return record } + /// The new exit stored with its transfer in one write, so no running exit is ever stored without the transfer + /// counting it. A failed save settles the transfer again; one a killed start left behind is settled as an orphan + /// on the next load. + nonisolated static func writeStart( + _ record: SparkExitRecord, + incomingSats: UInt64, + createTransfer: (UInt64) async throws -> String, + save: (SparkExitRecord) throws -> Void, + markSettled: (String) async -> Void + ) async throws -> SparkExitRecord { + var record = record + let transferId = try await createTransfer(incomingSats) + record.transferId = transferId + do { + try save(record) + } catch { + await markSettled(transferId) + throw error + } + return record + } + /// The started exit moved onto the fresh quote: a transfer for its amount, saved, and the confirmed quote's transfer /// settled. Nil when that write fails, with a transfer made for it settled again: the start goes on with the /// confirmed quote, as after a failed requote, so it still reconnects, pays and ticks. diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 0b4318083..3a9f9470b 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -635,6 +635,55 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(settled, ["transfer-2", "transfer-1"]) } + /// A start killed before its transfer is written stores no exit: none runs on without the transfer counting it. + func testAStartCutShortBeforeItsTransferStoresNoExit() async { + struct WriteFailed: Error {} + var record = SparkExitFixtures.record() + record.transferId = nil + var saved: [SparkExitRecord] = [] + + let started = try? await SparkExitManager.writeStart( + record, + incomingSats: 111_000, + createTransfer: { _ in throw WriteFailed() }, + save: { saved.append($0) }, + markSettled: { _ in } + ) + + XCTAssertNil(started) + XCTAssertTrue(saved.isEmpty, "no exit is stored before its transfer exists") + } + + /// A start killed after its transfer is written leaves only that transfer, settled as an orphan on the next load; + /// a failed save settles it at once. Every exit stored carries its transfer. + func testAStartCutShortAfterItsTransferLeavesOnlyAnOrphanToSettle() async throws { + struct WriteFailed: Error {} + var record = SparkExitFixtures.record() + record.transferId = nil + var saved: [SparkExitRecord] = [] + var settled: [String] = [] + + let notSaved = try? await SparkExitManager.writeStart( + record, + incomingSats: 111_000, + createTransfer: { _ in "transfer-1" }, + save: { _ in throw WriteFailed() }, + markSettled: { settled.append($0) } + ) + XCTAssertNil(notSaved) + XCTAssertEqual(settled, ["transfer-1"]) + + let started = try await SparkExitManager.writeStart( + record, + incomingSats: 111_000, + createTransfer: { _ in "transfer-2" }, + save: { saved.append($0) }, + markSettled: { settled.append($0) } + ) + XCTAssertEqual(started.transferId, "transfer-2") + XCTAssertEqual(saved.map(\.transferId), ["transfer-2"], "every exit stored carries its transfer") + } + /// No tick drives an exit while its start still settles its leaves: the optimization wait, the requote and the /// reconnect. func testNoTickDrivesAnExitWhileItsStartSettles() { From 53871d5ffb7120ddaab39a4d76841df6a0989fc7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 10:57:00 +0100 Subject: [PATCH 179/378] fix: refuse a spark funding that changed after approval Before broadcast, a max Spark funding now checks the prepared send-all against the approved quote: the amount reaching Spark must match and the mining fee must not exceed it. A send-all is prepared against the balance at that moment, so new UTXOs could raise the amount sent or the fee with no new confirmation. On a mismatch nothing is broadcast; the confirm screen reads the balance again, prices the transfer and asks for another swipe. A fixed amount stays pinned as before. --- .../Services/OnchainSendAttemptService.swift | 9 ++++++ Bitkit/Views/Transfer/SpendingConfirm.swift | 15 ++++++++++ .../OnchainSendAttemptServiceTests.swift | 30 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index f33dd69e5..c82e9bd00 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -350,6 +350,10 @@ struct OnchainSendFollowupContext: Codable, Equatable { var channelId: String? = nil } +/// A max Spark funding refused before broadcast because it would send another amount, or pay a higher mining fee, than +/// the user approved, as after the balance grew. A fixed amount's fee isn't bound: its coin selection can vary. +struct SparkFundingChanged: Error {} + struct OnchainSendTransferContext: Codable, Equatable { let clientBalanceSats: UInt64 let txTotalSats: UInt64 @@ -648,6 +652,11 @@ actor OnchainSendAttemptService { prepared.recipientAmountSats <= context.txTotalSats - miningFee else { throw OnchainSendAttemptError.unresolved } } + if isSparkFunding && isMaxAmount { + guard prepared.recipientAmountSats == amountSats, let miningFee = prepared.miningFeeSats, + let approvedFee = followupContext?.feeSats, miningFee <= approvedFee + else { throw SparkFundingChanged() } + } attempt.amountSats = prepared.recipientAmountSats attempt.txid = prepared.txid if let miningFee = prepared.miningFeeSats { diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 9d86f583b..8df817fc6 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -158,6 +158,21 @@ struct SpendingConfirm: View { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { hideSwipeButton = true } + } catch let OnchainSendAttemptError.preDispatch(error) where error is SparkFundingChanged { + // The max quote reads the cached savings balance, so it is read again before pricing the new transfer. + await wallet.syncStateAsync() + guard let repriced = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) else { + throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "Re-quote after refusal failed") + } + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: repriced.increase(over: fresh, speed: flow.speed))] + ) + ) + throw SpendingFeesIncreasedError() } catch { if let attemptId = await OnchainSendAttemptService.shared.unresolvedSparkFundingAttemptId() { flow.bindApproval(to: attemptId) diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 1fb261c21..0408be650 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -349,6 +349,36 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } + func testSparkFundingThatGrewAfterApprovalIsRefusedBeforeBroadcast() async throws { + // The approved quote: 99000 reaches Spark for a 500 sat mining fee. + for (isMax, amount, fee, valid): (Bool, UInt64, UInt64?, Bool) in [ + (true, 99000, 500, true), (true, 99000, 400, true), + (true, 99500, 500, false), (true, 98500, 500, false), + (true, 99000, 501, false), (true, 99000, nil, false), + // A fixed amount is already pinned, and its coin selection may differ from the quote's, so its fee isn't bound. + (false, 99000, 500, true), (false, 99000, 501, true), + ] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + sender.amount = amount + sender.miningFee = fee + let context = OnchainSendFollowupContext(feeSats: 500, feeRate: 2, tags: [], contact: nil, createdAt: 0) + do { + _ = try await service.send(using: sender, address: "spark", amountSats: 99000, satsPerVbyte: 2, + utxosToSpend: nil, isMaxAmount: isMax, followupContext: context, + isSparkFunding: true) + XCTAssertTrue(valid, "A funding that sends \(amount) for a \(String(describing: fee)) fee needs a new approval") + XCTAssertEqual(sender.broadcasts, 1) + } catch let OnchainSendAttemptError.preDispatch(error) { + XCTAssertFalse(valid, "The approved funding must still go out") + XCTAssertTrue(error is SparkFundingChanged, "Refused with \(error)") + XCTAssertEqual(sender.broadcasts, 0) + XCTAssertTrue(store.snapshot().isEmpty) + } + } + } + func testPreparedReceiptIsDurableBeforeNativeDispatch() async throws { for max in [false, true] { let store = MemoryAttemptStore() From 5a16d047076bb83260fea4bdc5410cf2602f67c2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 10:57:00 +0100 Subject: [PATCH 180/378] fix: retry spark activity that failed to save A Spark payment whose activity failed to save is kept and written again at the next refresh, and an activity sync that failed to save one is not marked complete, so the next start syncs again. Before, the failure was dropped and receives or sends with a known fee stayed missing or pending until the app restarted. --- Bitkit/Managers/SparkManager.swift | 53 +++++++++++++++++++---- BitkitTests/SparkPaymentSafetyTests.swift | 49 ++++++++++++++++++++- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 1f97d5cf8..be4deec34 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -51,6 +51,8 @@ final class SparkManager: ChannelFreeLightningRail { @ObservationIgnored private var broadcastDeposits: [String: UInt64] = [:] /// Sends recorded before Spark knew their fee; its completion event reports 0 until a later sync. @ObservationIgnored private var sendsRecordedWithoutFee: Set = [] + /// Payments whose activity failed to save, written again at the next refresh. + @ObservationIgnored private var unsavedPayments: Set = [] @ObservationIgnored private var sdk: BreezSdk? /// The last withdrawal quote, which the withdrawal reuses while it holds. @ObservationIgnored private var preparedWithdrawal: (quote: SparkWithdrawalQuote, response: PrepareSendPaymentResponse)? @@ -241,6 +243,7 @@ final class SparkManager: ChannelFreeLightningRail { withdrawals = [] broadcastDeposits = [:] sendsRecordedWithoutFee = [] + unsavedPayments = [] sentWithdrawalTxIds = [] preparedWithdrawal = nil onStateChanged?() @@ -678,7 +681,7 @@ final class SparkManager: ChannelFreeLightningRail { if changed { onStateChanged?() } - for payment in payments where sendsRecordedWithoutFee.contains(payment.id) && Self.sats(payment.fees) > 0 { + for payment in Self.paymentsToRecordAgain(payments, unsaved: unsavedPayments, recordedWithoutFee: sendsRecordedWithoutFee) { await recordActivity(payment) } for txId in Self.transferTxIds(unclaimed: unclaimed, payments: payments) { @@ -756,17 +759,45 @@ final class SparkManager: ChannelFreeLightningRail { private func syncActivities() async throws { let payments = try await requireSdk().listPayments(request: ListPaymentsRequest()).payments - for payment in payments { - await recordActivity(payment) + var recordError: Error? + do { + try await Self.recordAll(payments) { await self.recordActivity($0) } + } catch { + recordError = error } // A send that failed while the app was closed released no hold on a channel retry. Spark's list is read again, // so a retry the user started while the activities were recorded keeps its hold. - guard let latest = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments else { return } - for paymentHash in Self.failedSendHashes(latest) { - LightningService.shared.releaseRailAttempt(paymentHash: paymentHash) + if let latest = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments { + for paymentHash in Self.failedSendHashes(latest) { + LightningService.shared.releaseRailAttempt(paymentHash: paymentHash) + } + } + if let recordError { + throw recordError + } + } + + /// Records every payment, then throws when any failed to save, so the activity sync isn't marked complete. + static func recordAll(_ payments: [Payment], record: (Payment) async -> Bool) async throws { + var unsaved = 0 + for payment in payments where await !record(payment) { + unsaved += 1 + } + guard unsaved == 0 else { + throw AppError(message: "Failed to save \(unsaved) Spark activities", debugMessage: nil) } } + /// The payments a refresh writes again: those whose last write failed, and sends recorded before Spark knew the fee + /// it now reports. + nonisolated static func paymentsToRecordAgain( + _ payments: [Payment], + unsaved: Set, + recordedWithoutFee: Set + ) -> [Payment] { + payments.filter { unsaved.contains($0.id) || (recordedWithoutFee.contains($0.id) && sats($0.fees) > 0) } + } + private func onSdkEvent(_ event: SdkEvent) async { switch event { case .synced, .newDeposits, .claimedDeposits: @@ -808,8 +839,10 @@ final class SparkManager: ChannelFreeLightningRail { } } - private func recordActivity(_ payment: Payment) async { - guard case let .lightning(description, invoice, _, htlcDetails, _, _, _, _) = payment.details else { return } + /// Returns whether the payment's activity is saved; a failed write is kept in `unsavedPayments` for the next refresh. + @discardableResult + private func recordActivity(_ payment: Payment) async -> Bool { + guard case let .lightning(description, invoice, _, htlcDetails, _, _, _, _) = payment.details else { return true } let details = PaymentDetails( id: htlcDetails.paymentHash, kind: .bolt11( @@ -832,8 +865,12 @@ final class SparkManager: ChannelFreeLightningRail { } do { try await CoreService.shared.activity.recordChannelFreeRailPayment(details) + unsavedPayments.remove(payment.id) + return true } catch { Logger.error("Failed to record Spark payment '\(payment.id)': \(error)", context: "SparkManager") + unsavedPayments.insert(payment.id) + return false } } diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index d400be777..32b81e8d9 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -39,6 +39,46 @@ final class SparkPaymentSafetyTests: XCTestCase { XCTAssertEqual(syncs, 1) } + func testAStartWhoseActivityWriteFailedSyncsTheActivitiesAgain() async throws { + let payments = ["a", "b"].map { lightningPayment(type: .receive, status: .completed, id: $0) } + var writes: [String: Int] = [:] + var failing: Set = ["b"] + var synced = false + let sync = { + try await SparkManager.recordAll(payments) { payment in + writes[payment.id, default: 0] += 1 + return !failing.contains(payment.id) + } + } + + do { + synced = try await SparkManager.startConnected(activitiesSynced: synced, refresh: {}, syncActivities: sync) + } catch {} + XCTAssertFalse(synced, "A sync that failed to save a payment was marked complete") + failing = [] + synced = try await SparkManager.startConnected(activitiesSynced: synced, refresh: {}, syncActivities: sync) + + XCTAssertTrue(synced) + XCTAssertEqual(writes, ["a": 2, "b": 2]) + } + + func testAPaymentThatFailedToSaveIsWrittenAgainAtTheNextRefresh() { + let payments = [ + lightningPayment(type: .receive, status: .completed, id: "receive"), + lightningPayment(type: .send, status: .completed, id: "send"), + lightningPayment(type: .send, status: .completed, id: "fee-now-known"), + lightningPayment(type: .receive, status: .completed, id: "saved"), + ] + + let again = SparkManager.paymentsToRecordAgain( + payments, + unsaved: ["receive", "send"], + recordedWithoutFee: ["fee-now-known"] + ) + + XCTAssertEqual(again.map(\.id), ["receive", "send", "fee-now-known"]) + } + // MARK: - Settled payment events func testSettledPaymentIsRecordedAndEmittedEvenWhenTheRefreshFails() async { @@ -235,9 +275,14 @@ final class SparkPaymentSafetyTests: XCTestCase { XCTAssertEqual(SparkManager.failedSendHashes(payments), ["failed"]) } - private func lightningPayment(type: PaymentType, status: BreezSdkSpark.PaymentStatus, hash: String = "hash") -> Payment { + private func lightningPayment( + type: PaymentType, + status: BreezSdkSpark.PaymentStatus, + hash: String = "hash", + id: String = "id" + ) -> Payment { Payment( - id: "id", + id: id, paymentType: type, status: status, amount: 1000, From 86432fad3d5cf16885af43792ca905fdd588dda4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:07:07 +0100 Subject: [PATCH 181/378] fix: import a salvaged exit's kept data before its rebuild --- .../Services/SparkExit/SparkExitDriver.swift | 3 ++- BitkitTests/SparkExitReviewTests.swift | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index d7f651f94..6c6a9d86f 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -443,7 +443,8 @@ struct SparkExitDriver { } catch { Logger.warn("Failed to export the Spark exit data before the first build: \(error)", context: "SparkExitDriver") } - } else if let snapshot = record.exitStateSnapshot, record.recovery != nil || record.generation > 0 { + } else if let snapshot = record.exitStateSnapshot { + // Also a salvaged exit's, before its first rebuild: the SDK may no longer hold a leaf being exited. do { try await recovering.importExitState(snapshot) } catch { diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 3a9f9470b..975e597b5 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -475,6 +475,28 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(salvaged.earlierReceivedSats, ["sweep-1": 40000]) } + /// A salvaged exit imports the exit data it kept before it is prepared again: the SDK may no longer hold a leaf + /// being exited. + func testASalvagedExitImportsItsSnapshotBeforeItsFirstRebuild() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 25000) + record.exitStateSnapshot = "snapshot-1" + let salvaged = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(record))) + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])] + var importsAtPrepare: [[String]] = [] + sdk.preparedByRequest = { [sdk] _ in + importsAtPrepare.append(sdk?.imports ?? []) + return SparkExitFixtures.prepared() + } + + _ = try await driver().tick(salvaged) + + XCTAssertFalse(importsAtPrepare.isEmpty, "the salvaged exit is prepared again") + XCTAssertEqual(importsAtPrepare.first, ["snapshot-1"], "its kept exit data is imported before it is prepared") + XCTAssertEqual(sdk.exports, 0) + } + /// Background runs wrap the chain to time it: the wrapper forwards the economy tier, so their sweeps pay it. func testTheTimedBackgroundChainForwardsTheNetworkFees() async throws { chain.fastestFee = 9 From 5261e49b474c77cd35ea438a653818b7ebb9ea6a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:10:09 +0100 Subject: [PATCH 182/378] fix: forget an unsettled move when the wallet is wiped --- Bitkit/Managers/MoveToSparkManager.swift | 35 +++++++++++++++---- Bitkit/Utilities/AppReset.swift | 1 + BitkitTests/MoveToSparkLateOutcomeTests.swift | 16 +++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/MoveToSparkManager.swift b/Bitkit/Managers/MoveToSparkManager.swift index 50384a5e6..f5c5e186e 100644 --- a/Bitkit/Managers/MoveToSparkManager.swift +++ b/Bitkit/Managers/MoveToSparkManager.swift @@ -86,6 +86,10 @@ enum MoveToSparkPayments { static func contains(_ paymentHash: String?) -> Bool { paymentHash.map { paymentHashes.contains($0.lowercased()) } ?? false } + + static func removeAll() { + paymentHashes.removeAll() + } } enum MoveToSparkFailure { @@ -133,7 +137,7 @@ final class MoveToSparkManager { /// A move was started and its outcome is still the screen's, so showing the screen again keeps it. private var keepsOutcome = false /// Counts quotes, moves and resets; a quote writes its result only while it is still the latest. - private var generation = 0 + private(set) var generation = 0 private var isQuoting = false /// A swiped payment that has not settled yet; leaving the screen keeps it, so it is never quoted over. private var isMoveInFlight = false @@ -162,6 +166,17 @@ final class MoveToSparkManager { state = .quoting } + /// Forgets everything of the wiped wallet, its unsettled move too: a new generation makes that move's pending result + /// and late outcome leave the screen of the next wallet alone. + func resetForWipe() { + generation += 1 + isQuoting = false + keepsOutcome = false + isMoveInFlight = false + state = .quoting + MoveToSparkPayments.removeAll() + } + /// Quotes moving `targetSats`, or everything the channels can send. func quote(wallet: WalletViewModel, targetSats: UInt64? = nil) async { generation += 1 @@ -184,10 +199,7 @@ final class MoveToSparkManager { func move(wallet: WalletViewModel) async { guard case let .confirm(quote, isMoving) = state, !isMoving else { return } - generation += 1 - let moveGeneration = generation - keepsOutcome = true - isMoveInFlight = true + let moveGeneration = beginMove() // A payment still unsettled after the wait keeps the move until its late outcome, so leaving and reopening the // screen shows it again instead of a new quote. var isSettled = true @@ -222,16 +234,27 @@ final class MoveToSparkManager { isSettled = false return } + guard moveGeneration == generation else { return } await wallet.syncStateAsync() + guard moveGeneration == generation else { return } state = Self.doneState(amountSats: quote.amountSats, feeSats: settled.feePaidSats, channels: wallet.channels) } catch { Logger.error("Failed to move '\(quote.amountSats)' sats to Spark: \(error)", context: "MoveToSpark") + guard moveGeneration == generation else { return } state = Self.isNoRoute(error) || Self.isSendWithoutRoute(error, invoice: quote.invoice) ? .tooLarge(amountSats: quote.amountSats, smallerSats: MoveToSparkMath.smallerAmount(quote.amountSats)) : .failed(.unknown) } } + /// Starts a move: it keeps its outcome and stays in flight until it settles. Returns the move's generation. + func beginMove() -> Int { + generation += 1 + keepsOutcome = true + isMoveInFlight = true + return generation + } + func makeQuote(channels: [ChannelDetails]?, targetSats: UInt64?) async throws -> MoveToSparkQuote { let limitSats = MoveToSparkMath.movableSats(channels: channels) let wantedSats = min(targetSats ?? limitSats, limitSats) @@ -294,7 +317,7 @@ final class MoveToSparkManager { /// The wait for the move of `moveGeneration` ended unsettled: the screen, if it still shows that move, shows it in /// flight. - private func showInFlight(amountSats: UInt64, moveGeneration: Int) { + func showInFlight(amountSats: UInt64, moveGeneration: Int) { guard generation == moveGeneration else { return } state = .inFlight(amountSats: amountSats) } diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index fc2e584ec..76be1efa6 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -80,6 +80,7 @@ final class AppReset: WipeStepExecutor { await VssBackupClient.shared.reset() VssStoreIdProvider.shared.clearCache() OnChainHwService.shared.stopAllWatchers() + MoveToSparkManager.shared.resetForWipe() // Before the paired devices are wiped, so a reconnect still running cannot save one back. try await Self.bounded("hardware wallets") { await Self.hardwareWallets?.resetForWipe() } do { diff --git a/BitkitTests/MoveToSparkLateOutcomeTests.swift b/BitkitTests/MoveToSparkLateOutcomeTests.swift index 1e8d9df90..7346231f9 100644 --- a/BitkitTests/MoveToSparkLateOutcomeTests.swift +++ b/BitkitTests/MoveToSparkLateOutcomeTests.swift @@ -66,4 +66,20 @@ final class MoveToSparkLateOutcomeTests: XCTestCase { XCTAssertEqual(settled?.feePaidSats, 12) XCTAssertFalse(timedOut) } + + @MainActor + func testWipeForgetsAnUnsettledMove() { + let manager = MoveToSparkManager() + let moveGeneration = manager.beginMove() + manager.showInFlight(amountSats: 40000, moveGeneration: moveGeneration) + manager.reset() + XCTAssertEqual(manager.state, .inFlight(amountSats: 40000), "leaving the screen keeps the unsettled move") + + manager.resetForWipe() + XCTAssertEqual(manager.state, .quoting) + manager.showInFlight(amountSats: 40000, moveGeneration: moveGeneration) + XCTAssertEqual(manager.state, .quoting, "the wiped move's late result leaves the next wallet's screen alone") + manager.reset() + XCTAssertFalse(MoveToSparkManager.showsInFlightMove(manager.state, generation: manager.generation, moveGeneration: moveGeneration)) + } } From 0ce947b0a278fe934a8d98a65c1e3791c71a2a58 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:18:50 +0100 Subject: [PATCH 183/378] fix: uniform move illustrations and bold inline amount --- .../Settings/Advanced/MoveToSparkView.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift index a680cfa91..0204db68d 100644 --- a/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift +++ b/Bitkit/Views/Settings/Advanced/MoveToSparkView.swift @@ -46,7 +46,7 @@ struct MoveToSparkView: View { VStack(alignment: .leading, spacing: 16) { DisplayText(title, accentColor: .purpleAccent) .fixedSize(horizontal: false, vertical: true) - BodyMText(text) + BodyMText(text, accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) } } @@ -56,10 +56,21 @@ struct MoveToSparkView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: 256, maxHeight: 256) + .scaleEffect(Self.illustrationScale(name)) .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) } + /// Scales each illustration so its drawing fills its box like the zap; the others have wider transparent margins. + private static func illustrationScale(_ name: String) -> CGFloat { + switch name { + case "check": 1.25 + case "cross": 1.4 + case "exclamation-mark": 1.1 + default: 1 + } + } + private var quoting: some View { VStack(alignment: .leading, spacing: 0) { header(title: t("lightning__move_to_spark__confirm_title"), text: t("lightning__move_to_spark__quoting")) @@ -140,7 +151,10 @@ struct MoveToSparkView: View { private func tooLarge(amountSats: UInt64, smallerSats: UInt64?) -> some View { let next = smallerSats == nil ? t("lightning__move_to_spark__too_large_none") : t("lightning__move_to_spark__too_large_smaller") - let amount = t("lightning__move_to_spark__too_large_text", variables: ["amount": currency.primaryAmountText(sats: amountSats)]) + let amount = t( + "lightning__move_to_spark__too_large_text", + variables: ["amount": "\(currency.primaryAmountText(sats: amountSats))"] + ) return VStack(alignment: .leading, spacing: 0) { header(title: t("lightning__move_to_spark__too_large_title"), text: "\(amount) \(next)") illustration("exclamation-mark") From 2b6bf07302d97ccb3f39e87b0e1954afbd3c5ae4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:25:53 +0100 Subject: [PATCH 184/378] fix: offer spending on confirm while spark can pay an on-chain request Confirm listed Spending only for a scanned Lightning invoice, so an on-chain request that Savings covers could not be switched to Spending and paid through the Spark leg, and one that opened on Spending had no way back to it. It now offers what the amount screen offers. --- .../Wallets/Send/SendConfirmationView.swift | 32 ++++++++++++++----- BitkitTests/PaykitRouteTests.swift | 22 +++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 441384f0b..06ccfa4ae 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -132,18 +132,34 @@ struct SendConfirmationView: View { } private var fundingSources: [SendFundingSource] { + Self.fundingSources( + hasLightningInvoice: app.scannedLightningInvoice != nil, + hasOnchainInvoice: app.scannedOnchainInvoice != nil, + offersSparkLeg: app.paykitRoute?.offersSparkLeg == true, + hardwareWalletIds: hwWalletManager.wallets.compactMap { hardwareWallet in + guard hardwareWallet.fundingBalanceSats > 0 || hardwareWallet.walletId == hwSend.walletId else { + return nil + } + return hardwareWallet.walletId + } + ) + } + + /// The sources the amount screen offers: a Spark leg can carry an on-chain contact payment from Spending, whether or not + /// Savings covers it. + static func fundingSources( + hasLightningInvoice: Bool, + hasOnchainInvoice: Bool, + offersSparkLeg: Bool, + hardwareWalletIds: [String] + ) -> [SendFundingSource] { var sources: [SendFundingSource] = [] - if app.scannedLightningInvoice != nil { + if hasLightningInvoice || offersSparkLeg { sources.append(.spending) } - if app.scannedOnchainInvoice != nil { + if hasOnchainInvoice { sources.append(.savings) - sources.append(contentsOf: hwWalletManager.wallets.compactMap { hardwareWallet in - guard hardwareWallet.fundingBalanceSats > 0 || hardwareWallet.walletId == hwSend.walletId else { - return nil - } - return .hardware(walletId: hardwareWallet.walletId) - }) + sources.append(contentsOf: hardwareWalletIds.map { .hardware(walletId: $0) }) } return sources } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 2a7ab0222..66007d05d 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -505,6 +505,28 @@ final class PaykitRouteTests: XCTestCase { ) } + func testConfirmOffersSpendingWhileASparkLegCanCarryAnOnchainRequest() { + // Savings covers the request, so its Confirm opens on Savings; Spending through Spark is still a source. + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: false, hasOnchainInvoice: true, offersSparkLeg: true, hardwareWalletIds: []), + [.spending, .savings] + ) + // Savings is short, so the request opens on Spending, which must be in the list to switch back to. + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: false, hasOnchainInvoice: true, offersSparkLeg: true, hardwareWalletIds: ["hw"]), + [.spending, .savings, .hardware(walletId: "hw")] + ) + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: false, hasOnchainInvoice: true, offersSparkLeg: false, hardwareWalletIds: []), + [.savings], + "Without a Spark leg an on-chain request has no Spending source" + ) + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: true, hasOnchainInvoice: true, offersSparkLeg: true, hardwareWalletIds: []), + [.spending, .savings] + ) + } + func testRefusalWhileAnOlderJournalIsOpenReleasesThisCallsProof() { struct Refused: Error {} let older = SparkContactWithdrawal.PaykitRequest( From c5f9f611ca30e19b92451e4149d154f597bdf60a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:25:54 +0100 Subject: [PATCH 185/378] fix: publish a spark withdrawal's resolution only when its journal closes A stale close left the newer journal open but still published its resolution, which a retry of the same request read as its own. --- Bitkit/Managers/SparkManager.swift | 18 +++++++++++------- BitkitTests/PaykitRouteTests.swift | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index c91f94f2f..93763fa33 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -724,8 +724,9 @@ final class SparkManager: ChannelFreeLightningRail { if let request = pending.request { await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .succeeded, transactionId: txId) } - Self.clearJournal(pending) - Self.resolveContactWithdrawal(pending, txId: txId) + if Self.clearJournal(pending) { + Self.resolveContactWithdrawal(pending, txId: txId) + } } else { Logger.warn("Kept the Spark contact withdrawal journal: its local records are not all saved yet", context: "SparkManager") } @@ -751,15 +752,18 @@ final class SparkManager: ChannelFreeLightningRail { break } } - clearJournal(pending) - resolveContactWithdrawal(pending, txId: nil) + if clearJournal(pending) { + resolveContactWithdrawal(pending, txId: nil) + } } /// Clears `pending`'s journal only while it is still the open one, so a stale recovery never drops a newer - /// withdrawal's journal, which could then be paid twice. - nonisolated static func clearJournal(_ pending: SparkContactWithdrawal) { - guard pendingContactWithdrawal == pending else { return } + /// withdrawal's journal, which could then be paid twice. Returns whether it cleared it: only then is the resolution + /// this attempt's to publish, since a retry of the same request shares its attempt. + nonisolated static func clearJournal(_ pending: SparkContactWithdrawal) -> Bool { + guard pendingContactWithdrawal == pending else { return false } pendingContactWithdrawal = nil + return true } static func resolveContactWithdrawal(_ pending: SparkContactWithdrawal, txId: String?) { diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 66007d05d..d3a0d533e 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -399,6 +399,28 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer, "Losing the newer journal could pay it twice") } + @MainActor + func testAStaleCloseNeverTellsTheNewerWithdrawalsScreenItEnded() async { + snapshotAppDefaultsDomain() + let request = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee"), + identity: "payer" + ) + // A retry of the same request: same address, amount and request, so both share one attempt. + let older = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) + let newer = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 2000, request: request) + XCTAssertEqual(older.attempt, newer.attempt) + SparkManager.pendingContactWithdrawal = newer + var resolutions: [SparkContactWithdrawalResolution] = [] + let subscription = SparkManager.contactWithdrawalResolutionPublisher.sink { resolutions.append($0) } + defer { subscription.cancel() } + + await SparkManager.closeUnsentWithdrawal(older, releaseProof: { _ in .nothingStarted }, failAllowance: { _ in }) + + XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer) + XCTAssertEqual(resolutions, [], "An unsent resolution for the newer send's attempt would offer Try Again while it is in flight") + } + @MainActor func testAPendingSparkSendNeverAdoptsTheNodesOnchainAttempt() { let attempt = SparkWithdrawalAttempt(address: onchain.value, amountSats: 20000, requestId: nil) From 4b9d3d006e6880f0776f005c8381a918d9171bc9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:28:29 +0100 Subject: [PATCH 186/378] fix: use the flat hourglass on exit steps --- Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 49e3f9f07..a4091e8fa 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -998,7 +998,7 @@ struct SparkExitSteps: View { private func step(_ title: String, detail: String? = nil, isDone: Bool) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center, spacing: 12) { - Image(isDone ? "check-mark" : "hourglass") + Image(isDone ? "check-mark" : "hourglass-simple") .resizable() .foregroundColor(isDone ? .greenAccent : .textSecondary) .frame(width: 20, height: 20) From 1af9389181e7c6cd306374146fb72ddb6e9af76a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:32:53 +0100 Subject: [PATCH 187/378] fix: align exit screens with the explore cell and accents --- .../Localization/ar.lproj/Localizable.strings | 3 - .../Localization/ca.lproj/Localizable.strings | 3 - .../Localization/cs.lproj/Localizable.strings | 3 - .../Localization/de.lproj/Localizable.strings | 3 - .../Localization/el.lproj/Localizable.strings | 3 - .../Localization/en.lproj/Localizable.strings | 8 +- .../es-419.lproj/Localizable.strings | 3 - .../Localization/es.lproj/Localizable.strings | 3 - .../Localization/fr.lproj/Localizable.strings | 3 - .../Localization/it.lproj/Localizable.strings | 3 - .../Localization/nl.lproj/Localizable.strings | 3 - .../Localization/pl.lproj/Localizable.strings | 3 - .../pt-BR.lproj/Localizable.strings | 3 - .../Localization/pt.lproj/Localizable.strings | 3 - .../Localization/ru.lproj/Localizable.strings | 3 - .../Settings/Advanced/SparkExitViews.swift | 207 ++++++++++++------ .../Activity/ActivityExplorerView.swift | 75 +++---- 17 files changed, 176 insertions(+), 156 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 45c6fb3db..d800fb1d7 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -364,10 +364,8 @@ "lightning__spark_exit__leftover_swept" = "أُرسل {amount} من تمويل الرسوم المتبقي إلى المدخرات."; "lightning__spark_exit__leftover_sending" = "جارٍ إرسال {amount} من تمويل الرسوم المتبقي إلى المدخرات."; "lightning__spark_exit__leftover_dust" = "{amount} من تمويل الرسوم المتبقي صغير جدًا بحيث لا يمكن إرساله ويبقى في {address}، مسار الاشتقاق {path}."; -"lightning__spark_exit__leftover_txid" = "معرّف المعاملة: {txid}"; "lightning__spark_exit__fund_paid" = "تم الدفع من المدخرات، بانتظار التأكيد."; "lightning__spark_exit__fund_pending" = "تم رصد الدفعة، بانتظار التأكيد."; -"lightning__spark_exit__funding_txid" = "معرّف المعاملة: {txid}"; "lightning__spark_exit__lost_leaves" = "طالب مالك سابق لهذه الأموال بمبلغ {amount} على السلسلة ولا يمكن استرداده."; "lightning__spark_exit__notification_lost" = "لا يمكن استرداد {amount} من خروجك. افتح Bitkit لمعرفة السبب."; "lightning__spark_exit__notification_failed" = "توقف خروجك الطارئ. افتح Bitkit لمعرفة السبب."; @@ -390,7 +388,6 @@ "lightning__spark_exit__waiting_leaves" = "تتعذّر قراءة جزء من هذا الخروج على السلسلة الآن. يحاول Bitkit مجددًا لمدة تصل إلى أسبوع."; "lightning__spark_exit__rebuilt_from_chain" = "تعذّر على Bitkit قراءة التقدم المحفوظ لهذا الخروج، لذا يعيد بناءه من السلسلة."; "lightning__spark_exit__done_received" = "وصل {amount} إلى مدخراتك."; -"lightning__spark_exit__sweep_txid" = "معرّف المعاملة: {txid}"; "lightning__spark_exit__leftover_rejected" = "تعذّر إرسال {amount} من تمويل الرسوم المتبقي ويبقى في {address}، مسار الاشتقاق {path}."; "lightning__spark_exit__notification_not_moving" = "خروجك الطارئ لا يتقدم. افتح Bitkit لمعرفة السبب."; "lightning__spark_exit__funding_split" = "يقسّمه Bitkit إلى {count} أجزاء، جزء لكل خطوة، حتى لا تعطّل خطوة عالقة الخطوات الأخرى."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 542567f07..06d5f044a 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -401,10 +401,8 @@ "lightning__spark_exit__leftover_swept" = "S'han enviat a estalvis {amount} del finançament de comissions sobrant."; "lightning__spark_exit__leftover_sending" = "Enviant a estalvis {amount} del finançament de comissions sobrant."; "lightning__spark_exit__leftover_dust" = "{amount} del finançament de comissions sobrant és massa poc per enviar i es queda a {address}, camí de derivació {path}."; -"lightning__spark_exit__leftover_txid" = "ID de transacció: {txid}"; "lightning__spark_exit__fund_paid" = "Pagat des d'estalvis, esperant la confirmació."; "lightning__spark_exit__fund_pending" = "Pagament detectat, esperant la confirmació."; -"lightning__spark_exit__funding_txid" = "ID de transacció: {txid}"; "lightning__spark_exit__lost_leaves" = "Un propietari anterior d'aquests fons ha reclamat {amount} on-chain i no es pot recuperar."; "lightning__spark_exit__notification_lost" = "{amount} de la teva sortida no es pot recuperar. Obre Bitkit per saber per què."; "lightning__spark_exit__notification_failed" = "La teva sortida d'emergència s'ha aturat. Obre Bitkit per saber per què."; @@ -427,7 +425,6 @@ "lightning__spark_exit__waiting_leaves" = "Ara mateix no es pot llegir on-chain una part d'aquesta sortida. Bitkit ho torna a provar durant una setmana com a màxim."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit no ha pogut llegir el progrés desat d'aquesta sortida, així que el reconstrueix a partir de la cadena."; "lightning__spark_exit__done_received" = "{amount} ha arribat als teus estalvis."; -"lightning__spark_exit__sweep_txid" = "ID de transacció: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} del finançament de comissions sobrant no s'ha pogut enviar i es queda a {address}, camí de derivació {path}."; "lightning__spark_exit__notification_not_moving" = "La teva sortida d'emergència no avança. Obre Bitkit per saber per què."; "lightning__spark_exit__funding_split" = "Bitkit el divideix en {count} parts, una per a cada pas, perquè un pas encallat no pugui retenir els altres."; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index aabb844ee..32b7d30e0 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -417,10 +417,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} zbývajícího krytí poplatků bylo odesláno do úspor."; "lightning__spark_exit__leftover_sending" = "Odesíláme {amount} zbývajícího krytí poplatků do úspor."; "lightning__spark_exit__leftover_dust" = "{amount} zbývajícího krytí poplatků je příliš málo k odeslání a zůstává na adrese {address}, derivační cesta {path}."; -"lightning__spark_exit__leftover_txid" = "ID transakce: {txid}"; "lightning__spark_exit__fund_paid" = "Zaplaceno z úspor, čeká se na potvrzení."; "lightning__spark_exit__fund_pending" = "Platba zaznamenána, čeká se na potvrzení."; -"lightning__spark_exit__funding_txid" = "ID transakce: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} si on-chain nárokoval dřívější vlastník těchto prostředků a nelze to získat zpět."; "lightning__spark_exit__notification_lost" = "{amount} z vašeho výstupu nelze získat zpět. Otevřete Bitkit a zjistěte proč."; "lightning__spark_exit__notification_failed" = "Váš nouzový výstup se zastavil. Otevřete Bitkit a zjistěte proč."; @@ -443,7 +441,6 @@ "lightning__spark_exit__waiting_leaves" = "Část tohoto výstupu teď nelze na blockchainu načíst. Bitkit to bude zkoušet znovu až týden."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit nedokázal načíst uložený postup tohoto výstupu, proto ho obnovuje z blockchainu."; "lightning__spark_exit__done_received" = "{amount} dorazilo do vašich úspor."; -"lightning__spark_exit__sweep_txid" = "ID transakce: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} zbývajícího krytí poplatků se nepodařilo odeslat a zůstává na adrese {address}, derivační cesta {path}."; "lightning__spark_exit__notification_not_moving" = "Váš nouzový výstup nepostupuje. Otevřete Bitkit a zjistěte proč."; "lightning__spark_exit__funding_split" = "Bitkit ji rozdělí na {count} částí, jednu pro každý krok, aby jeden zaseknutý krok nezdržel ostatní."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 66eb5e7f5..4a9be84e7 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -415,10 +415,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} übrige Gebührendeckung wurden ans Sparkonto gesendet."; "lightning__spark_exit__leftover_sending" = "{amount} übrige Gebührendeckung werden ans Sparkonto gesendet."; "lightning__spark_exit__leftover_dust" = "{amount} übrige Gebührendeckung sind zu klein zum Senden und bleiben auf {address}, Ableitungspfad {path}."; -"lightning__spark_exit__leftover_txid" = "Transaktions-ID: {txid}"; "lightning__spark_exit__fund_paid" = "Aus dem Sparkonto bezahlt, warte auf Bestätigung."; "lightning__spark_exit__fund_pending" = "Zahlung erkannt, warte auf Bestätigung."; -"lightning__spark_exit__funding_txid" = "Transaktions-ID: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} wurden von einem früheren Besitzer dieser Mittel on-chain beansprucht und können nicht wiederhergestellt werden."; "lightning__spark_exit__notification_lost" = "{amount} deines Ausstiegs können nicht wiederhergestellt werden. Öffne Bitkit, um den Grund zu sehen."; "lightning__spark_exit__notification_failed" = "Dein Notausstieg wurde gestoppt. Öffne Bitkit, um den Grund zu sehen."; @@ -441,7 +439,6 @@ "lightning__spark_exit__waiting_leaves" = "Ein Teil dieses Ausstiegs kann derzeit nicht on-chain gelesen werden. Bitkit versucht es bis zu einer Woche lang erneut."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit konnte den gespeicherten Fortschritt dieses Ausstiegs nicht lesen und stellt ihn daher aus der Blockchain wieder her."; "lightning__spark_exit__done_received" = "{amount} sind in deinem Sparkonto angekommen."; -"lightning__spark_exit__sweep_txid" = "Transaktions-ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} übrige Gebührendeckung konnten nicht gesendet werden und bleiben auf {address}, Ableitungspfad {path}."; "lightning__spark_exit__notification_not_moving" = "Dein Notausstieg kommt nicht voran. Öffne Bitkit, um den Grund zu sehen."; "lightning__spark_exit__funding_split" = "Bitkit teilt sie in {count} Teile auf, einen pro Schritt, damit ein hängender Schritt die anderen nicht aufhält."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 0cd14ccd2..7bb4453aa 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -388,10 +388,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} από την υπόλοιπη χρηματοδότηση τελών στάλθηκε στις αποταμιεύσεις."; "lightning__spark_exit__leftover_sending" = "Αποστολή {amount} από την υπόλοιπη χρηματοδότηση τελών στις αποταμιεύσεις."; "lightning__spark_exit__leftover_dust" = "{amount} από την υπόλοιπη χρηματοδότηση τελών είναι πολύ μικρό για αποστολή και παραμένει στη διεύθυνση {address}, διαδρομή παραγωγής {path}."; -"lightning__spark_exit__leftover_txid" = "ID Συναλλαγής: {txid}"; "lightning__spark_exit__fund_paid" = "Πληρώθηκε από τις αποταμιεύσεις, αναμονή επιβεβαίωσης."; "lightning__spark_exit__fund_pending" = "Η πληρωμή εντοπίστηκε, αναμονή επιβεβαίωσης."; -"lightning__spark_exit__funding_txid" = "ID Συναλλαγής: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} διεκδικήθηκε on-chain από προηγούμενο κάτοχο αυτών των κεφαλαίων και δεν μπορεί να ανακτηθεί."; "lightning__spark_exit__notification_lost" = "{amount} της εξόδου σας δεν μπορεί να ανακτηθεί. Ανοίξτε το Bitkit για να δείτε γιατί."; "lightning__spark_exit__notification_failed" = "Η έξοδος έκτακτης ανάγκης σταμάτησε. Ανοίξτε το Bitkit για να δείτε γιατί."; @@ -414,7 +412,6 @@ "lightning__spark_exit__waiting_leaves" = "Μέρος αυτής της εξόδου δεν μπορεί να διαβαστεί on-chain αυτή τη στιγμή. Το Bitkit προσπαθεί ξανά για έως μία εβδομάδα."; "lightning__spark_exit__rebuilt_from_chain" = "Το Bitkit δεν μπόρεσε να διαβάσει την αποθηκευμένη πρόοδο αυτής της εξόδου, οπότε την ανακατασκευάζει από την αλυσίδα."; "lightning__spark_exit__done_received" = "{amount} έφτασε στις αποταμιεύσεις σας."; -"lightning__spark_exit__sweep_txid" = "ID Συναλλαγής: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} από την υπόλοιπη χρηματοδότηση τελών δεν ήταν δυνατό να σταλεί και παραμένει στη διεύθυνση {address}, διαδρομή παραγωγής {path}."; "lightning__spark_exit__notification_not_moving" = "Η έξοδος έκτακτης ανάγκης δεν προχωρά. Ανοίξτε το Bitkit για να δείτε γιατί."; "lightning__spark_exit__funding_split" = "Το Bitkit τη χωρίζει σε {count} μέρη, ένα για κάθε βήμα, ώστε ένα κολλημένο βήμα να μην καθυστερεί τα υπόλοιπα."; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 7a202915b..455779b25 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -396,7 +396,7 @@ "lightning__spark_exit__confirm_text" = "You won't be able to use these funds for up to 4 weeks."; "lightning__spark_exit__swipe" = "Swipe To Exit"; "lightning__spark_exit__progress_title" = "Exit in\nprogress"; -"lightning__spark_exit__progress_text" = "This runs in the background. Bitkit notifies you if it needs you."; +"lightning__spark_exit__progress_text" = "The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you."; "lightning__spark_exit__step_funding" = "Fee funding confirms"; "lightning__spark_exit__step_publish" = "Publishing exit transactions"; "lightning__spark_exit__step_timelock" = "Waiting for the timelock"; @@ -410,10 +410,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; "lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; -"lightning__spark_exit__leftover_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; -"lightning__spark_exit__funding_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} was claimed on-chain by an older owner of these funds and can't be recovered."; "lightning__spark_exit__notification_lost" = "{amount} of your exit can't be recovered. Open Bitkit to see why."; "lightning__spark_exit__notification_failed" = "Your emergency exit stopped. Open Bitkit to see why."; @@ -430,13 +428,13 @@ "lightning__spark_exit__notification_funding" = "Your exit needs more fees to continue. Open Bitkit to fund it."; "lightning__spark_exit__bg_refresh_off" = "Background App Refresh is off for Bitkit, so the exit only moves while Bitkit is open. Turn it on in Settings to let it run in the background."; "lightning__spark_exit__low_power" = "Low Power Mode pauses background work. The exit continues once it is off, or when you open Bitkit."; -"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you when the exit needs you. Turn them on in Settings to be reminded."; +"lightning__spark_exit__notifications_off" = "Notifications are off, so Bitkit can't tell you if the exit needs you. Without them, open Bitkit every few hours until the exit finishes."; +"lightning__spark_exit__note_notifications_button" = "Turn on notifications"; "lightning__spark_exit__not_moving" = "The exit is not moving: {error}"; "lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop."; "lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain."; "lightning__spark_exit__done_received" = "{amount} reached your savings."; -"lightning__spark_exit__sweep_txid" = "Transaction ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}."; "lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why."; "lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index eafcd3f78..ec48cc8d6 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -418,10 +418,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} sobrante de los fondos para comisiones se envió a ahorros."; "lightning__spark_exit__leftover_sending" = "Enviando a ahorros {amount} sobrante de los fondos para comisiones."; "lightning__spark_exit__leftover_dust" = "{amount} sobrante de los fondos para comisiones es demasiado pequeño para enviarlo y se queda en {address}, ruta de derivación {path}."; -"lightning__spark_exit__leftover_txid" = "ID de la transacción: {txid}"; "lightning__spark_exit__fund_paid" = "Pagado desde ahorros, esperando confirmación."; "lightning__spark_exit__fund_pending" = "Pago detectado, esperando confirmación."; -"lightning__spark_exit__funding_txid" = "ID de la transacción: {txid}"; "lightning__spark_exit__lost_leaves" = "Un propietario anterior de estos fondos reclamó {amount} on-chain y no se puede recuperar."; "lightning__spark_exit__notification_lost" = "{amount} de tu salida no se puede recuperar. Abre Bitkit para ver por qué."; "lightning__spark_exit__notification_failed" = "Tu salida de emergencia se detuvo. Abre Bitkit para ver por qué."; @@ -444,7 +442,6 @@ "lightning__spark_exit__waiting_leaves" = "En este momento no se puede leer on-chain una parte de esta salida. Bitkit lo vuelve a intentar durante hasta una semana."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit no pudo leer el progreso guardado de esta salida, así que lo reconstruye a partir de la cadena."; "lightning__spark_exit__done_received" = "{amount} llegó a tus ahorros."; -"lightning__spark_exit__sweep_txid" = "ID de la transacción: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} sobrante de los fondos para comisiones no se pudo enviar y se queda en {address}, ruta de derivación {path}."; "lightning__spark_exit__notification_not_moving" = "Tu salida de emergencia no avanza. Abre Bitkit para ver por qué."; "lightning__spark_exit__funding_split" = "Bitkit lo divide en {count} partes, una para cada paso, para que un paso atascado no retrase a los demás."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index dfdd3acde..ac0bcb4ff 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -402,10 +402,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} sobrante de los fondos para comisiones se envió a ahorros."; "lightning__spark_exit__leftover_sending" = "Enviando a ahorros {amount} sobrante de los fondos para comisiones."; "lightning__spark_exit__leftover_dust" = "{amount} sobrante de los fondos para comisiones es demasiado pequeño para enviarlo y se queda en {address}, ruta de derivación {path}."; -"lightning__spark_exit__leftover_txid" = "ID de la transacción: {txid}"; "lightning__spark_exit__fund_paid" = "Pagado desde ahorros, esperando confirmación."; "lightning__spark_exit__fund_pending" = "Pago detectado, esperando confirmación."; -"lightning__spark_exit__funding_txid" = "ID de la transacción: {txid}"; "lightning__spark_exit__lost_leaves" = "Un propietario anterior de estos fondos reclamó {amount} on-chain y no se puede recuperar."; "lightning__spark_exit__notification_lost" = "{amount} de tu salida no se puede recuperar. Abre Bitkit para ver por qué."; "lightning__spark_exit__notification_failed" = "Tu salida de emergencia se detuvo. Abre Bitkit para ver por qué."; @@ -428,7 +426,6 @@ "lightning__spark_exit__waiting_leaves" = "Ahora mismo no se puede leer on-chain una parte de esta salida. Bitkit lo vuelve a intentar durante hasta una semana."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit no pudo leer el progreso guardado de esta salida, así que lo reconstruye a partir de la cadena."; "lightning__spark_exit__done_received" = "{amount} llegó a tus ahorros."; -"lightning__spark_exit__sweep_txid" = "ID de la transacción: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} sobrante de los fondos para comisiones no se pudo enviar y se queda en {address}, ruta de derivación {path}."; "lightning__spark_exit__notification_not_moving" = "Tu salida de emergencia no avanza. Abre Bitkit para ver por qué."; "lightning__spark_exit__funding_split" = "Bitkit lo divide en {count} partes, una para cada paso, para que un paso atascado no retrase a los demás."; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 2cc67a62d..dc20b9c69 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -418,10 +418,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} de financement des frais restant a été envoyé vers l'épargne."; "lightning__spark_exit__leftover_sending" = "Envoi de {amount} de financement des frais restant vers l'épargne."; "lightning__spark_exit__leftover_dust" = "{amount} de financement des frais restant est trop faible pour être envoyé et reste à {address}, chemin de dérivation {path}."; -"lightning__spark_exit__leftover_txid" = "ID de la transaction : {txid}"; "lightning__spark_exit__fund_paid" = "Payé depuis l'épargne, en attente de confirmation."; "lightning__spark_exit__fund_pending" = "Paiement détecté, en attente de confirmation."; -"lightning__spark_exit__funding_txid" = "ID de la transaction : {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} a été réclamé on-chain par un ancien propriétaire de ces fonds et ne peut pas être récupéré."; "lightning__spark_exit__notification_lost" = "{amount} de votre sortie ne peut pas être récupéré. Ouvrez Bitkit pour savoir pourquoi."; "lightning__spark_exit__notification_failed" = "Votre sortie d'urgence s'est arrêtée. Ouvrez Bitkit pour savoir pourquoi."; @@ -444,7 +442,6 @@ "lightning__spark_exit__waiting_leaves" = "Une partie de cette sortie ne peut pas être lue on-chain pour le moment. Bitkit réessaie pendant une semaine au maximum."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit n'a pas pu lire la progression enregistrée de cette sortie, il la reconstruit donc à partir de la blockchain."; "lightning__spark_exit__done_received" = "{amount} est arrivé dans votre épargne."; -"lightning__spark_exit__sweep_txid" = "ID de la transaction : {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} de financement des frais restant n'a pas pu être envoyé et reste à {address}, chemin de dérivation {path}."; "lightning__spark_exit__notification_not_moving" = "Votre sortie d'urgence n'avance pas. Ouvrez Bitkit pour savoir pourquoi."; "lightning__spark_exit__funding_split" = "Bitkit le divise en {count} parties, une pour chaque étape, afin qu'une étape bloquée ne retarde pas les autres."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 2671dd02d..bba90adf6 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -411,10 +411,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} di finanziamento commissioni avanzato è stato inviato ai risparmi."; "lightning__spark_exit__leftover_sending" = "Invio di {amount} di finanziamento commissioni avanzato ai risparmi."; "lightning__spark_exit__leftover_dust" = "{amount} di finanziamento commissioni avanzato è troppo poco per essere inviato e resta su {address}, percorso di derivazione {path}."; -"lightning__spark_exit__leftover_txid" = "ID Transazione: {txid}"; "lightning__spark_exit__fund_paid" = "Pagato dai risparmi, in attesa di conferma."; "lightning__spark_exit__fund_pending" = "Pagamento rilevato, in attesa di conferma."; -"lightning__spark_exit__funding_txid" = "ID Transazione: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} è stato riscattato on-chain da un precedente proprietario di questi fondi e non può essere recuperato."; "lightning__spark_exit__notification_lost" = "{amount} della tua uscita non può essere recuperato. Apri Bitkit per scoprire perché."; "lightning__spark_exit__notification_failed" = "La tua uscita di emergenza si è fermata. Apri Bitkit per scoprire perché."; @@ -437,7 +435,6 @@ "lightning__spark_exit__waiting_leaves" = "Parte di questa uscita non può essere letta on-chain al momento. Bitkit riprova per un massimo di una settimana."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit non è riuscito a leggere i progressi salvati di questa uscita, quindi li ricostruisce dalla blockchain."; "lightning__spark_exit__done_received" = "{amount} è arrivato nei tuoi risparmi."; -"lightning__spark_exit__sweep_txid" = "ID Transazione: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} di finanziamento commissioni avanzato non è stato inviato e resta su {address}, percorso di derivazione {path}."; "lightning__spark_exit__notification_not_moving" = "La tua uscita di emergenza non avanza. Apri Bitkit per scoprire perché."; "lightning__spark_exit__funding_split" = "Bitkit lo divide in {count} parti, una per ogni passaggio, così un passaggio bloccato non può trattenere gli altri."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 7d97db84a..05a203bf6 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -415,10 +415,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} aan overgebleven vergoedingsdekking is naar je spaargeld gestuurd."; "lightning__spark_exit__leftover_sending" = "{amount} aan overgebleven vergoedingsdekking wordt naar je spaargeld gestuurd."; "lightning__spark_exit__leftover_dust" = "{amount} aan overgebleven vergoedingsdekking is te klein om te versturen en blijft op {address}, afleidingspad {path}."; -"lightning__spark_exit__leftover_txid" = "Transactie-ID: {txid}"; "lightning__spark_exit__fund_paid" = "Betaald uit spaargeld, wachten op bevestiging."; "lightning__spark_exit__fund_pending" = "Betaling gezien, wachten op bevestiging."; -"lightning__spark_exit__funding_txid" = "Transactie-ID: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} is on-chain geclaimd door een eerdere eigenaar van dit geld en kan niet worden teruggehaald."; "lightning__spark_exit__notification_lost" = "{amount} van je exit kan niet worden teruggehaald. Open Bitkit om te zien waarom."; "lightning__spark_exit__notification_failed" = "Je nooduitgang is gestopt. Open Bitkit om te zien waarom."; @@ -441,7 +439,6 @@ "lightning__spark_exit__waiting_leaves" = "Een deel van deze exit kan nu niet on-chain worden gelezen. Bitkit probeert het tot een week lang opnieuw."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit kon de opgeslagen voortgang van deze exit niet lezen, dus bouwt het die opnieuw op vanuit de blockchain."; "lightning__spark_exit__done_received" = "{amount} is aangekomen in je spaargeld."; -"lightning__spark_exit__sweep_txid" = "Transactie-ID: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} aan overgebleven vergoedingsdekking kon niet worden verstuurd en blijft op {address}, afleidingspad {path}."; "lightning__spark_exit__notification_not_moving" = "Je nooduitgang komt niet verder. Open Bitkit om te zien waarom."; "lightning__spark_exit__funding_split" = "Bitkit splitst het in {count} delen, één per stap, zodat één vastgelopen stap de andere niet ophoudt."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 6d7efdb28..9e174c438 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -417,10 +417,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} pozostałych środków na opłaty wysłano do oszczędności."; "lightning__spark_exit__leftover_sending" = "Wysyłanie {amount} pozostałych środków na opłaty do oszczędności."; "lightning__spark_exit__leftover_dust" = "{amount} pozostałych środków na opłaty to za mało, by je wysłać, i zostaje pod adresem {address}, ścieżka derywacji {path}."; -"lightning__spark_exit__leftover_txid" = "ID transakcji: {txid}"; "lightning__spark_exit__fund_paid" = "Zapłacono z oszczędności, czekamy na potwierdzenie."; "lightning__spark_exit__fund_pending" = "Płatność wykryta, czekamy na potwierdzenie."; -"lightning__spark_exit__funding_txid" = "ID transakcji: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} zostało przejęte on-chain przez poprzedniego właściciela tych środków i nie można go odzyskać."; "lightning__spark_exit__notification_lost" = "Nie można odzyskać {amount} z Twojego wyjścia. Otwórz Bitkit, aby sprawdzić dlaczego."; "lightning__spark_exit__notification_failed" = "Twoje wyjście awaryjne zostało zatrzymane. Otwórz Bitkit, aby sprawdzić dlaczego."; @@ -443,7 +441,6 @@ "lightning__spark_exit__waiting_leaves" = "Części tego wyjścia nie można teraz odczytać on-chain. Bitkit będzie próbować ponownie przez maksymalnie tydzień."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit nie mógł odczytać zapisanego postępu tego wyjścia, więc odtwarza go z łańcucha."; "lightning__spark_exit__done_received" = "{amount} trafiło do Twoich oszczędności."; -"lightning__spark_exit__sweep_txid" = "ID transakcji: {txid}"; "lightning__spark_exit__leftover_rejected" = "Nie udało się wysłać {amount} pozostałych środków na opłaty, które zostają pod adresem {address}, ścieżka derywacji {path}."; "lightning__spark_exit__notification_not_moving" = "Twoje wyjście awaryjne nie postępuje. Otwórz Bitkit, aby sprawdzić dlaczego."; "lightning__spark_exit__funding_split" = "Bitkit dzieli je na części ({count}), po jednej na każdy krok, aby jeden zablokowany krok nie wstrzymywał pozostałych."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 8a7753434..dc086616d 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -417,10 +417,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} de fundos para taxas restantes foi enviado para a poupança."; "lightning__spark_exit__leftover_sending" = "Enviando {amount} de fundos para taxas restantes para a poupança."; "lightning__spark_exit__leftover_dust" = "{amount} de fundos para taxas restantes é pequeno demais para enviar e fica em {address}, caminho de derivação {path}."; -"lightning__spark_exit__leftover_txid" = "ID da transação: {txid}"; "lightning__spark_exit__fund_paid" = "Pago da poupança, aguardando confirmação."; "lightning__spark_exit__fund_pending" = "Pagamento detectado, aguardando confirmação."; -"lightning__spark_exit__funding_txid" = "ID da transação: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} foi resgatado on-chain por um antigo dono desses fundos e não pode ser recuperado."; "lightning__spark_exit__notification_lost" = "{amount} da sua saída não pode ser recuperado. Abra a Bitkit para ver o motivo."; "lightning__spark_exit__notification_failed" = "Sua saída de emergência parou. Abra a Bitkit para ver o motivo."; @@ -443,7 +441,6 @@ "lightning__spark_exit__waiting_leaves" = "Parte desta saída não pode ser lida on-chain no momento. A Bitkit tenta novamente por até uma semana."; "lightning__spark_exit__rebuilt_from_chain" = "A Bitkit não conseguiu ler o progresso salvo desta saída, então o reconstrói a partir da blockchain."; "lightning__spark_exit__done_received" = "{amount} chegou à sua poupança."; -"lightning__spark_exit__sweep_txid" = "ID da transação: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} de fundos para taxas restantes não pôde ser enviado e fica em {address}, caminho de derivação {path}."; "lightning__spark_exit__notification_not_moving" = "Sua saída de emergência não está avançando. Abra a Bitkit para ver o motivo."; "lightning__spark_exit__funding_split" = "A Bitkit divide em {count} partes, uma para cada etapa, para que uma etapa travada não segure as outras."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 569376120..0ae176a35 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -415,10 +415,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} de fundos para taxas restantes foi enviado para a poupança."; "lightning__spark_exit__leftover_sending" = "A enviar {amount} de fundos para taxas restantes para a poupança."; "lightning__spark_exit__leftover_dust" = "{amount} de fundos para taxas restantes é demasiado pequeno para enviar e fica em {address}, caminho de derivação {path}."; -"lightning__spark_exit__leftover_txid" = "ID da transação: {txid}"; "lightning__spark_exit__fund_paid" = "Pago da poupança, a aguardar confirmação."; "lightning__spark_exit__fund_pending" = "Pagamento detetado, a aguardar confirmação."; -"lightning__spark_exit__funding_txid" = "ID da transação: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} foi reclamado on-chain por um antigo dono destes fundos e não pode ser recuperado."; "lightning__spark_exit__notification_lost" = "{amount} da sua saída não pode ser recuperado. Abra a Bitkit para ver porquê."; "lightning__spark_exit__notification_failed" = "A sua saída de emergência parou. Abra a Bitkit para ver porquê."; @@ -441,7 +439,6 @@ "lightning__spark_exit__waiting_leaves" = "Parte desta saída não pode ser lida on-chain neste momento. A Bitkit tenta novamente durante até uma semana."; "lightning__spark_exit__rebuilt_from_chain" = "A Bitkit não conseguiu ler o progresso guardado desta saída, por isso reconstrói-o a partir da blockchain."; "lightning__spark_exit__done_received" = "{amount} chegou à sua poupança."; -"lightning__spark_exit__sweep_txid" = "ID da transação: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} de fundos para taxas restantes não pôde ser enviado e fica em {address}, caminho de derivação {path}."; "lightning__spark_exit__notification_not_moving" = "A sua saída de emergência não está a avançar. Abra a Bitkit para ver porquê."; "lightning__spark_exit__funding_split" = "A Bitkit divide-o em {count} partes, uma para cada passo, para que um passo bloqueado não atrase os outros."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 1060c07b5..1215c0b95 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -412,10 +412,8 @@ "lightning__spark_exit__leftover_swept" = "{amount} оставшихся средств на комиссии отправлено в сбережения."; "lightning__spark_exit__leftover_sending" = "Отправляем {amount} оставшихся средств на комиссии в сбережения."; "lightning__spark_exit__leftover_dust" = "{amount} оставшихся средств на комиссии слишком мало для отправки, они остаются на адресе {address}, путь деривации {path}."; -"lightning__spark_exit__leftover_txid" = "ID транзакции: {txid}"; "lightning__spark_exit__fund_paid" = "Оплачено из сбережений, ждём подтверждения."; "lightning__spark_exit__fund_pending" = "Платёж обнаружен, ждём подтверждения."; -"lightning__spark_exit__funding_txid" = "ID транзакции: {txid}"; "lightning__spark_exit__lost_leaves" = "{amount} забрал ончейн один из прежних владельцев этих средств, их нельзя вернуть."; "lightning__spark_exit__notification_lost" = "{amount} из вашего выхода нельзя вернуть. Откройте Bitkit, чтобы узнать причину."; "lightning__spark_exit__notification_failed" = "Ваш экстренный выход остановлен. Откройте Bitkit, чтобы узнать причину."; @@ -438,7 +436,6 @@ "lightning__spark_exit__waiting_leaves" = "Часть этого выхода сейчас не удаётся прочитать в блокчейне. Bitkit будет пробовать снова в течение недели."; "lightning__spark_exit__rebuilt_from_chain" = "Bitkit не смог прочитать сохранённый прогресс этого выхода и восстанавливает его по блокчейну."; "lightning__spark_exit__done_received" = "{amount} поступило в ваши сбережения."; -"lightning__spark_exit__sweep_txid" = "ID транзакции: {txid}"; "lightning__spark_exit__leftover_rejected" = "{amount} оставшихся средств на комиссии не удалось отправить, они остаются на адресе {address}, путь деривации {path}."; "lightning__spark_exit__notification_not_moving" = "Ваш экстренный выход не продвигается. Откройте Bitkit, чтобы узнать причину."; "lightning__spark_exit__funding_split" = "Bitkit делит их на части ({count}), по одной на каждый шаг, чтобы один застрявший шаг не задерживал остальные."; diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index a4091e8fa..cb40fd64c 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -78,11 +78,11 @@ struct SparkExitQuoteView: View { if let unexitableSats = SparkExitMath.unexitableSats(sparkBalanceSats: SparkManager.shared.balanceSats, quote: quote) { BodySText(t( "lightning__spark_exit__quote_outside", - variables: ["amount": currency.primaryAmountText(sats: unexitableSats)] - )) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitQuoteOutside") + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: unexitableSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitQuoteOutside") } } else if exit.isQuoting { let key = SparkManager.shared.isSettlingForExit ? "lightning__spark_exit__quote_settling" : "lightning__spark_exit__quote_loading" @@ -234,7 +234,7 @@ struct SparkExitFundingView: View { .accessibilityIdentifier("SparkExitFundingContinue") } else { if hasSavingsFeeFailed || (savingsFeeSats != nil && !canFundFromSavings) { - BodySText(t("lightning__spark_exit__fund_insufficient")) + CaptionText(t("lightning__spark_exit__fund_insufficient")) .padding(.bottom, 16) } @@ -330,18 +330,25 @@ struct SparkExitFundingAddress: View { var body: some View { VStack(alignment: .leading, spacing: 16) { + // The receive screen's tile, at a fixed size with black space around it. QR(content: address) - .frame(maxWidth: 200) + .frame(width: 200, height: 200) + .padding(.vertical, 16) .frame(maxWidth: .infinity) - BodySText(t("lightning__spark_exit__fund_external_text", variables: ["amount": currency.primaryAmountText(sats: requiredSats)])) + BodySText( + t("lightning__spark_exit__fund_external_text", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: requiredSats))]), + accentColor: .white, + accentFont: Fonts.bold + ) Button { UIPasteboard.general.string = address Haptics.play(.copiedToClipboard) app.toast(type: .success, title: t("common__copied"), description: address) } label: { - BodySText(address, textColor: .textPrimary) + CaptionBText(address, textColor: .textPrimary) .lineLimit(2) .truncationMode(.middle) .contentShape(.rect) @@ -351,32 +358,48 @@ struct SparkExitFundingAddress: View { if let missingSats = SparkExitMath.partialFundingSats(receivedSats: receivedSats, requiredSats: targetSats) { BodySText(t("lightning__spark_exit__fund_partial", variables: [ - "received": currency.primaryAmountText(sats: receivedSats), - "required": currency.primaryAmountText(sats: targetSats), - "missing": currency.primaryAmountText(sats: missingSats), - ])) - .accessibilityIdentifier("SparkExitFundingPartial") + "received": sparkExitAmountAccent(currency.primaryAmountText(sats: receivedSats)), + "required": sparkExitAmountAccent(currency.primaryAmountText(sats: targetSats)), + "missing": sparkExitAmountAccent(currency.primaryAmountText(sats: missingSats)), + ]), accentColor: .white, accentFont: Fonts.bold) + .accessibilityIdentifier("SparkExitFundingPartial") } else if showsSeen, receivedSats >= targetSats, targetSats > 0 { - BodySText(t("lightning__spark_exit__fund_seen"), textColor: .greenAccent) + CaptionText(t("lightning__spark_exit__fund_seen"), textColor: .greenAccent) } } } } +/// The amount marked as an inline accent: bold white on a grey line, bold red on a red one. +func sparkExitAmountAccent(_ amount: String) -> String { + "\(amount)" +} + /// A fee funding payment on its way: no QR and no amount to send, only what is awaited and its transaction. struct SparkExitWaitingPayment: View { let text: String let txid: String + let testId: String var body: some View { - VStack(alignment: .leading, spacing: 8) { - BodySText(text) - BodySText(t("lightning__spark_exit__funding_txid", variables: ["txid": txid])) - .lineLimit(2) - .truncationMode(.middle) + SparkExitTxNote(text: text, txid: txid) + .accessibilityElement(children: .contain) + .accessibilityIdentifier(testId) + } +} + +/// A line of progress with the transaction it is about, in the activity explore cell whose id copies on tap. +struct SparkExitTxNote: View { + let text: String + let txid: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + BodySText(text, accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + ActivityInfoSection(title: t("wallet__activity_tx_id"), content: txid) + .padding(.top, 16) } - .accessibilityElement(children: .contain) - .accessibilityIdentifier("SparkExitFundingWaiting") } } @@ -395,41 +418,65 @@ struct SparkExitLeftoverStatus: View { var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(record.sweptLeftover ?? [], id: \.txid) { swept in - BodySText(t("lightning__spark_exit__leftover_swept", variables: ["amount": currency.primaryAmountText(sats: swept.valueSats)])) - BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": swept.txid])) - .lineLimit(2) - .truncationMode(.middle) + SparkExitTxNote( + text: t("lightning__spark_exit__leftover_swept", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: swept.valueSats))]), + txid: swept.txid + ) } switch record.leftoverLine { case let .sending(sats, txid): - BodySText(t("lightning__spark_exit__leftover_sending", variables: ["amount": currency.primaryAmountText(sats: sats)])) if let txid { - BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": txid])) - .lineLimit(2) - .truncationMode(.middle) + SparkExitTxNote( + text: t("lightning__spark_exit__leftover_sending", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]), + txid: txid + ) + } else { + BodySText( + t( + "lightning__spark_exit__leftover_sending", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))] + ), + accentColor: .white, + accentFont: Fonts.bold + ) } case let .swept(sats, txid): - BodySText(t("lightning__spark_exit__leftover_swept", variables: ["amount": currency.primaryAmountText(sats: sats)])) - BodySText(t("lightning__spark_exit__leftover_txid", variables: ["txid": txid])) - .lineLimit(2) - .truncationMode(.middle) + SparkExitTxNote( + text: t("lightning__spark_exit__leftover_swept", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]), + txid: txid + ) case let .waiting(sats): - BodySText(t("lightning__spark_exit__leftover_waiting", variables: ["amount": currency.primaryAmountText(sats: sats)])) - .fixedSize(horizontal: false, vertical: true) + BodySText( + t("lightning__spark_exit__leftover_waiting", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]), + accentColor: .white, + accentFont: Fonts.bold + ) + .fixedSize(horizontal: false, vertical: true) case let .kept(sats): BodySText(t("lightning__spark_exit__leftover_dust", variables: [ - "amount": currency.primaryAmountText(sats: sats), "address": address, "path": path, - ])) - .fixedSize(horizontal: false, vertical: true) + "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)), "address": address, "path": path, + ]), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) case let .rejected(sats): // Refused for good: where the coins stay, and the path that spends them with the recovery phrase. BodySText(t("lightning__spark_exit__leftover_rejected", variables: [ - "amount": currency.primaryAmountText(sats: sats), "address": address, "path": path, - ])) - .fixedSize(horizontal: false, vertical: true) - case let .stillAt(sats): - BodySText(t("lightning__spark_exit__leftover", variables: ["amount": currency.primaryAmountText(sats: sats), "address": address])) + "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)), "address": address, "path": path, + ]), accentColor: .white, accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) + case let .stillAt(sats): + BodySText( + t("lightning__spark_exit__leftover", variables: [ + "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)), + "address": address, + ]), + accentColor: .white, + accentFont: Fonts.bold + ) + .fixedSize(horizontal: false, vertical: true) case nil: EmptyView() } @@ -507,6 +554,7 @@ struct SparkExitConfirmView: View { /// The running exit's steps and countdown, and its result once it ends. struct SparkExitProgressView: View { @EnvironmentObject var app: AppViewModel + @ObservedObject private var notifications = PushNotificationManager.shared @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var currency: CurrencyViewModel @@ -523,7 +571,10 @@ struct SparkExitProgressView: View { private var lostText: String? { guard let record, record.lostSats > 0 else { return nil } - return t("lightning__spark_exit__lost_leaves", variables: ["amount": currency.primaryAmountText(sats: record.lostSats)]) + return t( + "lightning__spark_exit__lost_leaves", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.lostSats))] + ) } /// Lost leaves are gone, so the text claims only what Spark still holds. @@ -534,7 +585,10 @@ struct SparkExitProgressView: View { let staysSats = record.flatMap { SparkExitMath.staysOnSparkSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: $0) } return [ t("lightning__spark_exit__failed_text"), - staysSats.map { t("lightning__spark_exit__failed_stays", variables: ["amount": currency.primaryAmountText(sats: $0)]) }, + staysSats.map { t( + "lightning__spark_exit__failed_stays", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: $0))] + ) }, ].compactMap { $0 }.joined(separator: " ") } @@ -562,12 +616,12 @@ struct SparkExitProgressView: View { [ // The amount that reached savings says it all; the generic line only stands in when it is unknown. record.flatMap(SparkExitMath.received).map { - t("lightning__spark_exit__done_received", variables: ["amount": currency.primaryAmountText(sats: $0.sats)]) + t("lightning__spark_exit__done_received", variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: $0.sats))]) } ?? t("lightning__spark_exit__done_text"), (record?.skippedLeafCount ?? 0) > 0 ? t("lightning__spark_exit__partial_text", variables: ["count": "\(record?.skippedLeafCount ?? 0)"]) : nil, record.flatMap { $0.isFailedLeavesReleased == true && $0.failedLeavesSats > 0 ? $0.failedLeavesSats : nil }.map { - t("lightning__spark_exit__failed_leaves", variables: ["amount": currency.primaryAmountText(sats: $0)]) + t("lightning__spark_exit__failed_leaves", variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: $0))]) }, ].compactMap { $0 }.joined(separator: " ") case .failed: failedText @@ -598,11 +652,8 @@ struct SparkExitProgressView: View { SparkExitSteps(record: record, tipHeight: exit.tipHeight) if let sweepTxid = SparkExitMath.received(record)?.sweepTxid { - BodySText(t("lightning__spark_exit__sweep_txid", variables: ["txid": sweepTxid])) - .lineLimit(2) - .truncationMode(.middle) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitSweepTxid") + ActivityInfoSection(title: t("wallet__activity_tx_id"), content: sweepTxid, testId: "SparkExitSweepTxid") + .padding(.top, 24) } if record.isActive, record.isRebuiltFromChain == true { @@ -622,11 +673,11 @@ struct SparkExitProgressView: View { if record.isWaitingForOperators { BodySText(t( "lightning__spark_exit__waiting_operators", - variables: ["amount": currency.primaryAmountText(sats: record.failedLeavesSats)] - )) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitWaitingOperators") + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingOperators") } if let address = record.funding.first?.address { @@ -643,10 +694,10 @@ struct SparkExitProgressView: View { ) .padding(.top, 24) case let .paid(txid): - SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_paid"), txid: txid) + SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_paid"), txid: txid, testId: "SparkExitFundingPaid") .padding(.top, 24) case let .seen(txid): - SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_pending"), txid: txid) + SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_pending"), txid: txid, testId: "SparkExitFundingSeen") .padding(.top, 24) case nil: EmptyView() @@ -656,15 +707,15 @@ struct SparkExitProgressView: View { if record.leftBehindSats > 0 { BodySText(t( "lightning__spark_exit__left_behind_leaves", - variables: ["amount": currency.primaryAmountText(sats: record.leftBehindSats)] - )) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitLeftBehind") + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLeftBehind") } if let lostText { - BodySText(lostText, textColor: .redAccent) + BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) .padding(.top, 16) .accessibilityIdentifier("SparkExitLostLeaves") @@ -697,10 +748,10 @@ struct SparkExitProgressView: View { if let outsideSats = SparkExitMath.outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) { BodySText(t( "lightning__spark_exit__outside_exit", - variables: ["amount": currency.primaryAmountText(sats: outsideSats)] - )) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitOutsideExit") + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: outsideSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitOutsideExit") } if exit.isSavingsPaymentRefused { @@ -714,7 +765,9 @@ struct SparkExitProgressView: View { .padding(.top, 16) } - if !record.isActive, !record.leftover.isEmpty, let address = record.funding.first?.address { + if !record.isActive, !record.leftover.isEmpty || !(record.sweptLeftover ?? []).isEmpty, + let address = record.funding.first?.address + { SparkExitLeftoverStatus(record: record, address: address) .padding(.top, 16) } @@ -722,6 +775,16 @@ struct SparkExitProgressView: View { } } + if record?.isActive == true, notifications.authorizationStatus == .denied { + CustomButton(title: t("lightning__spark_exit__note_notifications_button"), variant: .secondary) { + if let url = URL(string: UIApplication.openSettingsURLString) { + await UIApplication.shared.open(url) + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitNotificationSettings") + } + if exit.isSavingsPaymentRefused { CustomButton(title: t("lightning__spark_exit__fund_savings"), variant: .secondary) { do { @@ -857,7 +920,7 @@ struct SparkExitFeeBumpView: View { } if isInsufficient { - BodySText(t("lightning__spark_exit__fund_insufficient")) + CaptionText(t("lightning__spark_exit__fund_insufficient")) .padding(.bottom, 16) .accessibilityIdentifier("SparkExitFeeBumpInsufficient") } diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 52466387d..4bd151f5f 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -118,38 +118,6 @@ struct ActivityExplorerView: View { } } - private struct InfoSection: View { - let title: String - let content: String - let testId: String? - @EnvironmentObject var app: AppViewModel - - init(title: String, content: String, testId: String? = nil) { - self.title = title - self.content = content - self.testId = testId - } - - var body: some View { - Button { - UIPasteboard.general.string = content - app.toast(type: .success, title: t("common__copied"), description: content) - } label: { - VStack(alignment: .leading, spacing: 0) { - CaptionMText(title) - .padding(.bottom, 8) - BodySSBText(content) - .padding(.bottom, 16) - - Divider() - .padding(.bottom, 16) - } - } - .buttonStyle(.plain) - .accessibilityIdentifierIfPresent(testId) - } - } - private var navTitle: String { switch item { case let .lightning(activity) where activity.status == .pending: @@ -181,7 +149,7 @@ struct ActivityExplorerView: View { .padding(.bottom, 32) if let onchain { - InfoSection( + ActivityInfoSection( title: t("wallet__activity_tx_id"), content: onchain.txId, testId: "TXID" @@ -227,7 +195,7 @@ struct ActivityExplorerView: View { .padding(.bottom, 16) ForEach(Array(onchain.boostTxIds.enumerated()), id: \.offset) { index, boostTxId in let isRBF = onchain.txType == .sent || !(boostTxDoesExist[boostTxId] ?? true) - InfoSection( + ActivityInfoSection( title: t(isRBF ? "wallet__activity_boosted_rbf" : "wallet__activity_boosted_cpfp", variables: ["num": String(index + 1)]), content: boostTxId, testId: isRBF ? "RBFBoosted" : "CPFPBoosted" @@ -236,20 +204,20 @@ struct ActivityExplorerView: View { } } else if let lightning { if let preimage = lightning.preimage { - InfoSection( + ActivityInfoSection( title: t("wallet__activity_preimage"), content: preimage ) } if let paymentHash { - InfoSection( + ActivityInfoSection( title: t("wallet__activity_payment_hash"), content: paymentHash ) } - InfoSection( + ActivityInfoSection( title: t("wallet__activity_invoice"), content: lightning.invoice ) @@ -346,3 +314,36 @@ struct ActivityExplorer_Previews: PreviewProvider { .preferredColorScheme(.dark) } } + +/// A detail of an activity, its title above its value, that copies the value on tap. +struct ActivityInfoSection: View { + let title: String + let content: String + let testId: String? + @EnvironmentObject var app: AppViewModel + + init(title: String, content: String, testId: String? = nil) { + self.title = title + self.content = content + self.testId = testId + } + + var body: some View { + Button { + UIPasteboard.general.string = content + app.toast(type: .success, title: t("common__copied"), description: content) + } label: { + VStack(alignment: .leading, spacing: 0) { + CaptionMText(title) + .padding(.bottom, 8) + BodySSBText(content) + .padding(.bottom, 16) + + Divider() + .padding(.bottom, 16) + } + } + .buttonStyle(.plain) + .accessibilityIdentifierIfPresent(testId) + } +} From 8f5e15f4df85afdb837b45c1334a1127acc8965d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:33:02 +0100 Subject: [PATCH 188/378] fix: keep a stale spark withdrawal settle off a retry's proof and allowance The request's proof and allowance attempt match by request id, so a stale close released a retry's started proof and failed its allowance, and a stale record could complete the retry's proof with the older txid and settle its allowance. Both now act only while their own journal is still open. --- Bitkit/Managers/SparkManager.swift | 10 +++++++++- BitkitTests/PaykitRouteTests.swift | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 93763fa33..72e231fd5 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -687,6 +687,8 @@ final class SparkManager: ChannelFreeLightningRail { /// Writes the withdrawal's sent activity, tags and request proof, and closes the journal only once all of them are /// saved, so a failed or interrupted write is retried from the journal. private func recordContactWithdrawal(_ payment: Payment, txId: String, pending: SparkContactWithdrawal) async { + // A settle that raced this one already saved everything; its request's proof and allowance may be a retry's now. + guard Self.isOpenJournal(pending) else { return } guard payment.status != .failed else { await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) return @@ -740,6 +742,8 @@ final class SparkManager: ChannelFreeLightningRail { releaseProof: (SparkContactWithdrawal.PaykitRequest) async -> SparkProofRelease, failAllowance: (SparkContactWithdrawal.PaykitRequest) async -> Void ) async { + // The proof and allowance attempt match by request, so a stale close would release a retry's. + guard isOpenJournal(pending) else { return } if let request = pending.request { switch await releaseProof(request) { case .failed: @@ -757,11 +761,15 @@ final class SparkManager: ChannelFreeLightningRail { } } + nonisolated static func isOpenJournal(_ pending: SparkContactWithdrawal) -> Bool { + pendingContactWithdrawal == pending + } + /// Clears `pending`'s journal only while it is still the open one, so a stale recovery never drops a newer /// withdrawal's journal, which could then be paid twice. Returns whether it cleared it: only then is the resolution /// this attempt's to publish, since a retry of the same request shares its attempt. nonisolated static func clearJournal(_ pending: SparkContactWithdrawal) -> Bool { - guard pendingContactWithdrawal == pending else { return false } + guard isOpenJournal(pending) else { return false } pendingContactWithdrawal = nil return true } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index d3a0d533e..cf5061ed2 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -421,6 +421,30 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(resolutions, [], "An unsent resolution for the newer send's attempt would offer Try Again while it is in flight") } + @MainActor + func testAStaleCloseDuringARetryKeepsTheNewerProofAndAllowanceAttempt() async { + snapshotAppDefaultsDomain() + let request = SparkContactWithdrawal.PaykitRequest( + id: PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "payee"), + identity: "payer" + ) + let older = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1000, request: request) + let newer = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 2000, request: request) + SparkManager.pendingContactWithdrawal = newer + var released: [SparkContactWithdrawal.PaykitRequest] = [] + var failedAllowances: [SparkContactWithdrawal.PaykitRequest] = [] + + await SparkManager.closeUnsentWithdrawal( + older, + releaseProof: { released.append($0); return .released }, + failAllowance: { failedAllowances.append($0) } + ) + + XCTAssertEqual(released, [], "The proof matches by request, so a stale close would release the retry's started proof") + XCTAssertEqual(failedAllowances, [], "The retry's allowance attempt stays open while it is in flight") + XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer) + } + @MainActor func testAPendingSparkSendNeverAdoptsTheNodesOnchainAttempt() { let attempt = SparkWithdrawalAttempt(address: onchain.value, amountSats: 20000, requestId: nil) From 430fa7219003517b1c7344effe0e982ec65c3f5a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:46:43 +0100 Subject: [PATCH 189/378] fix: keep paid send guards until their invoices expire --- Bitkit/Services/LightningService.swift | 72 +++++++++++++++++++------- BitkitTests/ChannelFreeRailTests.swift | 41 +++++++++++++++ 2 files changed, 94 insertions(+), 19 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index a7d74fac8..af425cd03 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1038,7 +1038,9 @@ class LightningService: NodeLifecycleService { channelsOnly: Bool = false, beforeSubmission: @escaping () throws -> Void = {} ) async throws -> PaymentHash { - let paymentHash = (try? Bolt11Invoice.fromStr(invoiceStr: bolt11).paymentHash()).map { String($0) } + let invoice = try? Bolt11Invoice.fromStr(invoiceStr: bolt11) + let paymentHash = invoice.map { String($0.paymentHash()) } + let expiresAt = invoice.map { Int(clamping: min($0.secondsSinceEpoch(), .max - $0.expiryTimeSeconds()) + $0.expiryTimeSeconds()) } if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { // LDK refuses its own repeats; Spark doesn't, so a Spark send checks both rails. if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.ldk, .channelFree]) { @@ -1072,7 +1074,7 @@ class LightningService: NodeLifecycleService { ?? Self.paymentAmountSats(bolt11: bolt11, sats: sats).map(Self.defaultMaxFeeSats), beforeSubmission: { [weak self] in try beforeSubmission() - self?.recordRailAttempt(paymentHash: paymentHash) + self?.recordRailAttempt(paymentHash: paymentHash, expiresAt: expiresAt) } ) } catch let refusal as ChannelFreeRailSendRefused { @@ -1096,7 +1098,7 @@ class LightningService: NodeLifecycleService { do { return try await Self.submitPayment(beforeSubmission: { [weak self] in try beforeSubmission() - self?.recordLdkAttempt(paymentHash: paymentHash) + self?.recordLdkAttempt(paymentHash: paymentHash, expiresAt: expiresAt) }) { if let sats { try node.bolt11Payment().sendUsingAmount( @@ -1123,9 +1125,12 @@ class LightningService: NodeLifecycleService { /// Payment hashes sent over the rail, kept across launches: while the rail can't list its payments, only these may /// have an attempt there that a node payment would repeat. var railAttemptsStore: UserDefaults = .standard + /// The clock a trim checks invoice expiries against; tests set it. + var now: () -> Date = Date.init private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" - /// Past this many hashes per key, a listing drops the oldest it shows as paid or failed. A hash whose payment may - /// still be pending, or that no listing has shown yet, is kept past it. + /// Past this many hashes per key, a listing drops the oldest it shows as failed, or as paid once the invoice has + /// expired, so the other rail can't pay it again. A paid hash with no known expiry is dropped like a failed one. A + /// hash whose payment may still be pending, or that no listing has shown yet, is kept past it. private static let railAttemptsLimit = 500 /// Payment hashes sent over the node, kept across launches: while the node can't list its payments (stopped or not @@ -1135,40 +1140,58 @@ class LightningService: NodeLifecycleService { /// there. Until then an unlistable node refuses every Spark payment. static let ldkAttemptsSeededKey = "ldkAttemptedPaymentHashesSeeded" - func recordRailAttempt(paymentHash: String?) { - recordAttempt(paymentHash: paymentHash, key: Self.railAttemptsKey) + /// `expiresAt` is the invoice's expiry in seconds since 1970, kept beside each key's hashes. + func recordRailAttempt(paymentHash: String?, expiresAt: Int? = nil) { + recordAttempt(paymentHash: paymentHash, expiresAt: expiresAt, key: Self.railAttemptsKey) } - func recordLdkAttempt(paymentHash: String?) { - recordAttempt(paymentHash: paymentHash, key: Self.ldkAttemptsKey) + func recordLdkAttempt(paymentHash: String?, expiresAt: Int? = nil) { + recordAttempt(paymentHash: paymentHash, expiresAt: expiresAt, key: Self.ldkAttemptsKey) + } + + private static func expiriesKey(_ key: String) -> String { + key + "Expiries" + } + + /// Callers hold `eventCallbackLock`. + private func attemptExpiries(key: String) -> [String: Int] { + railAttemptsStore.dictionary(forKey: Self.expiriesKey(key)) as? [String: Int] ?? [:] } /// A retry of a kept hash moves it to the newest end, so a trim working from a listing read before the retry /// drops older hashes first. - private func recordAttempt(paymentHash: String?, key: String) { + private func recordAttempt(paymentHash: String?, expiresAt: Int?, key: String) { guard let paymentHash = paymentHash?.lowercased() else { return } eventCallbackLock.withLock { var hashes = railAttemptsStore.stringArray(forKey: key) ?? [] hashes.removeAll { $0 == paymentHash } hashes.append(paymentHash) railAttemptsStore.set(hashes, forKey: key) + var expiries = attemptExpiries(key: key) + expiries[paymentHash] = expiresAt + railAttemptsStore.set(expiries, forKey: Self.expiriesKey(key)) } } - /// Past the limit, drops the oldest hashes a listing showed as paid or failed (`settled`), so a payment that may - /// still be pending keeps holding the other rail. Callers hold `eventCallbackLock`. - private func trimSettledAttempts(_ settled: Set, key: String) { + /// Past the limit, drops the oldest hashes a listing showed as failed, or as paid with an expired or unknown + /// invoice expiry, so a payment that may still be pending, or a paid one whose invoice can still be paid, keeps + /// holding the other rail. Callers hold `eventCallbackLock`. + private func trimSettledAttempts(paid: Set, failed: Set, key: String) { guard let hashes = railAttemptsStore.stringArray(forKey: key), hashes.count > Self.railAttemptsLimit else { return } + let expiries = attemptExpiries(key: key) + let nowSeconds = Int(now().timeIntervalSince1970) var excess = hashes.count - Self.railAttemptsLimit var kept: [String] = [] for hash in hashes { - if excess > 0, settled.contains(hash) { + let expired = expiries[hash].map { $0 <= nowSeconds } ?? true + if excess > 0, failed.contains(hash) || paid.contains(hash) && expired { excess -= 1 } else { kept.append(hash) } } railAttemptsStore.set(kept, forKey: key) + railAttemptsStore.set(expiries.filter { kept.contains($0.key) }, forKey: Self.expiriesKey(key)) } /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. @@ -1190,6 +1213,9 @@ class LightningService: NodeLifecycleService { guard var hashes = railAttemptsStore.stringArray(forKey: key), hashes.contains(hash) else { return } hashes.removeAll { $0 == hash } railAttemptsStore.set(hashes, forKey: key) + var expiries = attemptExpiries(key: key) + expiries[hash] = nil + railAttemptsStore.set(expiries, forKey: Self.expiriesKey(key)) } private func hasRailAttempt(paymentHash: String) -> Bool { @@ -1204,13 +1230,17 @@ class LightningService: NodeLifecycleService { /// Called when the rail's wallet is wiped, as its payments go with it. func forgetRailAttempts() { - eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.railAttemptsKey) } + eventCallbackLock.withLock { + railAttemptsStore.removeObject(forKey: Self.railAttemptsKey) + railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.railAttemptsKey)) + } } /// Called when the node's wallet is wiped, as its payments go with it. func forgetLdkAttempts() { eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.ldkAttemptsKey) + railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.ldkAttemptsKey)) railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } } @@ -1222,7 +1252,7 @@ class LightningService: NodeLifecycleService { let sends = outbound .filter { $0.status != .failed } .sorted { $0.latestUpdateTimestamp < $1.latestUpdateTimestamp } - let settled = Set(outbound.filter { $0.status != .pending }.map { $0.id.lowercased() }) + let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.id.lowercased() }) } eventCallbackLock.withLock { if !railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) { let kept = railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] @@ -1230,7 +1260,7 @@ class LightningService: NodeLifecycleService { railAttemptsStore.set(seeded + kept, forKey: Self.ldkAttemptsKey) railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } - trimSettledAttempts(settled, key: Self.ldkAttemptsKey) + trimSettledAttempts(paid: hashes(.succeeded), failed: hashes(.failed), key: Self.ldkAttemptsKey) } } @@ -1239,9 +1269,13 @@ class LightningService: NodeLifecycleService { private func keepRailAttempts(from payments: [ChannelFreeRailPayment]) { let outbound = payments.filter { $0.direction == .outbound } let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.paymentHash.lowercased() }) } - let settled = hashes(.succeeded).union(hashes(.failed)).subtracting(hashes(.pending)) eventCallbackLock.withLock { - trimSettledAttempts(settled.subtracting(railSendsInFlight), key: Self.railAttemptsKey) + let live = hashes(.pending).union(railSendsInFlight) + trimSettledAttempts( + paid: hashes(.succeeded).subtracting(live), + failed: hashes(.failed).subtracting(live), + key: Self.railAttemptsKey + ) } } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index cd26b6edf..0820809a5 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -13,6 +13,7 @@ final class ChannelFreeRailTests: XCTestCase { private var previousRail: Bitkit.ChannelFreeLightningRail? private var previousAttemptsStore: UserDefaults! private var previousRailSupported: (() async -> Bool)! + private var previousNow: (() -> Date)! override func setUp() { super.setUp() @@ -26,6 +27,7 @@ final class ChannelFreeRailTests: XCTestCase { service.forgetLdkAttempts() previousRailSupported = service.channelFreeRailSupported service.channelFreeRailSupported = { true } + previousNow = service.now } override func tearDown() { @@ -34,6 +36,7 @@ final class ChannelFreeRailTests: XCTestCase { service.forgetLdkAttempts() service.railAttemptsStore = previousAttemptsStore service.channelFreeRailSupported = previousRailSupported + service.now = previousNow service.clearEventCallback() service.channelFreeRail = previousRail rail = nil @@ -392,6 +395,44 @@ extension ChannelFreeRailTests { await assertRefusedAsDuplicate() } + func testAPaidSparkSendWhoseInvoiceIsUnexpiredStillHoldsTheNodeBehindTheCapWhileSparkCantList() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: TimeInterval(invoice.secondsSinceEpoch())) } + try await sendOverSpark() + try await listPaidSparkSendBehindTheCap(String(invoice.paymentHash())) + + rail.listError = NSError(domain: "spark", code: 1) + + await assertRefusedAsDuplicate() + } + + func testAPaidSparkSendWhoseInvoiceExpiredIsTrimmedPastTheCap() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let hash = String(invoice.paymentHash()) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: TimeInterval(invoice.secondsSinceEpoch() + invoice.expiryTimeSeconds())) } + try await sendOverSpark() + + try await listPaidSparkSendBehindTheCap(hash) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept.count, 500, "Paid Spark hashes stayed past the cap") + XCTAssertFalse(kept.contains(hash), "The paid Spark hash whose invoice expired was kept") + } + + /// Records 500 newer Spark sends after `hash` and lists them all as paid, so a node send trims past the cap. + private func listPaidSparkSendBehindTheCap(_ hash: String) async throws { + let service = Bitkit.LightningService.shared + (1 ... 500).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } + rail.payments = ([hash] + (1 ... 500).map(Self.otherHash)).map { + Bitkit.ChannelFreeRailPayment(paymentHash: $0, direction: .outbound, status: .succeeded, preimage: nil) + } + rail.balanceSats = 0 + _ = try? await service.send(bolt11: Self.invoice) + rail.sentBolt11 = nil + } + func testSparkHashesListedAsFailedAreTrimmedPastTheCap() async { let service = Bitkit.LightningService.shared (1 ... 501).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } From cecb5bf7fe14942add33dcb1b57681a89d9d8797 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:52:31 +0100 Subject: [PATCH 190/378] fix: drop node send hashes listed only as failed --- Bitkit/Services/LightningService.swift | 49 +++++++++++++++++++------- BitkitTests/ChannelFreeRailTests.swift | 23 ++++++++---- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index af425cd03..742744fa2 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -130,6 +130,10 @@ class LightningService: NodeLifecycleService { private var attemptRails: [String: LightningPaymentRail] = [:] /// Payment hashes inside a rail send, which Spark lists only once it returns. private var railSendsInFlight: Set = [] + /// Counts node attempts recorded in this run, so a listing drops a hash it shows only as failed just when the hash + /// was recorded before the listing was read, never a retry recorded while it was read. + private var ldkAttemptRecords = 0 + private var ldkAttemptRecordedAt: [String: Int] = [:] weak var channelFreeRail: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? @@ -370,9 +374,7 @@ class LightningService: NodeLifecycleService { await refreshChannelCache() await refreshCache() - if let payments = await nodePayments() { - keepLdkAttempts(from: payments) - } + _ = await listNodePaymentsKeepingAttempts() Logger.info("Node started") } @@ -1167,6 +1169,10 @@ class LightningService: NodeLifecycleService { hashes.removeAll { $0 == paymentHash } hashes.append(paymentHash) railAttemptsStore.set(hashes, forKey: key) + if key == Self.ldkAttemptsKey { + ldkAttemptRecords += 1 + ldkAttemptRecordedAt[paymentHash] = ldkAttemptRecords + } var expiries = attemptExpiries(key: key) expiries[paymentHash] = expiresAt railAttemptsStore.set(expiries, forKey: Self.expiriesKey(key)) @@ -1210,12 +1216,18 @@ class LightningService: NodeLifecycleService { /// Callers hold `eventCallbackLock`. private func removeAttempt(paymentHash hash: String, key: String) { - guard var hashes = railAttemptsStore.stringArray(forKey: key), hashes.contains(hash) else { return } - hashes.removeAll { $0 == hash } + removeAttempts([hash], key: key) + } + + /// Callers hold `eventCallbackLock`. + private func removeAttempts(_ removed: Set, key: String) { + guard var hashes = railAttemptsStore.stringArray(forKey: key), hashes.contains(where: removed.contains) else { return } + hashes.removeAll { removed.contains($0) } railAttemptsStore.set(hashes, forKey: key) - var expiries = attemptExpiries(key: key) - expiries[hash] = nil - railAttemptsStore.set(expiries, forKey: Self.expiriesKey(key)) + railAttemptsStore.set(attemptExpiries(key: key).filter { !removed.contains($0.key) }, forKey: Self.expiriesKey(key)) + if key == Self.ldkAttemptsKey { + removed.forEach { ldkAttemptRecordedAt[$0] = nil } + } } private func hasRailAttempt(paymentHash: String) -> Bool { @@ -1241,13 +1253,23 @@ class LightningService: NodeLifecycleService { eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.ldkAttemptsKey) railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.ldkAttemptsKey)) + ldkAttemptRecordedAt = [:] railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } } + /// The node's payments, after keeping its attempts from them; nil while the node can't list them. + private func listNodePaymentsKeepingAttempts() async -> [PaymentDetails]? { + let recordsBefore = eventCallbackLock.withLock { ldkAttemptRecords } + guard let payments = await nodePayments() else { return nil } + keepLdkAttempts(from: payments, recordsBefore: recordsBefore) + return payments + } + /// Keeps the hashes of the node's pending and paid outbound payments, once, so those made before hashes were kept - /// hold Spark while the node can't list them, then trims only paid and failed ones past the limit. - private func keepLdkAttempts(from payments: [PaymentDetails]) { + /// hold Spark while the node can't list them. Then drops the ones listed only as failed that were recorded before + /// the listing was read (`recordsBefore`), and trims paid ones past the limit. + private func keepLdkAttempts(from payments: [PaymentDetails], recordsBefore: Int) { let outbound = payments.filter { $0.direction == .outbound } let sends = outbound .filter { $0.status != .failed } @@ -1260,7 +1282,9 @@ class LightningService: NodeLifecycleService { railAttemptsStore.set(seeded + kept, forKey: Self.ldkAttemptsKey) railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } - trimSettledAttempts(paid: hashes(.succeeded), failed: hashes(.failed), key: Self.ldkAttemptsKey) + let failedOnly = hashes(.failed).subtracting(hashes(.succeeded)).subtracting(hashes(.pending)) + removeAttempts(failedOnly.filter { ldkAttemptRecordedAt[$0, default: 0] <= recordsBefore }, key: Self.ldkAttemptsKey) + trimSettledAttempts(paid: hashes(.succeeded), failed: [], key: Self.ldkAttemptsKey) } } @@ -1290,8 +1314,7 @@ class LightningService: NodeLifecycleService { guard let paymentHash else { return nil } var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] if rails.contains(.ldk) { - if let payments = await nodePayments() { - keepLdkAttempts(from: payments) + if let payments = await listNodePaymentsKeepingAttempts() { let payment = payments.first { $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 0820809a5..90ea9ef4b 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -339,7 +339,21 @@ extension ChannelFreeRailTests { await assertRefusedAsDuplicate() } - func testNodeHashesListedAsFailedAreTrimmedPastTheCap() async throws { + func testANodeHashListedOnlyAsFailedIsDropped() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: Self.otherHash(1)) + service.nodePayments = { [Self.nodePayment(Self.otherHash(1), status: .failed)] } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertFalse(kept.contains(Self.otherHash(1)), "A node hash listed only as failed was kept") + } + + func testNodeHashesListedOnlyAsFailedAreDroppedPastTheCapToo() async throws { let service = Bitkit.LightningService.shared let nodePayments = service.nodePayments defer { service.nodePayments = nodePayments } @@ -352,9 +366,7 @@ extension ChannelFreeRailTests { _ = try await service.send(bolt11: Self.invoice) let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] - XCTAssertEqual(kept.count, 500, "Failed node hashes stayed past the cap") - XCTAssertEqual(kept.first, Self.otherHash(0), "The pending node hash was dropped") - XCTAssertFalse(kept.contains(Self.otherHash(1)), "The oldest failed node hash was kept") + XCTAssertEqual(kept, [Self.otherHash(0)], "Failed node hashes stayed, or the pending one was dropped") } func testANodeRetryRecordedDuringAnOlderListingSurvivesItsTrim() async throws { @@ -374,8 +386,7 @@ extension ChannelFreeRailTests { _ = try await service.send(bolt11: Self.invoice) let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] - XCTAssertEqual(kept.count, 500, "Failed node hashes stayed past the cap") - XCTAssertEqual(kept.last, retried, "The retry in flight was dropped by a trim from an older listing") + XCTAssertEqual(kept, [retried], "Failed node hashes stayed, or an older listing dropped the retry in flight") } func testAPendingNodePaymentBehindTheCapOfFailedOnesStillHoldsSparkWhileTheNodeCantList() async throws { From 5aadc5897c149f5ab10a64354a0a608dfb8acacd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:02:11 +0100 Subject: [PATCH 191/378] fix: withdraw spark to savings at the confirmed fee --- Bitkit/ViewModels/TransferViewModel.swift | 8 +++- BitkitTests/SparkWithdrawalTests.swift | 49 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index f13a70924..5b71bf29d 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -59,6 +59,8 @@ class TransferViewModel: ObservableObject { private var sparkFeeTask: Task? /// The Spark balance the fee was priced for, so it is priced once per balance. private var sparkFeeBalanceSats: UInt64? + /// The Spark withdrawal fee shown when the transfer was confirmed, the most `withdrawSparkToSavings` pays. + private var confirmedSparkFeeSats: UInt64? /// One Savings address for Spark's withdrawal, priced on the confirm screen and then paid to, so the withdrawal /// reuses the quote the fee came from. private var sparkWithdrawAddress: String? @@ -388,6 +390,8 @@ class TransferViewModel: ObservableObject { selectAllForSavings() channelsToClose = channels self.withdrawsSpark = withdrawsSpark + // Spark withdraws at the fee shown now, even if the screen prices it again before the withdrawal runs. + confirmedSparkFeeSats = withdrawsSpark ? sparkWithdrawFeeSats : nil // Each swipe commit is a new transfer; the previous run's memo must not satisfy it. savingsSwapRun = nil return true @@ -425,10 +429,10 @@ class TransferViewModel: ObservableObject { sparkFeeTask = nil isPricingSparkWithdrawal = false guard sparkBalanceSats() > 0 else { return } - guard let shownFeeSats = sparkWithdrawFeeSats, let address = sparkWithdrawAddress else { + guard let confirmedFeeSats = confirmedSparkFeeSats, let address = sparkWithdrawAddress else { throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced") } - try await withdrawSpark(address, shownFeeSats) + try await withdrawSpark(address, confirmedFeeSats) sparkWithdrawAddress = nil sparkFeeBalanceSats = nil sparkWithdrawFeeSats = nil diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index 1223d4064..07a5c5b39 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -154,6 +154,50 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2300) } + @MainActor + func testTheSwipeWithdrawsSparkAtTheFeeItConfirmedWhenSparkIsPricedAgainBeforeTheWithdrawal() async throws { + let transfer = await pricedTransfer() + try await transfer.confirmSavingsSwipe(channels: [], withdrawsSpark: true) + + let withdrawnFeeSats = try await repriceAndWithdraw(transfer) + + XCTAssertEqual(withdrawnFeeSats, 1470, "Spark withdrew at a fee priced after the swipe") + } + + @MainActor + func testCloseInsteadWithdrawsSparkAtTheFeeShownWhenSparkIsPricedAgainBeforeTheWithdrawal() async throws { + let transfer = await pricedTransfer() + XCTAssertTrue(transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close)) + + let withdrawnFeeSats = try await repriceAndWithdraw(transfer) + + XCTAssertEqual(withdrawnFeeSats, 1470, "Spark withdrew at a fee priced after close instead") + } + + @MainActor + private func pricedTransfer() async -> TransferViewModel { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 1470 } + transfer.loadSparkWithdrawFee(balanceSats: 40000, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + return transfer + } + + /// A receive lands during the transition, so the confirm screen prices Spark's new balance, then the transfer runs. + @MainActor + private func repriceAndWithdraw(_ transfer: TransferViewModel) async throws -> UInt64? { + transfer.priceSparkWithdrawal = { _ in 1620 } + transfer.loadSparkWithdrawFee(balanceSats: 52000, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + let withdrawn = WithdrawnFee() + transfer.sparkBalanceSats = { 52000 } + transfer.withdrawSpark = { _, feeSats in withdrawn.feeSats = feeSats } + _ = try await transfer.closeSelectedChannels() + return withdrawn.feeSats + } + + // MARK: - Failed withdrawal + // MARK: - Failed withdrawal func testFailedSparkWithdrawalIsShownAndNamesWhetherChannelsMoved() { @@ -216,3 +260,8 @@ private actor PriceGate { waiters = [] } } + +@MainActor +private final class WithdrawnFee { + var feeSats: UInt64? +} From cd1f960af5b7183ff40ebef293a709fadeab7556 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:22:34 +0100 Subject: [PATCH 192/378] fix: trim send hashes by invoice expiry on both rails --- Bitkit/Services/LightningService.swift | 58 ++++++++++++++++---------- BitkitTests/ChannelFreeRailTests.swift | 23 ++++++++-- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 742744fa2..f0a14c7d4 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -133,6 +133,8 @@ class LightningService: NodeLifecycleService { /// Counts node attempts recorded in this run, so a listing drops a hash it shows only as failed just when the hash /// was recorded before the listing was read, never a retry recorded while it was read. private var ldkAttemptRecords = 0 + /// Node sends under way, so a listing that shows only an earlier failed attempt doesn't drop their hash. + private var ldkSendsInFlight: Set = [] private var ldkAttemptRecordedAt: [String: Int] = [:] weak var channelFreeRail: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? @@ -1097,10 +1099,20 @@ class LightningService: NodeLifecycleService { Logger.info("Paying bolt11: \(bolt11)") noteAttempt(on: .ldk, bolt11: bolt11) + let sendKey = paymentHash?.lowercased() + defer { + if let sendKey { + _ = eventCallbackLock.withLock { self.ldkSendsInFlight.remove(sendKey) } + } + } do { return try await Self.submitPayment(beforeSubmission: { [weak self] in try beforeSubmission() - self?.recordLdkAttempt(paymentHash: paymentHash, expiresAt: expiresAt) + guard let self else { return } + if let sendKey { + _ = eventCallbackLock.withLock { self.ldkSendsInFlight.insert(sendKey) } + } + recordLdkAttempt(paymentHash: paymentHash, expiresAt: expiresAt) }) { if let sats { try node.bolt11Payment().sendUsingAmount( @@ -1130,9 +1142,10 @@ class LightningService: NodeLifecycleService { /// The clock a trim checks invoice expiries against; tests set it. var now: () -> Date = Date.init private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" - /// Past this many hashes per key, a listing drops the oldest it shows as failed, or as paid once the invoice has - /// expired, so the other rail can't pay it again. A paid hash with no known expiry is dropped like a failed one. A - /// hash whose payment may still be pending, or that no listing has shown yet, is kept past it. + /// A listing drops the hashes it shows failed only, or paid once the invoice has expired, so the other rail can't + /// pay it again. Paid hashes with no known expiry (kept before expiries were, or whose invoice can't be parsed) are + /// dropped oldest first only past this many per key. A hash whose payment may still be pending, or that no listing + /// has shown yet, always stays. private static let railAttemptsLimit = 500 /// Payment hashes sent over the node, kept across launches: while the node can't list its payments (stopped or not @@ -1179,23 +1192,26 @@ class LightningService: NodeLifecycleService { } } - /// Past the limit, drops the oldest hashes a listing showed as failed, or as paid with an expired or unknown - /// invoice expiry, so a payment that may still be pending, or a paid one whose invoice can still be paid, keeps - /// holding the other rail. Callers hold `eventCallbackLock`. + /// Drops the hashes a listing showed as `failed` only, or as `paid` with an expired invoice, and past the limit the + /// oldest paid ones with no known expiry. Callers pass no hash whose payment may still be pending, so it keeps + /// holding the other rail, as does a paid one whose invoice can still be paid. Callers hold `eventCallbackLock`. private func trimSettledAttempts(paid: Set, failed: Set, key: String) { - guard let hashes = railAttemptsStore.stringArray(forKey: key), hashes.count > Self.railAttemptsLimit else { return } + guard let hashes = railAttemptsStore.stringArray(forKey: key) else { return } let expiries = attemptExpiries(key: key) let nowSeconds = Int(now().timeIntervalSince1970) - var excess = hashes.count - Self.railAttemptsLimit + let listed = hashes.filter { hash in + !failed.contains(hash) && !(paid.contains(hash) && expiries[hash].map { $0 <= nowSeconds } == true) + } + var excess = listed.count - Self.railAttemptsLimit var kept: [String] = [] - for hash in hashes { - let expired = expiries[hash].map { $0 <= nowSeconds } ?? true - if excess > 0, failed.contains(hash) || paid.contains(hash) && expired { + for hash in listed { + if excess > 0, paid.contains(hash), expiries[hash] == nil { excess -= 1 } else { kept.append(hash) } } + guard kept.count < hashes.count else { return } railAttemptsStore.set(kept, forKey: key) railAttemptsStore.set(expiries.filter { kept.contains($0.key) }, forKey: Self.expiriesKey(key)) } @@ -1282,24 +1298,22 @@ class LightningService: NodeLifecycleService { railAttemptsStore.set(seeded + kept, forKey: Self.ldkAttemptsKey) railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } - let failedOnly = hashes(.failed).subtracting(hashes(.succeeded)).subtracting(hashes(.pending)) - removeAttempts(failedOnly.filter { ldkAttemptRecordedAt[$0, default: 0] <= recordsBefore }, key: Self.ldkAttemptsKey) - trimSettledAttempts(paid: hashes(.succeeded), failed: [], key: Self.ldkAttemptsKey) + let failedOnly = hashes(.failed).subtracting(hashes(.succeeded)).subtracting(hashes(.pending)).subtracting(ldkSendsInFlight) + .filter { ldkAttemptRecordedAt[$0, default: 0] <= recordsBefore } + trimSettledAttempts(paid: hashes(.succeeded), failed: failedOnly, key: Self.ldkAttemptsKey) + failedOnly.forEach { ldkAttemptRecordedAt[$0] = nil } } } - /// Trims only the rail's paid and failed hashes past the limit, so a payment that may still be pending, or a - /// send in flight that the listing predates, keeps holding the node. + /// Trims the rail's paid and failed-only hashes, so a payment that may still be pending, or a send in flight that + /// the listing predates, keeps holding the node. private func keepRailAttempts(from payments: [ChannelFreeRailPayment]) { let outbound = payments.filter { $0.direction == .outbound } let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.paymentHash.lowercased() }) } eventCallbackLock.withLock { let live = hashes(.pending).union(railSendsInFlight) - trimSettledAttempts( - paid: hashes(.succeeded).subtracting(live), - failed: hashes(.failed).subtracting(live), - key: Self.railAttemptsKey - ) + let paid = hashes(.succeeded).subtracting(live) + trimSettledAttempts(paid: paid, failed: hashes(.failed).subtracting(paid).subtracting(live), key: Self.railAttemptsKey) } } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 90ea9ef4b..f7e9bf135 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -432,6 +432,24 @@ extension ChannelFreeRailTests { XCTAssertFalse(kept.contains(hash), "The paid Spark hash whose invoice expired was kept") } + func testSparkHashesListedFailedOnlyOrPaidWithAnExpiredInvoiceAreDroppedUnderTheCap() async { + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: 1000) } + service.recordRailAttempt(paymentHash: Self.otherHash(1), expiresAt: 1000) + service.recordRailAttempt(paymentHash: Self.otherHash(2), expiresAt: 1001) + service.recordRailAttempt(paymentHash: Self.otherHash(3)) + service.recordRailAttempt(paymentHash: Self.otherHash(4)) + rail.payments = (1 ... 3).map { + Bitkit.ChannelFreeRailPayment(paymentHash: Self.otherHash($0), direction: .outbound, status: .succeeded, preimage: nil) + } + [Bitkit.ChannelFreeRailPayment(paymentHash: Self.otherHash(4), direction: .outbound, status: .failed, preimage: nil)] + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, [Self.otherHash(2), Self.otherHash(3)], "Expired or failed Spark hashes stayed, or live ones went") + } + /// Records 500 newer Spark sends after `hash` and lists them all as paid, so a node send trims past the cap. private func listPaidSparkSendBehindTheCap(_ hash: String) async throws { let service = Bitkit.LightningService.shared @@ -444,7 +462,7 @@ extension ChannelFreeRailTests { rail.sentBolt11 = nil } - func testSparkHashesListedAsFailedAreTrimmedPastTheCap() async { + func testSparkHashesListedAsFailedAreDroppedPastTheCapToo() async { let service = Bitkit.LightningService.shared (1 ... 501).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } rail.payments = (1 ... 501).map { @@ -455,8 +473,7 @@ extension ChannelFreeRailTests { await assertReachesTheNode() let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] - XCTAssertEqual(kept.count, 500, "Failed Spark hashes stayed past the cap") - XCTAssertFalse(kept.contains(Self.otherHash(1)), "The oldest failed Spark hash was kept") + XCTAssertEqual(kept, [], "Failed Spark hashes stayed") } func testASendSparkRefusedAfterItWasRecordedIsNotHeldWhileSparkCannotList() async { From edeec8df2bb3dfaa6535ee9ecdbbb26b52d74fe9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:35:11 +0100 Subject: [PATCH 193/378] fix: claim an older spark funding only at its own approval --- Bitkit/ViewModels/SparkTransferFlow.swift | 10 +++++++ Bitkit/Views/Transfer/SpendingConfirm.swift | 2 +- BitkitTests/SparkTransferFlowTests.swift | 29 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 1d4da3860..6dcd74040 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -303,6 +303,16 @@ final class SparkTransferFlow { approvals[attemptId] = approved } + /// Ties the approval to the unresolved funding only when the swipe that failed with `error` created it. A swipe + /// refused before that, as blocked by or a duplicate of an earlier funding, finds the earlier one unresolved, and + /// after a relaunch nothing is tied to it yet, so its early claim must still ask. + func bindApproval(to attemptId: UUID, after error: Error) { + switch error { + case OnchainSendAttemptError.blocked, OnchainSendAttemptError.duplicate: return + default: bindApproval(to: attemptId) + } + } + /// Starts the claim of a funding whose outcome was unknown at the swipe, at the ceiling and speed then approved; with /// nothing approved for that funding, an early claim asks first. func onRecoveredBroadcast(txid: String, attemptId: UUID) { diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 8df817fc6..fa8e7437f 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -175,7 +175,7 @@ struct SpendingConfirm: View { throw SpendingFeesIncreasedError() } catch { if let attemptId = await OnchainSendAttemptService.shared.unresolvedSparkFundingAttemptId() { - flow.bindApproval(to: attemptId) + flow.bindApproval(to: attemptId, after: error) } Logger.error("Failed to transfer to spending: \(error)", context: "SpendingConfirm") app.toast( diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 02823bbec..2d494d728 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -284,6 +284,35 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "The first funding claimed early at the rejected swipe's ceiling") } + @MainActor + func testASwipeRefusedForAnOlderFundingAfterARelaunchLeavesItsEarlyClaimAsking() async { + // After a relaunch nothing is tied to the older funding still unresolved, which refuses the new swipe. + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let older = UUID() + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: older, after: OnchainSendAttemptError.blocked(nil)) + + flow.onRecoveredBroadcast(txid: "txid", attemptId: older) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .repriced(feeSats: 800), "The older funding claimed early at the refused swipe's ceiling") + } + + @MainActor + func testASwipeWhoseFundingIsLeftUnresolvedTiesItsApprovalToIt() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let attemptId = UUID() + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: attemptId, after: OnchainSendAttemptError.unresolved) + + flow.onRecoveredBroadcast(txid: "txid", attemptId: attemptId) + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .adding, "The funding this swipe left unresolved lost the approval it gave") + } + @MainActor func testARecoveredFundingWithNoApprovalAsksBeforeAnEarlyClaim() async { let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) From 8fcf7c55505b6e5aa189ffe3fc9d1c65cdf2aaa3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:38:49 +0100 Subject: [PATCH 194/378] fix: take the exit hourglass from the android vector --- .../hourglass-simple.imageset/Contents.json | 3 ++- .../hourglass-simple.pdf | Bin 4563 -> 0 bytes .../hourglass-simple.svg | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) delete mode 100644 Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf create mode 100644 Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json index f90a70255..0838f5531 100644 --- a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json +++ b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "hourglass-simple.pdf", + "filename" : "hourglass-simple.svg", "idiom" : "universal" } ], @@ -10,6 +10,7 @@ "version" : 1 }, "properties" : { + "preserves-vector-representation" : true, "template-rendering-intent" : "template" } } diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf deleted file mode 100644 index cc1ee0ef03c6bc17c2adddd35e19700ec3e1225c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4563 zcmai2c{r5c+c#O8k|j$>CMvrz!(dVxYh)XR>+^iha~`RpCJvF11p*-;Nf6fB0SE$J zx%I^odnE+`xt1Xe+NVre_6E9F1K*@ASg^m z_7|c9k|NI(6o4K$cQncwNJy(mz#vbKb97<7wj;y=0OO9{r(JPIw#+PgQ*^Yn`et{I17FC=-RBW0r{=>gYmXG)EPvZp z)a2*8oHn_Tv7dcN73D&$BI%yT@GQs5QrBC>mW_9Z=4ekK+^wDqGdn};+vyd!f%Q67 z*%UUF+{L(m0;m6!XPbl7xaAPJ-@-;99hay~{V!Btb;_+ZiB=F3|H8ZWooDRDmR%s6!H zd1afi`QQjB^B7`7yi6qy&Zpe#atfiM6HfQqpJRafxj(?kpW2mf^$E{zAc7gl-|p~) z_9%!p6u~h@ReR?Wl4{%kd)DdG09e)uX@99K+UmftSztZDB@iHa{GrwHW||wbyh#Bd z26{b^$U{1eGF=pxEj8rq&2Ao(BecrV$m48GN6#Lu229YgBc37Q`m`g8SCkjB!UkXk zOpQzth?4`B1A?b$V^Q2CM|nCDBu{SpQ_ms^?5COXJ6C4S2(o5$H#_rZ?RNQz)Mxzj zzG@PLV{lm_a)?iudvDF?A{BEjUv z>;?1!j6UG$^nDWhb%Mat%%lJWKV6vBToYX5-MvlnARSi>utK=<)36ePYJ&Kc7_%c> z!z9&`&Wf-`gi1U`TJyV!R)misnze}PL}mVHoJQos59a`wV};?k6kNQt(w@2@e`<{J zynSU*b1E@yN~2ZxwsJP|xbSM6(EX&!)azFpRQ}QW2D%zw6=l=AHZRj_&<*R|>TQV; z?B?nA=`K&O6tooaGdxHjJv^e=jtt0qqBr&sneD2BQb(CY7ulDdDoDyPDj-~6OL)t*NipB4dGgF z(*~t)&6WDJdDaCTh8l*#*WtO)f^|&5$0HADNwiHI(qfC2UoG(2MNJ}>Fv}V}JYp-B z+x(@exQ27NN!Ww{zPhn+&wWd5t8+PVM38fc<05!oqR*0GH%HBA)Tu9EWYh8-5|PS1 zoMMvFnu1B8hk-FV4u$p&j^`cX?eX^7_BU$GYiK9VPjFUS4#Y2=*|hcMYDrbd%guhD zFECg*v^0`D2pclbPR*Sx*jIm0(DgcOSafh@D0OJ`jb%}rVw=Tb0HEC;6J_Q4B&%z- zB|92bKX;;&qti{mK%jQ;Vy<^?$a6Lu+4s2j4)2>T7@d5i5z})vcJk5V!gS0X%-tn% zsj$j#OKE= zmHXCTtiYAn$C<7JUZHLUxKq|cNs4_+9}TXC=jOk+>+6Z_q*oo(^n+0De3^%n1z_~x60M4v0$gIo7@3U`v%Yc|ZkOfH429s4@DBK!1> zt;ab2ldvECVZ^~SVBSB%m|3qV$m7 z1LeIHy@3fg#r!y&c$?4G3kGplUYNJ-ZbJ4CFfJ{gP}7MJT-_}-2nalcBb zf+Qt0Q zG!&!(&_qfQ(iam6=hl3$TMex}9H0L>|D26@P3r|bJ4RAtEWIc`C4C~%E+HZDg0|PS znM{TAK;{uU1XnTCo?4ev)1Qe zHytD2IPK-*n_-qAgtZUZh!l_?l&6*V@e-fFyN-?$Ee^Jh6yF)T(|>oWW95p`A#%I( z(`x;6i|V#&$d1(x!_M(l{G?BgQ3b(#ZLu|PKSgu#{I~Pio$_(N($}?{-GFz19sp)M zrt(eT9{tW~l34~)0Xb(pXjG`zP`v*A?Az>v&eaf}Nc%Iwa_dV4vx_YvGesXAI+urR z#%z+iGDd<%Hc8#2iZa8N+N|wfw>h_^d$T8}@h9+jL6{E5Nat3}-n|=x`7Vd^TQ?l3 zbF+)|@+xMf<;3CN-z_9%()ZJ(EnV!rH2F2G9UQOy`Vq;l;2-A=eNCmXYFK5>m$bu% zGMR@a@kp5ecKC2 zEVo!|a4+o2UFa}KEdD&2R+irMs`;&Z=YiUWc2lZ$+xYi4uN`M5iM?}%E6!b$&c0#$ z7x$+3z;D+YJw8b6uSNu*seJ=Q>Aoy$mrsSP(e6JPK zcDqSq8^WaFjpUO4`hK*fR%^8{+ivi4DevdcTr$TU8#%8;3TSW) zqhCeFr=*Fn3@Q)FnJJ_zCML%prt03i%z)_iq|)ajhMfhb`1Scz!WCyTY5i_`oocYP zaO|oxa)43wVW^NkP?|Llh@q>{_a?aHoo{YL7Ryj4nAVeiR)+V{-;e!sqyl81&dIRk z$1{A`y3w{`nmN;4k(4Q(u_6U?E>&mF1U;&{Yu&2e9gTmgks;3;UD6ew>I6dn$j=}0@i#2>2Q~$PHI$W=P#$Po5JjtA2bojC{8%jg$BUv}RSCGOH*gf~ z`r{4q<6(mWNlHK^DTAy>DU$T3npMU+Vcl=IqHKP8D?>n(l-GYckiCAVfc=%?|JA@U zlpudgUqD2Hb{QL-IcNue-{OJv7Af(-I2ZmTRG^whjOu91z9uo+v*B3#5%3~W4|B^_ zW#ZjuWg>Bx(7d)mymI?A_V8KP!j3>j`>23fdz<{k%zCoYF}T7IhQzo|@(ijvFRCo> z!C1<9heeI)!`cDq)j~Y?4rC_ME!d^Bj%Sx$a{Kxu@9A61ki@;vv~eY8x;V#L4&M83 zDlca^i9eh8+I-@kUbmF`rdYw&>pQh37;!yN8s1fc_3=ofaUh>o*QrubY*aE$JOsEE zU=lH1bxr-5ok7*a2yH37=!j-ebdj>~n<*%=N4dt|x-d_=w(N0^L7h{=5?_mH&AGFt z{p=Z_LM9yZjM5oJhTLEst6XCiMp=i~W##tH7wC~-)pX<~eujpSqFndIk&8U-DU3wc z(S+a=p%$|aw}q5Yi4Zw zv~5PKIDLa$)-sMLgE(dBisgB13l3nD{h4j#ubPXzWsTNpK#G{c( z2|4Zjy22p`uJ7)LAEjqgShFy&ft2$gr)Q2?mp#B$cVEr1sR%0I9D@QLO(T3>1M+A5 zSFRx(xKC6|oQN}$J5dczQg8ThWI^>IX_JIE7w%_jKjwYLGE%o|=e=J>3Zv4~(ag6= zxZ0J7Io&swEf81H>4;*nEm{`(O^qxZyXvR%ebCXXA)!-Cfmg&6IkL_~*3Z^j*oC5UTg zGd*#}5c@}f@<6Kq^MiGZc#K`t(m;;7Ajd#EmqiOQDBAr_F&fXRgrXjJ9lSXPJ-KX} z7z(^!_-4q&O9Ud4GT;mZc!g+0_qb%Nf|qI*r@caW*JzI%W9(29f-6tE-!_s4$0!)_ z1m-^Kull&eY^%5TT=6(WH+D%s{!B#vEoQ`w`c2k^%PfS-pqzi(s4F~`3+Am?>j{1_N+`Ve0y z5P)wD_7`QXZ>26%pH)T1>;|6UXozs?L8}=GC9Xf^4%HTPZm{AYmhCxEDnvf zUDlAA0v&IOZ}{M>AnU~ITb%tO^I(mhBnbchjqm^9ISMiSyku#kSUqfevq2vzY~beZ|L9I??~6t9$37) z4cY@l;YJDj8W3_@ei&SSK~Nbud82oejE+>2F4pr ashiwK4;;!J_oEZiP&p{@{CQPfwf_P4#PHz& diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg new file mode 100644 index 000000000..510ad0b7e --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg @@ -0,0 +1,4 @@ + + + + From 7b73b6684b84c4c5746e1916f5e65a7a29245264 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:43:02 +0100 Subject: [PATCH 195/378] fix: match the exit screens across apps --- Bitkit/Managers/SparkExitManager.swift | 10 +- .../Localization/ar.lproj/Localizable.strings | 1 - .../Localization/ca.lproj/Localizable.strings | 1 - .../Localization/cs.lproj/Localizable.strings | 1 - .../Localization/de.lproj/Localizable.strings | 1 - .../Localization/el.lproj/Localizable.strings | 1 - .../Localization/en.lproj/Localizable.strings | 3 +- .../es-419.lproj/Localizable.strings | 1 - .../Localization/es.lproj/Localizable.strings | 1 - .../Localization/fr.lproj/Localizable.strings | 1 - .../Localization/it.lproj/Localizable.strings | 1 - .../Localization/nl.lproj/Localizable.strings | 1 - .../Localization/pl.lproj/Localizable.strings | 1 - .../pt-BR.lproj/Localizable.strings | 1 - .../Localization/pt.lproj/Localizable.strings | 1 - .../Localization/ru.lproj/Localizable.strings | 1 - .../Services/SparkExit/SparkExitDriver.swift | 17 +- .../Settings/Advanced/SparkExitViews.swift | 171 +++++++++++------- 18 files changed, 125 insertions(+), 90 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 27b3d4f6e..ce6adfc93 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -38,6 +38,8 @@ final class SparkExitManager { private(set) var feeBumpOptions: [SparkExitFeeBumpOption] = [] private(set) var isPricingFeeBump = false private(set) var feeBumpError: String? + /// Every higher rate would leave leaves on Spark, so none is offered. + private(set) var isFeeBumpLeavingLeaves = false /// What the running exit shows about its fee funding while it lacks some. var fundingState: SparkExitFundingState? { Self.isFundingOfExit(fundingExitCreatedAt, record: record) ? storedFundingState : nil @@ -577,6 +579,7 @@ final class SparkExitManager { guard let record, let chain else { return } isPricingFeeBump = true feeBumpError = nil + isFeeBumpLeavingLeaves = false defer { isPricingFeeBump = false } await refreshNetworkFees(chain: chain) guard let networkFees else { @@ -607,8 +610,11 @@ final class SparkExitManager { } feeBumpOptions = options.filter { $0.extraSats != nil } if feeBumpOptions.isEmpty { - let key = leavesBehind ? "lightning__spark_exit__fee_bump_leaves_behind" : "lightning__spark_exit__fee_bump_error" - feeBumpError = t(key) + if leavesBehind { + isFeeBumpLeavingLeaves = true + } else { + feeBumpError = t("lightning__spark_exit__fee_bump_error") + } } } diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index d800fb1d7..e045e568c 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -396,7 +396,6 @@ "lightning__spark_exit__fee_bump_button" = "ادفع رسومًا أعلى"; "lightning__spark_exit__fee_bump_title" = "ادفع\nرسومًا أعلى"; "lightning__spark_exit__fee_bump_text" = "يدفع هذا الخروج {rate} sat/vB، وهو أقل مما تطلبه الشبكة الآن. اختر معدلًا أعلى وسيعيد Bitkit إرسال الخطوات المنتظرة. يدفع الفرق من المدخرات: المبلغ الظاهر بجانب كل سرعة."; -"lightning__spark_exit__fee_bump_loading" = "جارٍ حساب تكلفة رسوم أعلى…"; "lightning__spark_exit__fee_bump_error" = "تعذّر تسعير رسوم أعلى. حاول مرة أخرى."; "lightning__spark_exit__fee_bump_confirm" = "استخدم هذه الرسوم"; "lightning__spark_exit__leftover_waiting" = "سيصل {amount} من فكة الرسوم إلى مدخراتك عند انخفاض رسوم الشبكة."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 06d5f044a..7d40f72e1 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -433,7 +433,6 @@ "lightning__spark_exit__fee_bump_button" = "Paga una comissió més alta"; "lightning__spark_exit__fee_bump_title" = "Paga una\ncomissió més alta"; "lightning__spark_exit__fee_bump_text" = "Aquesta sortida paga {rate} sat/vB, menys del que la xarxa demana ara. Tria una taxa més alta i Bitkit tornarà a enviar els passos pendents. Paga la diferència des d'estalvis: l'import al costat de cada velocitat."; -"lightning__spark_exit__fee_bump_loading" = "Calculant quant costa una comissió més alta…"; "lightning__spark_exit__fee_bump_error" = "No s'ha pogut calcular el preu d'una comissió més alta. Torna-ho a provar."; "lightning__spark_exit__fee_bump_confirm" = "Fes servir aquesta comissió"; "lightning__spark_exit__leftover_waiting" = "{amount} de canvi de comissions arribarà als teus estalvis quan baixin les comissions de xarxa."; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 32b7d30e0..2c9fe1124 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -449,7 +449,6 @@ "lightning__spark_exit__fee_bump_button" = "Zaplatit vyšší poplatek"; "lightning__spark_exit__fee_bump_title" = "Zaplatit\nvyšší poplatek"; "lightning__spark_exit__fee_bump_text" = "Tento výstup platí {rate} sat/vB, méně, než síť nyní požaduje. Zvolte vyšší sazbu a Bitkit čekající kroky odešle znovu. Rozdíl zaplatí z úspor: částku uvedenou u každé rychlosti."; -"lightning__spark_exit__fee_bump_loading" = "Zjišťujeme, kolik stojí vyšší poplatek…"; "lightning__spark_exit__fee_bump_error" = "Cenu vyššího poplatku se nepodařilo zjistit. Zkuste to znovu."; "lightning__spark_exit__fee_bump_confirm" = "Použít tento poplatek"; "lightning__spark_exit__leftover_waiting" = "{amount} vrácených poplatků dorazí do vašich úspor, až síťové poplatky klesnou."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 4a9be84e7..bfe7ffd4e 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -447,7 +447,6 @@ "lightning__spark_exit__fee_bump_button" = "Höhere Gebühr zahlen"; "lightning__spark_exit__fee_bump_title" = "Höhere Gebühr\nzahlen"; "lightning__spark_exit__fee_bump_text" = "Dieser Ausstieg zahlt {rate} sat/vB, weniger als das Netzwerk derzeit verlangt. Wähle einen höheren Satz, und Bitkit sendet die wartenden Schritte erneut. Die Differenz wird aus dem Sparkonto bezahlt: der Betrag neben jeder Geschwindigkeit."; -"lightning__spark_exit__fee_bump_loading" = "Kosten einer höheren Gebühr werden berechnet…"; "lightning__spark_exit__fee_bump_error" = "Höhere Gebühr konnte nicht berechnet werden. Versuche es erneut."; "lightning__spark_exit__fee_bump_confirm" = "Diese Gebühr nutzen"; "lightning__spark_exit__leftover_waiting" = "{amount} Gebührenwechselgeld erreichen dein Sparkonto, wenn die Netzwerkgebühren sinken."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 7bb4453aa..16eb51f4c 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -420,7 +420,6 @@ "lightning__spark_exit__fee_bump_button" = "Πληρωμή υψηλότερου τέλους"; "lightning__spark_exit__fee_bump_title" = "Πληρώστε\nυψηλότερο τέλος"; "lightning__spark_exit__fee_bump_text" = "Αυτή η έξοδος πληρώνει {rate} sat/vB, λιγότερο από όσο ζητά τώρα το δίκτυο. Επιλέξτε υψηλότερο ρυθμό και το Bitkit στέλνει ξανά τα βήματα σε αναμονή. Πληρώνει τη διαφορά από τις αποταμιεύσεις: το ποσό δίπλα σε κάθε ταχύτητα."; -"lightning__spark_exit__fee_bump_loading" = "Υπολογισμός κόστους υψηλότερου τέλους…"; "lightning__spark_exit__fee_bump_error" = "Δεν ήταν δυνατός ο υπολογισμός υψηλότερου τέλους. Δοκιμάστε ξανά."; "lightning__spark_exit__fee_bump_confirm" = "Χρήση αυτού του τέλους"; "lightning__spark_exit__leftover_waiting" = "{amount} ρέστα τελών θα φτάσουν στις αποταμιεύσεις σας όταν μειωθούν τα τέλη δικτύου."; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 455779b25..64fa9709d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -443,13 +443,14 @@ "lightning__spark_exit__fee_bump_button" = "Pay a higher fee"; "lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee"; "lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed."; -"lightning__spark_exit__fee_bump_loading" = "Working out what a higher fee costs…"; "lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again."; "lightning__spark_exit__fee_bump_confirm" = "Use this fee"; "lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop."; "lightning__spark_exit__cancelled_title" = "Exit\ncancelled"; "lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings."; "lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again."; +"lightning__spark_exit__cancel_text" = "Spark goes back to how it was. If a payment reached the exit's fee funding address, Bitkit sends it back to savings."; +"lightning__spark_exit__cancel_title" = "Cancel exit?"; "lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop."; "lightning__spark_learn__title" = "Transfer\nspeed"; "lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index ec48cc8d6..93e4f244f 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -450,7 +450,6 @@ "lightning__spark_exit__fee_bump_button" = "Pagar una comisión más alta"; "lightning__spark_exit__fee_bump_title" = "Paga una\ncomisión más alta"; "lightning__spark_exit__fee_bump_text" = "Esta salida paga {rate} sat/vB, menos de lo que pide la red ahora. Elige una tasa más alta y Bitkit vuelve a enviar los pasos pendientes. Paga la diferencia desde ahorros: la cantidad junto a cada velocidad."; -"lightning__spark_exit__fee_bump_loading" = "Calculando cuánto cuesta una comisión más alta…"; "lightning__spark_exit__fee_bump_error" = "No se pudo calcular una comisión más alta. Vuelve a intentarlo."; "lightning__spark_exit__fee_bump_confirm" = "Usar esta comisión"; "lightning__spark_exit__leftover_waiting" = "{amount} de cambio de comisiones llegará a tus ahorros cuando bajen las comisiones de red."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index ac0bcb4ff..b1695eb91 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -434,7 +434,6 @@ "lightning__spark_exit__fee_bump_button" = "Pagar una comisión más alta"; "lightning__spark_exit__fee_bump_title" = "Paga una\ncomisión más alta"; "lightning__spark_exit__fee_bump_text" = "Esta salida paga {rate} sat/vB, menos de lo que pide la red ahora. Elige una tasa más alta y Bitkit vuelve a enviar los pasos pendientes. Paga la diferencia desde ahorros: la cantidad junto a cada velocidad."; -"lightning__spark_exit__fee_bump_loading" = "Calculando cuánto cuesta una comisión más alta…"; "lightning__spark_exit__fee_bump_error" = "No se pudo calcular una comisión más alta. Inténtalo de nuevo."; "lightning__spark_exit__fee_bump_confirm" = "Usar esta comisión"; "lightning__spark_exit__leftover_waiting" = "{amount} de cambio de comisiones llegará a tus ahorros cuando bajen las comisiones de red."; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index dc20b9c69..a7f0ec066 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -450,7 +450,6 @@ "lightning__spark_exit__fee_bump_button" = "Payer des frais plus élevés"; "lightning__spark_exit__fee_bump_title" = "Payer des\nfrais plus élevés"; "lightning__spark_exit__fee_bump_text" = "Cette sortie paie {rate} sat/vB, moins que ce que le réseau demande actuellement. Choisissez un taux plus élevé et Bitkit renvoie les étapes en attente. La différence est payée depuis l'épargne : le montant affiché à côté de chaque vitesse."; -"lightning__spark_exit__fee_bump_loading" = "Calcul du coût de frais plus élevés…"; "lightning__spark_exit__fee_bump_error" = "Impossible d'estimer des frais plus élevés. Réessayez."; "lightning__spark_exit__fee_bump_confirm" = "Utiliser ces frais"; "lightning__spark_exit__leftover_waiting" = "{amount} de monnaie de frais arrivera dans votre épargne lorsque les frais de réseau baisseront."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index bba90adf6..925f41e3a 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -443,7 +443,6 @@ "lightning__spark_exit__fee_bump_button" = "Paga una commissione più alta"; "lightning__spark_exit__fee_bump_title" = "Paga una\ncommissione più alta"; "lightning__spark_exit__fee_bump_text" = "Questa uscita paga {rate} sat/vB, meno di quanto chiede ora la rete. Scegli un tasso più alto e Bitkit invia di nuovo i passaggi in attesa. Paga la differenza dai risparmi: l'importo accanto a ogni velocità."; -"lightning__spark_exit__fee_bump_loading" = "Calcolo del costo di una commissione più alta…"; "lightning__spark_exit__fee_bump_error" = "Impossibile calcolare una commissione più alta. Riprova."; "lightning__spark_exit__fee_bump_confirm" = "Usa questa commissione"; "lightning__spark_exit__leftover_waiting" = "{amount} di resto delle commissioni arriverà nei tuoi risparmi quando le commissioni di rete scenderanno."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 05a203bf6..58ab4aae2 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -447,7 +447,6 @@ "lightning__spark_exit__fee_bump_button" = "Hogere vergoeding betalen"; "lightning__spark_exit__fee_bump_title" = "Betaal een\nhogere vergoeding"; "lightning__spark_exit__fee_bump_text" = "Deze exit betaalt {rate} sat/vB, minder dan het netwerk nu vraagt. Kies een hoger tarief en Bitkit verstuurt de wachtende stappen opnieuw. Het verschil wordt uit je spaargeld betaald: het bedrag naast elke snelheid."; -"lightning__spark_exit__fee_bump_loading" = "Berekenen wat een hogere vergoeding kost…"; "lightning__spark_exit__fee_bump_error" = "Kon de prijs van een hogere vergoeding niet berekenen. Probeer het opnieuw."; "lightning__spark_exit__fee_bump_confirm" = "Deze vergoeding gebruiken"; "lightning__spark_exit__leftover_waiting" = "{amount} aan wisselgeld van de vergoeding komt in je spaargeld wanneer de netwerkkosten dalen."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 9e174c438..4540d7aaf 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -449,7 +449,6 @@ "lightning__spark_exit__fee_bump_button" = "Zapłać wyższą opłatę"; "lightning__spark_exit__fee_bump_title" = "Zapłać\nwyższą opłatę"; "lightning__spark_exit__fee_bump_text" = "To wyjście płaci {rate} sat/vB, mniej niż sieć wymaga teraz. Wybierz wyższą stawkę, a Bitkit ponownie wyśle oczekujące kroki. Różnicę zapłaci z oszczędności: kwota obok każdej prędkości."; -"lightning__spark_exit__fee_bump_loading" = "Obliczanie kosztu wyższej opłaty…"; "lightning__spark_exit__fee_bump_error" = "Nie udało się wycenić wyższej opłaty. Spróbuj ponownie."; "lightning__spark_exit__fee_bump_confirm" = "Użyj tej opłaty"; "lightning__spark_exit__leftover_waiting" = "{amount} reszty z opłat trafi do Twoich oszczędności, gdy opłaty sieciowe spadną."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index dc086616d..f12f6e3c8 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -449,7 +449,6 @@ "lightning__spark_exit__fee_bump_button" = "Pagar uma taxa maior"; "lightning__spark_exit__fee_bump_title" = "Pagar uma\ntaxa maior"; "lightning__spark_exit__fee_bump_text" = "Esta saída paga {rate} sat/vB, menos do que a rede pede agora. Escolha uma taxa maior e a Bitkit envia novamente as etapas pendentes. Ela paga a diferença da poupança: o valor ao lado de cada velocidade."; -"lightning__spark_exit__fee_bump_loading" = "Calculando quanto custa uma taxa maior…"; "lightning__spark_exit__fee_bump_error" = "Não foi possível calcular o preço de uma taxa maior. Tente novamente."; "lightning__spark_exit__fee_bump_confirm" = "Usar esta taxa"; "lightning__spark_exit__leftover_waiting" = "{amount} de troco das taxas chegará à sua poupança quando as taxas de rede caírem."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 0ae176a35..51375186a 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -447,7 +447,6 @@ "lightning__spark_exit__fee_bump_button" = "Pagar uma taxa mais alta"; "lightning__spark_exit__fee_bump_title" = "Pagar uma\ntaxa mais alta"; "lightning__spark_exit__fee_bump_text" = "Esta saída paga {rate} sat/vB, menos do que a rede pede agora. Escolha uma taxa mais alta e a Bitkit volta a enviar os passos em espera. Paga a diferença da poupança: o montante ao lado de cada velocidade."; -"lightning__spark_exit__fee_bump_loading" = "A calcular quanto custa uma taxa mais alta…"; "lightning__spark_exit__fee_bump_error" = "Não foi possível calcular o preço de uma taxa mais alta. Tente novamente."; "lightning__spark_exit__fee_bump_confirm" = "Usar esta taxa"; "lightning__spark_exit__leftover_waiting" = "{amount} de troco das taxas chegará à sua poupança quando as taxas de rede descerem."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 1215c0b95..3d4c2794b 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -444,7 +444,6 @@ "lightning__spark_exit__fee_bump_button" = "Заплатить больше"; "lightning__spark_exit__fee_bump_title" = "Повысить\nкомиссию"; "lightning__spark_exit__fee_bump_text" = "Этот выход платит {rate} sat/vB, меньше, чем сейчас требует сеть. Выберите более высокую ставку, и Bitkit заново отправит ожидающие шаги. Разницу он оплатит из сбережений: сумма указана рядом с каждой скоростью."; -"lightning__spark_exit__fee_bump_loading" = "Считаем стоимость более высокой комиссии…"; "lightning__spark_exit__fee_bump_error" = "Не удалось рассчитать более высокую комиссию. Попробуйте снова."; "lightning__spark_exit__fee_bump_confirm" = "Использовать эту комиссию"; "lightning__spark_exit__leftover_waiting" = "{amount} сдачи с комиссий поступит в ваши сбережения, когда сетевые комиссии снизятся."; diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 6c6a9d86f..3b07b9b51 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -111,11 +111,16 @@ struct SparkExitDriver { try save(record) return record } - guard let recovering else { return try saveIfChanged(record, from: before) } + guard let recovering else { + await updateClaimableHeight(&record) + return try saveIfChanged(record, from: before) + } // Nothing to fund when every leaf is recovered cooperatively. if let shortfall = record.fundingShortfallSats { guard try await adoptFunding(&record, requiredSats: shortfall) else { await countShortOwnPayment(&record) + // The steps already out keep their countdown while the top-up is awaited. + await updateClaimableHeight(&record) return try saveIfChanged(record, from: before) } } @@ -151,14 +156,18 @@ struct SparkExitDriver { } } } - // Without the tip the countdown keeps its last target rather than jump to another measure. + await updateClaimableHeight(&record) + try save(record) + return record + } + + /// Without the tip the countdown keeps its last target rather than jump to another measure. + private func updateClaimableHeight(_ record: inout SparkExitRecord) async { if let tip = try? await chain.tipHeight() { record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response, tip: tip) } } else if record.claimableAtHeight == nil { record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response) } } - try save(record) - return record } private func saveIfChanged(_ record: SparkExitRecord, from before: SparkExitRecord) throws -> SparkExitRecord { diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index cb40fd64c..5809c9110 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -12,10 +12,11 @@ struct SparkExitIntroView: View { .padding(.bottom, 16) DisplayText(t("lightning__spark_exit__intro_title"), accentColor: .brandAccent) + .padding(.top, 32) BodyMText(t("lightning__spark_exit__intro_text"), accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) + .padding(.top, 8) Spacer() @@ -70,6 +71,7 @@ struct SparkExitQuoteView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { DisplayText(t("lightning__spark_exit__quote_title"), accentColor: .brandAccent) + .padding(.top, 32) .padding(.bottom, 32) if let quote = exit.quote { @@ -97,7 +99,7 @@ struct SparkExitQuoteView: View { .accessibilityIdentifier("SparkExitQuoteBlocked") } } - .padding(.bottom, 16) + .padding(.bottom, 32) } CustomButton(title: t("common__continue"), isLoading: exit.isQuoting) { @@ -197,13 +199,14 @@ struct SparkExitFundingView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { DisplayText(t("lightning__spark_exit__funding_title"), accentColor: .brandAccent) + .padding(.top, 32) BodyMText( t("lightning__spark_exit__funding_text", variables: ["amount": currency.primaryAmountText(sats: fundingSats)]), accentFont: Fonts.bold ) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) + .padding(.top, 8) if let steps = exit.quote?.fundedSteps, steps > 1 { BodySText(t("lightning__spark_exit__funding_split", variables: ["count": "\(steps)"])) @@ -222,7 +225,7 @@ struct SparkExitFundingView: View { .padding(.top, 24) } } - .padding(.bottom, 16) + .padding(.bottom, 32) } if showsAddress { @@ -501,10 +504,11 @@ struct SparkExitConfirmView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { DisplayText(t("lightning__spark_exit__confirm_title"), accentColor: .brandAccent) + .padding(.top, 32) BodyMText(t("lightning__spark_exit__confirm_text"), accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) + .padding(.top, 8) .padding(.bottom, 32) if let quote = exit.quote { @@ -515,11 +519,8 @@ struct SparkExitConfirmView: View { SparkExitAmountRow(label: t("lightning__spark_exit__savings_fee"), sats: savingsPayment.feeSats) SparkExitAmountRow(label: t("lightning__spark_exit__savings_total"), sats: savingsPayment.totalSats) } - - SparkExitBackgroundNotes() - .padding(.top, 16) } - .padding(.bottom, 16) + .padding(.bottom, 32) } SwipeButton(title: t("lightning__spark_exit__swipe"), accentColor: .brandAccent) { @@ -559,6 +560,7 @@ struct SparkExitProgressView: View { @EnvironmentObject var currency: CurrencyViewModel private let exit = SparkExitManager.shared + @State private var isConfirmingCancel = false private var record: SparkExitRecord? { exit.record @@ -639,15 +641,16 @@ struct SparkExitProgressView: View { NavigationBar(title: t("lightning__spark_exit__nav_title")) .padding(.bottom, 16) - DisplayText(title, accentColor: .brandAccent) - .padding(.bottom, 16) - - BodyMText(text, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 24) - ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { + DisplayText(title, accentColor: .brandAccent) + .padding(.top, 32) + + BodyMText(text, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + .padding(.bottom, 24) + if let record { SparkExitSteps(record: record, tipHeight: exit.tipHeight) @@ -656,28 +659,10 @@ struct SparkExitProgressView: View { .padding(.top, 24) } - if record.isActive, record.isRebuiltFromChain == true { - BodySText(t("lightning__spark_exit__rebuilt_from_chain")) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitRebuiltFromChain") - } - - if record.isActive, record.isWaitingForLeaves { - BodySText(t("lightning__spark_exit__waiting_leaves")) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitWaitingLeaves") - } - - if record.isWaitingForOperators { - BodySText(t( - "lightning__spark_exit__waiting_operators", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))] - ), accentColor: .white, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitWaitingOperators") + if exit.isSavingsPaymentRefused { + BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent) + .padding(.top, 24) + .accessibilityIdentifier("SparkExitSavingsRefused") } if let address = record.funding.first?.address { @@ -704,24 +689,31 @@ struct SparkExitProgressView: View { } } - if record.leftBehindSats > 0 { + if record.isActive, record.isRebuiltFromChain == true { + BodySText(t("lightning__spark_exit__rebuilt_from_chain")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitRebuiltFromChain") + } + + if record.isWaitingForOperators { BodySText(t( - "lightning__spark_exit__left_behind_leaves", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))] + "lightning__spark_exit__waiting_operators", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))] ), accentColor: .white, accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) .padding(.top, 16) - .accessibilityIdentifier("SparkExitLeftBehind") + .accessibilityIdentifier("SparkExitWaitingOperators") } - if let lostText { - BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold) + if record.isActive, record.isWaitingForLeaves { + BodySText(t("lightning__spark_exit__waiting_leaves")) .fixedSize(horizontal: false, vertical: true) .padding(.top, 16) - .accessibilityIdentifier("SparkExitLostLeaves") + .accessibilityIdentifier("SparkExitWaitingLeaves") } - if record.isActive, let rejection = record.surfacedRejection { + if let rejection = record.surfacedRejection { BodySText(t("lightning__spark_exit__not_moving", variables: ["error": rejection]), textColor: .redAccent) .padding(.top, 16) .accessibilityIdentifier("SparkExitNotMoving") @@ -754,10 +746,21 @@ struct SparkExitProgressView: View { .accessibilityIdentifier("SparkExitOutsideExit") } - if exit.isSavingsPaymentRefused { - BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent) + if let lostText { + BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) .padding(.top, 16) - .accessibilityIdentifier("SparkExitSavingsRefused") + .accessibilityIdentifier("SparkExitLostLeaves") + } + + if record.leftBehindSats > 0 { + BodySText(t( + "lightning__spark_exit__left_behind_leaves", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLeftBehind") } if record.isActive { @@ -773,6 +776,11 @@ struct SparkExitProgressView: View { } } } + .padding(.bottom, 32) + } + .refreshable { + await SparkManager.shared.refreshState() + await exit.tick() } if record?.isActive == true, notifications.authorizationStatus == .denied { @@ -807,12 +815,7 @@ struct SparkExitProgressView: View { if exit.canCancel { CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { - // A funding address that cannot be read keeps the exit: a payment there is never orphaned. - if await exit.cancel() == .keep { - app.toast(type: .error, title: t("lightning__spark_exit__cancel_failed"), description: nil) - } else { - navigation.reset() - } + isConfirmingCancel = true } .padding(.bottom, 16) .accessibilityIdentifier("SparkExitCancel") @@ -826,9 +829,23 @@ struct SparkExitProgressView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .bottomSafeAreaPadding() - .refreshable { - await SparkManager.shared.refreshState() - await exit.tick() + .alert(t("lightning__spark_exit__cancel_title"), isPresented: $isConfirmingCancel) { + Button(t("lightning__spark_exit__cancel"), role: .destructive) { + Task { + // A funding address that cannot be read keeps the exit: a payment there is never orphaned. + switch await exit.cancel() { + case .keep: app.toast(type: .error, title: t("lightning__spark_exit__cancel_failed"), description: nil) + // Funding that reached the address is swept back on this screen, as the exit's leftover. + case .sweepFunding: break + case .forget: navigation.reset() + } + } + } + .accessibilityIdentifier("SparkExitCancelConfirm") + + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { + Text(t("lightning__spark_exit__cancel_text")) } .task { await SparkManager.shared.refreshState() @@ -881,19 +898,26 @@ struct SparkExitFeeBumpView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { DisplayText(t("lightning__spark_exit__fee_bump_title"), accentColor: .brandAccent) + .padding(.top, 32) BodyMText( t("lightning__spark_exit__fee_bump_text", variables: ["rate": "\(exit.record?.feeRateSatPerVbyte ?? 0)"]), accentFont: Fonts.bold ) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) + .padding(.top, 8) .padding(.bottom, 24) if exit.isPricingFeeBump { - BodyMText(t("lightning__spark_exit__fee_bump_loading")) + ProgressView() + .tint(.white32) + .frame(maxWidth: .infinity) + } else if exit.isFeeBumpLeavingLeaves { + BodyMText(t("lightning__spark_exit__fee_bump_leaves_behind")) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("SparkExitFeeBumpLeavesBehind") } else if let error = exit.feeBumpError { - BodyMText(error, textColor: .redAccent) + BodyMText(error) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("SparkExitFeeBumpError") } else { @@ -916,7 +940,7 @@ struct SparkExitFeeBumpView: View { .padding(.horizontal, -16) } } - .padding(.bottom, 16) + .padding(.bottom, 32) } if isInsufficient { @@ -925,14 +949,23 @@ struct SparkExitFeeBumpView: View { .accessibilityIdentifier("SparkExitFeeBumpInsufficient") } - CustomButton( - title: t(selected?.extraSats == 0 ? "lightning__spark_exit__fee_bump_confirm" : "lightning__spark_exit__fund_savings"), - isLoading: isPaying || exit.isPricingFeeBump - ) { - await pay() + if exit.feeBumpError != nil { + CustomButton(title: t("common__try_again")) { + await exit.loadFeeBumpOptions() + await priceSavingsPayments() + selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate + } + .accessibilityIdentifier("SparkExitFeeBumpRetry") + } else if !exit.isFeeBumpLeavingLeaves { + CustomButton( + title: t(selected?.extraSats == 0 ? "lightning__spark_exit__fee_bump_confirm" : "lightning__spark_exit__fund_savings"), + isLoading: isPaying || exit.isPricingFeeBump + ) { + await pay() + } + .disabled(selected.map { !canPay($0) } ?? true) + .accessibilityIdentifier("SparkExitFeeBumpPay") } - .disabled(selected.map { !canPay($0) } ?? true) - .accessibilityIdentifier("SparkExitFeeBumpPay") } .navigationBarHidden(true) .padding(.horizontal, 16) From 844463b66618f02d7aed0228edf2e0c211a94a92 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 13:05:03 +0100 Subject: [PATCH 196/378] docs: link the app status spark journey --- journeys/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/README.md b/journeys/README.md index 53b04b44c..da665ba63 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -215,7 +215,7 @@ journey PR, which is what made this file conflict on every merge. | USDT wallet navigation without funding | an onboarded build with USDT configured; wallet syncing remains silent while offline; payment-action errors remain available — [USDT QA](../Docs/USDT-QA.md) | | Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../Docs/USDT-QA.md) | | On-chain funds and blocks on regtest | `../bitkit-android/lsp` deposit and mine, borrowed from the sibling Android checkout until #694 lands an iOS copy — [Backend preconditions](#backend-preconditions) | -| Spending through Spark (transfer to spending, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key; transfers fund it from regtest Savings. Spark's claim of a deposit (Instant or after 3 confirmations) depends on the provider's quote, so the setting-up states are covered by previews and unit tests — [transfer](transfer/spark-transfer-to-spending.xml) | +| Spark connected (App Status Spark row, spending through Spark, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key; transfers fund it from regtest Savings. Spark's claim of a deposit (Instant or after 3 confirmations) depends on the provider's quote, so the setting-up states are covered by previews and unit tests — [app status](settings/app-status-spark-row.xml), [transfer](transfer/spark-transfer-to-spending.xml) | | Lightning channels | a channel opened through Manual Setup to the node of the LSP the `E2E_BUILD` app targets, as an external LN peer — [Backend preconditions](#backend-preconditions) | | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker`, reached through Trezor Bridge on the host; the simulator has no Bluetooth LE and iOS cannot do WebUSB, so BLE pairing and USB are not covered — [hardware-wallet](hardware-wallet/README.md) | | The OS notification-permission dialog | the one-shot `UNUserNotificationCenter` alert, reset with `xcrun simctl uninstall to.bitkit` and a rebuild | From f881eee9cad848075c206c443511c009e59d738f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:19:00 +0100 Subject: [PATCH 197/378] fix: keep the step connector between the end dots --- Bitkit/Components/ProgressSteps.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Bitkit/Components/ProgressSteps.swift b/Bitkit/Components/ProgressSteps.swift index 2772d2e67..114a8c80b 100644 --- a/Bitkit/Components/ProgressSteps.swift +++ b/Bitkit/Components/ProgressSteps.swift @@ -6,18 +6,20 @@ struct ProgressSteps: View { let currentStep: Int private let size: CGFloat = 32 + private let stepPadding: CGFloat = 16 var body: some View { VStack(spacing: 0) { // Steps with circles and separators GeometryReader { geometry in ZStack(alignment: .center) { - // Dashed line background + // Dashed line from the first circle's centre to the last one's, behind the circles Path { path in let y = geometry.size.height / 2 - let padding = 36.0 * 2.5 // Account for circle radius (16) + horizontal padding (20) - path.move(to: CGPoint(x: padding, y: y)) - path.addLine(to: CGPoint(x: geometry.size.width - padding, y: y)) + let stepWidth = size + stepPadding * 2 + let inset = (geometry.size.width - stepWidth * CGFloat(steps.count)) / 2 + stepWidth / 2 + path.move(to: CGPoint(x: inset, y: y)) + path.addLine(to: CGPoint(x: geometry.size.width - inset, y: y)) } .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) .foregroundColor(Color.white32) @@ -49,7 +51,7 @@ struct ProgressSteps: View { .frame(width: size, height: size) } } - .padding(.horizontal, 16) + .padding(.horizontal, stepPadding) } } } From f08bc7087f74956e5118fd5429781d2eba9910fb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:19:01 +0100 Subject: [PATCH 198/378] fix: restore the funds moved copy and accent the repriced amount --- Bitkit/Resources/Localization/ar.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/ca.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/cs.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/de.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/el.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 3 ++- .../Resources/Localization/es-419.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/es.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/fr.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/it.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/nl.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/pl.lproj/Localizable.strings | 3 ++- .../Resources/Localization/pt-BR.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/pt.lproj/Localizable.strings | 3 ++- Bitkit/Resources/Localization/ru.lproj/Localizable.strings | 3 ++- Bitkit/Views/Transfer/SettingUpView.swift | 5 +++-- 16 files changed, 33 insertions(+), 17 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index a69ab2606..1cb00e15f 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -243,6 +243,7 @@ "lightning__savings_spark_failed__toast" = "فشل تحويل Spark"; "lightning__transfer_success__nav_title" = "نجح التحويل"; "lightning__transfer_success__title_spending" = "انتقلت الأموال\nإلى الإنفاق"; +"lightning__transfer_success__text_spending" = "تم تحويل أموالك ورصيد إنفاقك جاهز للاستخدام."; "lightning__transfer_success__title_savings" = "انتقلت الأموال\nإلى المدخرات"; "lightning__transfer_success__text_savings" = "تم تحويل أموالك. يمكنك استخدام هذه الأموال فورًا."; "lightning__external__nav_title" = "اتصال Lightning"; @@ -309,7 +310,7 @@ "lightning__spark_deposit__wait_button" = "انتظر"; "lightning__spark_deposit__waiting" = "يضيفه Spark إلى رصيد الإنفاق {when}."; "lightning__spark_deposit__waiting_next" = "يضيفه Spark إلى رصيد الإنفاق بعد التأكيد التالي."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 1f71464a9..1b7da6e28 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -270,6 +270,7 @@ "lightning__savings_spark_failed__toast" = "Transferència Spark fallida"; "lightning__transfer_success__nav_title" = "Transferència realitzada"; "lightning__transfer_success__title_spending" = "Fons transferits\na despesa"; +"lightning__transfer_success__text_spending" = "Els teus fons s\'han transferit i el teu saldo de despesa està llest per utilitzar."; "lightning__transfer_success__title_savings" = "Fons transferits\na estalvis"; "lightning__transfer_success__text_savings" = "Els teus fons s\'han transferit. Pots utilitzar aquests fons immediatament."; "lightning__external__nav_title" = "Connexió Lightning"; @@ -346,7 +347,7 @@ "lightning__spark_deposit__wait_button" = "Espera"; "lightning__spark_deposit__waiting" = "Spark ho afegeix al teu saldo de despeses {when}."; "lightning__spark_deposit__waiting_next" = "Spark ho afegeix al teu saldo de despeses després de la confirmació següent."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 55a0d77e4..c65e6e4a8 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -278,6 +278,7 @@ "lightning__savings_spark_failed__toast" = "Převod Spark selhal"; "lightning__transfer_success__nav_title" = "Přenos úspěšný"; "lightning__transfer_success__title_spending" = "Prostředky převedeny\ndo výdajů"; +"lightning__transfer_success__text_spending" = "Vaše prostředky byly převedeny a disponibilní zůstatek je připraven k použití."; "lightning__transfer_success__title_savings" = "Prostředky převedeny\ndo úspor"; "lightning__transfer_success__text_savings" = "Vaše prostředky byly převedeny. Tyto prostředky můžete okamžitě použít."; "lightning__external__nav_title" = "Připojení lightning"; @@ -362,7 +363,7 @@ "lightning__spark_deposit__wait_button" = "Počkat"; "lightning__spark_deposit__waiting" = "Spark ji připíše na váš zůstatek k utrácení {when}."; "lightning__spark_deposit__waiting_next" = "Spark ji připíše na váš zůstatek k utrácení po dalším potvrzení."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 8f0b9354d..673ef93a5 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -276,6 +276,7 @@ "lightning__savings_spark_failed__toast" = "Spark-Transfer fehlgeschlagen"; "lightning__transfer_success__nav_title" = "Übertragung erfolgreich"; "lightning__transfer_success__title_spending" = "Bewegte Mittel\nzu den Ausgaben"; +"lightning__transfer_success__text_spending" = "Deine Gelder wurden übertragen und dein Ausgabenguthaben ist einsatzbereit."; "lightning__transfer_success__title_savings" = "Bewegte Mittel\nzu den Einsparungen"; "lightning__transfer_success__text_savings" = "Ihr Geld wurde überwiesen. Sie können diese Mittel sofort verwenden."; "lightning__external__nav_title" = "Lightning-Verbindung"; @@ -360,7 +361,7 @@ "lightning__spark_deposit__wait_button" = "Warten"; "lightning__spark_deposit__waiting" = "Spark schreibt es deinem Ausgabenguthaben {when} gut."; "lightning__spark_deposit__waiting_next" = "Spark schreibt es deinem Ausgabenguthaben nach der nächsten Bestätigung gut."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index a8b85970e..b4a7cc15e 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -266,6 +266,7 @@ "lightning__savings_spark_failed__toast" = "Η μεταφορά Spark απέτυχε"; "lightning__transfer_success__nav_title" = "Επιτυχής Μεταφορά"; "lightning__transfer_success__title_spending" = "Τα κεφάλαια μεταφέρθηκαν\nστις δαπάνες"; +"lightning__transfer_success__text_spending" = "Τα κεφάλαιά σας έχουν μεταφερθεί και το υπόλοιπο δαπανών σας είναι έτοιμο για χρήση."; "lightning__transfer_success__title_savings" = "Τα κεφάλαια μεταφέρθηκαν\nστις αποταμιεύσεις"; "lightning__transfer_success__text_savings" = "Τα κεφάλαιά σας έχουν μεταφερθεί. Μπορείτε να χρησιμοποιήσετε αυτά τα κεφάλαια αμέσως."; "lightning__external__nav_title" = "Σύνδεση Lightning"; @@ -333,7 +334,7 @@ "lightning__spark_deposit__wait_button" = "Αναμονή"; "lightning__spark_deposit__waiting" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας {when}."; "lightning__spark_deposit__waiting_next" = "Το Spark το προσθέτει στο υπόλοιπο δαπανών σας μετά την επόμενη επιβεβαίωση."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 5accc924d..0b1afd99f 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -271,6 +271,7 @@ "lightning__savings_spark_failed__toast" = "Spark transfer failed"; "lightning__transfer_success__nav_title" = "Transfer Successful"; "lightning__transfer_success__title_spending" = "Funds moved\nto spending"; +"lightning__transfer_success__text_spending" = "Your funds have been transferred and your spending balance is ready to use."; "lightning__transfer_success__title_savings" = "Funds moved\nto savings"; "lightning__transfer_success__text_savings" = "Your funds have been transferred. You are able to use these funds immediately."; "lightning__external__nav_title" = "Lightning Connection"; @@ -355,7 +356,7 @@ "lightning__spark_deposit__wait_button" = "Wait"; "lightning__spark_deposit__waiting" = "Spark adds it to your spending balance {when}."; "lightning__spark_deposit__waiting_next" = "Spark adds it to your spending balance after the next confirmation."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 15afeaeec..79c300561 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -279,6 +279,7 @@ "lightning__savings_spark_failed__toast" = "Transferencia de Spark fallida"; "lightning__transfer_success__nav_title" = "Transferencia exitosa"; "lightning__transfer_success__title_spending" = "Fondos movidos\na gastos"; +"lightning__transfer_success__text_spending" = "Tus fondos han sido transferidos y tu saldo para gastar está listo para usarse."; "lightning__transfer_success__title_savings" = "Fondos movidos\na gastos"; "lightning__transfer_success__text_savings" = "Sus fondos han sido transferidos. Podrá utilizar estos fondos inmediatamente."; "lightning__external__nav_title" = "Conexión Lightning"; @@ -363,7 +364,7 @@ "lightning__spark_deposit__wait_button" = "Esperar"; "lightning__spark_deposit__waiting" = "Spark lo agrega a tu saldo de gastos {when}."; "lightning__spark_deposit__waiting_next" = "Spark lo agrega a tu saldo de gastos después de la próxima confirmación."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index c24724933..a9f5742ed 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -268,6 +268,7 @@ "lightning__savings_spark_failed__toast" = "Transferencia de Spark fallida"; "lightning__transfer_success__nav_title" = "Transferencia exitosa"; "lightning__transfer_success__title_spending" = "Fondos movidos\na gasto"; +"lightning__transfer_success__text_spending" = "Sus fondos han sido transferidos y su saldo de gasto está listo para usar."; "lightning__transfer_success__title_savings" = "Fondos movidos\na ahorros"; "lightning__transfer_success__text_savings" = "Sus fondos han sido transferidos. Puede usar estos fondos inmediatamente."; "lightning__external__nav_title" = "Conexión Lightning"; @@ -347,7 +348,7 @@ "lightning__spark_deposit__wait_button" = "Esperar"; "lightning__spark_deposit__waiting" = "Spark lo añade a tu saldo de gastos {when}."; "lightning__spark_deposit__waiting_next" = "Spark lo añade a tu saldo de gastos tras la próxima confirmación."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 46a8f765c..1a16dc569 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -279,6 +279,7 @@ "lightning__savings_spark_failed__toast" = "Échec du transfert Spark"; "lightning__transfer_success__nav_title" = "Transfert réussi"; "lightning__transfer_success__title_spending" = "Fonds transférés \nvers Dépenses"; +"lightning__transfer_success__text_spending" = "Vos fonds ont été transférés et votre solde Dépenses est prêt à être utilisé."; "lightning__transfer_success__title_savings" = "Fonds transférés \nà l\'épargne"; "lightning__transfer_success__text_savings" = "Vos fonds ont été transférés. Vous pouvez utiliser ces fonds immédiatement."; "lightning__external__nav_title" = "Connexion Lightning"; @@ -363,7 +364,7 @@ "lightning__spark_deposit__wait_button" = "Attendre"; "lightning__spark_deposit__waiting" = "Spark l'ajoute à votre solde de dépenses {when}."; "lightning__spark_deposit__waiting_next" = "Spark l'ajoute à votre solde de dépenses après la prochaine confirmation."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index d93231509..5bfc9059a 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -273,6 +273,7 @@ "lightning__savings_spark_failed__toast" = "Trasferimento Spark non riuscito"; "lightning__transfer_success__nav_title" = "Trasferimento Riuscito"; "lightning__transfer_success__title_spending" = "Fondi trasferiti\na spesa"; +"lightning__transfer_success__text_spending" = "I tuoi fondi sono stati trasferiti e il tuo conto di spesa è pronto per essere utilizzato."; "lightning__transfer_success__title_savings" = "Fondi trasferiti\nai risparmi"; "lightning__transfer_success__text_savings" = "I tuoi fondi sono stati trasferiti. Puoi utilizzare questi fondi immediatamente."; "lightning__external__nav_title" = "Connessione Lightning"; @@ -356,7 +357,7 @@ "lightning__spark_deposit__wait_button" = "Attendi"; "lightning__spark_deposit__waiting" = "Spark lo aggiunge al tuo saldo di spesa {when}."; "lightning__spark_deposit__waiting_next" = "Spark lo aggiunge al tuo saldo di spesa dopo la prossima conferma."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 2b989592c..2cd941ed3 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -276,6 +276,7 @@ "lightning__savings_spark_failed__toast" = "Spark-overboeking mislukt"; "lightning__transfer_success__nav_title" = "Overboeking Voltooid"; "lightning__transfer_success__title_spending" = "Geld verplaatst\nnaar bestedingssaldo"; +"lightning__transfer_success__text_spending" = "Uw geld is overgeboekt en uw bestedingssaldo is klaar voor gebruik."; "lightning__transfer_success__title_savings" = "Geld verplaatst\nnaar spaargeld"; "lightning__transfer_success__text_savings" = "Uw geld is overgeboekt. U kunt dit geld direct gebruiken."; "lightning__external__nav_title" = "Lightning-verbinding"; @@ -360,7 +361,7 @@ "lightning__spark_deposit__wait_button" = "Wachten"; "lightning__spark_deposit__waiting" = "Spark voegt het {when} toe aan je bestedingssaldo."; "lightning__spark_deposit__waiting_next" = "Spark voegt het na de volgende bevestiging toe aan je bestedingssaldo."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 98c5813a2..d0409909c 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -278,6 +278,7 @@ "lightning__savings_spark_failed__toast" = "Transfer Spark nieudany"; "lightning__transfer_success__nav_title" = "Transfer środków powiódł się"; "lightning__transfer_success__title_spending" = "Środki przeniesione\nna saldo wydatków"; +"lightning__transfer_success__text_spending" = "Środki zostały przeniesione i saldo wydatków jest gotowe do użycia."; "lightning__transfer_success__title_savings" = "Środki przeniesione\nna saldo oszczędności"; "lightning__transfer_success__text_savings" = "Środki zostały przelane i są gotowe do natychmiastowego użycia."; "lightning__external__nav_title" = "Połączenie z Lightning"; @@ -362,7 +363,7 @@ "lightning__spark_deposit__wait_button" = "Czekaj"; "lightning__spark_deposit__waiting" = "Spark doda je do Twojego salda wydatków {when}."; "lightning__spark_deposit__waiting_next" = "Spark doda je do Twojego salda wydatków po następnym potwierdzeniu."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 18a5543a0..c397ae091 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -278,6 +278,7 @@ "lightning__savings_spark_failed__toast" = "Transferência Spark falhou"; "lightning__transfer_success__nav_title" = "Transferência bem-sucedida"; "lightning__transfer_success__title_spending" = "Fundos transferidos\npara gastos"; +"lightning__transfer_success__text_spending" = "Seus fundos foram transferidos e seu saldo de gastos está pronto para ser usado."; "lightning__transfer_success__title_savings" = "Fundos transferidos\npara poupança"; "lightning__transfer_success__text_savings" = "Seus fundos foram transferidos. Você já pode usar esses fundos imediatamente."; "lightning__external__nav_title" = "Conexões Lightning"; @@ -362,7 +363,7 @@ "lightning__spark_deposit__wait_button" = "Esperar"; "lightning__spark_deposit__waiting" = "A Spark adiciona ao seu saldo de gastos {when}."; "lightning__spark_deposit__waiting_next" = "A Spark adiciona ao seu saldo de gastos após a próxima confirmação."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 573f1250f..6af3702ba 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -294,6 +294,7 @@ "lightning__savings_spark_failed__toast" = "Transferência Spark falhou"; "lightning__transfer_success__nav_title" = "Transferência bem-sucedida"; "lightning__transfer_success__title_spending" = "Fundos transferidos\npara gastos"; +"lightning__transfer_success__text_spending" = "Seus fundos foram transferidos e seu saldo de gastos está pronto para ser usado."; "lightning__transfer_success__title_savings" = "Fundos transferidos\npara poupança"; "lightning__transfer_success__text_savings" = "Seus fundos foram transferidos. Você já pode usar esses fundos imediatamente."; "lightning__external__nav_title" = "Conexões Lightning"; @@ -360,7 +361,7 @@ "lightning__spark_deposit__wait_button" = "Esperar"; "lightning__spark_deposit__waiting" = "A Spark adiciona-o ao seu saldo de gastos {when}."; "lightning__spark_deposit__waiting_next" = "A Spark adiciona-o ao seu saldo de gastos após a próxima confirmação."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 2feb410f3..ed59e8b68 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -274,6 +274,7 @@ "lightning__savings_spark_failed__toast" = "Перевод Spark не выполнен"; "lightning__transfer_success__nav_title" = "Перевод Выполнен Успешно"; "lightning__transfer_success__title_spending" = "Средства Переведены\nна Расходы"; +"lightning__transfer_success__text_spending" = "Ваши средства переведены, и ваш баланс расходов готов к использованию."; "lightning__transfer_success__title_savings" = "Средства Переведены\nв Сбережения"; "lightning__transfer_success__text_savings" = "Ваши средства переведены. Вы можете использовать эти средства немедленно."; "lightning__external__nav_title" = "Lightning Соединение"; @@ -357,7 +358,7 @@ "lightning__spark_deposit__wait_button" = "Ждать"; "lightning__spark_deposit__waiting" = "Spark зачислит их на ваш расходный баланс {when}."; "lightning__spark_deposit__waiting_next" = "Spark зачислит их на ваш расходный баланс после следующего подтверждения."; -"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; +"lightning__spark_deposit__repriced" = "Instant now costs {fee}. Pay it to add your transfer now, or wait for 3 confirmations, about 30 minutes."; "lightning__spark_deposit__pay" = "Pay {fee}"; "lightning__spark_deposit__instant_unavailable" = "Instant isn't available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes."; "lightning__spark_deposit__not_seen" = "Spark hasn't seen your transfer yet. It's added after 3 confirmations; you can leave this screen."; diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift index c47f21bcd..11ee768db 100644 --- a/Bitkit/Views/Transfer/SettingUpView.swift +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -25,7 +25,8 @@ struct SettingUpView: View { } var text: String { - switch state { + guard isTransferring else { return t("lightning__transfer_success__text_spending") } + return switch state { case .looking: t("lightning__spark_deposit__looking") case .claiming, .adding: t("lightning__spark_deposit__claimed") case let .repriced(feeSats): t("lightning__spark_deposit__repriced", variables: ["fee": currency.primaryAmountText(sats: feeSats)]) @@ -76,7 +77,7 @@ struct SettingUpView: View { VStack(alignment: .leading, spacing: 0) { DisplayText(title, accentColor: .purpleAccent) - .padding(.bottom, 14) + .padding(.bottom, 8) .layoutPriority(1) BodyMText(text, accentColor: .white, accentFont: Fonts.bold) From fbac31f21136a69716cd15215776e823b0e548b6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:19:01 +0100 Subject: [PATCH 199/378] fix: align the spending speed picker with the fee rows --- Bitkit/Components/FeeDisplayRow.swift | 2 +- Bitkit/Components/FeeItem.swift | 17 +++- .../Localization/ar.lproj/Localizable.strings | 1 - .../Localization/ca.lproj/Localizable.strings | 1 - .../Localization/cs.lproj/Localizable.strings | 1 - .../Localization/de.lproj/Localizable.strings | 1 - .../Localization/el.lproj/Localizable.strings | 1 - .../Localization/en.lproj/Localizable.strings | 1 - .../es-419.lproj/Localizable.strings | 1 - .../Localization/es.lproj/Localizable.strings | 1 - .../Localization/fr.lproj/Localizable.strings | 1 - .../Localization/it.lproj/Localizable.strings | 1 - .../Localization/nl.lproj/Localizable.strings | 1 - .../Localization/pl.lproj/Localizable.strings | 1 - .../pt-BR.lproj/Localizable.strings | 1 - .../Localization/pt.lproj/Localizable.strings | 1 - .../Localization/ru.lproj/Localizable.strings | 1 - Bitkit/Views/Transfer/SpendingConfirm.swift | 93 +++++++++++-------- .../transfer/spark-transfer-to-spending.xml | 4 +- 19 files changed, 71 insertions(+), 60 deletions(-) diff --git a/Bitkit/Components/FeeDisplayRow.swift b/Bitkit/Components/FeeDisplayRow.swift index d9c482ff9..37ae26d21 100644 --- a/Bitkit/Components/FeeDisplayRow.swift +++ b/Bitkit/Components/FeeDisplayRow.swift @@ -3,7 +3,7 @@ import SwiftUI struct FeeDisplayRow: View { let label: String let amount: UInt64 - /// Shown before the amount when it is a bound or an estimate ("up to", "≈"). + /// Shown before the amount when it is a bound or an estimate ("±"). var prefix: String? /// Shimmers the amount while it is being priced. var isLoading = false diff --git a/Bitkit/Components/FeeItem.swift b/Bitkit/Components/FeeItem.swift index e66fd2394..ec8c31633 100644 --- a/Bitkit/Components/FeeItem.swift +++ b/Bitkit/Components/FeeItem.swift @@ -7,10 +7,12 @@ struct FeeItem: View { let iconName: String let iconColor: Color let amount: UInt64 - /// Shown before the amount when it is a bound or an estimate ("up to", "≈"). + /// Shown before the amount when it is a bound or an estimate ("±"). var amountPrefix: String? let isSelected: Bool let isDisabled: Bool + /// Draws the row's divider under it instead of above it, so a selected row's fill touches nothing above. + var dividerAtBottom = false let onPress: () -> Void @EnvironmentObject var currency: CurrencyViewModel @@ -45,8 +47,10 @@ struct FeeItem: View { amountPrefix: String? = nil, isSelected: Bool, isDisabled: Bool, + dividerAtBottom: Bool = false, onPress: @escaping () -> Void ) { + self.dividerAtBottom = dividerAtBottom self.title = title self.subtitle = subtitle self.iconName = iconName @@ -60,8 +64,10 @@ struct FeeItem: View { var body: some View { VStack(spacing: 0) { - Divider() - .padding(.horizontal, 16) + if !dividerAtBottom { + Divider() + .padding(.horizontal, 16) + } Button(action: onPress) { HStack(spacing: 16) { @@ -91,6 +97,11 @@ struct FeeItem: View { } .disabled(isDisabled) .buttonStyle(PlainButtonStyle()) + + if dividerAtBottom { + Divider() + .padding(.horizontal, 16) + } } } } diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 1cb00e15f..c6fd65522 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -206,7 +206,6 @@ "lightning__spending_confirm__amount" = "إلى الإنفاق"; "lightning__spending_confirm__total" = "الإجمالي"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 1b7da6e28..ca99075ca 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -233,7 +233,6 @@ "lightning__spending_confirm__amount" = "A despesa"; "lightning__spending_confirm__total" = "Total"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index c65e6e4a8..45e0963f1 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -241,7 +241,6 @@ "lightning__spending_confirm__amount" = "Do výdajů"; "lightning__spending_confirm__total" = "Celkem"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 673ef93a5..7d09c7ed0 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -239,7 +239,6 @@ "lightning__spending_confirm__amount" = "Zu den Ausgaben"; "lightning__spending_confirm__total" = "Total"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index b4a7cc15e..d344927b3 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -229,7 +229,6 @@ "lightning__spending_confirm__amount" = "Στις δαπάνες"; "lightning__spending_confirm__total" = "Σύνολο"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 0b1afd99f..638414d04 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -234,7 +234,6 @@ "lightning__spending_confirm__amount" = "To spending"; "lightning__spending_confirm__total" = "Total"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 79c300561..fdf238a2c 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -242,7 +242,6 @@ "lightning__spending_confirm__amount" = "Hacia Gastos"; "lightning__spending_confirm__total" = "Total"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index a9f5742ed..82239b63d 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -231,7 +231,6 @@ "lightning__spending_confirm__amount" = "A gasto"; "lightning__spending_confirm__total" = "Total"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 1a16dc569..6b3d80fe8 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -242,7 +242,6 @@ "lightning__spending_confirm__amount" = "Vers Dépenses"; "lightning__spending_confirm__total" = "Total"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 5bfc9059a..e7b492813 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -236,7 +236,6 @@ "lightning__spending_confirm__amount" = "A spesa"; "lightning__spending_confirm__total" = "Totale"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 2cd941ed3..d2eb11416 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -239,7 +239,6 @@ "lightning__spending_confirm__amount" = "Naar bestedingssaldo"; "lightning__spending_confirm__total" = "Totaal"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index d0409909c..ddbfe5db7 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -241,7 +241,6 @@ "lightning__spending_confirm__amount" = "Do salda wydatków"; "lightning__spending_confirm__total" = "Razem"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index c397ae091..9c8871767 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -241,7 +241,6 @@ "lightning__spending_confirm__amount" = "Para gastos"; "lightning__spending_confirm__total" = "Total"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 6af3702ba..693ffcadf 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -257,7 +257,6 @@ "lightning__spending_confirm__amount" = "Para gastos"; "lightning__spending_confirm__total" = "Total"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index ed59e8b68..b1f825c5c 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -237,7 +237,6 @@ "lightning__spending_confirm__amount" = "На Баланс Расходов"; "lightning__spending_confirm__total" = "Итого"; "lightning__spending_confirm__spending_fee" = "Spending fee"; -"lightning__spending_confirm__up_to" = "up to"; "lightning__spending_confirm__instant_text" = "Ready in a few minutes"; "lightning__spending_confirm__later" = "Later"; "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index fa8e7437f..f4597073c 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -11,6 +11,9 @@ struct SpendingConfirm: View { @State private var isPaying = false @State private var hideSwipeButton = false + @State private var aboveCoinsHeight: CGFloat = 0 + + private static let bottomPadding: CGFloat = 16 /// Fee rates move while the screen is open, so the quote is redone this often as well as on every change. private static let requoteInterval: Duration = .seconds(60) @@ -27,36 +30,51 @@ struct SpendingConfirm: View { .disabled(isPaying) .padding(.bottom, 16) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) - - SpendingConfirmFees(quote: flow.quote, speed: flow.speed, isLoading: flow.quote == nil && flow.quoteError == nil) - .padding(.top, 32) - - if let error = flow.quoteError { - quoteError(error) - .padding(.bottom, 16) - } else if let quote = flow.quote, !quote.coversClaimFee { - BodyMText(quote.belowClaimFeeMessage, textColor: .redAccent) - .padding(.bottom, 16) - .accessibilityIdentifier("SpendingConfirmBelowMinimum") + // The coins take the space left above the swipe, as on Android; on a short screen the content scrolls. + GeometryReader { geo in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) + + SpendingConfirmFees(quote: flow.quote, speed: flow.speed, isLoading: flow.quote == nil && flow.quoteError == nil) + .padding(.top, 32) + + if let error = flow.quoteError { + quoteError(error) + .padding(.bottom, 16) + } else if let quote = flow.quote, !quote.coversClaimFee { + BodyMText(quote.belowClaimFeeMessage, textColor: .redAccent) + .padding(.bottom, 16) + .accessibilityIdentifier("SpendingConfirmBelowMinimum") + } + + SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) + .disabled(isPaying) + .padding(.top, 16) + .padding(.horizontal, -16) + .padding(.bottom, 16) + + CustomButton(title: t("common__learn_more"), size: .small) { + navigation.navigate(.transferLearnMore) + } + .disabled(isPaying) + .accessibilityIdentifier("SpendingConfirmMore") + } + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { aboveCoinsHeight = $0 } + + Image("coin-stack-x") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(.horizontal, 44) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .frame(height: max(0, geo.size.height - aboveCoinsHeight - Self.bottomPadding)) } - - SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) - .disabled(isPaying) - .padding(.horizontal, -16) - .padding(.bottom, 16) - - CustomButton(title: t("common__learn_more"), size: .small) { - navigation.navigate(.transferLearnMore) - } - .disabled(isPaying) - .accessibilityIdentifier("SpendingConfirmMore") + // The speed rows run edge to edge, so the scroll spans the screen and pads its own content. + .padding(.horizontal, 16) + .padding(.bottom, Self.bottomPadding) } - // The speed rows run edge to edge, so the scroll spans the screen and pads its own content. - .padding(.horizontal, 16) - .padding(.bottom, 16) } .padding(.horizontal, -16) @@ -250,11 +268,9 @@ struct SpendingSpeedPicker: View { let quote: SparkTransferQuote? @Binding var speed: SparkTransferSpeed - static func feePrefix(for speed: SparkTransferSpeed) -> String { - switch speed { - case .instant: t("lightning__spending_confirm__up_to") - case .later: "≈" - } + /// Both claim fees are estimates: Instant's is its ceiling, Later's the current rate. + static func feePrefix(for _: SparkTransferSpeed) -> String { + "±" } var body: some View { @@ -267,7 +283,8 @@ struct SpendingSpeedPicker: View { amount: quote?.claimFees.instantMaxSats ?? 0, amountPrefix: Self.feePrefix(for: .instant), isSelected: speed == .instant, - isDisabled: false + isDisabled: false, + dividerAtBottom: true ) { speed = .instant } @@ -276,19 +293,17 @@ struct SpendingSpeedPicker: View { FeeItem( title: t("lightning__spending_confirm__later"), subtitle: t("lightning__spending_confirm__later_text"), - iconName: TransactionSpeed.slow.iconName, + iconName: TransactionSpeed.normal.iconName, iconColor: .purpleAccent, amount: quote?.claimFees.laterSats ?? 0, amountPrefix: Self.feePrefix(for: .later), isSelected: speed == .later, - isDisabled: false + isDisabled: false, + dividerAtBottom: true ) { speed = .later } .accessibilityIdentifier("SpendingConfirmLater") - - Divider() - .padding(.horizontal, 16) } } } diff --git a/journeys/transfer/spark-transfer-to-spending.xml b/journeys/transfer/spark-transfer-to-spending.xml index a740aa4f9..30a9ef0e7 100644 --- a/journeys/transfer/spark-transfer-to-spending.xml +++ b/journeys/transfer/spark-transfer-to-spending.xml @@ -20,8 +20,8 @@ Tap "Get Started" (testTag "SpendingIntro-button") and verify the amount screen (testTag "SpendingAmount") opens with the available amount Tap "25%" (testTag "SpendingAmountQuarter") and verify the amount is about a quarter of the Savings balance; tap "Max" (testTag "SpendingAmountMax") and verify the amount equals the available amount Tap "Continue" (testTag "SpendingAmountContinue") and verify the confirm screen shows "Network fees", "Spending fee", "To spending" and "Total", with "Instant" (testTag "SpendingConfirmInstant") selected and "Later" (testTag "SpendingConfirmLater") below it - Verify "Spending fee" reads "up to" an amount equal to the Instant row's fee, and "To spending" equals the amount less that fee - Tap "Later" and verify "Spending fee" changes to "≈" the Later row's fee and "To spending" grows by the difference, while "Network fees" and "Total" stay the same + Verify "Spending fee" reads "±" an amount equal to the Instant row's fee, and "To spending" equals the amount less that fee + Tap "Later" and verify "Spending fee" shows "±" the Later row's fee and "To spending" grows by the difference, while "Network fees" and "Total" stay the same Tap "Learn More" (testTag "SpendingConfirmMore"), verify the screen explains Instant and Later and shows a spending-only balance bar, then tap "Understood" (testTag "LiquidityContinue") Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap "Transfer From Savings" (testTag "TransferFromSavings") and verify the amount screen opens without the intro Go back to the wallet home, open Settings › Advanced › Lightning Connections and tap add connection (testTag "add_connection_button") From d6162574c65884bb63954bdfdee89719e26b2ded Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:11:44 +0100 Subject: [PATCH 200/378] fix: show the transfer minimum as a toast An amount below the claim minimum now stays on the amount screen with a toast when Continue prices it, and the swipe or sign step toasts again if a re-price drops below it, as on Android. The red line on the confirm and hardware sign screens is gone. --- Bitkit/ViewModels/SparkTransferFlow.swift | 11 ++++++++++ .../Transfer/Hardware/SpendingAmountHw.swift | 22 ++++++++++++++++--- .../Transfer/Hardware/SpendingHwSign.swift | 12 ++-------- Bitkit/Views/Transfer/SpendingAmount.swift | 17 ++++++++++---- Bitkit/Views/Transfer/SpendingConfirm.swift | 12 ++-------- 5 files changed, 47 insertions(+), 27 deletions(-) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 6dcd74040..63c4b0309 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -60,6 +60,17 @@ struct SparkTransferQuote: Equatable { } } +extension AppViewModel { + /// The toast for a transfer below the claim minimum, shown wherever the transfer is priced, as on Android. + func toastBelowClaimFee(_ quote: SparkTransferQuote) { + toast( + type: .warning, + title: t("lightning__spending_amount__error_min__title"), + description: quote.belowClaimFeeMessage + ) + } +} + /// Where a transfer to spending stands once its deposit is broadcast. enum SparkClaimState: Equatable { /// Instant was chosen and Spark has not listed the deposit yet. diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index 963083d0f..48e2cfb31 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -9,12 +9,14 @@ struct SpendingAmountHw: View { @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel @Environment(HwWalletManager.self) private var hwWalletManager @Environment(SparkTransferFlow.self) private var flow @State private var amountViewModel = AmountInputViewModel() @State private var isPricingFailed = false @State private var maxAttempt = 0 + @State private var isPricing = false private var amountSats: UInt64 { amountViewModel.amountSats @@ -67,9 +69,8 @@ struct SpendingAmountHw: View { amountViewModel.handleNumberPadInput(key, currency: currency) } - CustomButton(title: t("common__continue"), isDisabled: !isValidAmount) { - flow.start(amountSats: amountSats, isMaxAmount: amountSats >= maxAllowed, walletId: walletId) - navigation.navigate(.spendingHwSign(walletId: walletId)) + CustomButton(title: t("common__continue"), isDisabled: !isValidAmount || isPricing, isLoading: isPricing) { + await onContinue() } .accessibilityIdentifier("HardwareTransferAmountContinue") } @@ -113,6 +114,21 @@ struct SpendingAmountHw: View { } } + /// Prices the transfer first, so an amount below the claim minimum stays here with a toast, as on Android. + private func onContinue() async { + guard isValidAmount, !isPricing else { return } + flow.start(amountSats: amountSats, isMaxAmount: amountSats >= maxAllowed, walletId: walletId) + isPricing = true + defer { isPricing = false } + if let quote = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager), + !quote.coversClaimFee + { + app.toastBelowClaimFee(quote) + return + } + navigation.navigate(.spendingHwSign(walletId: walletId)) + } + private func loadAvailable() async { isPricingFailed = false flow.hwSend.selectWallet(walletId, initialAvailableSats: 0, showsLoading: true) diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index 5802cf78f..7bf8d4bb5 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -45,10 +45,6 @@ struct SpendingHwSign: View { if let error = flow.quoteError { BodyMText(error, textColor: .redAccent) .padding(.bottom, 16) - } else if let quote = flow.quote, !quote.coversClaimFee, !hwSend.hasPendingBroadcast { - BodyMText(quote.belowClaimFeeMessage, textColor: .redAccent) - .padding(.bottom, 16) - .accessibilityIdentifier("HardwareTransferBelowMinimum") } SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) @@ -82,7 +78,7 @@ struct SpendingHwSign: View { CustomButton( title: hwSend.hasPendingBroadcast ? t("common__retry") : vendor.sendSignButtonTitle, - isDisabled: isBusy || flow.quote == nil || !(hwSend.hasPendingBroadcast || flow.quote?.coversClaimFee == true), + isDisabled: isBusy || flow.quote == nil, isLoading: isBusy ) { startSigning() @@ -193,11 +189,7 @@ struct SpendingHwSign: View { guard let fresh = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager) else { return nil } guard fresh.coversClaimFee else { - app.toast( - type: .warning, - title: t("lightning__spending_amount__error_min__title"), - description: fresh.belowClaimFeeMessage - ) + app.toastBelowClaimFee(fresh) return nil } let increase = fresh.increase(over: shown, speed: flow.speed) diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index f6adb1aa3..d35d7ae88 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -12,6 +12,7 @@ struct SpendingAmount: View { @State private var isCalculatingMax = true @State private var maxTransferAmount: UInt64? @State private var maxAttempt = 0 + @State private var isPricing = false private var amountSats: UInt64 { amountViewModel.amountSats @@ -69,9 +70,10 @@ struct SpendingAmount: View { CustomButton( title: t("common__continue"), - isDisabled: !isValidAmount + isDisabled: !isValidAmount, + isLoading: isPricing ) { - onContinue() + await onContinue() } .accessibilityIdentifier("SpendingAmountContinue") } @@ -166,9 +168,16 @@ struct SpendingAmount: View { } } - private func onContinue() { - guard let max = maxTransferAmount, isValidAmount else { return } + /// Prices the transfer first, so an amount below the claim minimum stays here with a toast, as on Android. + private func onContinue() async { + guard let max = maxTransferAmount, isValidAmount, !isPricing else { return } flow.start(amountSats: amountSats, isMaxAmount: amountSats >= max) + isPricing = true + defer { isPricing = false } + if let quote = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager), !quote.coversClaimFee { + app.toastBelowClaimFee(quote) + return + } navigation.navigate(.spendingConfirm) } diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index f4597073c..4c144cce7 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -43,10 +43,6 @@ struct SpendingConfirm: View { if let error = flow.quoteError { quoteError(error) .padding(.bottom, 16) - } else if let quote = flow.quote, !quote.coversClaimFee { - BodyMText(quote.belowClaimFeeMessage, textColor: .redAccent) - .padding(.bottom, 16) - .accessibilityIdentifier("SpendingConfirmBelowMinimum") } SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) @@ -117,7 +113,7 @@ struct SpendingConfirm: View { private var canConfirm: Bool { guard let quote = flow.quote, !isPaying, !flow.isQuoting else { return false } - return quote.networkFeeSats > 0 && quote.coversClaimFee && quote.toSpendingSats(for: flow.speed) > 0 + return quote.networkFeeSats > 0 && quote.toSpendingSats(for: flow.speed) > 0 } private func quoteError(_ message: String) -> some View { @@ -141,11 +137,7 @@ struct SpendingConfirm: View { throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "Re-quote before sending failed") } guard fresh.coversClaimFee else { - app.toast( - type: .warning, - title: t("lightning__spending_amount__error_min__title"), - description: fresh.belowClaimFeeMessage - ) + app.toastBelowClaimFee(fresh) throw SpendingFeesIncreasedError() } let increase = fresh.increase(over: shown, speed: flow.speed) From 130f0ad8f9030c88f4b5f134d7fe997c67d7d8bd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:47:29 +0100 Subject: [PATCH 201/378] fix: savings intro spacing, button inset and image size --- Bitkit/Views/OnboardingView.swift | 12 ++++++++++-- Bitkit/Views/Transfer/SavingsIntroView.swift | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/OnboardingView.swift b/Bitkit/Views/OnboardingView.swift index de01d7dce..c3a0a6fc6 100644 --- a/Bitkit/Views/OnboardingView.swift +++ b/Bitkit/Views/OnboardingView.swift @@ -16,6 +16,8 @@ struct OnboardingView: View { let accentColor: Color let imagePosition: ImagePosition let titleDescriptionSpacing: CGFloat + let buttonHorizontalPadding: CGFloat + let maxImageSize: CGFloat? let testID: String? enum ImagePosition { @@ -36,6 +38,8 @@ struct OnboardingView: View { accentColor: Color = .brandAccent, imagePosition: ImagePosition = .bottom, titleDescriptionSpacing: CGFloat = 14, + buttonHorizontalPadding: CGFloat = 16, + maxImageSize: CGFloat? = nil, testID: String? = nil ) { self.navTitle = navTitle ?? "" @@ -50,6 +54,8 @@ struct OnboardingView: View { self.accentColor = accentColor self.imagePosition = imagePosition self.titleDescriptionSpacing = titleDescriptionSpacing + self.buttonHorizontalPadding = buttonHorizontalPadding + self.maxImageSize = maxImageSize self.testID = testID } @@ -66,7 +72,7 @@ struct OnboardingView: View { Image(imageName) .resizable() .scaledToFit() - .frame(maxWidth: .infinity) + .frame(maxWidth: maxImageSize ?? .infinity, maxHeight: maxImageSize) .aspectRatio(1, contentMode: .fit) .padding(.bottom, imagePosition == .center ? 0 : 48) @@ -77,6 +83,7 @@ struct OnboardingView: View { .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) .layoutPriority(1) + .padding(.horizontal, 16) VStack(alignment: .leading, spacing: titleDescriptionSpacing) { DisplayText(title, textColor: titleColor, accentColor: accentColor) @@ -86,14 +93,15 @@ struct OnboardingView: View { .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 16) CustomButton(title: buttonText) { onButtonPress() } .padding(.top, 32) + .padding(.horizontal, buttonHorizontalPadding) .accessibilityIdentifierIfPresent(testID.map { "\($0)-button" }) } - .padding(.horizontal, 16) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 16) diff --git a/Bitkit/Views/Transfer/SavingsIntroView.swift b/Bitkit/Views/Transfer/SavingsIntroView.swift index 6d4a3e42d..681d64299 100644 --- a/Bitkit/Views/Transfer/SavingsIntroView.swift +++ b/Bitkit/Views/Transfer/SavingsIntroView.swift @@ -16,6 +16,9 @@ struct SavingsIntroView: View { navigation.navigate(.savingsAvailability) }, imagePosition: .center, + titleDescriptionSpacing: 8, + buttonHorizontalPadding: 0, + maxImageSize: 256, testID: "SavingsIntro" ) .navigationBarHidden(true) From 24d436f661cf216760126473739978de3110db33 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:47:36 +0100 Subject: [PATCH 202/378] fix: savings availability title spacing --- Bitkit/Views/Transfer/SavingsAvailabilityView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Views/Transfer/SavingsAvailabilityView.swift b/Bitkit/Views/Transfer/SavingsAvailabilityView.swift index ee6576749..3df0af74e 100644 --- a/Bitkit/Views/Transfer/SavingsAvailabilityView.swift +++ b/Bitkit/Views/Transfer/SavingsAvailabilityView.swift @@ -12,7 +12,7 @@ struct SavingsAvailabilityView: View { DisplayText(t("lightning__availability__title"), accentColor: .brandAccent) BodyMText(t("lightning__availability__text"), accentFont: Fonts.bold) - .padding(.top, 16) + .padding(.top, 8) Spacer() From c41e3a5ae1ab0f4b6c0cb76f7250ee4b778c5b03 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:48:24 +0100 Subject: [PATCH 203/378] fix: savings confirm amounts, too-low error and disabled swipe --- Bitkit/ViewModels/TransferViewModel.swift | 8 ++++- .../Views/Transfer/SavingsConfirmView.swift | 31 +++++++++++++++---- BitkitTests/SparkWithdrawalTests.swift | 19 ++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 5b71bf29d..3d2616d27 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -12,6 +12,8 @@ class TransferViewModel: ObservableObject { /// Spark's fee to withdraw its balance to savings, for the confirm screen; nil until priced. @Published var sparkWithdrawFeeSats: UInt64? @Published var isPricingSparkWithdrawal = false + /// Spark's balance would leave less than the dust limit after its withdrawal fee, so it can't move to savings. + @Published private(set) var isSparkWithdrawTooSmall = false /// While the swipe prices Spark's withdrawal again, nothing else may commit the transfer. @Published private(set) var isRecheckingSparkFee = false /// Why the last transfer's Spark withdrawal failed, so the progress screen shows it instead of success. @@ -286,6 +288,7 @@ class TransferViewModel: ObservableObject { sparkFeeTask = nil sparkFeeBalanceSats = balanceSats sparkWithdrawFeeSats = nil + isSparkWithdrawTooSmall = false isPricingSparkWithdrawal = false if sparkWithdrawAddress == nil, !savingsAddress.isEmpty { sparkWithdrawAddress = savingsAddress @@ -303,7 +306,10 @@ class TransferViewModel: ObservableObject { guard let self, !Task.isCancelled else { return } sparkFeeTask = nil isPricingSparkWithdrawal = false - if let fee { + if let fee, balanceSats < fee + UInt64(Env.dustLimit) { + isSparkWithdrawTooSmall = true + sparkFeeBalanceSats = nil + } else if let fee { sparkWithdrawFeeSats = fee } else { sparkFeeBalanceSats = nil diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index 34b767463..beb4b6f09 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -53,6 +53,13 @@ struct SavingsConfirmView: View { (swapState.quote?.amountSat ?? totalSats) + (selection.withdrawsSpark ? sparkSats : 0) } + /// What reaches savings: the swap's receive plus Spark's balance net of its withdrawal fee. + private func receiveSats(_ quote: SavingsSwapQuote) -> UInt64 { + guard selection.withdrawsSpark else { return quote.receiveSat } + let feeSats = transfer.sparkWithdrawFeeSats ?? 0 + return quote.receiveSat + (sparkSats > feeSats ? sparkSats - feeSats : 0) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__transfer__nav_title")) @@ -97,7 +104,7 @@ struct SavingsConfirmView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) + .padding(.top, 32) } Spacer() @@ -118,7 +125,7 @@ struct SavingsConfirmView: View { Spacer() - if !hideSwipeButton, !selection.isEmpty { + if !hideSwipeButton { SwipeButton( title: t("lightning__transfer__swipe"), accentColor: .brandAccent, @@ -141,7 +148,7 @@ struct SavingsConfirmView: View { throw error } } - .disabled(!transfer.isSparkWithdrawalPriced(withdrawsSpark: selection.withdrawsSpark)) + .disabled(selection.isEmpty || !transfer.isSparkWithdrawalPriced(withdrawsSpark: selection.withdrawsSpark)) } if swapState.quote != nil { @@ -181,8 +188,20 @@ struct SavingsConfirmView: View { } } - /// Spark's withdrawal fee could not be priced; the swipe waits for it, so it can be retried here. + /// Spark's withdrawal fee could not be priced; the swipe waits for it, so it can be retried here. A balance too + /// small to move after its fee has nothing to retry. + @ViewBuilder private var sparkFeeError: some View { + if transfer.isSparkWithdrawTooSmall { + BodyMText(t("lightning__savings_confirm__amount_too_low"), textColor: .redAccent) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SavingsSparkTooLow") + } else { + sparkFeeRetry + } + } + + private var sparkFeeRetry: some View { HStack(alignment: .center, spacing: 16) { BodyMText(t("lightning__spending_confirm__error_quote"), textColor: .redAccent) .frame(maxWidth: .infinity, alignment: .leading) @@ -226,9 +245,9 @@ struct SavingsConfirmView: View { .frame(maxWidth: .infinity, alignment: .leading) } HStack(alignment: .top, spacing: 16) { - FeeDisplayRow(label: t("lightning__savings_confirm__amount"), amount: quote.amountSat) + FeeDisplayRow(label: t("lightning__savings_confirm__amount"), amount: headlineSats) .frame(maxWidth: .infinity, alignment: .leading) - FeeDisplayRow(label: t("lightning__savings_confirm__receive"), amount: quote.receiveSat) + FeeDisplayRow(label: t("lightning__savings_confirm__receive"), amount: receiveSats(quote)) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index 07a5c5b39..a40221d1b 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -88,6 +88,25 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertLessThan(Date().timeIntervalSince(start), 1.5) } + @MainActor + func testABalanceThatLeavesDustAfterItsFeeIsTooSmallToMove() async { + let transfer = TransferViewModel() + transfer.priceSparkWithdrawal = { _ in 1093 } + + transfer.loadSparkWithdrawFee(balanceSats: 1093 + UInt64(Env.dustLimit) - 1, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertTrue(transfer.isSparkWithdrawTooSmall) + XCTAssertNil(transfer.sparkWithdrawFeeSats) + XCTAssertFalse(transfer.isSparkWithdrawalPriced(withdrawsSpark: true)) + + transfer.loadSparkWithdrawFee(balanceSats: 1093 + UInt64(Env.dustLimit), savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertFalse(transfer.isSparkWithdrawTooSmall) + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 1093) + } + @MainActor func testCloseInsteadWaitsForTheSparkFee() { let transfer = TransferViewModel() From fccaf82419afb07a002eb66b4b91f17a57a0a118 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:48:34 +0100 Subject: [PATCH 204/378] fix: savings progress title, spacing and hidden button --- Bitkit/Views/Transfer/SavingsProgressView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index a9cbb7c4e..51d932984 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -25,7 +25,8 @@ struct SavingsProgressContentView: View { switch progressState { case .inProgress, .settling: return t("lightning__transfer__nav_title") case .failed: return t("lightning__savings_interrupted__nav_title") - case .success, .sparkFailed: return t("lightning__transfer__nav_title") + case .success: return t("lightning__transfer_success__nav_title") + case .sparkFailed: return t("lightning__transfer__nav_title") } } @@ -64,7 +65,7 @@ struct SavingsProgressContentView: View { .padding(.bottom, 16) DisplayText(title, accentColor: .brandAccent) - .padding(.bottom, 16) + .padding(.bottom, 8) BodyMText(text, accentFont: Fonts.bold) @@ -122,13 +123,12 @@ struct SavingsProgressContentView: View { Spacer() - CustomButton( - title: t("common__ok"), - isLoading: progressState == .inProgress - ) { - navigation.reset() + if progressState != .inProgress { + CustomButton(title: t("common__ok")) { + navigation.reset() + } + .accessibilityIdentifierIfPresent(imageIdentifier.map { "\($0)-button" }) } - .accessibilityIdentifierIfPresent(imageIdentifier.map { "\($0)-button" }) } .navigationBarHidden(true) .allowSwipeBack(false) From e02e6411b002b0afc29c79ff2a4b00f0ce248fd2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:48:48 +0100 Subject: [PATCH 205/378] fix: show the open connection count in advanced settings --- Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index 434ff9946..ea379846c 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -7,6 +7,11 @@ struct AdvancedSettingsView: View { @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault + private var connectionsRowRightText: String? { + let openChannelCount = wallet.channels?.filter(\.isChannelReady).count ?? 0 + return openChannelCount > 0 ? String(openChannelCount) : nil + } + private var electrumRowRightText: String { let currentServerUrl = settings.electrumCurrentServer.fullUrl @@ -82,7 +87,8 @@ struct AdvancedSettingsView: View { NavigationLink(value: Route.connections) { SettingsRow( title: t("settings__adv__lightning_connections"), - iconName: "bolt-hollow" + iconName: "bolt-hollow", + rightText: connectionsRowRightText ) } .accessibilityIdentifier("Channels") From 84b8dc16f650752653d9614e4944d43347be0a28 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:49:14 +0100 Subject: [PATCH 206/378] fix: spending wallet transfer buttons and copy --- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 2 +- Bitkit/Views/Wallets/SpendingWalletScreen.swift | 4 ++-- journeys/transfer/spark-transfer-to-spending.xml | 6 +++--- journeys/transfer/transfer-unavailable-sheet.xml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 638414d04..d8c0d2e0d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -194,7 +194,7 @@ "lightning__funding__nav_title" = "Spending Balance"; "lightning__funding__text" = "The easiest option is to use your Bitkit savings or send bitcoin from a different wallet."; "lightning__funding__title" = "Fund your spending balance"; -"lightning__funding__button1" = "Transfer from Savings"; +"lightning__funding__button1" = "Transfer From Savings"; "lightning__funding__button2" = "Use Other Wallet"; "lightning__funding__button3" = "Manual Setup"; "lightning__funding_advanced__button_short" = "Advanced Setup"; diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index 925e7f65c..dbaf93389 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -41,7 +41,7 @@ struct SpendingWalletScreen: View { } if !shouldShowOnboarding { - if wallet.channels?.isEmpty == false || SparkManager.shared.balanceSats > 0 { + if wallet.totalLightningSats > 0, wallet.channels?.isEmpty == false || SparkManager.shared.balanceSats > 0 { transferButton .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, 28) @@ -107,7 +107,7 @@ struct SpendingWalletScreen: View { var transferFromSavingsButton: some View { CustomButton( - title: t("lightning__transfer_to_spending_button"), + title: t("lightning__funding__button1"), variant: .secondary, icon: Image("arrow-up-down") .resizable() diff --git a/journeys/transfer/spark-transfer-to-spending.xml b/journeys/transfer/spark-transfer-to-spending.xml index 30a9ef0e7..8e134e2df 100644 --- a/journeys/transfer/spark-transfer-to-spending.xml +++ b/journeys/transfer/spark-transfer-to-spending.xml @@ -1,6 +1,6 @@ - Verifies the transfer to spending flow on Spark. With Spark ready, Funding leads with "Transfer from Savings" + Verifies the transfer to spending flow on Spark. With Spark ready, Funding leads with "Transfer From Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") above "Manual Setup" (testTag "FundManual") and an enabled "Transfer from HW wallet" (testTag "FundHardware"), which opens a paired hardware wallet's transfer to spending, a funded one first, or the connect sheet when none is paired; and the Savings and @@ -25,8 +25,8 @@ Tap "Learn More" (testTag "SpendingConfirmMore"), verify the screen explains Instant and Later and shows a spending-only balance bar, then tap "Understood" (testTag "LiquidityContinue") Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap "Transfer From Savings" (testTag "TransferFromSavings") and verify the amount screen opens without the intro Go back to the wallet home, open Settings › Advanced › Lightning Connections and tap add connection (testTag "add_connection_button") - Verify "Transfer from Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") are enabled above "Manual Setup" (testTag "FundManual"), and "Transfer from HW wallet" (testTag "FundHardware") is enabled below it + Verify "Transfer From Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") are enabled above "Manual Setup" (testTag "FundManual"), and "Transfer from HW wallet" (testTag "FundHardware") is enabled below it With no hardware wallet paired, tap "Transfer from HW wallet" and verify the connect hardware wallet sheet opens; close it - Tap "Transfer from Savings" and verify the amount screen (testTag "SpendingAmount") opens + Tap "Transfer From Savings" and verify the amount screen (testTag "SpendingAmount") opens diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml index 4fc4ab0fd..0ca412412 100644 --- a/journeys/transfer/transfer-unavailable-sheet.xml +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -20,7 +20,7 @@ Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen shows the node id (testTag "NodeIdInput"), host (testTag "HostInput") and port (testTag "PortInput") fields Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap the transfer button (testTag "TransferFromSavings") and verify the same sheet opens; close it Open Settings › Advanced › Lightning Connections, tap add connection (testTag "add_connection_button") and verify the screen text reads "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." - Verify "Manual Setup" (testTag "FundManual") is the first option and enabled, and "Transfer from Savings" (testTag "FundTransfer"), "Transfer from HW wallet" (testTag "FundHardware") and "Use Other Wallet" (testTag "FundReceive") below it are disabled; tap "Transfer from Savings" and verify nothing opens + Verify "Manual Setup" (testTag "FundManual") is the first option and enabled, and "Transfer From Savings" (testTag "FundTransfer"), "Transfer from HW wallet" (testTag "FundHardware") and "Use Other Wallet" (testTag "FundReceive") below it are disabled; tap "Transfer From Savings" and verify nothing opens Tap "Learn More" (testTag "FundLearnMore") at the bottom and verify the browser opens blog.bitkit.to, then return to Bitkit and go back to the wallet home Tap "Receive" (testTag "Receive"), select the Spending tab (testTag "Tab-spending") and tap "Receive Lightning funds" Verify the screen titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider.", with a back button From 01fbfb5a0a62824864296571b752694e14e2e13b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:49:26 +0100 Subject: [PATCH 207/378] fix: receive show details button and spending onboarding copy --- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 2 +- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index d8c0d2e0d..736ddc63e 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1427,7 +1427,7 @@ "wallet__receive_show_qr" = "QR Code"; "wallet__receive_text_lnfunds" = "Want to receive Lightning funds?"; "wallet__receive_spending" = "Receive Lightning Funds"; -"wallet__receive_spending_text" = "Enjoy instant and cheap bitcoin payments on the Lightning Network."; +"wallet__receive_spending_text" = "Enjoy instant and cheap transactions with friends, family, and merchants."; "wallet__receive_spending_title" = "Receive on spending balance"; "wallet__receive_cjit_error" = "Transaction Failed"; "wallet__receive_cjit_error_msg" = "Failed to send funds to your spending account."; diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index e666abb9f..8c55f6d30 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -252,7 +252,6 @@ struct ReceiveQr: View { } else { CustomButton( title: t("common__show_details"), - variant: .tertiary, isDisabled: selectedTab == .hardware && displayedHardwareAddress == nil ) { showDetails.toggle() @@ -510,7 +509,7 @@ struct ReceiveQr: View { var cjitOnboarding: some View { VStack(alignment: .leading, spacing: 0) { DisplayText(t("wallet__receive_spending_title"), accentColor: .purpleAccent) - .padding(.bottom, 12) + .padding(.bottom, 8) BodyMText(t("wallet__receive_spending_text")) From 5ebff50778a36098ab94a3946855e15c54283f8f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 13:25:53 +0100 Subject: [PATCH 208/378] fix: match exit spacing and dialogs across apps --- .../Settings/Advanced/SparkExitViews.swift | 29 ++++++++++++++----- .../Activity/ActivityExplorerView.swift | 18 ++++++++++-- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 5809c9110..18fc4ddec 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -16,7 +16,7 @@ struct SparkExitIntroView: View { BodyMText(t("lightning__spark_exit__intro_text"), accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 8) + .padding(.top, SparkExitLayout.headlineGap) Spacer() @@ -206,7 +206,7 @@ struct SparkExitFundingView: View { accentFont: Fonts.bold ) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 8) + .padding(.top, SparkExitLayout.headlineGap) if let steps = exit.quote?.fundedSteps, steps > 1 { BodySText(t("lightning__spark_exit__funding_split", variables: ["count": "\(steps)"])) @@ -400,7 +400,7 @@ struct SparkExitTxNote: View { VStack(alignment: .leading, spacing: 0) { BodySText(text, accentColor: .white, accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) - ActivityInfoSection(title: t("wallet__activity_tx_id"), content: txid) + ActivityInfoSection(title: t("wallet__activity_tx_id"), content: txid, usesCustomDivider: true, bottomSpacing: 0) .padding(.top, 16) } } @@ -508,7 +508,7 @@ struct SparkExitConfirmView: View { BodyMText(t("lightning__spark_exit__confirm_text"), accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 8) + .padding(.top, SparkExitLayout.headlineGap) .padding(.bottom, 32) if let quote = exit.quote { @@ -648,15 +648,21 @@ struct SparkExitProgressView: View { BodyMText(text, accentFont: Fonts.bold) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 8) + .padding(.top, SparkExitLayout.headlineGap) .padding(.bottom, 24) if let record { SparkExitSteps(record: record, tipHeight: exit.tipHeight) if let sweepTxid = SparkExitMath.received(record)?.sweepTxid { - ActivityInfoSection(title: t("wallet__activity_tx_id"), content: sweepTxid, testId: "SparkExitSweepTxid") - .padding(.top, 24) + ActivityInfoSection( + title: t("wallet__activity_tx_id"), + content: sweepTxid, + testId: "SparkExitSweepTxid", + usesCustomDivider: true, + bottomSpacing: 0 + ) + .padding(.top, 24) } if exit.isSavingsPaymentRefused { @@ -731,6 +737,7 @@ struct SparkExitProgressView: View { BodySText(t("lightning__spark_exit__fee_rose"), textColor: .yellowAccent) .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(PlainButtonStyle()) .padding(.top, 16) @@ -905,7 +912,7 @@ struct SparkExitFeeBumpView: View { accentFont: Fonts.bold ) .fixedSize(horizontal: false, vertical: true) - .padding(.top, 8) + .padding(.top, SparkExitLayout.headlineGap) .padding(.bottom, 24) if exit.isPricingFeeBump { @@ -1109,3 +1116,9 @@ struct SparkExitSteps: View { } } } + +enum SparkExitLayout { + /// The gap under an exit headline: master's 8, plus 1 so it renders like Android's, whose Display line height + /// adds 9 dp under a headline. Measured on 2x media devices. + static let headlineGap: CGFloat = 9 +} diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 4bd151f5f..635813029 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -320,12 +320,18 @@ struct ActivityInfoSection: View { let title: String let content: String let testId: String? + /// The step lists' divider instead of the system one, for screens whose rows use it. + let usesCustomDivider: Bool + /// Space under the divider; a screen whose next row brings its own top spacing passes 0. + let bottomSpacing: CGFloat @EnvironmentObject var app: AppViewModel - init(title: String, content: String, testId: String? = nil) { + init(title: String, content: String, testId: String? = nil, usesCustomDivider: Bool = false, bottomSpacing: CGFloat = 16) { self.title = title self.content = content self.testId = testId + self.usesCustomDivider = usesCustomDivider + self.bottomSpacing = bottomSpacing } var body: some View { @@ -339,8 +345,14 @@ struct ActivityInfoSection: View { BodySSBText(content) .padding(.bottom, 16) - Divider() - .padding(.bottom, 16) + Group { + if usesCustomDivider { + CustomDivider() + } else { + Divider() + } + } + .padding(.bottom, bottomSpacing) } } .buttonStyle(.plain) From d30664b5bdbb1355c377bfbd128334f3fe11a57c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 14:09:09 +0100 Subject: [PATCH 209/378] fix: leave a spark balance too small to move out of savings --- Bitkit/ViewModels/TransferViewModel.swift | 11 +++++++---- Bitkit/Views/Transfer/SavingsConfirmView.swift | 9 +++++++-- BitkitTests/SparkWithdrawalTests.swift | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 3d2616d27..a02da2d91 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -273,7 +273,8 @@ class TransferViewModel: ObservableObject { readyChannelIds: readyChannelIds, selectedChannelIds: selectedChannelIds, isSparkSelected: isSparkSelected, - sparkSats: sparkSats + sparkSats: sparkSats, + isSparkTooSmall: isSparkWithdrawTooSmall ) } @@ -911,16 +912,18 @@ struct SavingsSelection: Equatable { channelIds.isEmpty && !withdrawsSpark } - /// Without a pick (`selectedChannelIds` nil) every ready channel moves. Spark moves when picked and holding a balance. + /// Without a pick (`selectedChannelIds` nil) every ready channel moves. Spark moves when picked and holding a balance + /// its withdrawal fee leaves something of, so one too small to move never holds the channels back. static func resolve( readyChannelIds: [String], selectedChannelIds: Set?, isSparkSelected: Bool, - sparkSats: UInt64 + sparkSats: UInt64, + isSparkTooSmall: Bool = false ) -> SavingsSelection { SavingsSelection( channelIds: readyChannelIds.filter { selectedChannelIds?.contains($0) ?? true }, - withdrawsSpark: isSparkSelected && sparkSats > 0 + withdrawsSpark: isSparkSelected && sparkSats > 0 && !isSparkTooSmall ) } } diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index beb4b6f09..b2b21275b 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -31,6 +31,11 @@ struct SavingsConfirmView: View { transfer.savingsSelection(readyChannelIds: readyChannels.map(\.channelId), sparkSats: sparkSats) } + /// Spark is priced while picked with a balance, also once too small to move, so a receive can bring it back in. + private var pricesSpark: Bool { + transfer.isSparkSelected && sparkSats > 0 + } + private var channels: [ChannelDetails] { let ids = selection.channelIds return readyChannels.filter { ids.contains($0.channelId) } @@ -86,7 +91,7 @@ struct SavingsConfirmView: View { ) .padding(.top, swapState.quote == nil ? 24 : 16) .accessibilityIdentifier("SavingsSparkFee") - } else if selection.withdrawsSpark { + } else if selection.withdrawsSpark || selection.isEmpty && pricesSpark && transfer.isSparkWithdrawTooSmall { sparkFeeError .padding(.top, swapState.quote == nil ? 24 : 16) } @@ -182,7 +187,7 @@ struct SavingsConfirmView: View { spendableSats: channelsSendableSats ) } - .onChange(of: selection.withdrawsSpark ? sparkSats : nil, initial: true) { _, balanceSats in + .onChange(of: pricesSpark ? sparkSats : nil, initial: true) { _, balanceSats in guard let balanceSats else { return } transfer.loadSparkWithdrawFee(balanceSats: balanceSats, savingsAddress: wallet.onchainAddress) } diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index a40221d1b..ad218b35a 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -88,6 +88,20 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertLessThan(Date().timeIntervalSince(start), 1.5) } + @MainActor + func testASparkBalanceTooSmallToMoveLeavesTheChannelsToMoveByDefault() async { + let transfer = TransferViewModel() + transfer.beginSavingsTransfer() + transfer.priceSparkWithdrawal = { _ in 1250 } + transfer.loadSparkWithdrawFee(balanceSats: 1000, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + let selection = transfer.savingsSelection(readyChannelIds: ["channel"], sparkSats: 1000) + + XCTAssertEqual(selection, SavingsSelection(channelIds: ["channel"], withdrawsSpark: false)) + XCTAssertTrue(transfer.isSparkWithdrawalPriced(withdrawsSpark: selection.withdrawsSpark), "Spark held the channels back") + } + @MainActor func testABalanceThatLeavesDustAfterItsFeeIsTooSmallToMove() async { let transfer = TransferViewModel() From 2b6b52082223d18c2ac7b6b94973274d404404b6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 14:20:13 +0100 Subject: [PATCH 210/378] fix: trim unlisted expired send hashes and keep spark retries --- Bitkit/Services/LightningService.swift | 94 ++++++++++++++++---------- BitkitTests/ChannelFreeRailTests.swift | 68 +++++++++++++++++++ 2 files changed, 125 insertions(+), 37 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index f0a14c7d4..1ac4aeafc 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -130,12 +130,13 @@ class LightningService: NodeLifecycleService { private var attemptRails: [String: LightningPaymentRail] = [:] /// Payment hashes inside a rail send, which Spark lists only once it returns. private var railSendsInFlight: Set = [] - /// Counts node attempts recorded in this run, so a listing drops a hash it shows only as failed just when the hash - /// was recorded before the listing was read, never a retry recorded while it was read. - private var ldkAttemptRecords = 0 + /// Counts attempts recorded in this run, so a listing drops a hash just when the hash was recorded before the + /// listing was read, never a retry recorded while it was read. + private var attemptRecords = 0 + /// Per store key, the `attemptRecords` count at each hash's latest record. + private var attemptRecordedAt: [String: [String: Int]] = [:] /// Node sends under way, so a listing that shows only an earlier failed attempt doesn't drop their hash. private var ldkSendsInFlight: Set = [] - private var ldkAttemptRecordedAt: [String: Int] = [:] weak var channelFreeRail: ChannelFreeLightningRail? var onchainTransactionConfirmed: (@Sendable (String) async -> Void)? var onchainTransactionReceived: (@Sendable (String) async -> Void)? @@ -1143,10 +1144,13 @@ class LightningService: NodeLifecycleService { var now: () -> Date = Date.init private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" /// A listing drops the hashes it shows failed only, or paid once the invoice has expired, so the other rail can't - /// pay it again. Paid hashes with no known expiry (kept before expiries were, or whose invoice can't be parsed) are - /// dropped oldest first only past this many per key. A hash whose payment may still be pending, or that no listing - /// has shown yet, always stays. + /// pay it again, and ones it doesn't show once the invoice expired `unlistedAttemptSettleSeconds` ago. Paid or + /// unlisted hashes with no known expiry (kept before expiries were, or whose invoice can't be parsed) are dropped + /// oldest first only past this many per key. A hash whose payment may still be pending always stays. private static let railAttemptsLimit = 500 + /// No send starts after its invoice expires, and one settles within its HTLC timeout, which LDK caps at 1008 + /// blocks (about a week). Spark's routes are shorter, so twice that cap leaves room for slow blocks. + private static let unlistedAttemptSettleSeconds = 14 * 24 * 60 * 60 /// Payment hashes sent over the node, kept across launches: while the node can't list its payments (stopped or not /// started yet), only these may have an attempt there that a Spark payment would repeat. @@ -1182,38 +1186,41 @@ class LightningService: NodeLifecycleService { hashes.removeAll { $0 == paymentHash } hashes.append(paymentHash) railAttemptsStore.set(hashes, forKey: key) - if key == Self.ldkAttemptsKey { - ldkAttemptRecords += 1 - ldkAttemptRecordedAt[paymentHash] = ldkAttemptRecords - } + attemptRecords += 1 + attemptRecordedAt[key, default: [:]][paymentHash] = attemptRecords var expiries = attemptExpiries(key: key) expiries[paymentHash] = expiresAt railAttemptsStore.set(expiries, forKey: Self.expiriesKey(key)) } } - /// Drops the hashes a listing showed as `failed` only, or as `paid` with an expired invoice, and past the limit the - /// oldest paid ones with no known expiry. Callers pass no hash whose payment may still be pending, so it keeps + /// Drops the hashes a listing showed as `failed` only, or as `paid` with an expired invoice, ones it didn't show + /// (`listed` is every hash it shows) whose invoice expired past `unlistedAttemptSettleSeconds`, and past the limit + /// the oldest paid or unlisted ones with no known expiry. A hash whose payment may still be pending (`live`) keeps /// holding the other rail, as does a paid one whose invoice can still be paid. Callers hold `eventCallbackLock`. - private func trimSettledAttempts(paid: Set, failed: Set, key: String) { + private func trimSettledAttempts(paid: Set, failed: Set, listed: Set, live: Set, key: String) { guard let hashes = railAttemptsStore.stringArray(forKey: key) else { return } let expiries = attemptExpiries(key: key) let nowSeconds = Int(now().timeIntervalSince1970) - let listed = hashes.filter { hash in - !failed.contains(hash) && !(paid.contains(hash) && expiries[hash].map { $0 <= nowSeconds } == true) + let isExpired = { (hash: String, settleSeconds: Int) in expiries[hash].map { $0 + settleSeconds <= nowSeconds } == true } + let unsettled = hashes.filter { hash in + live.contains(hash) || !(failed.contains(hash) || paid.contains(hash) && isExpired(hash, 0) + || !listed.contains(hash) && isExpired(hash, Self.unlistedAttemptSettleSeconds)) } - var excess = listed.count - Self.railAttemptsLimit + var excess = unsettled.count - Self.railAttemptsLimit var kept: [String] = [] - for hash in listed { - if excess > 0, paid.contains(hash), expiries[hash] == nil { + for hash in unsettled { + if excess > 0, !live.contains(hash), expiries[hash] == nil, paid.contains(hash) || !listed.contains(hash) { excess -= 1 } else { kept.append(hash) } } guard kept.count < hashes.count else { return } + let keptSet = Set(kept) railAttemptsStore.set(kept, forKey: key) - railAttemptsStore.set(expiries.filter { kept.contains($0.key) }, forKey: Self.expiriesKey(key)) + railAttemptsStore.set(expiries.filter { keptSet.contains($0.key) }, forKey: Self.expiriesKey(key)) + attemptRecordedAt[key] = attemptRecordedAt[key]?.filter { keptSet.contains($0.key) } } /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. @@ -1241,9 +1248,12 @@ class LightningService: NodeLifecycleService { hashes.removeAll { removed.contains($0) } railAttemptsStore.set(hashes, forKey: key) railAttemptsStore.set(attemptExpiries(key: key).filter { !removed.contains($0.key) }, forKey: Self.expiriesKey(key)) - if key == Self.ldkAttemptsKey { - removed.forEach { ldkAttemptRecordedAt[$0] = nil } - } + removed.forEach { attemptRecordedAt[key]?[$0] = nil } + } + + /// The hashes recorded under `key` after `attemptRecords` read `records`. Callers hold `eventCallbackLock`. + private func attemptsRecorded(since records: Int, key: String) -> Set { + Set((attemptRecordedAt[key] ?? [:]).filter { $0.value > records }.keys) } private func hasRailAttempt(paymentHash: String) -> Bool { @@ -1261,6 +1271,7 @@ class LightningService: NodeLifecycleService { eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.railAttemptsKey) railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.railAttemptsKey)) + attemptRecordedAt[Self.railAttemptsKey] = nil } } @@ -1269,14 +1280,14 @@ class LightningService: NodeLifecycleService { eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.ldkAttemptsKey) railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.ldkAttemptsKey)) - ldkAttemptRecordedAt = [:] + attemptRecordedAt[Self.ldkAttemptsKey] = nil railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } } /// The node's payments, after keeping its attempts from them; nil while the node can't list them. private func listNodePaymentsKeepingAttempts() async -> [PaymentDetails]? { - let recordsBefore = eventCallbackLock.withLock { ldkAttemptRecords } + let recordsBefore = eventCallbackLock.withLock { attemptRecords } guard let payments = await nodePayments() else { return nil } keepLdkAttempts(from: payments, recordsBefore: recordsBefore) return payments @@ -1298,22 +1309,28 @@ class LightningService: NodeLifecycleService { railAttemptsStore.set(seeded + kept, forKey: Self.ldkAttemptsKey) railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } - let failedOnly = hashes(.failed).subtracting(hashes(.succeeded)).subtracting(hashes(.pending)).subtracting(ldkSendsInFlight) - .filter { ldkAttemptRecordedAt[$0, default: 0] <= recordsBefore } - trimSettledAttempts(paid: hashes(.succeeded), failed: failedOnly, key: Self.ldkAttemptsKey) - failedOnly.forEach { ldkAttemptRecordedAt[$0] = nil } + let live = hashes(.pending).union(ldkSendsInFlight).union(attemptsRecorded(since: recordsBefore, key: Self.ldkAttemptsKey)) + let paid = hashes(.succeeded).subtracting(live) + let listed = Set(outbound.map { $0.id.lowercased() }) + trimSettledAttempts( + paid: paid, failed: hashes(.failed).subtracting(paid).subtracting(live), listed: listed, live: live, key: Self.ldkAttemptsKey + ) } } - /// Trims the rail's paid and failed-only hashes, so a payment that may still be pending, or a send in flight that - /// the listing predates, keeps holding the node. - private func keepRailAttempts(from payments: [ChannelFreeRailPayment]) { + /// Trims the rail's settled hashes, so a payment that may still be pending, a send in flight, or one recorded + /// after the listing was read (`recordsBefore`) keeps holding the node. Returns those live hashes. + private func keepRailAttempts(from payments: [ChannelFreeRailPayment], recordsBefore: Int) -> Set { let outbound = payments.filter { $0.direction == .outbound } let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.paymentHash.lowercased() }) } - eventCallbackLock.withLock { - let live = hashes(.pending).union(railSendsInFlight) + return eventCallbackLock.withLock { + let live = hashes(.pending).union(railSendsInFlight).union(attemptsRecorded(since: recordsBefore, key: Self.railAttemptsKey)) let paid = hashes(.succeeded).subtracting(live) - trimSettledAttempts(paid: paid, failed: hashes(.failed).subtracting(paid).subtracting(live), key: Self.railAttemptsKey) + let listed = Set(outbound.map { $0.paymentHash.lowercased() }) + trimSettledAttempts( + paid: paid, failed: hashes(.failed).subtracting(paid).subtracting(live), listed: listed, live: live, key: Self.railAttemptsKey + ) + return live } } @@ -1347,13 +1364,16 @@ class LightningService: NodeLifecycleService { } } if rails.contains(.channelFree) { + let recordsBefore = eventCallbackLock.withLock { attemptRecords } if let payments = await readableRailPayments() { - keepRailAttempts(from: payments) + let live = keepRailAttempts(from: payments, recordsBefore: recordsBefore) let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) if let payment { statuses.append(payment.status) } - if payment == nil || payment?.status == .failed, !isRailSendInFlight(paymentHash: paymentHash) { + if payment == nil || payment?.status == .failed, !live.contains(paymentHash.lowercased()), + !isRailSendInFlight(paymentHash: paymentHash) + { releaseRailAttempt(paymentHash: paymentHash) } } else if hasRailAttempt(paymentHash: paymentHash) { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index f7e9bf135..ed7e1056d 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -389,6 +389,71 @@ extension ChannelFreeRailTests { XCTAssertEqual(kept, [retried], "Failed node hashes stayed, or an older listing dropped the retry in flight") } + func testASparkRetryRecordedDuringAnOlderListingKeepsItsHold() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + service.recordRailAttempt(paymentHash: hash) + service.recordRailAttempt(paymentHash: Self.otherHash(1)) + // Spark retries both invoices, and the retries return, while this listing, which still shows them failed, is read. + rail.payments = [hash, Self.otherHash(1)].map { + Bitkit.ChannelFreeRailPayment(paymentHash: $0, direction: .outbound, status: .failed, preimage: nil) + } + rail.onList = { [hash] in + service.recordRailAttempt(paymentHash: hash) + service.recordRailAttempt(paymentHash: Self.otherHash(1)) + } + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, [hash, Self.otherHash(1)], "An older listing dropped the hold of a Spark retry") + } + + func testSendsNoListingShowsAreDroppedOnceTheirInvoiceExpiredPastTheSettleWindow() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let window = 14 * 24 * 60 * 60 + let now = 10 * window + service.now = { Date(timeIntervalSince1970: TimeInterval(now)) } + for key in [Self.railAttemptsKey, Self.ldkAttemptsKey] { + let record = key == Self.railAttemptsKey ? service.recordRailAttempt : service.recordLdkAttempt + record(Self.otherHash(1), now - window) + record(Self.otherHash(2), now - window + 1) + record(Self.otherHash(3), nil) + } + // A send recorded while the listings are read is not in them yet. + rail.onList = { service.recordRailAttempt(paymentHash: Self.otherHash(4), expiresAt: now - window) } + service.nodePayments = { + service.recordLdkAttempt(paymentHash: Self.otherHash(4), expiresAt: now - window) + return [] + } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + for key in [Self.railAttemptsKey, Self.ldkAttemptsKey] { + let kept = service.railAttemptsStore.stringArray(forKey: key) ?? [] + XCTAssertFalse(kept.contains(Self.otherHash(1)), "\(key) kept a send no listing shows past the settle window") + XCTAssertTrue( + Set([2, 3, 4].map(Self.otherHash)).isSubset(of: kept), + "\(key) dropped a send that may be pending or has no known expiry" + ) + } + } + + func testSendsNoListingShowsWithNoKnownExpiryAreDroppedOldestFirstPastTheCap() async { + let service = Bitkit.LightningService.shared + (1 ... 501).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, (2 ... 501).map(Self.otherHash), "Spark hashes no listing shows grew past the cap") + } + func testAPendingNodePaymentBehindTheCapOfFailedOnesStillHoldsSparkWhileTheNodeCantList() async throws { let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) let service = Bitkit.LightningService.shared @@ -985,6 +1050,8 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var sendGate: (() async -> Void)? var refusal: Bitkit.ChannelFreeRailSendRefused? var refusalAfterSubmission: Bitkit.ChannelFreeRailSendRefused? + /// Runs while a listing is read, before it returns `payments`. + var onList: (() -> Void)? func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { "lnbcrt1fake" @@ -1012,6 +1079,7 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { } func lightningPayments() async throws -> [Bitkit.ChannelFreeRailPayment] { + onList?() if let listError { throw listError } From e84249fa42ad2b4ba261b8f0f20fbbf0a57b84a6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 14:25:22 +0100 Subject: [PATCH 211/378] fix: keep the move routing fee when spark settles first --- Bitkit/Services/CoreService.swift | 6 +++++- BitkitTests/MoveToSparkActivityTests.swift | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 74d2f3f54..8977a8d05 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1018,8 +1018,12 @@ class ActivityService { // Skip if existing activity has newer timestamp, unless payment status is changing if let existing = existingLightning, let existingUpdatedAt = existing.updatedAt { let statusChanging = existing.status != state + // The Spark receive of a move can settle the send before the node's routing fee is recorded. + let feeChanging = payment.feePaidMsat.map { $0 / 1000 != existing.fee } ?? false let needsPrivateContactAttribution = existing.contact == nil && payment.direction == .inbound - if existingUpdatedAt > paymentTimestamp && !statusChanging && !needsPrivateContactAttribution && !replacesSelfReceive { + if existingUpdatedAt > paymentTimestamp && !statusChanging && !feeChanging && !needsPrivateContactAttribution + && !replacesSelfReceive + { return } } diff --git a/BitkitTests/MoveToSparkActivityTests.swift b/BitkitTests/MoveToSparkActivityTests.swift index 268ba936b..322dbda73 100644 --- a/BitkitTests/MoveToSparkActivityTests.swift +++ b/BitkitTests/MoveToSparkActivityTests.swift @@ -70,6 +70,18 @@ final class MoveToSparkActivityTests: XCTestCase { try await assertOneOutgoingRowWithTheRoutingFee() } + func testSparkReceiveSettlingTheSendTakesTheRoutingFeeFromTheLaterLdkUpdate() async throws { + let activity = Bitkit.CoreService.shared.activity + // A pending send has no fee yet; the receive's settle stamps the row after the node's later update. + try await activity.syncLdkNodePayments([ + payment(direction: .outbound, feeSats: nil, timestamp: 1000, status: .pending), + ]) + try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1001)) + try await activity.syncLdkNodePayments([ldkSend(timestamp: 1002)]) + + try await assertOneOutgoingRowWithTheRoutingFee() + } + func testPendingLdkSendKeepsTheSucceededSparkReceiveUntilTheSendSucceeds() async throws { let activity = Bitkit.CoreService.shared.activity try await activity.recordChannelFreeRailPayment(sparkReceive(timestamp: 1000)) @@ -111,7 +123,7 @@ final class MoveToSparkActivityTests: XCTestCase { private func payment( direction: LDKNode.PaymentDirection, - feeSats: UInt64, + feeSats: UInt64?, timestamp: UInt64, status: LDKNode.PaymentStatus = .succeeded ) -> LDKNode.PaymentDetails { @@ -125,7 +137,7 @@ final class MoveToSparkActivityTests: XCTestCase { bolt11: invoice ), amountMsat: movedSats * 1000, - feePaidMsat: feeSats * 1000, + feePaidMsat: feeSats.map { $0 * 1000 }, direction: direction, status: status, latestUpdateTimestamp: timestamp From f143cc33fdc2065fdecc1764fe13eabbcf108a10 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 14:46:36 +0100 Subject: [PATCH 212/378] fix: keep a spark retry's hold through the activity sync --- Bitkit/Managers/SparkManager.swift | 19 ++++++++++++------- Bitkit/Services/LightningService.swift | 12 +++++++++++- BitkitTests/SparkPaymentSafetyTests.swift | 21 +++++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index be4deec34..e011dea38 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -765,18 +765,23 @@ final class SparkManager: ChannelFreeLightningRail { } catch { recordError = error } - // A send that failed while the app was closed released no hold on a channel retry. Spark's list is read again, - // so a retry the user started while the activities were recorded keeps its hold. - if let latest = try? await requireSdk().listPayments(request: ListPaymentsRequest()).payments { - for paymentHash in Self.failedSendHashes(latest) { - LightningService.shared.releaseRailAttempt(paymentHash: paymentHash) - } - } + // A send that failed while the app was closed released no hold on a channel retry. + await Self.releaseFailedSendHolds { try await self.requireSdk().listPayments(request: ListPaymentsRequest()).payments } if let recordError { throw recordError } } + /// Releases the holds of the sends Spark's list shows failed. The list is read after the activities were recorded, + /// so a retry the user started meanwhile keeps its hold, as does one recorded while the list is read. + static func releaseFailedSendHolds(listPayments: () async throws -> [Payment]) async { + let recordsBefore = LightningService.shared.attemptRecordCount + guard let latest = try? await listPayments() else { return } + for paymentHash in failedSendHashes(latest) { + LightningService.shared.releaseRailAttempt(paymentHash: paymentHash, recordedBy: recordsBefore) + } + } + /// Records every payment, then throws when any failed to save, so the activity sync isn't marked complete. static func recordAll(_ payments: [Payment], record: (Payment) async -> Bool) async throws { var unsaved = 0 diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 1ac4aeafc..14edd9fa3 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1225,14 +1225,24 @@ class LightningService: NodeLifecycleService { /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. /// With `unlessInFlight`, skipped while a rail send of the invoice is in flight, as that retry holds it again. - func releaseRailAttempt(paymentHash: String, unlessInFlight: Bool = true) { + /// With `recordedBy`, skipped too when the hash was recorded after `attemptRecordCount` read that count, as a retry + /// recorded while a listing was read holds it again. + func releaseRailAttempt(paymentHash: String, unlessInFlight: Bool = true, recordedBy records: Int? = nil) { let hash = paymentHash.lowercased() eventCallbackLock.withLock { guard !unlessInFlight || !railSendsInFlight.contains(hash) else { return } + if let records, attemptsRecorded(since: records, key: Self.railAttemptsKey).contains(hash) { + return + } removeAttempt(paymentHash: hash, key: Self.railAttemptsKey) } } + /// The attempts recorded so far, read before a listing so a release from it can skip the retries recorded since. + var attemptRecordCount: Int { + eventCallbackLock.withLock { attemptRecords } + } + private func releaseLdkAttempt(paymentHash: String) { eventCallbackLock.withLock { removeAttempt(paymentHash: paymentHash.lowercased(), key: Self.ldkAttemptsKey) } } diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index 32b81e8d9..e7a6c0e6c 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -275,6 +275,27 @@ final class SparkPaymentSafetyTests: XCTestCase { XCTAssertEqual(SparkManager.failedSendHashes(payments), ["failed"]) } + func testASparkRetryThatReturnsWhileTheSyncReadsSparksListKeepsItsHold() async throws { + let service = Bitkit.LightningService.shared + let store = service.railAttemptsStore + service.railAttemptsStore = try XCTUnwrap(UserDefaults(suiteName: "SparkPaymentSafetyTests")) + service.forgetRailAttempts() + defer { + service.forgetRailAttempts() + service.railAttemptsStore = store + } + service.recordRailAttempt(paymentHash: "failed") + service.recordRailAttempt(paymentHash: "retried") + + await SparkManager.releaseFailedSendHolds { + // The user retries one invoice, and the retry returns, while this list, which still shows it failed, is read. + service.recordRailAttempt(paymentHash: "retried") + return ["failed", "retried"].map { self.lightningPayment(type: .send, status: .failed, hash: $0) } + } + + XCTAssertEqual(service.railAttemptsStore.stringArray(forKey: "channelFreeRailAttemptedPaymentHashes"), ["retried"]) + } + private func lightningPayment( type: PaymentType, status: BreezSdkSpark.PaymentStatus, From d6f1c28a24d2e45c8589d1a417347419a1d5e4dc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 15:03:33 +0100 Subject: [PATCH 213/378] fix: list a spark deposit claimed early only once --- Bitkit/Managers/SparkManager.swift | 12 +++++++++++- BitkitTests/SparkTransferActivityTests.swift | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index e011dea38..7bf5e5379 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -661,9 +661,10 @@ final class SparkManager: ChannelFreeLightningRail { if let syncError = info.lastSyncError { Logger.warn("Spark's last sync failed: \(syncError)", context: "SparkManager") } - let unclaimed = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()).deposits + let listedUnclaimed = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()).deposits let payments = try await sdk.listPayments(request: ListPaymentsRequest()).payments let claimed = payments.filter { $0.method == .deposit && $0.status == .completed } + let unclaimed = Self.unclaimedDeposits(listedUnclaimed, claimed: claimed) let listedDeposits = await Self.deposits(unclaimed: unclaimed, claimed: claimed) // A wipe while this read was in flight leaves the state it cleared. guard self.sdk === sdk else { return } @@ -689,6 +690,15 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Spark can still list a deposit it already claimed early as unclaimed, so a claimed one is listed only as claimed. + nonisolated static func unclaimedDeposits(_ unclaimed: [DepositInfo], claimed: [Payment]) -> [DepositInfo] { + let claimedTxIds = Set(claimed.compactMap { payment -> String? in + guard case let .deposit(txId, _) = payment.details else { return nil } + return txId.lowercased() + }) + return unclaimed.filter { !claimedTxIds.contains($0.txid.lowercased()) } + } + nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { payments.compactMap { payment in guard payment.status != .failed, case let .withdraw(txId, _, _) = payment.details else { return nil } diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index 1acae7bdc..f7de04e7e 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -20,6 +20,20 @@ final class SparkTransferActivityTests: XCTestCase { XCTAssertEqual(txIds, ["pending-deposit", "claimed-deposit", "withdrawal"]) } + func testADepositClaimedEarlyIsListedOnlyAsClaimed() { + let deposits = ["claimed-early", "pending-deposit"].map { + DepositInfo( + txid: $0, vout: 0, amountSats: 7500, isMature: true, + refundTx: nil, refundTxId: nil, refundState: nil, claimError: nil, instantClaimStatus: nil, maxClaimFee: nil + ) + } + let claimed = [payment(id: "1", status: .completed, details: .deposit(txId: "claimed-early", vout: 0))] + + let unclaimed = SparkManager.unclaimedDeposits(deposits, claimed: claimed) + + XCTAssertEqual(unclaimed.map(\.txid), ["pending-deposit"], "A deposit claimed early still shows as in progress") + } + func testListsWithdrawalsWithWhatSparkKept() { let payments = [ payment(id: "1", status: .completed, details: .withdraw(txId: "withdrawal", vout: 0, destinationAddress: "bcrt1qsavings"), fees: 1470), From fb2025e9b879a77bc20d68c8632caa8877667166 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 15:03:34 +0100 Subject: [PATCH 214/378] fix: keep the spark balance shown after a failed withdrawal --- Bitkit/Managers/SparkManager.swift | 45 ++++++++++++++------ BitkitTests/SparkTransferActivityTests.swift | 6 +++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 7bf5e5379..0fa43fb8d 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -58,6 +58,8 @@ final class SparkManager: ChannelFreeLightningRail { @ObservationIgnored private var preparedWithdrawal: (quote: SparkWithdrawalQuote, response: PrepareSendPaymentResponse)? /// Withdrawals this app sent, known before Spark lists them. @ObservationIgnored private var sentWithdrawalTxIds: Set = [] + /// Set by a failed withdrawal until Spark reads a balance again (`shownBalanceSats`). + @ObservationIgnored private var withdrawalFailed = false @ObservationIgnored private var listenerId: String? @ObservationIgnored private var connectTask: Task? /// Bumped by `wipe`, so a connect that started before it disconnects instead of keeping the wiped wallet. @@ -238,6 +240,7 @@ final class SparkManager: ChannelFreeLightningRail { activitiesSynced = false isReady = false balanceSats = 0 + withdrawalFailed = false pendingDepositSats = 0 deposits = [] withdrawals = [] @@ -458,17 +461,23 @@ final class SparkManager: ChannelFreeLightningRail { /// Moves the whole Spark balance to the on-chain `address`, fees included, for Spending to Savings. Refuses before /// sending when the fee is above `maxFeeSats`, the fee the confirm screen showed. func withdrawAll(to address: String, maxFeeSats: UInt64) async throws { - let prepared = try await prepareWithdrawAll(to: address) - try Self.checkWithdrawalFee(Self.withdrawalFeeSats(prepared), shownFeeSats: maxFeeSats) - let response = try await requireSdk().sendPayment(request: SendPaymentRequest( - prepareResponse: prepared, - options: .bitcoinAddress(confirmationSpeed: .medium) - )) - preparedWithdrawal = nil - if case let .withdraw(txId, _, _) = response.payment.details { - sentWithdrawalTxIds.insert(txId) + do { + let prepared = try await prepareWithdrawAll(to: address) + try Self.checkWithdrawalFee(Self.withdrawalFeeSats(prepared), shownFeeSats: maxFeeSats) + let response = try await requireSdk().sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium) + )) + preparedWithdrawal = nil + if case let .withdraw(txId, _, _) = response.payment.details { + sentWithdrawalTxIds.insert(txId) + } + withdrawalFailed = response.payment.status == .failed + try await Self.settleWithdrawal(status: response.payment.status) { try await self.refresh() } + } catch { + withdrawalFailed = true + throw error } - try await Self.settleWithdrawal(status: response.payment.status) { try await self.refresh() } } /// A sent withdrawal has left the balance, so only Spark's own failed status fails it; reading the balance @@ -669,9 +678,15 @@ final class SparkManager: ChannelFreeLightningRail { // A wipe while this read was in flight leaves the state it cleared. guard self.sdk === sdk else { return } let pending = unclaimed.reduce(0) { $0 + $1.amountSats } - let changed = !isReady || balanceSats != info.balanceSats || pendingDepositSats != pending + if info.balanceSats > 0 { + withdrawalFailed = false + } + let shownBalance = Self.shownBalanceSats( + read: info.balanceSats, lastKnown: balanceSats, isStale: withdrawalFailed || info.lastSyncError != nil + ) + let changed = !isReady || balanceSats != shownBalance || pendingDepositSats != pending isReady = true - balanceSats = info.balanceSats + balanceSats = shownBalance pendingDepositSats = pending deposits = listedDeposits withdrawals = Self.withdrawals(payments) @@ -690,6 +705,12 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// After a failed withdrawal, or while Spark's last sync failed (`isStale`), Spark can read 0 with the funds still + /// there, so the last known balance stays shown until it reads one again. + nonisolated static func shownBalanceSats(read: UInt64, lastKnown: UInt64, isStale: Bool) -> UInt64 { + read == 0 && isStale ? lastKnown : read + } + /// Spark can still list a deposit it already claimed early as unclaimed, so a claimed one is listed only as claimed. nonisolated static func unclaimedDeposits(_ unclaimed: [DepositInfo], claimed: [Payment]) -> [DepositInfo] { let claimedTxIds = Set(claimed.compactMap { payment -> String? in diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index f7de04e7e..0e49c20f0 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -34,6 +34,12 @@ final class SparkTransferActivityTests: XCTestCase { XCTAssertEqual(unclaimed.map(\.txid), ["pending-deposit"], "A deposit claimed early still shows as in progress") } + func testAZeroReadAfterAFailedWithdrawalOrSyncKeepsTheLastKnownBalance() { + XCTAssertEqual(SparkManager.shownBalanceSats(read: 0, lastKnown: 9000, isStale: true), 9000, "The balance read 0 while stale") + XCTAssertEqual(SparkManager.shownBalanceSats(read: 0, lastKnown: 9000, isStale: false), 0) + XCTAssertEqual(SparkManager.shownBalanceSats(read: 4000, lastKnown: 9000, isStale: true), 4000) + } + func testListsWithdrawalsWithWhatSparkKept() { let payments = [ payment(id: "1", status: .completed, details: .withdraw(txId: "withdrawal", vout: 0, destinationAddress: "bcrt1qsavings"), fees: 1470), From 6c382ac9fb89116028944daebca152a5ef9fe222 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 15:32:54 +0100 Subject: [PATCH 215/378] fix: bind a fixed spark funding to its confirmed fee --- .../Services/OnchainSendAttemptService.swift | 10 +++++++--- Bitkit/ViewModels/SparkTransferFlow.swift | 18 +++++++++++++++++- Bitkit/Views/Transfer/SpendingConfirm.swift | 2 ++ .../OnchainSendAttemptServiceTests.swift | 7 ++++--- BitkitTests/SparkTransferFlowTests.swift | 16 ++++++++++++++++ 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index c82e9bd00..e5ce38729 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -352,7 +352,10 @@ struct OnchainSendFollowupContext: Codable, Equatable { /// A max Spark funding refused before broadcast because it would send another amount, or pay a higher mining fee, than /// the user approved, as after the balance grew. A fixed amount's fee isn't bound: its coin selection can vary. -struct SparkFundingChanged: Error {} +struct SparkFundingChanged: Error { + /// The mining fee of the refused prepared send, nil when it was unknown. + var preparedFeeSats: UInt64? +} struct OnchainSendTransferContext: Codable, Equatable { let clientBalanceSats: UInt64 @@ -652,10 +655,11 @@ actor OnchainSendAttemptService { prepared.recipientAmountSats <= context.txTotalSats - miningFee else { throw OnchainSendAttemptError.unresolved } } - if isSparkFunding && isMaxAmount { + // Savings never pay more than the Total the user confirmed, so a higher fee asks for another swipe. + if isSparkFunding { guard prepared.recipientAmountSats == amountSats, let miningFee = prepared.miningFeeSats, let approvedFee = followupContext?.feeSats, miningFee <= approvedFee - else { throw SparkFundingChanged() } + else { throw SparkFundingChanged(preparedFeeSats: prepared.miningFeeSats) } } attempt.amountSats = prepared.recipientAmountSats attempt.txid = prepared.txid diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 63c4b0309..d43576f19 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -180,6 +180,8 @@ final class SparkTransferFlow { @ObservationIgnored private var approvals: [UUID: (ceilingSats: UInt64, speed: SparkTransferSpeed)] = [:] @ObservationIgnored private var watchTasks: [String: Task] = [:] @ObservationIgnored private var quoteRequestId = 0 + /// The mining fee a refused fixed-amount funding needed, which this transfer's quotes show at least. + @ObservationIgnored private(set) var fixedFeeFloorSats: UInt64? @ObservationIgnored private let operations: SparkClaimOperations @ObservationIgnored private let pollAttempts: Int @@ -204,6 +206,7 @@ final class SparkTransferFlow { func start(amountSats: UInt64, isMaxAmount: Bool, walletId: String? = nil) { if self.amountSats != amountSats || self.isMaxAmount != isMaxAmount || self.walletId != walletId { quote = nil + fixedFeeFloorSats = nil } self.amountSats = amountSats self.isMaxAmount = isMaxAmount @@ -220,6 +223,7 @@ final class SparkTransferFlow { speed = .instant quote = nil quoteError = nil + fixedFeeFloorSats = nil } // MARK: Quote @@ -236,6 +240,7 @@ final class SparkTransferFlow { let amountSats = amountSats let isMaxAmount = isMaxAmount let walletId = walletId + let fixedFeeFloorSats = fixedFeeFloorSats ?? 0 isQuoting = true defer { if requestId == quoteRequestId { @@ -262,7 +267,11 @@ final class SparkTransferFlow { networkFeeSats = try await wallet.estimateSendAllFee(address: address, satsPerVByte: rate) depositSats = UInt64(max(0, wallet.spendableOnchainBalanceSats)).saturatingSub(networkFeeSats) } else { - networkFeeSats = try await wallet.calculateTotalFee(address: address, amountSats: amountSats, satsPerVByte: rate) + // A fixed amount's coin selection can need more fee than its quote, so after a refusal it shows at least that. + networkFeeSats = try await max( + wallet.calculateTotalFee(address: address, amountSats: amountSats, satsPerVByte: rate), + fixedFeeFloorSats + ) depositSats = amountSats } let claimFees = try await SparkManager.shared.depositClaimFees(amountSats: depositSats) @@ -301,6 +310,13 @@ final class SparkTransferFlow { } } + /// A fixed-amount funding refused for needing `preparedFeeSats` keeps that fee in this transfer's quotes, so the next + /// swipe approves it. + func onFundingChanged(preparedFeeSats: UInt64?) { + guard !isMaxAmount, walletId == nil else { return } + fixedFeeFloorSats = preparedFeeSats + } + /// Records the early-claim ceiling and speed the user swiped, as the confirm screen keeps requoting while a payment /// whose outcome is unknown waits on the pending screen. func approveSwipe() { diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 4c144cce7..4462621ac 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -171,6 +171,8 @@ struct SpendingConfirm: View { } catch let OnchainSendAttemptError.preDispatch(error) where error is SparkFundingChanged { // The max quote reads the cached savings balance, so it is read again before pricing the new transfer. await wallet.syncStateAsync() + // The next swipe approves the fee this one was refused for. + flow.onFundingChanged(preparedFeeSats: (error as? SparkFundingChanged)?.preparedFeeSats) guard let repriced = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) else { throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "Re-quote after refusal failed") } diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index 0408be650..c3cc178f4 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -355,8 +355,9 @@ final class OnchainSendAttemptServiceTests: XCTestCase { (true, 99000, 500, true), (true, 99000, 400, true), (true, 99500, 500, false), (true, 98500, 500, false), (true, 99000, 501, false), (true, 99000, nil, false), - // A fixed amount is already pinned, and its coin selection may differ from the quote's, so its fee isn't bound. - (false, 99000, 500, true), (false, 99000, 501, true), + // A fixed amount must not spend more Savings than the confirmed Total either, so its fee is bound too. + (false, 99000, 500, true), (false, 99000, 400, true), + (false, 99000, 501, false), (false, 99000, nil, false), ] { let store = MemoryAttemptStore() let service = OnchainSendAttemptService(store: store) @@ -372,7 +373,7 @@ final class OnchainSendAttemptServiceTests: XCTestCase { XCTAssertEqual(sender.broadcasts, 1) } catch let OnchainSendAttemptError.preDispatch(error) { XCTAssertFalse(valid, "The approved funding must still go out") - XCTAssertTrue(error is SparkFundingChanged, "Refused with \(error)") + XCTAssertEqual((error as? SparkFundingChanged)?.preparedFeeSats, fee, "Refused with \(error)") XCTAssertEqual(sender.broadcasts, 0) XCTAssertTrue(store.snapshot().isEmpty) } diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 2d494d728..a7bb85040 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -349,6 +349,22 @@ final class SparkTransferFlowTests: XCTestCase { return SparkTransferFlow(amountSats: 100_000, quote: quote, operations: operations, pollAttempts: pollAttempts ?? offers.count + 1) } + @MainActor + func testARefusedFixedFundingKeepsItsFeeUntilTheTransferChanges() { + let flow = SparkTransferFlow() + flow.start(amountSats: 50000, isMaxAmount: false) + flow.onFundingChanged(preparedFeeSats: 700) + XCTAssertEqual(flow.fixedFeeFloorSats, 700, "The next swipe would approve less than the refused fee") + flow.start(amountSats: 50000, isMaxAmount: false) + XCTAssertEqual(flow.fixedFeeFloorSats, 700) + flow.start(amountSats: 60000, isMaxAmount: false) + XCTAssertNil(flow.fixedFeeFloorSats) + + flow.start(amountSats: 60000, isMaxAmount: true) + flow.onFundingChanged(preparedFeeSats: 700) + XCTAssertNil(flow.fixedFeeFloorSats, "Max re-prices from the balance instead") + } + func testQuoteTiles() { XCTAssertEqual(quote.toSpendingSats(for: .instant), 100_000 - 1093) XCTAssertEqual(quote.toSpendingSats(for: .later), 100_000 - 198) From ed7630fd7d20cfbb8795cd590e5703a26fddb1f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 15:32:54 +0100 Subject: [PATCH 216/378] fix: clear the kept spark balance once spark syncs --- Bitkit/Managers/SparkManager.swift | 51 +++++++++++++------ Bitkit/ViewModels/WalletViewModel.swift | 4 +- .../Advanced/LightningConnectionsView.swift | 2 +- .../Advanced/SparkConnectionViews.swift | 12 ++--- BitkitTests/SparkTransferActivityTests.swift | 18 +++++-- 5 files changed, 60 insertions(+), 27 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 0fa43fb8d..4dd3def5f 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -38,7 +38,15 @@ final class SparkManager: ChannelFreeLightningRail { private nonisolated static let depositClaimFeePpm: UInt32 = 2000 private(set) var isReady = false + /// What Spark read, which sends and withdrawals use. private(set) var balanceSats: UInt64 = 0 + /// The last balance shown, kept while Spark reads 0 after a failed withdrawal or a failed sync; display only. + private(set) var keptBalanceSats: UInt64? + /// The balance Home and the Spark connection show. + var shownBalanceSats: UInt64 { + keptBalanceSats ?? balanceSats + } + /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. private(set) var pendingDepositSats: UInt64 = 0 /// On-chain deposits into Spark, pending ones first, for the Lightning Connections screens. @@ -58,8 +66,9 @@ final class SparkManager: ChannelFreeLightningRail { @ObservationIgnored private var preparedWithdrawal: (quote: SparkWithdrawalQuote, response: PrepareSendPaymentResponse)? /// Withdrawals this app sent, known before Spark lists them. @ObservationIgnored private var sentWithdrawalTxIds: Set = [] - /// Set by a failed withdrawal until Spark reads a balance again (`shownBalanceSats`). - @ObservationIgnored private var withdrawalFailed = false + /// When a withdrawal last failed, in seconds since 1970, until Spark reads a balance again or syncs after it + /// (`keptBalanceSats`). + @ObservationIgnored private var withdrawalFailedAt: UInt64? @ObservationIgnored private var listenerId: String? @ObservationIgnored private var connectTask: Task? /// Bumped by `wipe`, so a connect that started before it disconnects instead of keeping the wiped wallet. @@ -240,7 +249,8 @@ final class SparkManager: ChannelFreeLightningRail { activitiesSynced = false isReady = false balanceSats = 0 - withdrawalFailed = false + keptBalanceSats = nil + withdrawalFailedAt = nil pendingDepositSats = 0 deposits = [] withdrawals = [] @@ -472,10 +482,10 @@ final class SparkManager: ChannelFreeLightningRail { if case let .withdraw(txId, _, _) = response.payment.details { sentWithdrawalTxIds.insert(txId) } - withdrawalFailed = response.payment.status == .failed + withdrawalFailedAt = response.payment.status == .failed ? Self.nowSecs() : nil try await Self.settleWithdrawal(status: response.payment.status) { try await self.refresh() } } catch { - withdrawalFailed = true + withdrawalFailedAt = Self.nowSecs() throw error } } @@ -678,15 +688,16 @@ final class SparkManager: ChannelFreeLightningRail { // A wipe while this read was in flight leaves the state it cleared. guard self.sdk === sdk else { return } let pending = unclaimed.reduce(0) { $0 + $1.amountSats } - if info.balanceSats > 0 { - withdrawalFailed = false - } - let shownBalance = Self.shownBalanceSats( - read: info.balanceSats, lastKnown: balanceSats, isStale: withdrawalFailed || info.lastSyncError != nil + withdrawalFailedAt = Self.withdrawalFailedAt( + withdrawalFailedAt, read: info.balanceSats, lastSyncedAt: info.lastSyncedAt, syncFailed: info.lastSyncError != nil + ) + let kept = Self.keptBalanceSats( + read: info.balanceSats, lastShown: shownBalanceSats, isStale: withdrawalFailedAt != nil || info.lastSyncError != nil ) - let changed = !isReady || balanceSats != shownBalance || pendingDepositSats != pending + let changed = !isReady || shownBalanceSats != (kept ?? info.balanceSats) || pendingDepositSats != pending isReady = true - balanceSats = shownBalance + balanceSats = info.balanceSats + keptBalanceSats = kept pendingDepositSats = pending deposits = listedDeposits withdrawals = Self.withdrawals(payments) @@ -706,9 +717,19 @@ final class SparkManager: ChannelFreeLightningRail { } /// After a failed withdrawal, or while Spark's last sync failed (`isStale`), Spark can read 0 with the funds still - /// there, so the last known balance stays shown until it reads one again. - nonisolated static func shownBalanceSats(read: UInt64, lastKnown: UInt64, isStale: Bool) -> UInt64 { - read == 0 && isStale ? lastKnown : read + /// there, so the last shown balance stays shown until it reads one again. + nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool) -> UInt64? { + read == 0 && isStale && lastShown > 0 ? lastShown : nil + } + + /// A failed withdrawal stops keeping the balance once Spark reads one, or reads 0 after a sync that ended after it. + nonisolated static func withdrawalFailedAt(_ failedAt: UInt64?, read: UInt64, lastSyncedAt: UInt64?, syncFailed: Bool) -> UInt64? { + guard let failedAt, read == 0, syncFailed || (lastSyncedAt ?? 0) <= failedAt else { return nil } + return failedAt + } + + private nonisolated static func nowSecs() -> UInt64 { + UInt64(Date().timeIntervalSince1970) } /// Spark can still list a deposit it already claimed early as unclaimed, so a claimed one is listed only as claimed. diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 47179ea8a..ddca5319c 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1431,8 +1431,8 @@ class WalletViewModel: ObservableObject { // Update display values with adjusted balances totalOnchainSats = Int(state.totalOnchainSats) - totalLightningSats = Int(state.totalLightningSats + spark.balanceSats) - totalBalanceSats = Int(state.totalBalanceSats + spark.balanceSats + spark.pendingDepositSats) + totalLightningSats = Int(state.totalLightningSats + spark.shownBalanceSats) + totalBalanceSats = Int(state.totalBalanceSats + spark.shownBalanceSats + spark.pendingDepositSats) // A payment goes over one rail, so the larger of the two balances caps it. maxSendLightningSats = Int(max(state.maxSendLightningSats, spark.sendableSats)) diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 35e4605b9..57392d2eb 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -240,7 +240,7 @@ struct LightningConnectionsView: View { // MARK: - Computed Properties private var spendingBalance: UInt64 { - let sparkSats = SparkManager.shared.balanceSats + let sparkSats = SparkManager.shared.shownBalanceSats guard let balanceDetails = wallet.balanceDetails else { return sparkSats } return balanceDetails.totalLightningBalanceSats + sparkSats } diff --git a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift index e673479a9..1214bddad 100644 --- a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift @@ -9,7 +9,7 @@ struct SparkConnectionRow: View { static var isVisible: Bool { let spark = SparkManager.shared - return spark.isReady && (spark.balanceSats > 0 || !spark.deposits.isEmpty || Env.network == .regtest) + return spark.isReady && (spark.shownBalanceSats > 0 || !spark.deposits.isEmpty || Env.network == .regtest) } var body: some View { @@ -28,8 +28,8 @@ struct SparkConnectionRow: View { .padding(.bottom, 6) LightningChannel( - capacity: spark.balanceSats, - localBalance: spark.balanceSats, + capacity: spark.shownBalanceSats, + localBalance: spark.shownBalanceSats, remoteBalance: 0, status: .open, showReceiving: false @@ -120,8 +120,8 @@ struct SparkConnectionDetailView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { LightningChannel( - capacity: spark.balanceSats, - localBalance: spark.balanceSats, + capacity: spark.shownBalanceSats, + localBalance: spark.shownBalanceSats, remoteBalance: 0, status: .open, showReceiving: false @@ -153,7 +153,7 @@ struct SparkConnectionDetailView: View { VStack(alignment: .leading, spacing: 0) { SettingsSectionHeader(t("lightning__balance")) - amountRow(label: t("lightning__spending_label"), amount: spark.balanceSats) + amountRow(label: t("lightning__spending_label"), amount: spark.shownBalanceSats) } let pending = spark.deposits.filter { !$0.isClaimed } diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index 0e49c20f0..bac3489bd 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -35,9 +35,21 @@ final class SparkTransferActivityTests: XCTestCase { } func testAZeroReadAfterAFailedWithdrawalOrSyncKeepsTheLastKnownBalance() { - XCTAssertEqual(SparkManager.shownBalanceSats(read: 0, lastKnown: 9000, isStale: true), 9000, "The balance read 0 while stale") - XCTAssertEqual(SparkManager.shownBalanceSats(read: 0, lastKnown: 9000, isStale: false), 0) - XCTAssertEqual(SparkManager.shownBalanceSats(read: 4000, lastKnown: 9000, isStale: true), 4000) + XCTAssertEqual(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true), 9000, "The balance read 0 while stale") + XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false)) + XCTAssertNil(SparkManager.keptBalanceSats(read: 4000, lastShown: 9000, isStale: true)) + XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 0, isStale: true)) + } + + func testASyncedZeroReadAfterAFailedWithdrawalClearsTheKeptBalance() { + XCTAssertNil( + SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: 101, syncFailed: false), + "A sync after the failed withdrawal read 0 and the balance stayed kept" + ) + XCTAssertEqual(SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: 40, syncFailed: false), 100) + XCTAssertEqual(SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: 101, syncFailed: true), 100) + XCTAssertEqual(SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: nil, syncFailed: false), 100) + XCTAssertNil(SparkManager.withdrawalFailedAt(100, read: 4000, lastSyncedAt: nil, syncFailed: true)) } func testListsWithdrawalsWithWhatSparkKept() { From 12d41bdd050a0d4658d7d45fb540053cf42afad0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 15:32:55 +0100 Subject: [PATCH 217/378] fix: keep a node retry's hold through an older listing --- Bitkit/Services/LightningService.swift | 24 ++++++++++++++---------- BitkitTests/ChannelFreeRailTests.swift | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 14edd9fa3..ecfe1278f 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -377,7 +377,7 @@ class LightningService: NodeLifecycleService { await refreshChannelCache() await refreshCache() - _ = await listNodePaymentsKeepingAttempts() + await listNodePaymentsKeepingAttempts() Logger.info("Node started") } @@ -1295,24 +1295,26 @@ class LightningService: NodeLifecycleService { } } - /// The node's payments, after keeping its attempts from them; nil while the node can't list them. - private func listNodePaymentsKeepingAttempts() async -> [PaymentDetails]? { + /// The node's payments, after keeping its attempts from them, with the hashes still holding Spark (`live`); nil + /// while the node can't list them. + @discardableResult + private func listNodePaymentsKeepingAttempts() async -> (payments: [PaymentDetails], live: Set)? { let recordsBefore = eventCallbackLock.withLock { attemptRecords } guard let payments = await nodePayments() else { return nil } - keepLdkAttempts(from: payments, recordsBefore: recordsBefore) - return payments + let live = keepLdkAttempts(from: payments, recordsBefore: recordsBefore) + return (payments, live) } /// Keeps the hashes of the node's pending and paid outbound payments, once, so those made before hashes were kept /// hold Spark while the node can't list them. Then drops the ones listed only as failed that were recorded before - /// the listing was read (`recordsBefore`), and trims paid ones past the limit. - private func keepLdkAttempts(from payments: [PaymentDetails], recordsBefore: Int) { + /// the listing was read (`recordsBefore`), and trims paid ones past the limit. Returns the hashes still live. + private func keepLdkAttempts(from payments: [PaymentDetails], recordsBefore: Int) -> Set { let outbound = payments.filter { $0.direction == .outbound } let sends = outbound .filter { $0.status != .failed } .sorted { $0.latestUpdateTimestamp < $1.latestUpdateTimestamp } let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.id.lowercased() }) } - eventCallbackLock.withLock { + return eventCallbackLock.withLock { if !railAttemptsStore.bool(forKey: Self.ldkAttemptsSeededKey) { let kept = railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] let seeded = sends.map { $0.id.lowercased() }.filter { !kept.contains($0) } @@ -1325,6 +1327,7 @@ class LightningService: NodeLifecycleService { trimSettledAttempts( paid: paid, failed: hashes(.failed).subtracting(paid).subtracting(live), listed: listed, live: live, key: Self.ldkAttemptsKey ) + return live } } @@ -1355,14 +1358,15 @@ class LightningService: NodeLifecycleService { guard let paymentHash else { return nil } var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] if rails.contains(.ldk) { - if let payments = await listNodePaymentsKeepingAttempts() { + if let (payments, live) = await listNodePaymentsKeepingAttempts() { let payment = payments.first { $0.direction == .outbound && $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame } if let payment { statuses.append(payment.status) } - if payment == nil || payment?.status == .failed { + // A retry in flight, or one recorded while the listing was read, holds the invoice again. + if payment == nil || payment?.status == .failed, !live.contains(paymentHash.lowercased()) { releaseLdkAttempt(paymentHash: paymentHash) } } else if hasAttempt(paymentHash: paymentHash, key: Self.ldkAttemptsKey) { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index ed7e1056d..af9500898 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -389,6 +389,25 @@ extension ChannelFreeRailTests { XCTAssertEqual(kept, [retried], "Failed node hashes stayed, or an older listing dropped the retry in flight") } + func testANodeRetryOfTheSameInvoiceRecordedDuringAnOlderListingKeepsItsHold() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + service.recordLdkAttempt(paymentHash: hash) + // The node retries the invoice Spark is about to pay, and the retry returns, while this listing is read. + service.nodePayments = { + service.recordLdkAttempt(paymentHash: hash) + return [Self.nodePayment(hash, status: .failed)] + } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertEqual(kept, [hash], "An older listing released the node retry's hold") + } + func testASparkRetryRecordedDuringAnOlderListingKeepsItsHold() async throws { let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) let service = Bitkit.LightningService.shared From d862c34ba66d212ece0df89ab3f62f4c9b92eae9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 13:47:26 +0100 Subject: [PATCH 218/378] fix: draw the spending confirm coins at android's size --- Bitkit/Views/Transfer/SpendingConfirm.swift | 50 ++++++++++----------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 4462621ac..a76de4dc8 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -11,9 +11,9 @@ struct SpendingConfirm: View { @State private var isPaying = false @State private var hideSwipeButton = false - @State private var aboveCoinsHeight: CGFloat = 0 - private static let bottomPadding: CGFloat = 16 + /// The coin drawing fills 72% of its square image, so this draws it about 134 pt wide, Android's third of the screen. + private static let coinsSize: CGFloat = 186 /// Fee rates move while the screen is open, so the quote is redone this often as well as on every change. private static let requoteInterval: Duration = .seconds(60) @@ -30,46 +30,46 @@ struct SpendingConfirm: View { .disabled(isPaying) .padding(.bottom, 16) - // The coins take the space left above the swipe, as on Android; on a short screen the content scrolls. + // The coins sit in the space left above the swipe, as on Android; on a short screen the content scrolls. GeometryReader { geo in ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 0) { - DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) + DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) - SpendingConfirmFees(quote: flow.quote, speed: flow.speed, isLoading: flow.quote == nil && flow.quoteError == nil) - .padding(.top, 32) + SpendingConfirmFees(quote: flow.quote, speed: flow.speed, isLoading: flow.quote == nil && flow.quoteError == nil) + .padding(.top, 16) - if let error = flow.quoteError { - quoteError(error) - .padding(.bottom, 16) - } - - SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) - .disabled(isPaying) - .padding(.top, 16) - .padding(.horizontal, -16) + if let error = flow.quoteError { + quoteError(error) .padding(.bottom, 16) + } - CustomButton(title: t("common__learn_more"), size: .small) { - navigation.navigate(.transferLearnMore) - } + SpendingSpeedPicker(quote: flow.quote, speed: Bindable(flow).speed) .disabled(isPaying) - .accessibilityIdentifier("SpendingConfirmMore") + .padding(.top, 8) + .padding(.horizontal, -16) + .padding(.bottom, 10) + + CustomButton(title: t("common__learn_more"), size: .small) { + navigation.navigate(.transferLearnMore) } - .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { aboveCoinsHeight = $0 } + .disabled(isPaying) + .accessibilityIdentifier("SpendingConfirmMore") + + Spacer(minLength: 0) Image("coin-stack-x") .resizable() .aspectRatio(contentMode: .fit) - .padding(.horizontal, 44) - .padding(.vertical, 8) + .frame(width: Self.coinsSize, height: Self.coinsSize) .frame(maxWidth: .infinity) - .frame(height: max(0, geo.size.height - aboveCoinsHeight - Self.bottomPadding)) + + Spacer(minLength: 0) } // The speed rows run edge to edge, so the scroll spans the screen and pads its own content. .padding(.horizontal, 16) - .padding(.bottom, Self.bottomPadding) + .padding(.bottom, 16) + .frame(minHeight: geo.size.height) } } .padding(.horizontal, -16) From d5037360e214a5c9c6b8e947b58083f809308bfb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 13:47:26 +0100 Subject: [PATCH 219/378] fix: mark to spending as an estimate on the confirm screen --- Bitkit/Views/Transfer/SpendingConfirm.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index a76de4dc8..6b9540e72 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -240,6 +240,8 @@ struct SpendingConfirmFees: View { FeeDisplayRow( label: t("lightning__spending_confirm__amount"), amount: quote?.toSpendingSats(for: speed) ?? 0, + // What reaches spending is net of the estimated claim fee, so it is an estimate too. + prefix: SpendingSpeedPicker.feePrefix(for: speed), isLoading: isLoading ) .frame(maxWidth: .infinity) From 658d1b25368e8b16531141eab8cba5abdbe5d466 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 13:47:27 +0100 Subject: [PATCH 220/378] fix: keep the savings confirm piggybank at full size --- .../Views/Transfer/SavingsConfirmView.swift | 122 ++++++++++-------- 1 file changed, 69 insertions(+), 53 deletions(-) diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift index b2b21275b..a8530c185 100644 --- a/Bitkit/Views/Transfer/SavingsConfirmView.swift +++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift @@ -70,66 +70,63 @@ struct SavingsConfirmView: View { NavigationBar(title: t("lightning__transfer__nav_title")) .padding(.bottom, 16) - DisplayText(t("lightning__transfer__confirm"), accentColor: .brandAccent) - .fixedSize(horizontal: false, vertical: true) + // The piggybank keeps its size; when the fees, slider and Spark row make the content tall, it scrolls above the swipe. + GeometryReader { geo in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("lightning__transfer__confirm"), accentColor: .brandAccent) + .fixedSize(horizontal: false, vertical: true) - CaptionMText(t("lightning__savings_confirm__label")) - .padding(.top, 32) - .padding(.bottom, 16) - - MoneyText(sats: Int(headlineSats), size: .display, symbol: true) + CaptionMText(t("lightning__savings_confirm__label")) + .padding(.top, 32) + .padding(.bottom, 16) - if let quote = swapState.quote { - quoteSection(quote) - } + MoneyText(sats: Int(headlineSats), size: .display, symbol: true) - if selection.withdrawsSpark, transfer.sparkWithdrawFeeSats != nil || transfer.isPricingSparkWithdrawal { - FeeDisplayRow( - label: t("lightning__savings_confirm__spark_fee"), - amount: transfer.sparkWithdrawFeeSats ?? 0, - isLoading: transfer.isPricingSparkWithdrawal - ) - .padding(.top, swapState.quote == nil ? 24 : 16) - .accessibilityIdentifier("SavingsSparkFee") - } else if selection.withdrawsSpark || selection.isEmpty && pricesSpark && transfer.isSparkWithdrawTooSmall { - sparkFeeError - .padding(.top, swapState.quote == nil ? 24 : 16) - } + if let quote = swapState.quote { + quoteSection(quote) + } - if hasMultipleItems { - HStack(spacing: 16) { - if hasSelectedItems { - CustomButton(title: t("lightning__savings_confirm__transfer_all"), size: .small) { - transfer.selectAllForSavings() + sparkSection + + if hasMultipleItems { + HStack(spacing: 16) { + if hasSelectedItems { + CustomButton(title: t("lightning__savings_confirm__transfer_all"), size: .small) { + transfer.selectAllForSavings() + } + } else { + CustomButton(title: t("common__advanced"), size: .small) { + navigation.navigate(.savingsAdvanced) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 32) } - } else { - CustomButton(title: t("common__advanced"), size: .small) { - navigation.navigate(.savingsAdvanced) + + Spacer(minLength: 0) + + // A spinner stands in for the piggybank while the quote loads. + Group { + if swapState.quote == nil, swapState.isLoading { + ProgressView() + } else { + Image("piggybank-right") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + } } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + + Spacer(minLength: 0) } + .frame(minHeight: geo.size.height) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 32) - } - - Spacer() - - // 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() - if !hideSwipeButton { SwipeButton( title: t("lightning__transfer__swipe"), @@ -193,6 +190,23 @@ struct SavingsConfirmView: View { } } + /// Spark's withdrawal fee, or why it could not be priced, below the swap's fees or the headline when there is no swap. + @ViewBuilder + private var sparkSection: some View { + if selection.withdrawsSpark, transfer.sparkWithdrawFeeSats != nil || transfer.isPricingSparkWithdrawal { + FeeDisplayRow( + label: t("lightning__savings_confirm__spark_fee"), + amount: transfer.sparkWithdrawFeeSats ?? 0, + isLoading: transfer.isPricingSparkWithdrawal + ) + .padding(.top, swapState.quote == nil ? 40 : 16) + .accessibilityIdentifier("SavingsSparkFee") + } else if selection.withdrawsSpark || selection.isEmpty && pricesSpark && transfer.isSparkWithdrawTooSmall { + sparkFeeError + .padding(.top, 16) + } + } + /// Spark's withdrawal fee could not be priced; the swipe waits for it, so it can be retried here. A balance too /// small to move after its fee has nothing to retry. @ViewBuilder @@ -242,7 +256,7 @@ struct SavingsConfirmView: View { } private func quoteSection(_ quote: SavingsSwapQuote) -> some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top, spacing: 16) { FeeDisplayRow(label: t("lightning__savings_confirm__network_fee"), amount: quote.networkFeeSat) .frame(maxWidth: .infinity, alignment: .leading) @@ -266,10 +280,12 @@ struct SavingsConfirmView: View { minValue: swapState.minSat, maxValue: swapState.maxSat ) - .padding(.top, 16) + // Inset so the knob stays on screen at either end. + .padding(.horizontal, 16) + .padding(.top, 12) } } - .padding(.top, 24) + .padding(.top, 40) } } From 49147d4d7007da96a04f4e83e5db3f30e10803c3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 13:47:27 +0100 Subject: [PATCH 221/378] fix: size the receive on spending card to figma --- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 8c55f6d30..e46384349 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -525,9 +525,9 @@ struct ReceiveQr: View { } } .padding(32) + .frame(maxHeight: .infinity) .background(Color.black) .cornerRadius(8) - .aspectRatio(1, contentMode: .fit) .overlay(alignment: .bottomLeading) { if !UIScreen.main.isSmall { Image("arrow-cjit") @@ -537,6 +537,10 @@ struct ReceiveQr: View { .offset(x: 70, y: 110) } } + // Figma leaves 122 pt between the card and the button; the spacer above the button adds the last 8. + .padding(.bottom, UIScreen.main.isSmall ? 0 : 114) + // Ahead of the tab's bottom spacer, so the card takes the rest of the page. + .layoutPriority(1) } func detailsContent(for tab: ReceiveTab) -> some View { From 1c90b2cf145d8205d6c15d605bfc50dd2b206858 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 15:23:41 +0100 Subject: [PATCH 222/378] fix: keep transfer closed after back while pricing A quote that finishes after Back no longer pushes Confirm or Sign from the amount screen it left. --- Bitkit/ViewModels/NavigationViewModel.swift | 7 ++++ .../Transfer/Hardware/SpendingAmountHw.swift | 8 +++- Bitkit/Views/Transfer/SpendingAmount.swift | 8 +++- BitkitTests/TransferNavigationTests.swift | 37 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 BitkitTests/TransferNavigationTests.swift diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 5bae41280..9288dc98a 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -217,6 +217,13 @@ class NavigationViewModel: ObservableObject { path.append(route) } + /// Pushes `route` only while the stack is still `expectedPath`, so a screen that awaited something before + /// navigating doesn't reopen a flow the user left with Back meanwhile. + func navigate(_ route: Route, from expectedPath: [Route]) { + guard path == expectedPath else { return } + path.append(route) + } + func navigateBack() { guard !path.isEmpty else { return } path.removeLast() diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index 48e2cfb31..c78f930d4 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -120,13 +120,17 @@ struct SpendingAmountHw: View { flow.start(amountSats: amountSats, isMaxAmount: amountSats >= maxAllowed, walletId: walletId) isPricing = true defer { isPricing = false } + let shownPath = navigation.path if let quote = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager, hwWalletManager: hwWalletManager), !quote.coversClaimFee { - app.toastBelowClaimFee(quote) + if navigation.path == shownPath { + app.toastBelowClaimFee(quote) + } return } - navigation.navigate(.spendingHwSign(walletId: walletId)) + // Back while the quote was loading leaves this screen, so the transfer stays closed. + navigation.navigate(.spendingHwSign(walletId: walletId), from: shownPath) } private func loadAvailable() async { diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index d35d7ae88..215dedb0b 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -174,11 +174,15 @@ struct SpendingAmount: View { flow.start(amountSats: amountSats, isMaxAmount: amountSats >= max) isPricing = true defer { isPricing = false } + let shownPath = navigation.path if let quote = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager), !quote.coversClaimFee { - app.toastBelowClaimFee(quote) + if navigation.path == shownPath { + app.toastBelowClaimFee(quote) + } return } - navigation.navigate(.spendingConfirm) + // Back while the quote was loading leaves this screen, so the transfer stays closed. + navigation.navigate(.spendingConfirm, from: shownPath) } /// The most savings can send to the Spark deposit address after the fast-rate network fee for sending all of it. diff --git a/BitkitTests/TransferNavigationTests.swift b/BitkitTests/TransferNavigationTests.swift new file mode 100644 index 000000000..30b44221b --- /dev/null +++ b/BitkitTests/TransferNavigationTests.swift @@ -0,0 +1,37 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class TransferNavigationTests: XCTestCase { + private let amountPath: [Route] = [.transferIntro, .fundingOptions, .spendingIntro, .spendingAmount] + + func testContinueOpensConfirmWhileAmountIsShown() { + let navigation = NavigationViewModel() + navigation.path = amountPath + + navigation.navigate(.spendingConfirm, from: amountPath) + + XCTAssertEqual(navigation.path, amountPath + [.spendingConfirm]) + } + + func testQuoteFinishingAfterBackKeepsTransferClosed() { + let navigation = NavigationViewModel() + navigation.path = amountPath + + // Back while the quote loads, then the quote returns. + navigation.navigateBack() + navigation.navigate(.spendingConfirm, from: amountPath) + + XCTAssertEqual(navigation.path, Array(amountPath.dropLast())) + } + + func testQuoteFinishingAfterLeavingTransferKeepsItClosed() { + let navigation = NavigationViewModel() + navigation.path = amountPath + + navigation.reset() + navigation.navigate(.spendingHwSign(walletId: "wallet"), from: amountPath) + + XCTAssertEqual(navigation.path, []) + } +} From a68018176acc10de0e04b6cbe5957eb6eb581732 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 15:23:42 +0100 Subject: [PATCH 223/378] fix: name the new max when a max transfer is refused A max transfer refused because its funding changed now says the amount changed and shows the new amount, instead of calling the amount change a fee increase. --- .../Localization/en.lproj/Localizable.strings | 2 ++ Bitkit/Views/Transfer/SpendingConfirm.swift | 27 ++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 736ddc63e..f7a27dc64 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -239,6 +239,8 @@ "lightning__spending_confirm__later_text" = "After 3 confirmations, about 30 minutes"; "lightning__spending_confirm__error_quote" = "Bitkit couldn't get the fees for this transfer. Check your connection and try again."; "lightning__spending_confirm__fees_changed_title" = "Fees changed"; +"lightning__spending_confirm__max_changed_title" = "Amount changed"; +"lightning__spending_confirm__max_changed" = "The most you can transfer is now {amount}, because fee rates or your savings balance changed while you were on this screen. Swipe again to confirm."; "lightning__spending_confirm__fees_changed_network" = "Fees are {amount} higher because fee rates or your savings balance changed while you were on this screen. Swipe again to confirm."; "lightning__savings_intro__title" = "Transfer\nto savings"; "lightning__savings_intro__text" = "Transfer your spending balance to your savings to store your Bitcoin long-term and secure your future."; diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 6b9540e72..0826969ca 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -176,14 +176,27 @@ struct SpendingConfirm: View { guard let repriced = await flow.refreshQuote(wallet: wallet, feeEstimatesManager: feeEstimatesManager) else { throw AppError(message: t("lightning__spending_confirm__error_quote"), debugMessage: "Re-quote after refusal failed") } - app.toast( - type: .info, - title: t("lightning__spending_confirm__fees_changed_title"), - description: t( - "lightning__spending_confirm__fees_changed_network", - variables: ["amount": currency.primaryAmountText(sats: repriced.increase(over: fresh, speed: flow.speed))] + let toSpendingSats = repriced.toSpendingSats(for: flow.speed) + if flow.isMaxAmount, toSpendingSats != fresh.toSpendingSats(for: flow.speed) { + // A max transfer's amount moves with the balance and fees, so a fee increase would mostly be that move. + app.toast( + type: .info, + title: t("lightning__spending_confirm__max_changed_title"), + description: t( + "lightning__spending_confirm__max_changed", + variables: ["amount": currency.primaryAmountText(sats: toSpendingSats)] + ) ) - ) + } else { + app.toast( + type: .info, + title: t("lightning__spending_confirm__fees_changed_title"), + description: t( + "lightning__spending_confirm__fees_changed_network", + variables: ["amount": currency.primaryAmountText(sats: repriced.increase(over: fresh, speed: flow.speed))] + ) + ) + } throw SpendingFeesIncreasedError() } catch { if let attemptId = await OnchainSendAttemptService.shared.unresolvedSparkFundingAttemptId() { From b4f3e047e26be606fad9b152e9ace0fad92f469e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 15:23:42 +0100 Subject: [PATCH 224/378] fix: show adding until a submitted claim settles An early claim the SDK reports as submitted, or one already in progress, keeps Setting up on the Spark is adding it state until Spark lists the deposit as claimed; only then does it show success. --- Bitkit/Managers/SparkManager.swift | 10 +++++-- Bitkit/ViewModels/SparkTransferFlow.swift | 15 ++++++---- Bitkit/Views/Transfer/SettingUpView.swift | 19 ++++++++----- BitkitTests/SparkClaimOfferTests.swift | 2 +- BitkitTests/SparkFundingRecoveryTests.swift | 11 +++++++- BitkitTests/SparkTransferFlowTests.swift | 31 ++++++++++++++++----- 6 files changed, 63 insertions(+), 25 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 4dd3def5f..1da3e61d9 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -461,7 +461,7 @@ final class SparkManager: ChannelFreeLightningRail { let outcome = try await requireSdk().claimDeposit(request: request).outcome claim = SparkEarlyClaim(outcome) } catch SdkError.DepositClaimInProgress { - claim = .claimed + claim = .submitted } Logger.info("Early claim of Spark deposit \(offer.txid): \(claim)", context: "SparkManager") await refreshState() @@ -1034,8 +1034,10 @@ struct SparkClaimFees: Equatable { /// What accepting an early claim did. enum SparkEarlyClaim: Equatable { - /// The claim is submitted and the deposit reaches the balance shortly. + /// The claim settled, so the deposit is in the balance. case claimed + /// The claim is submitted and Spark adds the deposit to the balance once it settles. + case submitted /// Not offered at this depth yet; Spark claims it early once a confirmation brings the offer. case waiting /// The provider now asks `requiredFeeSats`, more than was accepted. @@ -1045,8 +1047,10 @@ enum SparkEarlyClaim: Equatable { init(_ outcome: ClaimDepositOutcome) { switch outcome { - case .settled, .submitted: + case .settled: self = .claimed + case .submitted: + self = .submitted case let .deferred(reason): switch reason { case .noEarlyClaimAvailable: self = .waiting diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index d43576f19..d263c0e54 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -77,8 +77,10 @@ enum SparkClaimState: Equatable { case looking /// Accepting the early claim. case claiming - /// The early claim is accepted, so the deposit reaches the spending balance shortly. + /// The early claim is submitted, so Spark is adding the deposit to the spending balance. case adding + /// The deposit reached the spending balance. + case added /// The early claim now costs more than the user accepted. case repriced(feeSats: UInt64) /// No early claim is offered for this deposit, so Spark adds it after 3 confirmations. @@ -89,7 +91,7 @@ enum SparkClaimState: Equatable { case notSeen var isDone: Bool { - self == .adding + self == .added } } @@ -100,7 +102,7 @@ enum SparkClaimDecision { guard speed == .instant else { return .later } switch kind { case .automatic: - return .adding + return .added case .standard: return .unavailable case let .choice(earlyFeeSats, _, _, _): @@ -111,7 +113,8 @@ enum SparkClaimDecision { /// What accepting the early claim did; nil keeps polling, since the offer comes with a later confirmation. static func onClaim(_ claim: SparkEarlyClaim) -> SparkClaimState? { switch claim { - case .claimed: .adding + case .claimed: .added + case .submitted: .adding case let .repriced(requiredFeeSats): .repriced(feeSats: requiredFeeSats) case .declined: .unavailable case .waiting: nil @@ -356,7 +359,7 @@ final class SparkTransferFlow { else { return } watchTasks[txid]?.cancel() watchTasks[txid] = nil - claimState = .adding + claimState = .added } /// Accepts the early claim at the repriced fee. @@ -424,7 +427,7 @@ final class SparkTransferFlow { guard !Task.isCancelled else { return } if found == nil, offers[txid] != nil { // Listed before and no longer unclaimed: the early claim went through. - setClaimState(.adding, for: txid) + setClaimState(.added, for: txid) return } if let found { diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift index 11ee768db..3489b2b90 100644 --- a/Bitkit/Views/Transfer/SettingUpView.swift +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -28,7 +28,7 @@ struct SettingUpView: View { guard isTransferring else { return t("lightning__transfer_success__text_spending") } return switch state { case .looking: t("lightning__spark_deposit__looking") - case .claiming, .adding: t("lightning__spark_deposit__claimed") + case .claiming, .adding, .added: t("lightning__spark_deposit__claimed") case let .repriced(feeSats): t("lightning__spark_deposit__repriced", variables: ["fee": currency.primaryAmountText(sats: feeSats)]) case .unavailable: t("lightning__spark_deposit__instant_unavailable") case .later: t("lightning__spark_deposit__standard", variables: [ @@ -62,11 +62,12 @@ struct SettingUpView: View { t("lightning__setting_up_step_spark"), ] - private var currentStep: Int { + /// The index of the step in progress; every step is done once the deposit is added. + static func currentStep(for state: SparkClaimState) -> Int { switch state { case .looking, .notSeen: 1 - case .claiming, .repriced, .unavailable, .later: 2 - case .adding: 3 + case .claiming, .adding, .repriced, .unavailable, .later: 2 + case .added: 3 } } @@ -108,7 +109,7 @@ struct SettingUpView: View { Spacer() if isTransferring { - ProgressSteps(steps: steps, currentStep: currentStep) + ProgressSteps(steps: steps, currentStep: Self.currentStep(for: state)) .padding(.vertical, 16) } @@ -151,14 +152,18 @@ struct SettingUpView: View { settingUpPreview(.looking) } -#Preview("Adding") { +#Preview("Claiming") { settingUpPreview(.claiming) } -#Preview("Done") { +#Preview("Adding") { settingUpPreview(.adding) } +#Preview("Done") { + settingUpPreview(.added) +} + #Preview("Repriced") { settingUpPreview(.repriced(feeSats: 2150)) } diff --git a/BitkitTests/SparkClaimOfferTests.swift b/BitkitTests/SparkClaimOfferTests.swift index 9874f03eb..a970d64bc 100644 --- a/BitkitTests/SparkClaimOfferTests.swift +++ b/BitkitTests/SparkClaimOfferTests.swift @@ -28,7 +28,7 @@ final class SparkClaimOfferTests: XCTestCase { } func testMapsEachClaimOutcome() { - XCTAssertEqual(SparkEarlyClaim(.submitted), .claimed) + XCTAssertEqual(SparkEarlyClaim(.submitted), .submitted) XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .noEarlyClaimAvailable)), .waiting) XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .maxFeeExceeded(requiredFeeSats: 1500, maxFeeSats: 1350))), .repriced(requiredFeeSats: 1500)) XCTAssertEqual(SparkEarlyClaim(.deferred(reason: .providerDeclined(message: "busy"))), .declined) diff --git a/BitkitTests/SparkFundingRecoveryTests.swift b/BitkitTests/SparkFundingRecoveryTests.swift index 4a26ff14b..1fabda955 100644 --- a/BitkitTests/SparkFundingRecoveryTests.swift +++ b/BitkitTests/SparkFundingRecoveryTests.swift @@ -60,9 +60,18 @@ final class SparkFundingRecoveryTests: XCTestCase { for state in [SparkClaimState.later, .unavailable, .notSeen] { XCTAssertTrue(SettingUpView.showsLater(speed: .instant, state: state), "\(state)") } - for state in [SparkClaimState.looking, .claiming, .adding, .repriced(feeSats: 1)] { + for state in [SparkClaimState.looking, .claiming, .adding, .added, .repriced(feeSats: 1)] { XCTAssertFalse(SettingUpView.showsLater(speed: .instant, state: state), "\(state)") } XCTAssertTrue(SettingUpView.showsLater(speed: .later, state: .looking)) } + + func testSettingUpShowsAStepForEveryStateStillInProgress() { + let states: [SparkClaimState] = [.looking, .claiming, .adding, .repriced(feeSats: 1), .unavailable, .later, .notSeen] + for state in states { + XCTAssertFalse(state.isDone, "\(state)") + XCTAssertLessThan(SettingUpView.currentStep(for: state), 3, "\(state) points past the last step") + } + XCTAssertEqual(SettingUpView.currentStep(for: .adding), 2, "Adding is the last step, Spark adding it") + } } diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index a7bb85040..2563d638f 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -30,7 +30,7 @@ final class SparkTransferFlowTests: XCTestCase { func testInstantClaimedBySdkAddsIt() { XCTAssertEqual( SparkClaimDecision.onOffer(.automatic(feeSats: 300, confirmations: 0), speed: .instant, ceilingSats: ceiling), - .adding + .added ) } @@ -43,7 +43,8 @@ final class SparkTransferFlowTests: XCTestCase { } func testClaimOutcomes() { - XCTAssertEqual(SparkClaimDecision.onClaim(.claimed), .adding) + XCTAssertEqual(SparkClaimDecision.onClaim(.claimed), .added) + XCTAssertEqual(SparkClaimDecision.onClaim(.submitted), .adding) XCTAssertEqual(SparkClaimDecision.onClaim(.repriced(requiredFeeSats: 1200)), .repriced(feeSats: 1200)) XCTAssertEqual(SparkClaimDecision.onClaim(.declined), .unavailable) XCTAssertNil(SparkClaimDecision.onClaim(.waiting)) @@ -170,7 +171,7 @@ final class SparkTransferFlowTests: XCTestCase { let flow = pollingFlow(offers: [choice(earlyFee: 900), nil], claims: [.waiting]) flow.onBroadcast(txid: "txid") await flow.waitForClaim() - XCTAssertEqual(flow.claimState, .adding) + XCTAssertEqual(flow.claimState, .added) } @MainActor @@ -178,7 +179,7 @@ final class SparkTransferFlowTests: XCTestCase { let flow = pollingFlow(offers: [choice(earlyFee: 900), choice(earlyFee: 900)], claims: [.waiting, .claimed]) flow.onBroadcast(txid: "txid") await flow.waitForClaim() - XCTAssertEqual(flow.claimState, .adding) + XCTAssertEqual(flow.claimState, .added) } @MainActor @@ -191,7 +192,7 @@ final class SparkTransferFlowTests: XCTestCase { flow.payRepriced() XCTAssertEqual(flow.claimState, .claiming) await flow.waitForClaim() - XCTAssertEqual(flow.claimState, .adding) + XCTAssertEqual(flow.claimState, .added) } @MainActor @@ -236,6 +237,22 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .later, "Its result stays off the new transfer's screen") } + @MainActor + func testSubmittedEarlyClaimShowsAddingUntilTheDepositSettles() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.submitted]) + flow.onBroadcast(txid: "txid") + await flow.waitForClaim() + + XCTAssertEqual(flow.claimState, .adding) + XCTAssertFalse(flow.claimState.isDone, "Setting up showed success while the claim was only submitted") + + flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: false)]) + XCTAssertEqual(flow.claimState, .adding) + flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: true)]) + XCTAssertEqual(flow.claimState, .added) + XCTAssertTrue(flow.claimState.isDone) + } + @MainActor func testALaterTransferEndsOnceSparkClaimsItsDeposit() { let flow = SparkTransferFlow(amountSats: 100_000, speed: .later, quote: quote) @@ -246,7 +263,7 @@ final class SparkTransferFlowTests: XCTestCase { flow.noteSettled([SparkDeposit(txid: "other", amountSats: 100_000, isClaimed: true)]) XCTAssertEqual(flow.claimState, .later) flow.noteSettled([SparkDeposit(txid: "txid", amountSats: 100_000, isClaimed: true)]) - XCTAssertEqual(flow.claimState, .adding, "Later stayed in progress after the deposit reached spending") + XCTAssertEqual(flow.claimState, .added, "Later stayed in progress after the deposit reached spending") } @MainActor @@ -310,7 +327,7 @@ final class SparkTransferFlowTests: XCTestCase { flow.onRecoveredBroadcast(txid: "txid", attemptId: attemptId) await flow.waitForClaim() - XCTAssertEqual(flow.claimState, .adding, "The funding this swipe left unresolved lost the approval it gave") + XCTAssertEqual(flow.claimState, .added, "The funding this swipe left unresolved lost the approval it gave") } @MainActor From 4b4b975ef1c3db583958248285f1e5d1c19cb2b2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:20:01 +0100 Subject: [PATCH 225/378] fix: keep the kept spark balance out of an exit --- Bitkit/Managers/SparkManager.swift | 15 +++++++++++---- BitkitTests/SparkTransferActivityTests.swift | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 4fea59b0c..ca742a705 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -134,6 +134,9 @@ final class SparkManager: ChannelFreeLightningRail { /// once it ends. func reconnectForExit() async { guard let network = Self.sparkNetwork, !isWiping else { return } + if isExiting { + keptBalanceSats = nil + } noteOptimizationCutShort() if let sdk { if let listenerId { @@ -915,7 +918,10 @@ final class SparkManager: ChannelFreeLightningRail { withdrawalFailedAt, read: info.balanceSats, lastSyncedAt: info.lastSyncedAt, syncFailed: info.lastSyncError != nil ) let kept = Self.keptBalanceSats( - read: info.balanceSats, lastShown: shownBalanceSats, isStale: withdrawalFailedAt != nil || info.lastSyncError != nil + read: info.balanceSats, + lastShown: shownBalanceSats, + isStale: withdrawalFailedAt != nil || info.lastSyncError != nil, + isExiting: isExiting ) let changed = !isReady || shownBalanceSats != (kept ?? info.balanceSats) || pendingDepositSats != pending isReady = true @@ -941,9 +947,10 @@ final class SparkManager: ChannelFreeLightningRail { } /// After a failed withdrawal, or while Spark's last sync failed (`isStale`), Spark can read 0 with the funds still - /// there, so the last shown balance stays shown until it reads one again. - nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool) -> UInt64? { - read == 0 && isStale && lastShown > 0 ? lastShown : nil + /// there, so the last shown balance stays shown until it reads one again. An emergency exit moves the balance to + /// savings, so a 0 read during one keeps nothing. + nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool, isExiting: Bool) -> UInt64? { + read == 0 && isStale && lastShown > 0 && !isExiting ? lastShown : nil } /// A failed withdrawal stops keeping the balance once Spark reads one, or reads 0 after a sync that ended after it. diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index bac3489bd..fce713354 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -35,10 +35,19 @@ final class SparkTransferActivityTests: XCTestCase { } func testAZeroReadAfterAFailedWithdrawalOrSyncKeepsTheLastKnownBalance() { - XCTAssertEqual(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true), 9000, "The balance read 0 while stale") - XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false)) - XCTAssertNil(SparkManager.keptBalanceSats(read: 4000, lastShown: 9000, isStale: true)) - XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 0, isStale: true)) + XCTAssertEqual( + SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, isExiting: false), 9000, "The balance read 0 while stale" + ) + XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false, isExiting: false)) + XCTAssertNil(SparkManager.keptBalanceSats(read: 4000, lastShown: 9000, isStale: true, isExiting: false)) + XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 0, isStale: true, isExiting: false)) + } + + func testAZeroReadDuringAnEmergencyExitKeepsNoBalance() { + XCTAssertNil( + SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, isExiting: true), + "The balance the exit moves to savings stayed shown in Spending" + ) } func testASyncedZeroReadAfterAFailedWithdrawalClearsTheKeptBalance() { From 09ab05fd25ebcbc6cbda23e0cad5819a79cabef5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:36:54 +0100 Subject: [PATCH 226/378] fix: show later on payment successful like android A Later transfer never asks Spark about its deposit, so the setting-up steps keep Payment Successful current, as for a deposit Spark has not seen yet, the same as Android and the Figma frame. --- Bitkit/Views/Transfer/SettingUpView.swift | 4 ++-- BitkitTests/SparkFundingRecoveryTests.swift | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift index 3489b2b90..9b23da6ff 100644 --- a/Bitkit/Views/Transfer/SettingUpView.swift +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -65,8 +65,8 @@ struct SettingUpView: View { /// The index of the step in progress; every step is done once the deposit is added. static func currentStep(for state: SparkClaimState) -> Int { switch state { - case .looking, .notSeen: 1 - case .claiming, .adding, .repriced, .unavailable, .later: 2 + case .looking, .notSeen, .later: 1 + case .claiming, .adding, .repriced, .unavailable: 2 case .added: 3 } } diff --git a/BitkitTests/SparkFundingRecoveryTests.swift b/BitkitTests/SparkFundingRecoveryTests.swift index 1fabda955..745eddbdf 100644 --- a/BitkitTests/SparkFundingRecoveryTests.swift +++ b/BitkitTests/SparkFundingRecoveryTests.swift @@ -74,4 +74,22 @@ final class SparkFundingRecoveryTests: XCTestCase { } XCTAssertEqual(SettingUpView.currentStep(for: .adding), 2, "Adding is the last step, Spark adding it") } + + /// The same step as Android's `SettingUpScreen` for every state: a deposit Spark has not seen stays on + /// Payment Successful, whatever the speed. + func testSettingUpStepMatchesAndroidForEveryState() { + let expected: [(SparkClaimState, Int)] = [ + (.looking, 1), + (.notSeen, 1), + (.later, 1), + (.claiming, 2), + (.adding, 2), + (.repriced(feeSats: 1), 2), + (.unavailable, 2), + (.added, 3), + ] + for (state, step) in expected { + XCTAssertEqual(SettingUpView.currentStep(for: state), step, "\(state)") + } + } } From 681818953f65191b435711a4893915d304d99c90 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:50:36 +0100 Subject: [PATCH 227/378] fix: show spark as zero in spending during an exit --- Bitkit/Managers/SparkManager.swift | 10 ++++++++++ .../Settings/Advanced/LightningConnectionsView.swift | 2 +- Bitkit/Views/Wallets/SpendingWalletScreen.swift | 2 +- BitkitTests/SparkExitSweepTests.swift | 6 ++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index ca742a705..632fa0c7f 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -48,6 +48,16 @@ final class SparkManager: ChannelFreeLightningRail { keptBalanceSats ?? balanceSats } + /// The balance Spending and its connections show: 0 during an emergency exit, whose amount shows in transfer to + /// savings instead. + var shownSpendingSats: UInt64 { + Self.shownSpendingSats(isExiting: isExiting, shownBalanceSats: shownBalanceSats) + } + + nonisolated static func shownSpendingSats(isExiting: Bool, shownBalanceSats: UInt64) -> UInt64 { + isExiting ? 0 : shownBalanceSats + } + /// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations. private(set) var pendingDepositSats: UInt64 = 0 /// Funds an emergency exit can still recover, as Spark reports them. diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 57392d2eb..7d52a29f8 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -240,7 +240,7 @@ struct LightningConnectionsView: View { // MARK: - Computed Properties private var spendingBalance: UInt64 { - let sparkSats = SparkManager.shared.shownBalanceSats + let sparkSats = SparkManager.shared.shownSpendingSats guard let balanceDetails = wallet.balanceDetails else { return sparkSats } return balanceDetails.totalLightningBalanceSats + sparkSats } diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift index dbaf93389..ccc54f710 100644 --- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift +++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift @@ -41,7 +41,7 @@ struct SpendingWalletScreen: View { } if !shouldShowOnboarding { - if wallet.totalLightningSats > 0, wallet.channels?.isEmpty == false || SparkManager.shared.balanceSats > 0 { + if wallet.totalLightningSats > 0, wallet.channels?.isEmpty == false || SparkManager.shared.shownSpendingSats > 0 { transferButton .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, 28) diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 987bbf71c..79516cb96 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -923,6 +923,12 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 50000, outsideExitSats: nil), 0) } + /// While an exit runs, Spark counts as 0 where Spending and its connections show it, as on Android. + func testSparkShowsZeroInSpendingDuringAnExit() { + XCTAssertEqual(SparkManager.shownSpendingSats(isExiting: false, shownBalanceSats: 50000), 50000) + XCTAssertEqual(SparkManager.shownSpendingSats(isExiting: true, shownBalanceSats: 50000), 0, "Spending showed Spark during an exit") + } + /// A start goes on only with its own exit still stored and active: one cancelled while it waited stops it. func testStartGoesOnOnlyWithItsExitStillRunning() throws { let createdAt = Date(timeIntervalSince1970: 1_700_000_000.123_456) From c33ff3a5c8b20687cc4ee722424b8cd7b790dbd9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:55:17 +0100 Subject: [PATCH 228/378] fix: keep a send that ends during a listing held --- Bitkit/Managers/SparkManager.swift | 6 +- Bitkit/Services/LightningService.swift | 75 ++++++++++++++--------- BitkitTests/ChannelFreeRailTests.swift | 36 +++++++++++ BitkitTests/SparkPaymentSafetyTests.swift | 22 +++++++ 4 files changed, 108 insertions(+), 31 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 1da3e61d9..55fb365fb 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -825,12 +825,12 @@ final class SparkManager: ChannelFreeLightningRail { } /// Releases the holds of the sends Spark's list shows failed. The list is read after the activities were recorded, - /// so a retry the user started meanwhile keeps its hold, as does one recorded while the list is read. + /// so a retry the user started meanwhile keeps its hold, as does one recorded or ending while the list is read. static func releaseFailedSendHolds(listPayments: () async throws -> [Payment]) async { - let recordsBefore = LightningService.shared.attemptRecordCount + let mark = LightningService.shared.attemptMark guard let latest = try? await listPayments() else { return } for paymentHash in failedSendHashes(latest) { - LightningService.shared.releaseRailAttempt(paymentHash: paymentHash, recordedBy: recordsBefore) + LightningService.shared.releaseRailAttempt(paymentHash: paymentHash, since: mark) } } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index ecfe1278f..d9fd1d7fe 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1101,18 +1101,12 @@ class LightningService: NodeLifecycleService { noteAttempt(on: .ldk, bolt11: bolt11) let sendKey = paymentHash?.lowercased() - defer { - if let sendKey { - _ = eventCallbackLock.withLock { self.ldkSendsInFlight.remove(sendKey) } - } - } + defer { endLdkSend(paymentHash: sendKey) } do { return try await Self.submitPayment(beforeSubmission: { [weak self] in try beforeSubmission() guard let self else { return } - if let sendKey { - _ = eventCallbackLock.withLock { self.ldkSendsInFlight.insert(sendKey) } - } + beginLdkSend(paymentHash: sendKey) recordLdkAttempt(paymentHash: paymentHash, expiresAt: expiresAt) }) { if let sats { @@ -1225,22 +1219,23 @@ class LightningService: NodeLifecycleService { /// Called once the rail reports the attempt failed or lists none, so a channel retry isn't held while it can't list. /// With `unlessInFlight`, skipped while a rail send of the invoice is in flight, as that retry holds it again. - /// With `recordedBy`, skipped too when the hash was recorded after `attemptRecordCount` read that count, as a retry - /// recorded while a listing was read holds it again. - func releaseRailAttempt(paymentHash: String, unlessInFlight: Bool = true, recordedBy records: Int? = nil) { + /// With `since`, skipped too when the hash was in flight when `attemptMark` was read, or recorded after it, as a + /// retry that ended or was recorded while a listing was read holds it again. + func releaseRailAttempt(paymentHash: String, unlessInFlight: Bool = true, since mark: AttemptMark? = nil) { let hash = paymentHash.lowercased() eventCallbackLock.withLock { guard !unlessInFlight || !railSendsInFlight.contains(hash) else { return } - if let records, attemptsRecorded(since: records, key: Self.railAttemptsKey).contains(hash) { + if let mark, attemptsLive(since: mark, key: Self.railAttemptsKey).contains(hash) { return } removeAttempt(paymentHash: hash, key: Self.railAttemptsKey) } } - /// The attempts recorded so far, read before a listing so a release from it can skip the retries recorded since. - var attemptRecordCount: Int { - eventCallbackLock.withLock { attemptRecords } + /// The attempts recorded so far and the sends in flight, read before a listing so a trim or release from it skips + /// the retries recorded since, and the ones in flight then that ended while it was read. + var attemptMark: AttemptMark { + eventCallbackLock.withLock { AttemptMark(records: attemptRecords, railInFlight: railSendsInFlight, ldkInFlight: ldkSendsInFlight) } } private func releaseLdkAttempt(paymentHash: String) { @@ -1266,6 +1261,13 @@ class LightningService: NodeLifecycleService { Set((attemptRecordedAt[key] ?? [:]).filter { $0.value > records }.keys) } + /// The hashes under `key` that may still be pending since `mark`: in flight now or then, or recorded after it. + /// Callers hold `eventCallbackLock`. + private func attemptsLive(since mark: AttemptMark, key: String) -> Set { + let inFlight = key == Self.railAttemptsKey ? railSendsInFlight.union(mark.railInFlight) : ldkSendsInFlight.union(mark.ldkInFlight) + return inFlight.union(attemptsRecorded(since: mark.records, key: key)) + } + private func hasRailAttempt(paymentHash: String) -> Bool { hasAttempt(paymentHash: paymentHash, key: Self.railAttemptsKey) } @@ -1299,16 +1301,16 @@ class LightningService: NodeLifecycleService { /// while the node can't list them. @discardableResult private func listNodePaymentsKeepingAttempts() async -> (payments: [PaymentDetails], live: Set)? { - let recordsBefore = eventCallbackLock.withLock { attemptRecords } + let mark = attemptMark guard let payments = await nodePayments() else { return nil } - let live = keepLdkAttempts(from: payments, recordsBefore: recordsBefore) + let live = keepLdkAttempts(from: payments, since: mark) return (payments, live) } /// Keeps the hashes of the node's pending and paid outbound payments, once, so those made before hashes were kept /// hold Spark while the node can't list them. Then drops the ones listed only as failed that were recorded before - /// the listing was read (`recordsBefore`), and trims paid ones past the limit. Returns the hashes still live. - private func keepLdkAttempts(from payments: [PaymentDetails], recordsBefore: Int) -> Set { + /// the listing was read (`mark`), and trims paid ones past the limit. Returns the hashes still live. + private func keepLdkAttempts(from payments: [PaymentDetails], since mark: AttemptMark) -> Set { let outbound = payments.filter { $0.direction == .outbound } let sends = outbound .filter { $0.status != .failed } @@ -1321,7 +1323,7 @@ class LightningService: NodeLifecycleService { railAttemptsStore.set(seeded + kept, forKey: Self.ldkAttemptsKey) railAttemptsStore.set(true, forKey: Self.ldkAttemptsSeededKey) } - let live = hashes(.pending).union(ldkSendsInFlight).union(attemptsRecorded(since: recordsBefore, key: Self.ldkAttemptsKey)) + let live = hashes(.pending).union(attemptsLive(since: mark, key: Self.ldkAttemptsKey)) let paid = hashes(.succeeded).subtracting(live) let listed = Set(outbound.map { $0.id.lowercased() }) trimSettledAttempts( @@ -1331,13 +1333,13 @@ class LightningService: NodeLifecycleService { } } - /// Trims the rail's settled hashes, so a payment that may still be pending, a send in flight, or one recorded - /// after the listing was read (`recordsBefore`) keeps holding the node. Returns those live hashes. - private func keepRailAttempts(from payments: [ChannelFreeRailPayment], recordsBefore: Int) -> Set { + /// Trims the rail's settled hashes, so a payment that may still be pending, a send in flight when the listing was + /// read (`mark`) or since, or one recorded after it keeps holding the node. Returns those live hashes. + private func keepRailAttempts(from payments: [ChannelFreeRailPayment], since mark: AttemptMark) -> Set { let outbound = payments.filter { $0.direction == .outbound } let hashes = { (status: PaymentStatus) in Set(outbound.filter { $0.status == status }.map { $0.paymentHash.lowercased() }) } return eventCallbackLock.withLock { - let live = hashes(.pending).union(railSendsInFlight).union(attemptsRecorded(since: recordsBefore, key: Self.railAttemptsKey)) + let live = hashes(.pending).union(attemptsLive(since: mark, key: Self.railAttemptsKey)) let paid = hashes(.succeeded).subtracting(live) let listed = Set(outbound.map { $0.paymentHash.lowercased() }) trimSettledAttempts( @@ -1378,9 +1380,9 @@ class LightningService: NodeLifecycleService { } } if rails.contains(.channelFree) { - let recordsBefore = eventCallbackLock.withLock { attemptRecords } + let mark = attemptMark if let payments = await readableRailPayments() { - let live = keepRailAttempts(from: payments, recordsBefore: recordsBefore) + let live = keepRailAttempts(from: payments, since: mark) let payment = ChannelFreeRailPayment.latestOutboundAttempt(payments, paymentHash: paymentHash) if let payment { statuses.append(payment.status) @@ -1415,12 +1417,22 @@ class LightningService: NodeLifecycleService { .DuplicatePayment(message: "An earlier attempt to pay '\(paymentHash ?? "")' is still pending") } - private func beginRailSend(paymentHash: String?) -> Bool { + func beginRailSend(paymentHash: String?) -> Bool { guard let paymentHash else { return true } return eventCallbackLock.withLock { railSendsInFlight.insert(paymentHash.lowercased()).inserted } } - private func endRailSend(paymentHash: String?) { + func beginLdkSend(paymentHash: String?) { + guard let paymentHash else { return } + _ = eventCallbackLock.withLock { ldkSendsInFlight.insert(paymentHash.lowercased()) } + } + + func endLdkSend(paymentHash: String?) { + guard let paymentHash else { return } + _ = eventCallbackLock.withLock { ldkSendsInFlight.remove(paymentHash.lowercased()) } + } + + func endRailSend(paymentHash: String?) { guard let paymentHash else { return } _ = eventCallbackLock.withLock { railSendsInFlight.remove(paymentHash.lowercased()) } } @@ -2486,3 +2498,10 @@ extension LightningService { } } } + +/// The attempts recorded and the sends in flight on each rail when a listing was about to be read. +struct AttemptMark { + let records: Int + let railInFlight: Set + let ldkInFlight: Set +} diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index af9500898..f7363f368 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -429,6 +429,42 @@ extension ChannelFreeRailTests { XCTAssertEqual(kept, [hash, Self.otherHash(1)], "An older listing dropped the hold of a Spark retry") } + func testASparkRetryInFlightWhenAnOlderListingIsReadKeepsItsHoldAfterItEnds() async { + let service = Bitkit.LightningService.shared + let retried = Self.otherHash(1) + service.recordRailAttempt(paymentHash: retried) + XCTAssertTrue(service.beginRailSend(paymentHash: retried)) + // A Spark retry recorded before this listing ends while the listing, which still shows it failed, is read. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: retried, direction: .outbound, status: .failed, preimage: nil)] + rail.onList = { service.endRailSend(paymentHash: retried) } + rail.balanceSats = 0 + + await assertReachesTheNode() + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertEqual(kept, [retried], "An older listing dropped the hold of a Spark retry that ended while it was read") + } + + func testANodeRetryInFlightWhenAnOlderListingIsReadKeepsItsHoldAfterItEnds() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + let retried = Self.otherHash(0) + service.recordLdkAttempt(paymentHash: retried) + service.beginLdkSend(paymentHash: retried) + // A node retry recorded before this listing ends while the listing, which still shows it failed, is read. + service.nodePayments = { + service.endLdkSend(paymentHash: retried) + return [Self.nodePayment(retried, status: .failed)] + } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] + XCTAssertEqual(kept, [retried], "An older listing dropped the hold of a node retry that ended while it was read") + } + func testSendsNoListingShowsAreDroppedOnceTheirInvoiceExpiredPastTheSettleWindow() async throws { let service = Bitkit.LightningService.shared let nodePayments = service.nodePayments diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index e7a6c0e6c..96d350554 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -296,6 +296,28 @@ final class SparkPaymentSafetyTests: XCTestCase { XCTAssertEqual(service.railAttemptsStore.stringArray(forKey: "channelFreeRailAttemptedPaymentHashes"), ["retried"]) } + func testASparkRetryInFlightWhenTheSyncReadsSparksListKeepsItsHoldAfterItEnds() async throws { + let service = Bitkit.LightningService.shared + let store = service.railAttemptsStore + service.railAttemptsStore = try XCTUnwrap(UserDefaults(suiteName: "SparkPaymentSafetyTests")) + service.forgetRailAttempts() + defer { + service.endRailSend(paymentHash: "retried") + service.forgetRailAttempts() + service.railAttemptsStore = store + } + service.recordRailAttempt(paymentHash: "retried") + XCTAssertTrue(service.beginRailSend(paymentHash: "retried")) + + await SparkManager.releaseFailedSendHolds { + // The retry recorded before this list ends while the list, which still shows it failed, is read. + service.endRailSend(paymentHash: "retried") + return [self.lightningPayment(type: .send, status: .failed, hash: "retried")] + } + + XCTAssertEqual(service.railAttemptsStore.stringArray(forKey: "channelFreeRailAttemptedPaymentHashes"), ["retried"]) + } + private func lightningPayment( type: PaymentType, status: BreezSdkSpark.PaymentStatus, From 27796778b28527318121144e7d115b518f14db6d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:55:17 +0100 Subject: [PATCH 229/378] fix: keep a send whose invoice never expires held --- Bitkit/Services/LightningService.swift | 3 ++- BitkitTests/ChannelFreeRailTests.swift | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index d9fd1d7fe..895a8cf72 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1196,7 +1196,8 @@ class LightningService: NodeLifecycleService { guard let hashes = railAttemptsStore.stringArray(forKey: key) else { return } let expiries = attemptExpiries(key: key) let nowSeconds = Int(now().timeIntervalSince1970) - let isExpired = { (hash: String, settleSeconds: Int) in expiries[hash].map { $0 + settleSeconds <= nowSeconds } == true } + // Against now minus the window, as an invoice that never expires is kept at Int.max. + let isExpired = { (hash: String, settleSeconds: Int) in expiries[hash].map { $0 <= nowSeconds - settleSeconds } == true } let unsettled = hashes.filter { hash in live.contains(hash) || !(failed.contains(hash) || paid.contains(hash) && isExpired(hash, 0) || !listed.contains(hash) && isExpired(hash, Self.unlistedAttemptSettleSeconds)) diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index f7363f368..d914d4c4f 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -498,6 +498,24 @@ extension ChannelFreeRailTests { } } + func testASendWhoseInvoiceNeverExpiresKeepsItsHold() async throws { + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + defer { service.nodePayments = nodePayments } + // An invoice whose expiry is past what an Int holds is kept at Int.max. + service.recordRailAttempt(paymentHash: Self.otherHash(1), expiresAt: .max) + service.recordLdkAttempt(paymentHash: Self.otherHash(1), expiresAt: .max) + service.nodePayments = { [] } + rail.balanceSats = 100_000 + + _ = try await service.send(bolt11: Self.invoice) + + for key in [Self.railAttemptsKey, Self.ldkAttemptsKey] { + let kept = service.railAttemptsStore.stringArray(forKey: key) ?? [] + XCTAssertTrue(kept.contains(Self.otherHash(1)), "\(key) dropped a send whose invoice never expires") + } + } + func testSendsNoListingShowsWithNoKnownExpiryAreDroppedOldestFirstPastTheCap() async { let service = Bitkit.LightningService.shared (1 ... 501).forEach { service.recordRailAttempt(paymentHash: Self.otherHash($0)) } From b3de79c61d4e88ca7a7729f997626235b01a2a3c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:55:17 +0100 Subject: [PATCH 230/378] fix: show adding until spark lists an automatic claim --- Bitkit/ViewModels/SparkTransferFlow.swift | 3 ++- BitkitTests/SparkTransferFlowTests.swift | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index d263c0e54..3ca2fa80b 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -102,7 +102,8 @@ enum SparkClaimDecision { guard speed == .instant else { return .later } switch kind { case .automatic: - return .added + // Spark claims it by itself; success shows once it lists the deposit as claimed (`noteSettled`). + return .adding case .standard: return .unavailable case let .choice(earlyFeeSats, _, _, _): diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 2563d638f..88d190840 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -27,10 +27,11 @@ final class SparkTransferFlowTests: XCTestCase { ) } - func testInstantClaimedBySdkAddsIt() { + func testInstantClaimedBySdkShowsAddingUntilSparkListsItClaimed() { XCTAssertEqual( SparkClaimDecision.onOffer(.automatic(feeSats: 300, confirmations: 0), speed: .instant, ceilingSats: ceiling), - .added + .adding, + "Success showed before Spark claimed the deposit" ) } From 9f4ca45575de095965cebcee8227384a6ec14950 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:55:18 +0100 Subject: [PATCH 231/378] docs: say a fixed spark funding's fee is bound too --- Bitkit/Services/OnchainSendAttemptService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index e5ce38729..4790a7bf4 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -350,8 +350,8 @@ struct OnchainSendFollowupContext: Codable, Equatable { var channelId: String? = nil } -/// A max Spark funding refused before broadcast because it would send another amount, or pay a higher mining fee, than -/// the user approved, as after the balance grew. A fixed amount's fee isn't bound: its coin selection can vary. +/// A Spark funding refused before broadcast because it would send another amount, or pay a higher mining fee, than the +/// user approved, as after the balance grew. A fixed amount's fee is bound too, as its coin selection can vary. struct SparkFundingChanged: Error { /// The mining fee of the refused prepared send, nil when it was unknown. var preparedFeeSats: UInt64? From 6bf2e2908a042bdef818541578845d1418887021 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:55:25 +0100 Subject: [PATCH 232/378] fix: hide only the claimed output of a deposit tx --- Bitkit/Managers/SparkManager.swift | 9 +++++---- BitkitTests/SparkTransferActivityTests.swift | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 55fb365fb..367e0820c 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -733,12 +733,13 @@ final class SparkManager: ChannelFreeLightningRail { } /// Spark can still list a deposit it already claimed early as unclaimed, so a claimed one is listed only as claimed. + /// Matched by output, as one transaction can hold several deposits. nonisolated static func unclaimedDeposits(_ unclaimed: [DepositInfo], claimed: [Payment]) -> [DepositInfo] { - let claimedTxIds = Set(claimed.compactMap { payment -> String? in - guard case let .deposit(txId, _) = payment.details else { return nil } - return txId.lowercased() + let claimedOutputs = Set(claimed.compactMap { payment -> String? in + guard case let .deposit(txId, vout) = payment.details else { return nil } + return "\(txId.lowercased()):\(vout)" }) - return unclaimed.filter { !claimedTxIds.contains($0.txid.lowercased()) } + return unclaimed.filter { !claimedOutputs.contains("\($0.txid.lowercased()):\($0.vout)") } } nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index bac3489bd..ba6f99d0e 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -34,6 +34,20 @@ final class SparkTransferActivityTests: XCTestCase { XCTAssertEqual(unclaimed.map(\.txid), ["pending-deposit"], "A deposit claimed early still shows as in progress") } + func testAClaimedDepositHidesOnlyItsOwnOutputOfTheTransaction() { + let deposits = [0, 1].map { + DepositInfo( + txid: "batched", vout: $0, amountSats: 7500, isMature: true, + refundTx: nil, refundTxId: nil, refundState: nil, claimError: nil, instantClaimStatus: nil, maxClaimFee: nil + ) + } + let claimed = [payment(id: "1", status: .completed, details: .deposit(txId: "batched", vout: 0))] + + let unclaimed = SparkManager.unclaimedDeposits(deposits, claimed: claimed) + + XCTAssertEqual(unclaimed.map(\.vout), [1], "Another output of a claimed deposit's transaction was hidden") + } + func testAZeroReadAfterAFailedWithdrawalOrSyncKeepsTheLastKnownBalance() { XCTAssertEqual(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true), 9000, "The balance read 0 while stale") XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false)) From 099105577e9cd0fbe34fa32fcd4b0d4104b8ce58 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:55:25 +0100 Subject: [PATCH 233/378] fix: notify when the read spark balance recovers --- Bitkit/Managers/SparkManager.swift | 18 +++++++++++++++++- BitkitTests/SparkTransferActivityTests.swift | 9 +++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 367e0820c..fb490b96c 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -694,7 +694,10 @@ final class SparkManager: ChannelFreeLightningRail { let kept = Self.keptBalanceSats( read: info.balanceSats, lastShown: shownBalanceSats, isStale: withdrawalFailedAt != nil || info.lastSyncError != nil ) - let changed = !isReady || shownBalanceSats != (kept ?? info.balanceSats) || pendingDepositSats != pending + let changed = !isReady || Self.balancesChanged( + from: SparkBalances(readSats: balanceSats, keptSats: keptBalanceSats, pendingSats: pendingDepositSats), + to: SparkBalances(readSats: info.balanceSats, keptSats: kept, pendingSats: pending) + ) isReady = true balanceSats = info.balanceSats keptBalanceSats = kept @@ -716,6 +719,12 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Whether balances and the receive invoice must follow a refresh: the read balance counts too, as sends use it, and + /// it can come back to the shown one while that stays the same. + nonisolated static func balancesChanged(from old: SparkBalances, to new: SparkBalances) -> Bool { + old != new + } + /// After a failed withdrawal, or while Spark's last sync failed (`isStale`), Spark can read 0 with the funds still /// there, so the last shown balance stays shown until it reads one again. nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool) -> UInt64? { @@ -1126,3 +1135,10 @@ private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked await handler(event) } } + +/// The balances a refresh reads: what Spark read (`readSats`), the balance kept on screen, and pending deposits. +struct SparkBalances: Equatable { + let readSats: UInt64 + let keptSats: UInt64? + let pendingSats: UInt64 +} diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index ba6f99d0e..1eb19ed07 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -48,6 +48,15 @@ final class SparkTransferActivityTests: XCTestCase { XCTAssertEqual(unclaimed.map(\.vout), [1], "Another output of a claimed deposit's transaction was hidden") } + func testARecoveredReadBalanceNotifiesEvenWhenItEqualsTheShownOne() { + let kept = SparkBalances(readSats: 0, keptSats: 9000, pendingSats: 0) + let recovered = SparkBalances(readSats: 9000, keptSats: nil, pendingSats: 0) + + XCTAssertTrue(SparkManager.balancesChanged(from: kept, to: recovered), "Sendable stayed 0 after Spark read the balance again") + XCTAssertFalse(SparkManager.balancesChanged(from: recovered, to: recovered)) + XCTAssertTrue(SparkManager.balancesChanged(from: recovered, to: SparkBalances(readSats: 9000, keptSats: nil, pendingSats: 1))) + } + func testAZeroReadAfterAFailedWithdrawalOrSyncKeepsTheLastKnownBalance() { XCTAssertEqual(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true), 9000, "The balance read 0 while stale") XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false)) From 1483196b9077c297d233941cc5e63c234686ab80 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:55:46 +0100 Subject: [PATCH 234/378] chore: leave the move to spark translations to a follow-up --- .../Localization/ar.lproj/Localizable.strings | 25 ------------------- .../Localization/ca.lproj/Localizable.strings | 25 ------------------- .../Localization/cs.lproj/Localizable.strings | 25 ------------------- .../Localization/de.lproj/Localizable.strings | 25 ------------------- .../Localization/el.lproj/Localizable.strings | 25 ------------------- .../es-419.lproj/Localizable.strings | 25 ------------------- .../Localization/es.lproj/Localizable.strings | 25 ------------------- .../Localization/fr.lproj/Localizable.strings | 25 ------------------- .../Localization/it.lproj/Localizable.strings | 25 ------------------- .../Localization/nl.lproj/Localizable.strings | 25 ------------------- .../Localization/pl.lproj/Localizable.strings | 25 ------------------- .../pt-BR.lproj/Localizable.strings | 25 ------------------- .../Localization/pt.lproj/Localizable.strings | 25 ------------------- .../Localization/ru.lproj/Localizable.strings | 25 ------------------- 14 files changed, 350 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index e45e03e19..c6fd65522 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -292,29 +292,6 @@ "lightning__transaction" = "المعاملة"; "lightning__order_fee" = "رسوم الطلب"; "lightning__balance" = "الرصيد"; -"lightning__move_to_spark__amount" = "المبلغ"; -"lightning__move_to_spark__confirm_text" = "ينتقل رصيدك إلى Spark عبر Lightning، وهي أقل طريقة رسومًا للإبقاء عليه في الإنفاق. رسوم التوجيه أدناه هي أقصى ما تدفعه."; -"lightning__move_to_spark__confirm_title" = "الانتقال إلى\nSpark"; -"lightning__move_to_spark__done_rest_text" = "جزء من رصيدك الآن على Spark. انقل الباقي بالطريقة نفسها، ثم أغلق الاتصال الذي أصبح فارغًا."; -"lightning__move_to_spark__done_text" = "رصيدك الآن على Spark. أغلق الاتصال الذي أصبح فارغًا لإعادة احتياطيه إلى المدخرات."; -"lightning__move_to_spark__done_title" = "تم النقل إلى\nSpark"; -"lightning__move_to_spark__failed_nothing" = "لم يتبق في اتصالات Lightning لديك رصيد للنقل."; -"lightning__move_to_spark__failed_spark" = "Spark غير جاهز للاستلام بعد. حاول مرة أخرى بعد قليل."; -"lightning__move_to_spark__failed_title" = "تعذّر إتمام\nالنقل"; -"lightning__move_to_spark__failed_unknown" = "حدث خطأ أثناء تحضير النقل. حاول مرة أخرى."; -"lightning__move_to_spark__fee" = "رسوم التوجيه"; -"lightning__move_to_spark__intro_text" = "يقوم Blocktank بإغلاق اتصالات Lightning الخاصة به. انقل رصيدك إلى Spark الآن: فهو الخيار الأقل رسومًا، ويبقى ضمن الإنفاق."; -"lightning__move_to_spark__intro_title" = "اتصالك على وشك\nالإغلاق"; -"lightning__move_to_spark__move_half" = "نقل النصف"; -"lightning__move_to_spark__move_now" = "نقل الآن"; -"lightning__move_to_spark__move_rest" = "نقل الباقي"; -"lightning__move_to_spark__nav_title" = "النقل إلى Spark"; -"lightning__move_to_spark__quoting" = "جارٍ البحث عن مسار ورسومه…"; -"lightning__move_to_spark__swipe" = "اسحب للنقل"; -"lightning__move_to_spark__too_large_none" = "أغلق الاتصال بدلاً من ذلك لإعادة الرصيد إلى المدخرات."; -"lightning__move_to_spark__too_large_smaller" = "يمكنك نقله على دفعات أصغر."; -"lightning__move_to_spark__too_large_text" = "{amount} أكبر من أن يُوجَّه إلى Spark في دفعة واحدة."; -"lightning__move_to_spark__too_large_title" = "المبلغ كبير\nجدًا"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "رسوم المطالبة"; "lightning__spark_confirmations" = "{count}/3 تأكيدات"; @@ -1670,5 +1647,3 @@ "wallet__balance_updating" = "جارٍ تحديث الرصيد…"; "other__error_keychain" = "مشكلة في الاتصال بالبيانات"; "other__error_keychain_msg" = "تعذر تحميل المفتاح الأساسي من سلسلة المفاتيح."; -"lightning__move_to_spark__in_flight_text" = "الدفعة في طريقها وستظهر في Spark عند اكتمالها. إذا فشلت، يبقى المبلغ في اتصالك."; -"lightning__move_to_spark__in_flight_title" = "جارٍ النقل إلى\nSpark"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 08649d045..ca99075ca 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -329,29 +329,6 @@ "lightning__transaction" = "Transacció"; "lightning__order_fee" = "Comissió de comanda"; "lightning__balance" = "Saldo"; -"lightning__move_to_spark__amount" = "Import"; -"lightning__move_to_spark__confirm_text" = "El teu saldo passa a Spark per Lightning, la manera amb menys tarifes de mantenir-lo a Despesa. La tarifa d'enrutament de sota és el màxim que pagues."; -"lightning__move_to_spark__confirm_title" = "Moure a\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Una part del teu saldo ja és a Spark. Mou la resta de la mateixa manera i després tanca la connexió buida."; -"lightning__move_to_spark__done_text" = "El teu saldo ja és a Spark. Tanca la connexió buida per tornar-ne la reserva als Estalvis."; -"lightning__move_to_spark__done_title" = "Mogut a\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Les teves connexions Lightning ja no tenen saldo per moure."; -"lightning__move_to_spark__failed_spark" = "Spark encara no està a punt per rebre. Torna-ho a provar d'aquí a un moment."; -"lightning__move_to_spark__failed_title" = "No s'ha pogut\nmoure"; -"lightning__move_to_spark__failed_unknown" = "Alguna cosa ha anat malament en preparar el moviment. Torna-ho a provar."; -"lightning__move_to_spark__fee" = "Tarifa d'enrutament"; -"lightning__move_to_spark__intro_text" = "Blocktank està tancant les seves connexions Lightning. Mou ara el teu saldo a Spark: és l'opció amb menys tarifes i es manté a Despesa."; -"lightning__move_to_spark__intro_title" = "La teva connexió\nes tanca"; -"lightning__move_to_spark__move_half" = "Moure meitat"; -"lightning__move_to_spark__move_now" = "Moure ara"; -"lightning__move_to_spark__move_rest" = "Moure resta"; -"lightning__move_to_spark__nav_title" = "Moure a Spark"; -"lightning__move_to_spark__quoting" = "Cercant una ruta i la seva tarifa…"; -"lightning__move_to_spark__swipe" = "Desplaça per moure"; -"lightning__move_to_spark__too_large_none" = "Tanca la connexió per tornar el saldo als Estalvis."; -"lightning__move_to_spark__too_large_smaller" = "El pots moure en parts més petites."; -"lightning__move_to_spark__too_large_text" = "{amount} és massa gran per enrutar-se a Spark en un sol pagament."; -"lightning__move_to_spark__too_large_title" = "Import massa\ngran"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comissió de reclamació"; "lightning__spark_confirmations" = "{count}/3 confirmacions"; @@ -1854,5 +1831,3 @@ "wallet__balance_updating" = "S’està actualitzant el saldo…"; "other__error_keychain" = "Problema de connexió de dades"; "other__error_keychain_msg" = "No s'ha pogut carregar la clau principal del clauer."; -"lightning__move_to_spark__in_flight_text" = "El pagament està en camí i apareixerà a Spark quan es liquidi. Si falla, l'import es queda a la teva connexió."; -"lightning__move_to_spark__in_flight_title" = "Movent a\nSpark"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 6a60732e1..45e0963f1 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -345,29 +345,6 @@ "lightning__transaction" = "Transakce"; "lightning__order_fee" = "Poplatek za objednávku"; "lightning__balance" = "Zůstatek"; -"lightning__move_to_spark__amount" = "Částka"; -"lightning__move_to_spark__confirm_text" = "Váš zůstatek se přesune do Spark přes Lightning, což je způsob s nejnižšími poplatky, jak jej udržet v Útratách. Poplatek za směrování níže je maximum, které zaplatíte."; -"lightning__move_to_spark__confirm_title" = "Přesun do\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Část vašeho zůstatku je nyní ve Spark. Zbytek přesuňte stejným způsobem a poté uzavřete vyprázdněné spojení."; -"lightning__move_to_spark__done_text" = "Váš zůstatek je nyní ve Spark. Uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor."; -"lightning__move_to_spark__done_title" = "Přesunuto do\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Vaše spojení Lightning už nemají žádný zůstatek k přesunu."; -"lightning__move_to_spark__failed_spark" = "Spark zatím není připraven přijímat. Zkuste to za chvíli znovu."; -"lightning__move_to_spark__failed_title" = "Přesun se\nnezdařil"; -"lightning__move_to_spark__failed_unknown" = "Při přípravě přesunu se něco pokazilo. Zkuste to znovu."; -"lightning__move_to_spark__fee" = "Poplatek za směrování"; -"lightning__move_to_spark__intro_text" = "Blocktank uzavírá svá spojení Lightning. Přesuňte svůj zůstatek do Spark hned: je to možnost s nejnižšími poplatky a zůstane v Útratách."; -"lightning__move_to_spark__intro_title" = "Vaše spojení\nse uzavírá"; -"lightning__move_to_spark__move_half" = "Přesun půlku"; -"lightning__move_to_spark__move_now" = "Přesun hned"; -"lightning__move_to_spark__move_rest" = "Zbytek"; -"lightning__move_to_spark__nav_title" = "Přesun do Spark"; -"lightning__move_to_spark__quoting" = "Hledání trasy a jejího poplatku…"; -"lightning__move_to_spark__swipe" = "Přejetím přesuňte"; -"lightning__move_to_spark__too_large_none" = "Místo toho uzavřete spojení, aby se zůstatek vrátil do Úspor."; -"lightning__move_to_spark__too_large_smaller" = "Můžete jej přesunout po menších částech."; -"lightning__move_to_spark__too_large_text" = "{amount} je příliš velká částka na směrování do Spark v jedné platbě."; -"lightning__move_to_spark__too_large_title" = "Částka je příliš\nvelká"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Poplatek za připsání"; "lightning__spark_confirmations" = "{count}/3 potvrzení"; @@ -1991,5 +1968,3 @@ "subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; "wallet__balance_updating" = "Aktualizace zůstatku…"; -"lightning__move_to_spark__in_flight_text" = "Platba je na cestě a ve Sparku se zobrazí po vypořádání. Pokud selže, částka zůstane ve vašem připojení."; -"lightning__move_to_spark__in_flight_title" = "Přesouvání do\nSpark"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 9e94222cc..7d09c7ed0 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -343,29 +343,6 @@ "lightning__transaction" = "Transaktion"; "lightning__order_fee" = "Auftragsgebühr"; "lightning__balance" = "Guthaben"; -"lightning__move_to_spark__amount" = "Betrag"; -"lightning__move_to_spark__confirm_text" = "Ihr Guthaben wechselt über Lightning zu Spark, dem günstigsten Weg, es im Ausgabenkonto zu behalten. Die Routing-Gebühr unten ist das Maximum, das Sie zahlen."; -"lightning__move_to_spark__confirm_title" = "Verschieben zu\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Ein Teil Ihres Guthabens ist jetzt bei Spark. Verschieben Sie den Rest auf dieselbe Weise und schließen Sie dann die geleerte Verbindung."; -"lightning__move_to_spark__done_text" = "Ihr Guthaben ist jetzt bei Spark. Schließen Sie die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben."; -"lightning__move_to_spark__done_title" = "Verschoben zu\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Ihre Lightning-Verbindungen haben kein Guthaben mehr zum Verschieben."; -"lightning__move_to_spark__failed_spark" = "Spark ist noch nicht bereit zum Empfangen. Versuchen Sie es gleich noch einmal."; -"lightning__move_to_spark__failed_title" = "Das Verschieben\nschlug fehl"; -"lightning__move_to_spark__failed_unknown" = "Beim Vorbereiten der Verschiebung ist etwas schiefgelaufen. Versuchen Sie es erneut."; -"lightning__move_to_spark__fee" = "Routing-Gebühr"; -"lightning__move_to_spark__intro_text" = "Blocktank schließt seine Lightning-Verbindungen. Verschieben Sie Ihr Guthaben jetzt zu Spark: Das ist die günstigste Option, und es bleibt im Ausgabenkonto."; -"lightning__move_to_spark__intro_title" = "Ihre Verbindung wird\ngeschlossen"; -"lightning__move_to_spark__move_half" = "Halb senden"; -"lightning__move_to_spark__move_now" = "Jetzt senden"; -"lightning__move_to_spark__move_rest" = "Rest senden"; -"lightning__move_to_spark__nav_title" = "Zu Spark verschieben"; -"lightning__move_to_spark__quoting" = "Route und Gebühr werden gesucht…"; -"lightning__move_to_spark__swipe" = "Swipe zum Verschieben"; -"lightning__move_to_spark__too_large_none" = "Schließen Sie stattdessen die Verbindung, um das Guthaben an das Sparkonto zurückzugeben."; -"lightning__move_to_spark__too_large_smaller" = "Sie können es in kleineren Teilen verschieben."; -"lightning__move_to_spark__too_large_text" = "{amount} ist zu groß, um in einer Zahlung zu Spark geroutet zu werden."; -"lightning__move_to_spark__too_large_title" = "Betrag ist\nzu groß"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Einlösegebühr"; "lightning__spark_confirmations" = "{count}/3 Bestätigungen"; @@ -1988,5 +1965,3 @@ "subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; "wallet__balance_updating" = "Kontostand wird aktualisiert…"; -"lightning__move_to_spark__in_flight_text" = "Die Zahlung ist unterwegs und erscheint in Spark, sobald sie abgeschlossen ist. Schlägt sie fehl, bleibt der Betrag in deiner Verbindung."; -"lightning__move_to_spark__in_flight_title" = "Wird verschoben zu\nSpark"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 3ad7f1fbe..d344927b3 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -316,29 +316,6 @@ "lightning__transaction" = "Συναλλαγή"; "lightning__order_fee" = "Τέλος παραγγελίας"; "lightning__balance" = "Υπόλοιπο"; -"lightning__move_to_spark__amount" = "Ποσό"; -"lightning__move_to_spark__confirm_text" = "Το υπόλοιπό σας μεταφέρεται στο Spark μέσω Lightning, τον τρόπο με τα χαμηλότερα τέλη για να παραμείνει στις Δαπάνες. Το τέλος δρομολόγησης παρακάτω είναι το μέγιστο που πληρώνετε."; -"lightning__move_to_spark__confirm_title" = "Μεταφορά στο\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Ένα μέρος του υπολοίπου σας βρίσκεται πλέον στο Spark. Μεταφέρετε τα υπόλοιπα με τον ίδιο τρόπο και έπειτα κλείστε την άδεια σύνδεση."; -"lightning__move_to_spark__done_text" = "Το υπόλοιπό σας βρίσκεται πλέον στο Spark. Κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις."; -"lightning__move_to_spark__done_title" = "Μεταφέρθηκε στο\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Οι συνδέσεις Lightning σας δεν έχουν πλέον υπόλοιπο για μεταφορά."; -"lightning__move_to_spark__failed_spark" = "Το Spark δεν είναι ακόμη έτοιμο να λάβει. Δοκιμάστε ξανά σε λίγο."; -"lightning__move_to_spark__failed_title" = "Η μεταφορά\nαπέτυχε"; -"lightning__move_to_spark__failed_unknown" = "Κάτι πήγε στραβά κατά την προετοιμασία της μεταφοράς. Δοκιμάστε ξανά."; -"lightning__move_to_spark__fee" = "Τέλος δρομολόγησης"; -"lightning__move_to_spark__intro_text" = "Το Blocktank κλείνει τις συνδέσεις Lightning του. Μεταφέρετε το υπόλοιπό σας στο Spark τώρα: είναι η επιλογή με τα χαμηλότερα τέλη και παραμένει στις Δαπάνες."; -"lightning__move_to_spark__intro_title" = "Η σύνδεσή σας\nκλείνει"; -"lightning__move_to_spark__move_half" = "Το μισό"; -"lightning__move_to_spark__move_now" = "Μεταφορά"; -"lightning__move_to_spark__move_rest" = "Τα υπόλοιπα"; -"lightning__move_to_spark__nav_title" = "Μεταφορά στο Spark"; -"lightning__move_to_spark__quoting" = "Εύρεση διαδρομής και του τέλους της…"; -"lightning__move_to_spark__swipe" = "Σύρε για μεταφορά"; -"lightning__move_to_spark__too_large_none" = "Κλείστε αντί αυτού τη σύνδεση για να επιστρέψει το υπόλοιπο στις Αποταμιεύσεις."; -"lightning__move_to_spark__too_large_smaller" = "Μπορείτε να το μεταφέρετε σε μικρότερα μέρη."; -"lightning__move_to_spark__too_large_text" = "Το {amount} είναι πολύ μεγάλο για δρομολόγηση στο Spark με μία πληρωμή."; -"lightning__move_to_spark__too_large_title" = "Το ποσό είναι\nπολύ μεγάλο"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Χρέωση εξαργύρωσης"; "lightning__spark_confirmations" = "{count}/3 επιβεβαιώσεις"; @@ -1725,5 +1702,3 @@ "wallet__balance_updating" = "Ενημέρωση υπολοίπου…"; "other__error_keychain" = "Πρόβλημα Σύνδεσης Δεδομένων"; "other__error_keychain_msg" = "Δεν ήταν δυνατή η φόρτωση του κύριου κλειδιού από την κλειδοθήκη."; -"lightning__move_to_spark__in_flight_text" = "Η πληρωμή είναι καθ' οδόν και θα εμφανιστεί στο Spark μόλις ολοκληρωθεί. Αν αποτύχει, το ποσό παραμένει στη σύνδεσή σας."; -"lightning__move_to_spark__in_flight_title" = "Μεταφέρεται στο\nSpark"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index c869cab3e..fdf238a2c 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -346,29 +346,6 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; -"lightning__move_to_spark__amount" = "Monto"; -"lightning__move_to_spark__confirm_text" = "Su saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que paga."; -"lightning__move_to_spark__confirm_title" = "Mover a\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Una parte de su saldo ya está en Spark. Mueva el resto de la misma forma y luego termine la conexión vacía."; -"lightning__move_to_spark__done_text" = "Su saldo ya está en Spark. Termine la conexión vacía para devolver su reserva a Ahorro."; -"lightning__move_to_spark__done_title" = "Movido a\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Sus conexiones Lightning ya no tienen saldo para mover."; -"lightning__move_to_spark__failed_spark" = "Spark aún no está listo para recibir. Inténtelo de nuevo en un momento."; -"lightning__move_to_spark__failed_title" = "No se pudo\nmover"; -"lightning__move_to_spark__failed_unknown" = "Algo salió mal al preparar el movimiento. Inténtelo de nuevo."; -"lightning__move_to_spark__fee" = "Comisión de enrutamiento"; -"lightning__move_to_spark__intro_text" = "Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto."; -"lightning__move_to_spark__intro_title" = "Su conexión\nse cierra"; -"lightning__move_to_spark__move_half" = "Mover mitad"; -"lightning__move_to_spark__move_now" = "Mover ahora"; -"lightning__move_to_spark__move_rest" = "Mover resto"; -"lightning__move_to_spark__nav_title" = "Mover a Spark"; -"lightning__move_to_spark__quoting" = "Buscando una ruta y su comisión…"; -"lightning__move_to_spark__swipe" = "Deslizar para mover"; -"lightning__move_to_spark__too_large_none" = "Termine la conexión para devolver el saldo a Ahorro."; -"lightning__move_to_spark__too_large_smaller" = "Puede moverlo en partes más pequeñas."; -"lightning__move_to_spark__too_large_text" = "{amount} es demasiado grande para enrutarse a Spark en un solo pago."; -"lightning__move_to_spark__too_large_title" = "El monto es\nexcesivo"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comisión de reclamo"; "lightning__spark_confirmations" = "{count}/3 confirmaciones"; @@ -1994,5 +1971,3 @@ "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; "wallet__balance_updating" = "Actualizando saldo…"; -"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se liquide. Si falla, el importe se queda en tu conexión."; -"lightning__move_to_spark__in_flight_title" = "Moviendo a\nSpark"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 17bdf119d..82239b63d 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -330,29 +330,6 @@ "lightning__transaction" = "Transacción"; "lightning__order_fee" = "Tarifa de pedido"; "lightning__balance" = "Saldo"; -"lightning__move_to_spark__amount" = "Cantidad"; -"lightning__move_to_spark__confirm_text" = "Su saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que paga."; -"lightning__move_to_spark__confirm_title" = "Mover a\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Una parte de su saldo ya está en Spark. Mueva el resto de la misma forma y luego termine la conexión vacía."; -"lightning__move_to_spark__done_text" = "Su saldo ya está en Spark. Termine la conexión vacía para devolver su reserva a Ahorro."; -"lightning__move_to_spark__done_title" = "Movido a\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Sus conexiones Lightning ya no tienen saldo para mover."; -"lightning__move_to_spark__failed_spark" = "Spark aún no está listo para recibir. Inténtelo de nuevo en un momento."; -"lightning__move_to_spark__failed_title" = "No se pudo\nmover"; -"lightning__move_to_spark__failed_unknown" = "Algo salió mal al preparar el movimiento. Inténtelo de nuevo."; -"lightning__move_to_spark__fee" = "Comisión de enrutamiento"; -"lightning__move_to_spark__intro_text" = "Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto."; -"lightning__move_to_spark__intro_title" = "Su conexión\nse cierra"; -"lightning__move_to_spark__move_half" = "Mover mitad"; -"lightning__move_to_spark__move_now" = "Mover ahora"; -"lightning__move_to_spark__move_rest" = "Mover resto"; -"lightning__move_to_spark__nav_title" = "Mover a Spark"; -"lightning__move_to_spark__quoting" = "Buscando una ruta y su comisión…"; -"lightning__move_to_spark__swipe" = "Deslizar para mover"; -"lightning__move_to_spark__too_large_none" = "Termine la conexión para devolver el saldo a Ahorro."; -"lightning__move_to_spark__too_large_smaller" = "Puede moverlo en partes más pequeñas."; -"lightning__move_to_spark__too_large_text" = "{amount} es demasiado grande para enrutarse a Spark en un solo pago."; -"lightning__move_to_spark__too_large_title" = "La cantidad es\nexcesiva"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Comisión de reclamo"; "lightning__spark_confirmations" = "{count}/3 confirmaciones"; @@ -1873,5 +1850,3 @@ "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; "wallet__balance_updating" = "Actualizando saldo…"; -"lightning__move_to_spark__in_flight_text" = "El pago está en camino y aparecerá en Spark cuando se liquide. Si falla, el importe se queda en tu conexión."; -"lightning__move_to_spark__in_flight_title" = "Moviendo a\nSpark"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 08142b2de..6b3d80fe8 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -346,29 +346,6 @@ "lightning__transaction" = "Transaction"; "lightning__order_fee" = "Frais"; "lightning__balance" = "Solde"; -"lightning__move_to_spark__amount" = "Montant"; -"lightning__move_to_spark__confirm_text" = "Votre solde passe à Spark via Lightning, le moyen le moins coûteux de le garder dans Dépenses. Les frais de routage ci-dessous sont le maximum que vous payez."; -"lightning__move_to_spark__confirm_title" = "Déplacer vers\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Une partie de votre solde est maintenant sur Spark. Déplacez le reste de la même façon, puis fermez la connexion vidée."; -"lightning__move_to_spark__done_text" = "Votre solde est maintenant sur Spark. Fermez la connexion vidée pour rendre sa réserve à Épargnes."; -"lightning__move_to_spark__done_title" = "Déplacé vers\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Vos connexions Lightning n'ont plus de solde à déplacer."; -"lightning__move_to_spark__failed_spark" = "Spark n'est pas encore prêt à recevoir. Réessayez dans un instant."; -"lightning__move_to_spark__failed_title" = "Le déplacement\na échoué"; -"lightning__move_to_spark__failed_unknown" = "Un problème est survenu pendant la préparation du déplacement. Réessayez."; -"lightning__move_to_spark__fee" = "Frais de routage"; -"lightning__move_to_spark__intro_text" = "Blocktank ferme ses connexions Lightning. Déplacez votre solde vers Spark maintenant : c'est l'option la moins coûteuse, et il reste dans Dépenses."; -"lightning__move_to_spark__intro_title" = "Votre connexion\nse ferme"; -"lightning__move_to_spark__move_half" = "Moitié"; -"lightning__move_to_spark__move_now" = "Déplacer"; -"lightning__move_to_spark__move_rest" = "Le reste"; -"lightning__move_to_spark__nav_title" = "Déplacer vers Spark"; -"lightning__move_to_spark__quoting" = "Recherche d'un itinéraire et de ses frais…"; -"lightning__move_to_spark__swipe" = "Glissez pour déplacer"; -"lightning__move_to_spark__too_large_none" = "Fermez plutôt la connexion pour rendre le solde à Épargnes."; -"lightning__move_to_spark__too_large_smaller" = "Vous pouvez le déplacer en plus petites parties."; -"lightning__move_to_spark__too_large_text" = "{amount} est trop élevé pour être acheminé vers Spark en un seul paiement."; -"lightning__move_to_spark__too_large_title" = "Le montant est\ntrop élevé"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Frais de réclamation"; "lightning__spark_confirmations" = "{count}/3 confirmations"; @@ -2002,5 +1979,3 @@ "subscriptions__allowance_auto_paid" = "Payé automatiquement"; "wallet__balance_updating" = "Mise à jour du solde…"; -"lightning__move_to_spark__in_flight_text" = "Le paiement est en route et apparaîtra dans Spark une fois réglé. S'il échoue, le montant reste dans votre connexion."; -"lightning__move_to_spark__in_flight_title" = "Déplacement vers\nSpark"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index a9226f7ee..e7b492813 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -339,29 +339,6 @@ "lightning__transaction" = "Transazione"; "lightning__order_fee" = "Fee Ordine"; "lightning__balance" = "Saldo"; -"lightning__move_to_spark__amount" = "Importo"; -"lightning__move_to_spark__confirm_text" = "Il tuo saldo passa a Spark tramite Lightning, il modo a commissioni più basse per mantenerlo in Spesa. La commissione di instradamento qui sotto è il massimo che paghi."; -"lightning__move_to_spark__confirm_title" = "Sposta su\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Una parte del tuo saldo è ora su Spark. Sposta il resto allo stesso modo, poi chiudi la connessione svuotata."; -"lightning__move_to_spark__done_text" = "Il tuo saldo è ora su Spark. Chiudi la connessione svuotata per restituire la sua riserva ai Risparmi."; -"lightning__move_to_spark__done_title" = "Spostato su\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Le tue connessioni Lightning non hanno più saldo da spostare."; -"lightning__move_to_spark__failed_spark" = "Spark non è ancora pronto a ricevere. Riprova tra un momento."; -"lightning__move_to_spark__failed_title" = "Impossibile\nspostare"; -"lightning__move_to_spark__failed_unknown" = "Qualcosa è andato storto durante la preparazione dello spostamento. Riprova."; -"lightning__move_to_spark__fee" = "Commissione di instradamento"; -"lightning__move_to_spark__intro_text" = "Blocktank sta chiudendo le sue connessioni Lightning. Sposta ora il tuo saldo su Spark: è l'opzione con le commissioni più basse e resta in Spesa."; -"lightning__move_to_spark__intro_title" = "La tua connessione\nsi sta chiudendo"; -"lightning__move_to_spark__move_half" = "Sposta Metà"; -"lightning__move_to_spark__move_now" = "Sposta Ora"; -"lightning__move_to_spark__move_rest" = "Sposta Resto"; -"lightning__move_to_spark__nav_title" = "Sposta Su Spark"; -"lightning__move_to_spark__quoting" = "Ricerca di un percorso e della sua commissione…"; -"lightning__move_to_spark__swipe" = "Swipe Per Spostare"; -"lightning__move_to_spark__too_large_none" = "Chiudi invece la connessione per restituire il saldo ai Risparmi."; -"lightning__move_to_spark__too_large_smaller" = "Puoi spostarlo in parti più piccole."; -"lightning__move_to_spark__too_large_text" = "{amount} è troppo grande per essere instradato su Spark in un unico pagamento."; -"lightning__move_to_spark__too_large_title" = "Importo troppo\ngrande"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Commissione di riscatto"; "lightning__spark_confirmations" = "{count}/3 conferme"; @@ -1961,5 +1938,3 @@ "subscriptions__allowance_auto_paid" = "Pagato automaticamente"; "wallet__balance_updating" = "Aggiornamento del saldo…"; -"lightning__move_to_spark__in_flight_text" = "Il pagamento è in corso e apparirà in Spark una volta completato. Se non va a buon fine, l'importo resta nella tua connessione."; -"lightning__move_to_spark__in_flight_title" = "Spostamento su\nSpark"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index b7d55bd15..d2eb11416 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -343,29 +343,6 @@ "lightning__transaction" = "Transactie"; "lightning__order_fee" = "Order kosten"; "lightning__balance" = "Saldo"; -"lightning__move_to_spark__amount" = "Bedrag"; -"lightning__move_to_spark__confirm_text" = "Uw saldo gaat via Lightning naar Spark, de goedkoopste manier om het op uw bestedingssaldo te houden. De routeringsvergoeding hieronder is het maximum dat u betaalt."; -"lightning__move_to_spark__confirm_title" = "Verplaatsen naar\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Een deel van uw saldo staat nu op Spark. Verplaats de rest op dezelfde manier en sluit daarna de lege verbinding."; -"lightning__move_to_spark__done_text" = "Uw saldo staat nu op Spark. Sluit de lege verbinding om de reserve terug te geven aan uw spaargeld."; -"lightning__move_to_spark__done_title" = "Verplaatst naar\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Uw Lightning-verbindingen hebben geen saldo meer om te verplaatsen."; -"lightning__move_to_spark__failed_spark" = "Spark is nog niet klaar om te ontvangen. Probeer het zo meteen opnieuw."; -"lightning__move_to_spark__failed_title" = "Verplaatsen\nmislukt"; -"lightning__move_to_spark__failed_unknown" = "Er is iets misgegaan bij het voorbereiden van de verplaatsing. Probeer het opnieuw."; -"lightning__move_to_spark__fee" = "Routeringsvergoeding"; -"lightning__move_to_spark__intro_text" = "Blocktank sluit zijn Lightning-verbindingen. Verplaats uw saldo nu naar Spark: dit is de goedkoopste optie en het blijft op uw bestedingssaldo."; -"lightning__move_to_spark__intro_title" = "Uw verbinding wordt\ngesloten"; -"lightning__move_to_spark__move_half" = "Stuur helft"; -"lightning__move_to_spark__move_now" = "Stuur nu"; -"lightning__move_to_spark__move_rest" = "Stuur rest"; -"lightning__move_to_spark__nav_title" = "Verplaatsen naar Spark"; -"lightning__move_to_spark__quoting" = "Route en vergoeding zoeken…"; -"lightning__move_to_spark__swipe" = "Veeg om te verplaatsen"; -"lightning__move_to_spark__too_large_none" = "Sluit in plaats daarvan de verbinding om het saldo terug te geven aan uw spaargeld."; -"lightning__move_to_spark__too_large_smaller" = "U kunt het in kleinere delen verplaatsen."; -"lightning__move_to_spark__too_large_text" = "{amount} is te groot om in één betaling naar Spark te routeren."; -"lightning__move_to_spark__too_large_title" = "Bedrag is\nte groot"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Claimkosten"; "lightning__spark_confirmations" = "{count}/3 bevestigingen"; @@ -1996,5 +1973,3 @@ "subscriptions__allowance_auto_paid" = "Automatisch betaald"; "wallet__balance_updating" = "Saldo bijwerken…"; -"lightning__move_to_spark__in_flight_text" = "De betaling is onderweg en verschijnt in Spark zodra die is afgerond. Mislukt die, dan blijft het bedrag in je verbinding."; -"lightning__move_to_spark__in_flight_title" = "Verplaatsen naar\nSpark"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index f5a99846a..ddbfe5db7 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -345,29 +345,6 @@ "lightning__transaction" = "Transakcja"; "lightning__order_fee" = "Opłata za zamówienie"; "lightning__balance" = "Saldo"; -"lightning__move_to_spark__amount" = "Kwota"; -"lightning__move_to_spark__confirm_text" = "Twoje saldo trafia do Spark przez Lightning, najtańszym sposobem na pozostawienie go w Wydatkach. Opłata za routing poniżej to maksimum, jakie zapłacisz."; -"lightning__move_to_spark__confirm_title" = "Przenieś do\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Część Twojego salda jest teraz w Spark. Przenieś resztę w ten sam sposób, a następnie zamknij opróżnione połączenie."; -"lightning__move_to_spark__done_text" = "Twoje saldo jest teraz w Spark. Zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności."; -"lightning__move_to_spark__done_title" = "Przeniesiono do\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Twoje połączenia Lightning nie mają już salda do przeniesienia."; -"lightning__move_to_spark__failed_spark" = "Spark nie jest jeszcze gotowy do odbioru. Spróbuj ponownie za chwilę."; -"lightning__move_to_spark__failed_title" = "Nie udało się\nprzenieść"; -"lightning__move_to_spark__failed_unknown" = "Coś poszło nie tak podczas przygotowania przeniesienia. Spróbuj ponownie."; -"lightning__move_to_spark__fee" = "Opłata za routing"; -"lightning__move_to_spark__intro_text" = "Blocktank zamyka swoje połączenia Lightning. Przenieś teraz saldo do Spark: to opcja z najniższymi opłatami, a środki zostają w Wydatkach."; -"lightning__move_to_spark__intro_title" = "Twoje połączenie\nsię zamyka"; -"lightning__move_to_spark__move_half" = "Połowę"; -"lightning__move_to_spark__move_now" = "Przenieś"; -"lightning__move_to_spark__move_rest" = "Resztę"; -"lightning__move_to_spark__nav_title" = "Przenieś do Spark"; -"lightning__move_to_spark__quoting" = "Szukanie trasy i jej opłaty…"; -"lightning__move_to_spark__swipe" = "Przesuń, aby przenieść"; -"lightning__move_to_spark__too_large_none" = "Zamiast tego zamknij połączenie, aby zwrócić saldo do Oszczędności."; -"lightning__move_to_spark__too_large_smaller" = "Możesz przenieść je w mniejszych częściach."; -"lightning__move_to_spark__too_large_text" = "Kwota {amount} jest zbyt duża, aby przekazać ją do Spark jedną płatnością."; -"lightning__move_to_spark__too_large_title" = "Kwota jest\nzbyt duża"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Opłata za odbiór"; "lightning__spark_confirmations" = "{count}/3 potwierdzeń"; @@ -2001,5 +1978,3 @@ "subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; "wallet__balance_updating" = "Aktualizowanie salda…"; -"lightning__move_to_spark__in_flight_text" = "Płatność jest w drodze i pojawi się w Spark po rozliczeniu. Jeśli się nie powiedzie, kwota pozostanie w Twoim połączeniu."; -"lightning__move_to_spark__in_flight_title" = "Przenoszenie do\nSpark"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 491c08259..9c8871767 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -345,29 +345,6 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; -"lightning__move_to_spark__amount" = "Valor"; -"lightning__move_to_spark__confirm_text" = "Seu saldo vai para o Spark pela Lightning, a forma de menor taxa de mantê-lo em Gastos. A taxa de roteamento abaixo é o máximo que você paga."; -"lightning__move_to_spark__confirm_title" = "Mover para o\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Parte do seu saldo agora está no Spark. Mova o resto da mesma forma e depois feche a conexão esvaziada."; -"lightning__move_to_spark__done_text" = "Seu saldo agora está no Spark. Feche a conexão esvaziada para devolver a reserva à Poupança."; -"lightning__move_to_spark__done_title" = "Movido para o\nSpark"; -"lightning__move_to_spark__failed_nothing" = "Suas conexões Lightning não têm mais saldo para mover."; -"lightning__move_to_spark__failed_spark" = "O Spark ainda não está pronto para receber. Tente novamente em instantes."; -"lightning__move_to_spark__failed_title" = "Não foi possível\nmover"; -"lightning__move_to_spark__failed_unknown" = "Algo deu errado ao preparar a movimentação. Tente novamente."; -"lightning__move_to_spark__fee" = "Taxa de roteamento"; -"lightning__move_to_spark__intro_text" = "O Blocktank está fechando suas conexões Lightning. Mova seu saldo para o Spark agora: é a opção de menor taxa e ele continua em Gastos."; -"lightning__move_to_spark__intro_title" = "Sua conexão está\nfechando"; -"lightning__move_to_spark__move_half" = "Mover Metade"; -"lightning__move_to_spark__move_now" = "Mover Agora"; -"lightning__move_to_spark__move_rest" = "Mover Resto"; -"lightning__move_to_spark__nav_title" = "Mover Para o Spark"; -"lightning__move_to_spark__quoting" = "Buscando uma rota e sua taxa…"; -"lightning__move_to_spark__swipe" = "Deslizar Para Mover"; -"lightning__move_to_spark__too_large_none" = "Feche a conexão para devolver o saldo à Poupança."; -"lightning__move_to_spark__too_large_smaller" = "Você pode movê-lo em partes menores."; -"lightning__move_to_spark__too_large_text" = "{amount} é grande demais para ser roteado ao Spark em um único pagamento."; -"lightning__move_to_spark__too_large_title" = "Valor grande\ndemais"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Taxa de resgate"; "lightning__spark_confirmations" = "{count}/3 confirmações"; @@ -2002,5 +1979,3 @@ "subscriptions__allowance_auto_paid" = "Pago automaticamente"; "wallet__balance_updating" = "Atualizando saldo…"; -"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparecerá no Spark quando for liquidado. Se falhar, o valor fica na sua conexão."; -"lightning__move_to_spark__in_flight_title" = "Movendo para o\nSpark"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 30f74b1f5..693ffcadf 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -343,29 +343,6 @@ "lightning__transaction" = "Transação"; "lightning__order_fee" = "Taxa de pedido"; "lightning__balance" = "Saldo"; -"lightning__move_to_spark__amount" = "Montante"; -"lightning__move_to_spark__confirm_text" = "O seu saldo passa para o Spark através do Lightning, a forma com as taxas mais baixas de o manter em Gastos. A taxa de encaminhamento abaixo é o máximo que paga."; -"lightning__move_to_spark__confirm_title" = "Mover para o\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Parte do seu saldo está agora no Spark. Mova o resto da mesma forma e depois feche a conexão esvaziada."; -"lightning__move_to_spark__done_text" = "O seu saldo está agora no Spark. Feche a conexão esvaziada para devolver a reserva à Poupança."; -"lightning__move_to_spark__done_title" = "Movido para o\nSpark"; -"lightning__move_to_spark__failed_nothing" = "As suas conexões Lightning já não têm saldo para mover."; -"lightning__move_to_spark__failed_spark" = "O Spark ainda não está pronto para receber. Tente novamente dentro de instantes."; -"lightning__move_to_spark__failed_title" = "Não foi possível\nmover"; -"lightning__move_to_spark__failed_unknown" = "Algo correu mal ao preparar a movimentação. Tente novamente."; -"lightning__move_to_spark__fee" = "Taxa de encaminhamento"; -"lightning__move_to_spark__intro_text" = "O Blocktank está a fechar as suas conexões Lightning. Mova já o seu saldo para o Spark: é a opção com as taxas mais baixas e fica em Gastos."; -"lightning__move_to_spark__intro_title" = "A sua conexão\nvai fechar"; -"lightning__move_to_spark__move_half" = "Mover Metade"; -"lightning__move_to_spark__move_now" = "Mover Agora"; -"lightning__move_to_spark__move_rest" = "Mover Resto"; -"lightning__move_to_spark__nav_title" = "Mover Para o Spark"; -"lightning__move_to_spark__quoting" = "A procurar uma rota e a respetiva taxa…"; -"lightning__move_to_spark__swipe" = "Deslizar Para Mover"; -"lightning__move_to_spark__too_large_none" = "Feche antes a conexão para devolver o saldo à Poupança."; -"lightning__move_to_spark__too_large_smaller" = "Pode movê-lo em partes mais pequenas."; -"lightning__move_to_spark__too_large_text" = "{amount} é demasiado grande para ser encaminhado para o Spark num só pagamento."; -"lightning__move_to_spark__too_large_title" = "Montante demasiado\ngrande"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Taxa de resgate"; "lightning__spark_confirmations" = "{count}/3 confirmações"; @@ -1650,5 +1627,3 @@ "wallet__balance_updating" = "A atualizar o saldo…"; "other__error_keychain" = "Problema de Ligação de Dados"; "other__error_keychain_msg" = "Não foi possível carregar a chave principal do porta-chaves."; -"lightning__move_to_spark__in_flight_text" = "O pagamento está a caminho e aparecerá no Spark quando for liquidado. Se falhar, o valor fica na sua conexão."; -"lightning__move_to_spark__in_flight_title" = "Movendo para o\nSpark"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 58ca58fb0..b1f825c5c 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -340,29 +340,6 @@ "lightning__transaction" = "Транзакция"; "lightning__order_fee" = "Комиссия Ордера"; "lightning__balance" = "Баланс"; -"lightning__move_to_spark__amount" = "Сумма"; -"lightning__move_to_spark__confirm_text" = "Ваш баланс переходит в Spark через Lightning — самым дешёвым по комиссиям способом сохранить его в Расходах. Комиссия за маршрутизацию ниже — максимум, который вы платите."; -"lightning__move_to_spark__confirm_title" = "Перенос в\nSpark"; -"lightning__move_to_spark__done_rest_text" = "Часть вашего баланса теперь в Spark. Перенесите остаток таким же способом, затем закройте опустевшее соединение."; -"lightning__move_to_spark__done_text" = "Ваш баланс теперь в Spark. Закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения."; -"lightning__move_to_spark__done_title" = "Перенесено в\nSpark"; -"lightning__move_to_spark__failed_nothing" = "На ваших соединениях Lightning больше нет баланса для переноса."; -"lightning__move_to_spark__failed_spark" = "Spark пока не готов к получению. Попробуйте ещё раз чуть позже."; -"lightning__move_to_spark__failed_title" = "Не удалось\nперенести"; -"lightning__move_to_spark__failed_unknown" = "Что-то пошло не так при подготовке переноса. Попробуйте ещё раз."; -"lightning__move_to_spark__fee" = "Комиссия за маршрутизацию"; -"lightning__move_to_spark__intro_text" = "Blocktank закрывает свои соединения Lightning. Перенесите баланс в Spark сейчас: это вариант с самой низкой комиссией, и средства остаются в Расходах."; -"lightning__move_to_spark__intro_title" = "Ваше соединение\nзакрывается"; -"lightning__move_to_spark__move_half" = "Половину"; -"lightning__move_to_spark__move_now" = "Перенести"; -"lightning__move_to_spark__move_rest" = "Остаток"; -"lightning__move_to_spark__nav_title" = "Перенос в Spark"; -"lightning__move_to_spark__quoting" = "Поиск маршрута и его комиссии…"; -"lightning__move_to_spark__swipe" = "Проведите для Переноса"; -"lightning__move_to_spark__too_large_none" = "Вместо этого закройте соединение, чтобы вернуть баланс в Сбережения."; -"lightning__move_to_spark__too_large_smaller" = "Вы можете перенести его меньшими частями."; -"lightning__move_to_spark__too_large_text" = "{amount} — слишком большая сумма для маршрутизации в Spark одним платежом."; -"lightning__move_to_spark__too_large_title" = "Сумма слишком\nвелика"; "lightning__spark" = "Spark"; "lightning__spark_claim_fee" = "Комиссия за зачисление"; "lightning__spark_confirmations" = "{count}/3 подтверждений"; @@ -1982,5 +1959,3 @@ "subscriptions__allowance_auto_paid" = "Оплачено автоматически"; "wallet__balance_updating" = "Обновление баланса…"; -"lightning__move_to_spark__in_flight_text" = "Платеж в пути и появится в Spark после завершения. Если он не пройдет, сумма останется в вашем подключении."; -"lightning__move_to_spark__in_flight_title" = "Перенос в\nSpark"; From 803bc6b12de32c71ee04bfefc02fd732eea7cf90 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 17:08:53 +0100 Subject: [PATCH 235/378] fix: treat spark's below-minimum quote refusal as too small --- Bitkit/Managers/SparkManager.swift | 6 ++++++ Bitkit/ViewModels/TransferViewModel.swift | 25 +++++++++++++++++++++-- BitkitTests/SparkWithdrawalTests.swift | 16 +++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index fb490b96c..b205e455e 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -503,6 +503,12 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Whether Spark refused to quote a withdrawal because even its cheapest fee leaves dust. + nonisolated static func isBelowMinimum(_ error: Error) -> Bool { + guard case let SdkError.InvalidInput(message) = error else { return false } + return message.lowercased().hasPrefix("amount is below the minimum") + } + /// The fee `withdrawAll(to:maxFeeSats:)` pays at its speed, which comes out of the balance it moves. func withdrawAllFeeSats(to address: String) async throws -> UInt64 { try await Self.withdrawalFeeSats(prepareWithdrawAll(to: address)) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index a02da2d91..c419ac876 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -303,11 +303,26 @@ class TransferViewModel: ObservableObject { let price = priceSparkWithdrawal let timeout = sparkFeeTimeout sparkFeeTask = Task { [weak self] in - let fee = await Self.withTimeout(seconds: timeout) { try await price(address) } + // Spark refuses to quote a balance that even its cheapest fee leaves as dust, which is too small, not unpriced. + let priced = await Self.withTimeout(seconds: timeout) { () -> SparkWithdrawalPrice in + do { + return try await .fee(price(address)) + } catch where SparkManager.isBelowMinimum(error) { + return .belowMinimum + } + } guard let self, !Task.isCancelled else { return } sparkFeeTask = nil isPricingSparkWithdrawal = false - if let fee, balanceSats < fee + UInt64(Env.dustLimit) { + let fee: UInt64? = if case let .fee(sats) = priced { + sats + } else { + nil + } + if priced == .belowMinimum { + isSparkWithdrawTooSmall = true + sparkFeeBalanceSats = nil + } else if let fee, balanceSats < fee + UInt64(Env.dustLimit) { isSparkWithdrawTooSmall = true sparkFeeBalanceSats = nil } else if let fee { @@ -1040,3 +1055,9 @@ private final class FirstResult: @unchecked Sendable { } } } + +/// A Spark withdrawal's fee, or Spark's refusal to quote a balance below its minimum. +private enum SparkWithdrawalPrice: Equatable { + case fee(UInt64) + case belowMinimum +} diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index ad218b35a..736b27404 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BreezSdkSpark import XCTest final class SparkWithdrawalTests: XCTestCase { @@ -121,6 +122,21 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertEqual(transfer.sparkWithdrawFeeSats, 1093) } + @MainActor + func testABalanceSparkRefusesToQuoteAsBelowTheMinimumIsTooSmallToMove() async { + let transfer = TransferViewModel() + // What the SDK answers when even its cheapest fee would leave dust. + transfer.priceSparkWithdrawal = { _ in + throw SdkError.InvalidInput("Amount is below the minimum of 330 sats required for this address after lowest fees of 1093 sats") + } + + transfer.loadSparkWithdrawFee(balanceSats: 500, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + + XCTAssertTrue(transfer.isSparkWithdrawTooSmall, "Spark stayed selected with nothing to retry") + XCTAssertNil(transfer.sparkWithdrawFeeSats) + } + @MainActor func testCloseInsteadWaitsForTheSparkFee() { let transfer = TransferViewModel() From 5c9179b1186e29fbf570124be407bde9c5c57dc3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 17:40:51 +0100 Subject: [PATCH 236/378] fix: check the quote deadline right before a spark withdrawal sends --- Bitkit/Managers/SparkManager.swift | 14 ++++++++--- Bitkit/Services/PaykitRouteService.swift | 2 ++ .../Wallets/Send/SendConfirmationView.swift | 3 +++ BitkitTests/PaykitRouteTests.swift | 25 +++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 22842e517..bcce16282 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -597,6 +597,7 @@ final class SparkManager: ChannelFreeLightningRail { request: SparkContactWithdrawal.PaykitRequest? = nil, tags: [String] = [], maxFeeSats: UInt64? = nil, + paymentDeadline: PaykitPreciseInstant? = nil, beforeSend: () async throws -> Void = {} ) async throws -> String { // Everything before the journal is written sends nothing, so a refusal here says so to the caller. @@ -642,7 +643,8 @@ final class SparkManager: ChannelFreeLightningRail { beforeSend: beforeSend, closeUnsent: { await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) - } + }, + paymentDeadline: paymentDeadline ) let response: SendPaymentResponse do { @@ -701,16 +703,20 @@ final class SparkManager: ChannelFreeLightningRail { } /// Writes the attempt's journal before `beforeSend` starts the request's proof, so a kill between the two leaves a - /// journal that recovery closes as unsent once it expires, releasing the proof; a refusal in `beforeSend` closes it - /// at once, releasing what it started. + /// journal that recovery closes as unsent once it expires, releasing the proof; a refusal in `beforeSend`, or a + /// `paymentDeadline` passed by then, closes it at once, releasing what it started. static func startJournaledWithdrawal( writeJournal: () -> Void, beforeSend: () async throws -> Void, - closeUnsent: () async -> Void + closeUnsent: () async -> Void, + paymentDeadline: PaykitPreciseInstant? = nil, + now: () -> Date = Date.init ) async throws { writeJournal() do { try await beforeSend() + // A quote can expire while the request and its allowance are checked, so it is checked last. + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: now()) } catch { await closeUnsent() throw SparkWithdrawalNotSent(underlying: error) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index e37100db7..3c434617d 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -212,6 +212,7 @@ enum PaykitRouteService { contact: String?, request: SparkContactWithdrawal.PaykitRequest? = nil, tags: [String] = [], + paymentDeadline: PaykitPreciseInstant? = nil, beforeSend: () async throws -> Void = {} ) async throws -> String { guard leg.service == SparkManager.routeService else { @@ -224,6 +225,7 @@ enum PaykitRouteService { request: request, tags: tags, maxFeeSats: leg.debitSat.saturatingSub(leg.deliveredSat), + paymentDeadline: paymentDeadline, beforeSend: beforeSend ) } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 1b98bd151..bd3709b03 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1509,6 +1509,8 @@ struct SendConfirmationView: View { var proofRequest: SparkContactWithdrawal.PaykitRequest? var markProofStarted: (() async throws -> Void)? do { + // The selected conversion quote's expiry bounds the send too, as on a direct send. + let paymentDeadline = try request?.submissionDeadline(quoteId: app.paykitPaymentTerms?.quoteId) if let request { guard let privateContext = context?.privatePaymentContext else { throw PaykitPaymentRequestError.requestUnavailable } let identity = try await PaykitRouteService.startLegRequestProof( @@ -1577,6 +1579,7 @@ struct SendConfirmationView: View { contact: context?.publicKey, request: proofRequest, tags: tagManager.selectedTagsArray, + paymentDeadline: paymentDeadline, beforeSend: { try await markProofStarted?() try await beforeSend() diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index cf5061ed2..98ac91cfa 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -357,6 +357,31 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(steps, ["journal", "proof started", "closed"], "A refusal closes the journal, releasing what it started") } + @MainActor + func testAWithdrawalWhoseQuoteExpiresBeforeItSendsNeverLeaves() async { + var now = Date(timeIntervalSince1970: 1_800_000_000) + var steps: [String] = [] + do { + try await SparkManager.startJournaledWithdrawal( + writeJournal: { steps.append("journal") }, + beforeSend: { + // The quote expires while the allowance and the request are checked. + now = now.addingTimeInterval(120) + steps.append("proof started") + }, + closeUnsent: { steps.append("closed") }, + paymentDeadline: PaykitPreciseInstant(date: now.addingTimeInterval(60)), + now: { now } + ) + XCTFail("An expired quote must stop the send") + } catch let error as SparkWithdrawalNotSent { + XCTAssertEqual(error.underlying as? PaykitPaymentRequestError, .requestExpired) + } catch { + XCTFail("Unexpected error \(error)") + } + XCTAssertEqual(steps, ["journal", "proof started", "closed"], "The refusal closes the journal, releasing the started proof") + } + @MainActor func testARouteThatReturnsAfterItsPaymentChangedNeverLandsOnTheNextOne() async { snapshotAppDefaultsDomain() From 6bb6134f495ab6e3e8cee32e3e7f952a8e517790 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 18:09:15 +0100 Subject: [PATCH 237/378] fix: keep the funding source switchable on a spark route --- .../Wallets/Send/SendConfirmationView.swift | 21 +++++++++++++---- BitkitTests/PaykitRouteTests.swift | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index bd3709b03..ec519e7a5 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -760,13 +760,17 @@ struct SendConfirmationView: View { VStack(alignment: .leading, spacing: 16) { HStack { SendSectionView(t("wallet__send_from")) { + // The payer can still move a fixed request to Savings or a hardware wallet here, which reroutes it. NumberPadActionButton( text: t("wallet__spending__title"), + imageName: canSwitchFundingSource ? "arrow-up-down" : nil, color: .purpleAccent, - variant: .secondary, - disabled: true - ) {} - .accessibilityIdentifier("SendConfirmAssetButton") + variant: canSwitchFundingSource ? .primary : .secondary, + disabled: !canSwitchFundingSource + ) { + selectNextFundingSource() + } + .accessibilityIdentifier("SendConfirmAssetButton") } Spacer(minLength: 16) @@ -937,6 +941,14 @@ struct SendConfirmationView: View { } } + /// A hardware wallet signs the on-chain send itself and the router never runs for it, so picking one drops the Spark + /// leg; Savings and Spending ask the router again, which settles the leg. + static func route(_ route: PaykitRouteUi?, afterSelecting source: SendFundingSource) -> PaykitRouteUi? { + guard case .hardware = source, var route else { return route } + route.serviceLeg = nil + return route + } + private func selectNextFundingSource() { guard canSwitchFundingSource else { return } let currentIndex = fundingSources.firstIndex(of: selectedFundingSource) @@ -956,6 +968,7 @@ struct SendConfirmationView: View { hwSend.selectWallet(nil) app.selectedWalletToPayFrom = .onchain case let .hardware(walletId): + app.paykitRoute = Self.route(app.paykitRoute, afterSelecting: source) let balance = hwWalletManager.fundingBalance(walletId: walletId) let reserve = HwFundingSigner.feeReserve( balanceSats: balance, diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 98ac91cfa..5106daf28 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -540,6 +540,29 @@ final class PaykitRouteTests: XCTestCase { XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: nil, walletType: .onchain, hasLightningPayment: false)) } + func testASparkRoutedConfirmCanMoveToAnotherSource() throws { + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] + ) + var routed = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true, canPayDirectly: false)) + routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) + + XCTAssertEqual( + SendConfirmationView.fundingSources(hasLightningInvoice: false, hasOnchainInvoice: true, offersSparkLeg: true, hardwareWalletIds: ["hw"]), + [.spending, .savings, .hardware(walletId: "hw")], + "Confirm on a Spark leg still offers Savings and the hardware wallet" + ) + // The router never runs for a hardware wallet, so its pick drops the leg for the on-chain send it signs. + XCTAssertNil(SendConfirmationView.route(routed, afterSelecting: .hardware(walletId: "hw"))?.serviceLeg) + XCTAssertEqual(SendConfirmationView.route(routed, afterSelecting: .hardware(walletId: "hw"))?.endpoints, routed.endpoints) + // Savings and Spending ask the router again, which settles the leg. + XCTAssertEqual(SendConfirmationView.route(routed, afterSelecting: .savings), routed) + XCTAssertEqual(SendConfirmationView.route(routed, afterSelecting: .spending), routed) + XCTAssertNil(SendConfirmationView.route(nil, afterSelecting: .hardware(walletId: "hw"))) + } + func testARefusedRerouteOnConfirmKeepsTheSwipeOffUntilAnotherSourceIsPicked() { let refused = SendConfirmationView.refusedRouteSource(after: .refused, on: .lightning, previous: nil) From 7be0484f95b1c2cd9f13e525563c81a0b431dfde Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 18:17:38 +0100 Subject: [PATCH 238/378] fix: admit one rail's send of an invoice at a time A send of an invoice live on the other rail, in flight or recorded while its listing was read, is refused as pending, and a last check under the in-flight lock refuses a send the other rail admitted after the checks. --- Bitkit/Services/LightningService.swift | 43 +++++++++++++++++++++----- BitkitTests/ChannelFreeRailTests.swift | 18 +++++++++-- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 895a8cf72..219c32cb4 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1046,6 +1046,8 @@ class LightningService: NodeLifecycleService { let invoice = try? Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = invoice.map { String($0.paymentHash()) } let expiresAt = invoice.map { Int(clamping: min($0.secondsSinceEpoch(), .max - $0.expiryTimeSeconds()) + $0.expiryTimeSeconds()) } + // Read before the checks, so a send on the other rail admitted after them refuses this one at submission. + let mark = attemptMark if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { // LDK refuses its own repeats; Spark doesn't, so a Spark send checks both rails. if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.ldk, .channelFree]) { @@ -1079,6 +1081,7 @@ class LightningService: NodeLifecycleService { ?? Self.paymentAmountSats(bolt11: bolt11, sats: sats).map(Self.defaultMaxFeeSats), beforeSubmission: { [weak self] in try beforeSubmission() + try self?.admitSend(paymentHash: paymentHash, on: .channelFree, since: mark) self?.recordRailAttempt(paymentHash: paymentHash, expiresAt: expiresAt) } ) @@ -1106,7 +1109,7 @@ class LightningService: NodeLifecycleService { return try await Self.submitPayment(beforeSubmission: { [weak self] in try beforeSubmission() guard let self else { return } - beginLdkSend(paymentHash: sendKey) + try admitSend(paymentHash: sendKey, on: .ldk, since: mark) recordLdkAttempt(paymentHash: paymentHash, expiresAt: expiresAt) }) { if let sats { @@ -1368,9 +1371,13 @@ class LightningService: NodeLifecycleService { if let payment { statuses.append(payment.status) } - // A retry in flight, or one recorded while the listing was read, holds the invoice again. - if payment == nil || payment?.status == .failed, !live.contains(paymentHash.lowercased()) { - releaseLdkAttempt(paymentHash: paymentHash) + // A retry in flight, or one recorded while the listing was read, holds the invoice as pending. + if payment == nil || payment?.status == .failed { + if live.contains(paymentHash.lowercased()) { + statuses.append(.pending) + } else { + releaseLdkAttempt(paymentHash: paymentHash) + } } } else if hasAttempt(paymentHash: paymentHash, key: Self.ldkAttemptsKey) { Logger.warn("Refused paying '\(paymentHash)', the node can't list the earlier attempt now", context: "LightningService") @@ -1388,10 +1395,12 @@ class LightningService: NodeLifecycleService { if let payment { statuses.append(payment.status) } - if payment == nil || payment?.status == .failed, !live.contains(paymentHash.lowercased()), - !isRailSendInFlight(paymentHash: paymentHash) - { - releaseRailAttempt(paymentHash: paymentHash) + if payment == nil || payment?.status == .failed { + if live.contains(paymentHash.lowercased()) || isRailSendInFlight(paymentHash: paymentHash) { + statuses.append(.pending) + } else { + releaseRailAttempt(paymentHash: paymentHash) + } } } else if hasRailAttempt(paymentHash: paymentHash) { Logger.warn("Refused paying '\(paymentHash)', Spark can't list the earlier attempt now", context: "LightningService") @@ -1423,6 +1432,24 @@ class LightningService: NodeLifecycleService { return eventCallbackLock.withLock { railSendsInFlight.insert(paymentHash.lowercased()).inserted } } + /// The last check before a send leaves on `rail`, under the same lock that marks the other rail's sends in flight, + /// so of two sends of one invoice only the first admitted goes out. Refuses while the other rail has one in flight, + /// or recorded one since `mark`. An admitted node send stays in flight until `endLdkSend`; a Spark send already is. + func admitSend(paymentHash: String?, on rail: LightningPaymentRail, since mark: AttemptMark) throws { + guard let hash = paymentHash?.lowercased() else { return } + try eventCallbackLock.withLock { + let otherInFlight = rail == .ldk ? railSendsInFlight : ldkSendsInFlight + let otherKey = rail == .ldk ? Self.railAttemptsKey : Self.ldkAttemptsKey + if otherInFlight.contains(hash) || attemptsRecorded(since: mark.records, key: otherKey).contains(hash) { + Logger.warn("Refused paying '\(hash)', the other rail sent it meanwhile", context: "LightningService") + throw Self.pendingDuplicate(paymentHash: hash) + } + if rail == .ldk { + ldkSendsInFlight.insert(hash) + } + } + } + func beginLdkSend(paymentHash: String?) { guard let paymentHash else { return } _ = eventCallbackLock.withLock { ldkSendsInFlight.insert(paymentHash.lowercased()) } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index d914d4c4f..22b8f514f 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -402,7 +402,7 @@ extension ChannelFreeRailTests { } rail.balanceSats = 100_000 - _ = try await service.send(bolt11: Self.invoice) + await assertRefusedAsDuplicate() let kept = service.railAttemptsStore.stringArray(forKey: Self.ldkAttemptsKey) ?? [] XCTAssertEqual(kept, [hash], "An older listing released the node retry's hold") @@ -423,7 +423,7 @@ extension ChannelFreeRailTests { } rail.balanceSats = 0 - await assertReachesTheNode() + await assertRefusedAsDuplicate() let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] XCTAssertEqual(kept, [hash, Self.otherHash(1)], "An older listing dropped the hold of a Spark retry") @@ -465,6 +465,17 @@ extension ChannelFreeRailTests { XCTAssertEqual(kept, [retried], "An older listing dropped the hold of a node retry that ended while it was read") } + func testANodeSendAdmittedWhileSparkPreparesRefusesSparksSubmission() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + defer { service.endLdkSend(paymentHash: hash) } + // The node starts sending the invoice after Spark's checks passed and before Spark submits it. + rail.beforeSubmit = { service.beginLdkSend(paymentHash: hash) } + rail.balanceSats = 100_000 + + await assertRefusedAsDuplicate() + } + func testSendsNoListingShowsAreDroppedOnceTheirInvoiceExpiredPastTheSettleWindow() async throws { let service = Bitkit.LightningService.shared let nodePayments = service.nodePayments @@ -1125,6 +1136,8 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var refusalAfterSubmission: Bitkit.ChannelFreeRailSendRefused? /// Runs while a listing is read, before it returns `payments`. var onList: (() -> Void)? + /// Runs as a send is about to be submitted, before `beforeSubmission`. + var beforeSubmit: (() -> Void)? func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { "lnbcrt1fake" @@ -1134,6 +1147,7 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { if let refusal { throw refusal } + beforeSubmit?() try beforeSubmission() if let refusalAfterSubmission { throw refusalAfterSubmission From a74211e21adf6da5a8e4d7983d56a32aa3da0265 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 18:25:39 +0100 Subject: [PATCH 239/378] docs: move the expired-invoice comment onto its check --- Bitkit/Managers/SparkManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index b205e455e..69839aff2 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -348,7 +348,6 @@ final class SparkManager: ChannelFreeLightningRail { return !overflow && total <= balanceSats } - /// The SDK refuses an expired Lightning invoice before anything leaves the wallet, with the scanner's expiry copy. /// What a failure inside `sendPayment` means for the caller. nonisolated static func sendPaymentFailure(_ error: Error) -> Error { // The SDK checks the expiry again before sending, so an invoice that expired on the confirm screen left nothing. @@ -363,6 +362,7 @@ final class SparkManager: ChannelFreeLightningRail { return error } + /// The SDK refuses an expired Lightning invoice before anything leaves the wallet, with the scanner's expiry copy. nonisolated static func expiredInvoiceRefusal(_ error: Error) -> ChannelFreeRailSendRefused? { guard case let SdkError.InvalidInput(message) = error, message.localizedCaseInsensitiveContains("invoice has expired") else { return nil } return ChannelFreeRailSendRefused(reason: t("other__scan__error__expired")) From 172e564faebfb5f34f502cd10450385ab681d744 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:17:58 +0100 Subject: [PATCH 240/378] fix: ask again for saved exit confirmations after a reorg --- .../Services/SparkExit/SparkExitDriver.swift | 47 ++++++++++-- BitkitTests/SparkExitReviewTests.swift | 2 +- BitkitTests/SparkExitTests.swift | 76 ++++++++++++++++++- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 3b07b9b51..ad0251f63 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -1220,27 +1220,38 @@ struct SparkExitDriver { guard var recovery = record.recovery?.response else { return } let tip = try await chain.tipHeight() let listed = Set(recovery.transactions.map(\.txid)) - var confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + let saved = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) + var confirmed = saved var heights: [String: UInt32] = [:] for tx in recovery.transactions { if case let .confirmed(height?) = tx.status { heights[tx.txid] = height } } - let unknown = recovery.transactions.filter { !$0.isConfirmed }.map(\.txid) - + Set(recovery.transactions.flatMap(\.dependsOn)).subtracting(listed).subtracting(confirmed) + // A saved confirmation is asked again, since a reorg can undo it; it stands only while no host answers. + let unknown = recovery.transactions.map(\.txid) + Set(recovery.transactions.flatMap(\.dependsOn)).subtracting(listed) let statuses = await statuses(unknown) + let reorged = saved.filter { statuses[$0]?.confirmed == false } + for txid in reorged { + confirmed.remove(txid) + heights[txid] = nil + } + if !reorged.isEmpty { + Self.undoReorg(reorged, in: recovery, record: &record) + } for (txid, status) in statuses where status.confirmed { confirmed.insert(txid) - heights[txid] = status.blockHeight + heights[txid] = status.blockHeight ?? heights[txid] } for (i, tx) in recovery.transactions.enumerated() { guard !Task.isCancelled else { break } - if tx.isConfirmed { + if let status = statuses[tx.txid], status.confirmed { + recovery.transactions[i].status = .confirmed(blockHeight: status.blockHeight ?? heights[tx.txid]) continue } - if let status = statuses[tx.txid], status.confirmed { - recovery.transactions[i].status = .confirmed(blockHeight: status.blockHeight) + if reorged.contains(tx.txid) { + recovery.transactions[i].status = .waitingForDependencies + } else if tx.isConfirmed { continue } guard tx.dependsOn.allSatisfy(confirmed.contains), isDue(tx.txid, in: record, withoutSdk: true) else { continue } @@ -1269,6 +1280,28 @@ struct SparkExitDriver { record.recovery = StoredRecovery(recovery) } + /// What was concluded from confirmations a reorg undid, a spent input or a failed fee chain of a step or of a step + /// after it, no longer holds. + private static func undoReorg(_ reorged: Set, in recovery: RecoverFundsResponse, record: inout SparkExitRecord) { + Logger.warn("Spark exit steps \(reorged.sorted()) lost their confirmation; asking again", context: "SparkExitDriver") + var undone = reorged + while true { + let added = recovery.transactions + .filter { !undone.contains($0.txid) && $0.dependsOn.contains(where: undone.contains) } + .map(\.txid) + if added.isEmpty { + break + } + undone.formUnion(added) + } + for i in record.broadcastLog.indices where record.broadcastLog[i].generation == record.generation + && undone.contains(record.broadcastLog[i].txid) + { + record.broadcastLog[i].isInputSpent = nil + record.broadcastLog[i].feeChainFailures = nil + } + } + /// Without the SDK, a step whose input someone else spent is never due: only a rebuild moves on from it. private func isDue(_ txid: String, in record: SparkExitRecord, withoutSdk: Bool = false) -> Bool { guard let last = record.broadcastLog.first(where: { $0.generation == record.generation && $0.txid == txid }) else { return true } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 975e597b5..c5efcbb4c 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -422,7 +422,7 @@ final class SparkExitReviewTests: XCTestCase { SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990)), SparkExitFixtures.tx("s", kind: .sweep, dependsOn: ["n1"]), ])) - chain.confirmedTxids = ["s"] + chain.confirmedTxids = ["n1", "s"] chain.utxosByAddress["bcrt1qfunding"] = [] record = try await driver(withSdk: false).tick(record) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 625b6c443..bff8bd3da 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -146,12 +146,20 @@ final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { /// Transactions every host answers not found for, as for one a node dropped. var droppedTxids: Set = [] + /// Block heights of confirmed transactions; 900 for any other. + var heights: [String: UInt32] = [:] + /// Confirmed transactions whose host answers no block height. + var unknownHeightTxids: Set = [] func txStatus(txid: String) async throws -> EsploraTxStatus { if droppedTxids.contains(txid) { throw URLError(.fileDoesNotExist) } - return lock.withLock { EsploraTxStatus(confirmed: confirmedTxids.contains(txid), blockHeight: confirmedTxids.contains(txid) ? 900 : nil) } + return lock.withLock { + let isConfirmed = confirmedTxids.contains(txid) + let height = isConfirmed && !unknownHeightTxids.contains(txid) ? heights[txid] ?? 900 : nil + return EsploraTxStatus(confirmed: isConfirmed, blockHeight: height) + } } func isKnown(txid: String) async throws -> Bool { @@ -1023,7 +1031,8 @@ final class SparkExitDriverTests: XCTestCase { fromHex, fallback, ])) - chain.confirmedTxids = [SparkExitFixtures.csvParentTxid] + chain.confirmedTxids = [SparkExitFixtures.csvParentTxid, "n1"] + chain.heights["n1"] = 995 chain.tip = 908 record = try await driver(withSdk: false).tick(record) @@ -1046,6 +1055,8 @@ final class SparkExitDriverTests: XCTestCase { SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: nil)), SparkExitFixtures.tx("r1", kind: .refund, dependsOn: ["n1"], status: .waitingForDependencies, csv: 5), ])) + chain.confirmedTxids = ["n1"] + chain.unknownHeightTxids = ["n1"] chain.tip = 5000 _ = try await driver(withSdk: false).tick(record) @@ -1077,6 +1088,7 @@ final class SparkExitDriverTests: XCTestCase { SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"]), ])) + chain.confirmedTxids = ["n1"] chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") record = try await driver(withSdk: false).tick(record) @@ -1091,6 +1103,66 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2", "n2"]) } + private func reorgedParentRecord() -> SparkExitRecord { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("r1", dependsOn: ["n1"], status: .waitingForDependencies), + ])) + return record + } + + /// A reorg undid a confirmation the record saved: the keyless pass asks the chain again before sending its child. + func testWithoutTheSdkASavedConfirmationAReorgUndidIsAskedAgain() async throws { + var record = reorgedParentRecord() + chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent") + + record = try await driver(withSdk: false).tick(record) + + XCTAssertFalse(chain.broadcasts.contains { $0.txid == "r1" }) + if case .confirmed = record.recovery?.response.transactions.first(where: { $0.txid == "n1" })?.status { + XCTFail("the reorged parent stayed confirmed") + } + XCTAssertFalse(record.broadcastLog.contains { $0.txid == "r1" && $0.isInputSpent == true }) + + chain.confirmedTxids = ["n1"] + chain.outcome = .accepted + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid).filter { $0 == "r1" }, ["r1"]) + } + + /// A child refused as spent while its parent's confirmation was a reorged one is sent again once it reconfirms. + func testWithoutTheSdkAReorgClearsTheSpentInputItsChildWasRefusedFor() async throws { + var record = reorgedParentRecord() + record.broadcastLog = [.init( + generation: 1, + txid: "r1", + attempts: 1, + rejections: 0, + lastAttemptAt: clock, + lastOutcome: "inputs missing", + isAccepted: false, + isInputSpent: true + )] + + record = try await driver(withSdk: false).tick(record) + XCTAssertNotEqual(record.broadcastLog.first { $0.txid == "r1" }?.isInputSpent, true) + + chain.confirmedTxids = ["n1"] + record = try await driver(withSdk: false).tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid).filter { $0 == "r1" }, ["r1"]) + } + + /// No host answering for a saved confirmation keeps it. + func testWithoutTheSdkASavedConfirmationStaysWhileNoHostAnswers() async throws { + chain.droppedTxids = ["n1"] + + let record = try await driver(withSdk: false).tick(reorgedParentRecord()) + + XCTAssertEqual(record.recovery?.response.transactions.first?.status, .confirmed(blockHeight: 900)) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["r1"]) + } + /// qa-03 shared-ancestor race, with the operators down so nothing else diverges: another wallet's CPFP child /// confirmed node n1, ours never will, and n2's child spends our child's change. `checkRecoverFunds` answers /// Valid, so the exit rebuilds from the same funding and never sends the dead chain again. From 1374063aaaf2d7df3dba5a2ddd0060c1e1bb0fba Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:18:06 +0100 Subject: [PATCH 241/378] fix: cap exit funding sends at the confirmed mining fee --- Bitkit/Managers/SparkExitManager.swift | 49 +++++++++++++------ .../Services/OnchainSendAttemptService.swift | 5 +- .../Settings/Advanced/SparkExitViews.swift | 6 +++ .../OnchainSendAttemptServiceTests.swift | 24 +++++++++ 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index ce6adfc93..b6126cd28 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -644,7 +644,10 @@ final class SparkExitManager { } var txid: String? if extraSats > 0 { - txid = try await payFunding(address: address, sats: extraSats, satsPerVbyte: savingsFeeRate, feeSats: option.paymentFeeSats ?? 0) + guard let paymentFeeSats = option.paymentFeeSats else { + throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "Fee bump payment not priced") + } + txid = try await payFunding(address: address, sats: extraSats, satsPerVbyte: savingsFeeRate, feeSats: paymentFeeSats) } // Written between ticks, so a tick in flight never saves over it. while isTicking { @@ -960,20 +963,8 @@ final class SparkExitManager { func payFunding(address: String, sats: UInt64, satsPerVbyte: UInt32, feeSats: UInt64) async throws -> String { let lightning = LightningService.shared let attempts = OnchainSendAttemptService.shared - let result = try await attempts.send( - using: lightning, - address: address, - amountSats: sats, - satsPerVbyte: satsPerVbyte, - utxosToSpend: nil, - isMaxAmount: false, - followupContext: OnchainSendFollowupContext( - feeSats: feeSats, - feeRate: satsPerVbyte, - tags: [], - contact: nil, - createdAt: UInt64(Date().timeIntervalSince1970) - ) + let result = try await Self.sendFunding( + using: lightning, attempts: attempts, address: address, sats: sats, satsPerVbyte: satsPerVbyte, feeSats: feeSats ) // An unknown outcome may still have gone out: it is kept as paid (pending), so the exit, and its address, stay // until the payment lands or the own-payment expiry forgets it. @@ -992,6 +983,34 @@ final class SparkExitManager { return txid } + /// Sends the fee funding from savings for the mining fee `feeSats` the user confirmed, which it never exceeds: a higher + /// one is refused before broadcast (`SparkFundingChanged`) and asks again. + nonisolated static func sendFunding( + using sender: any OnchainSending, + attempts: OnchainSendAttemptService, + address: String, + sats: UInt64, + satsPerVbyte: UInt32, + feeSats: UInt64 + ) async throws -> OnchainSendResult { + try await attempts.send( + using: sender, + address: address, + amountSats: sats, + satsPerVbyte: satsPerVbyte, + utxosToSpend: nil, + isMaxAmount: false, + followupContext: OnchainSendFollowupContext( + feeSats: feeSats, + feeRate: satsPerVbyte, + tags: [], + contact: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ), + approvedMiningFeeSats: feeSats + ) + } + /// The funding payment's txid to keep as paid: accepted, or unknown (it may still have gone out). A refused one is none. nonisolated static func fundingTxid(_ result: OnchainSendResult) -> String? { switch result { diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift index cb491ca0c..722f269eb 100644 --- a/Bitkit/Services/OnchainSendAttemptService.swift +++ b/Bitkit/Services/OnchainSendAttemptService.swift @@ -607,6 +607,7 @@ actor OnchainSendAttemptService { followupContext: OnchainSendFollowupContext? = nil, transferContext: OnchainSendTransferContext? = nil, isSparkFunding: Bool = false, + approvedMiningFeeSats: UInt64? = nil, paymentDeadline: PaykitPreciseInstant? = nil, beforeBroadcastAttempt: () async throws -> Void = {}, onPreDispatchFailure: (Error) async throws -> Void = { _ in throw OnchainSendAttemptError.preDispatchCleanupFailed } @@ -656,9 +657,9 @@ actor OnchainSendAttemptService { else { throw OnchainSendAttemptError.unresolved } } // Savings never pay more than the Total the user confirmed, so a higher fee asks for another swipe. - if isSparkFunding { + if isSparkFunding || approvedMiningFeeSats != nil { guard prepared.recipientAmountSats == amountSats, let miningFee = prepared.miningFeeSats, - let approvedFee = followupContext?.feeSats, miningFee <= approvedFee + let approvedFee = approvedMiningFeeSats ?? followupContext?.feeSats, miningFee <= approvedFee else { throw SparkFundingChanged(preparedFeeSats: prepared.miningFeeSats) } } attempt.amountSats = prepared.recipientAmountSats diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 18fc4ddec..10bc08b9b 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -1010,6 +1010,12 @@ struct SparkExitFeeBumpView: View { do { try await exit.payHigherFee(selected, savingsFeeRate: savingsFeeRate ?? 1) navigation.navigateBack() + } catch let OnchainSendAttemptError.preDispatch(error) where error is SparkFundingChanged { + // Savings never pays more than the fee confirmed: the rates are priced again for another confirm. + app.toast(type: .info, title: t("lightning__spending_confirm__fees_changed_title")) + await exit.loadFeeBumpOptions() + await priceSavingsPayments() + selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate } catch { app.toast(error) } diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift index c3cc178f4..1bd24052a 100644 --- a/BitkitTests/OnchainSendAttemptServiceTests.swift +++ b/BitkitTests/OnchainSendAttemptServiceTests.swift @@ -380,6 +380,30 @@ final class OnchainSendAttemptServiceTests: XCTestCase { } } + /// The emergency exit's fee funding from savings never pays more mining fee than the user confirmed: a changed + /// input set asks again instead. + func testSparkExitFundingThatGrewAfterApprovalIsRefusedBeforeBroadcast() async throws { + for (fee, valid): (UInt64?, Bool) in [(500, true), (400, true), (501, false), (nil, false)] { + let store = MemoryAttemptStore() + let service = OnchainSendAttemptService(store: store) + let sender = PreparedAttemptNodeMock() + sender.amount = 20000 + sender.miningFee = fee + sender.result = .accepted(txid: sender.txid) + do { + _ = try await SparkExitManager.sendFunding( + using: sender, attempts: service, address: "bcrt1qfunding", sats: 20000, satsPerVbyte: 2, feeSats: 500 + ) + XCTAssertTrue(valid, "A funding for a \(String(describing: fee)) fee needs a new approval") + XCTAssertEqual(sender.broadcasts, 1) + } catch let OnchainSendAttemptError.preDispatch(error) { + XCTAssertFalse(valid, "The approved funding must still go out") + XCTAssertEqual((error as? SparkFundingChanged)?.preparedFeeSats, fee, "Refused with \(error)") + XCTAssertEqual(sender.broadcasts, 0) + } + } + } + func testPreparedReceiptIsDurableBeforeNativeDispatch() async throws { for max in [false, true] { let store = MemoryAttemptStore() From c1a7ee4296a799c9da5baf8e59a0c056436df60e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:18:06 +0100 Subject: [PATCH 242/378] fix: keep the kept spark balance after a cancelled exit --- Bitkit/Managers/SparkExitManager.swift | 8 ++++---- Bitkit/Managers/SparkManager.swift | 16 ++++++++++++++-- .../Settings/Advanced/SparkConnectionViews.swift | 6 +++--- BitkitTests/SparkTransferActivityTests.swift | 7 +++++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index b6126cd28..c487e7e29 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -381,7 +381,7 @@ final class SparkExitManager { if let transferId = record.transferId { try? await TransferService(lightningService: .shared).markSettled(id: transferId) } - await reattachSpark() + await reattachSpark(isCancel: true) Logger.info("Cancelled the Spark exit after funding arrived; sweeping it back to savings", context: "SparkExitManager") // After the lock is released by this function's return. Task { await tick() } @@ -395,7 +395,7 @@ final class SparkExitManager { if let transferId = record.transferId { try? await TransferService(lightningService: .shared).markSettled(id: transferId) } - await reattachSpark() + await reattachSpark(isCancel: true) Logger.info("Cancelled the Spark exit before it was funded", context: "SparkExitManager") return .forget } @@ -500,9 +500,9 @@ final class SparkExitManager { } } - private func reattachSpark() async { + private func reattachSpark(isCancel: Bool = false) async { let spark = SparkManager.shared - await spark.reconnectForExit() + await spark.reconnectForExit(isCancel: isCancel) if LightningService.shared.channelFreeRail == nil, spark.isReady { LightningService.shared.channelFreeRail = spark } diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 632fa0c7f..b558ade58 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -43,6 +43,8 @@ final class SparkManager: ChannelFreeLightningRail { private(set) var balanceSats: UInt64 = 0 /// The last balance shown, kept while Spark reads 0 after a failed withdrawal or a failed sync; display only. private(set) var keptBalanceSats: UInt64? + /// The kept balance when an exit started, shown again should the exit be cancelled. + @ObservationIgnored private var keptBeforeExit: UInt64? /// The balance Home and the Spark connection show. var shownBalanceSats: UInt64 { keptBalanceSats ?? balanceSats @@ -141,11 +143,16 @@ final class SparkManager: ChannelFreeLightningRail { } /// Connects Spark again so its leaf optimization follows whether an exit runs: off when one starts, back on - /// once it ends. - func reconnectForExit() async { + /// once it ends. An exit cancelled before it paid anything (`isCancel`) shows the kept balance again. + func reconnectForExit(isCancel: Bool = false) async { guard let network = Self.sparkNetwork, !isWiping else { return } if isExiting { + keptBeforeExit = keptBalanceSats ?? keptBeforeExit keptBalanceSats = nil + } else { + keptBalanceSats = Self.keptBalanceSats(afterExitKept: keptBeforeExit, read: balanceSats, isCancel: isCancel) + ?? keptBalanceSats + keptBeforeExit = nil } noteOptimizationCutShort() if let sdk { @@ -963,6 +970,11 @@ final class SparkManager: ChannelFreeLightningRail { read == 0 && isStale && lastShown > 0 && !isExiting ? lastShown : nil } + /// The balance kept again once an exit ends: a cancelled exit moved nothing, so what was kept before it still is. + nonisolated static func keptBalanceSats(afterExitKept kept: UInt64?, read: UInt64, isCancel: Bool) -> UInt64? { + isCancel && read == 0 ? kept : nil + } + /// A failed withdrawal stops keeping the balance once Spark reads one, or reads 0 after a sync that ended after it. nonisolated static func withdrawalFailedAt(_ failedAt: UInt64?, read: UInt64, lastSyncedAt: UInt64?, syncFailed: Bool) -> UInt64? { guard let failedAt, read == 0, syncFailed || (lastSyncedAt ?? 0) <= failedAt else { return nil } diff --git a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift index ad1885554..cabdc9986 100644 --- a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift @@ -115,10 +115,10 @@ struct SparkConnectionDetailView: View { private let spark = SparkManager.shared - /// The emergency exit is offered while Spark holds funds, and leads to the exit's progress or result while it - /// runs or its leftover fee funding is still on its way to savings. + /// The emergency exit is offered while Spark holds funds, also a kept balance while it reads 0, and leads to the + /// exit's progress or result while it runs or its leftover fee funding is still on its way to savings. private var showsExit: Bool { - spark.balanceSats > 0 || spark.recoverableFundsSats > 0 || SparkExitManager.shared.showsResult + spark.shownBalanceSats > 0 || spark.recoverableFundsSats > 0 || SparkExitManager.shared.showsResult } var body: some View { diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index fce713354..a956ce4a8 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -50,6 +50,13 @@ final class SparkTransferActivityTests: XCTestCase { ) } + func testACancelledExitShowsTheBalanceKeptBeforeIt() { + XCTAssertEqual(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 0, isCancel: true), 10000, "The cancel dropped the kept balance") + XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 0, isCancel: false), "The exit's balance came back after it ended") + XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 4000, isCancel: true)) + XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: nil, read: 0, isCancel: true)) + } + func testASyncedZeroReadAfterAFailedWithdrawalClearsTheKeptBalance() { XCTAssertNil( SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: 101, syncFailed: false), From 76a1aee38a3447086569e60760525a7f8b4d04e4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:22:14 +0100 Subject: [PATCH 243/378] test: pin the funding source control on a spark route --- .../Wallets/Send/SendConfirmationView.swift | 28 +++++++++++++++---- BitkitTests/PaykitRouteTests.swift | 18 ++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index ec519e7a5..19532db2f 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -201,7 +201,23 @@ struct SendConfirmationView: View { } private var canSwitchFundingSource: Bool { - retainedHardwareRetry == nil && fundingSources.count > 1 + Self.canSwitchFundingSource(hasRetainedHardwareRetry: retainedHardwareRetry != nil, fundingSources: fundingSources) + } + + static func canSwitchFundingSource(hasRetainedHardwareRetry: Bool, fundingSources: [SendFundingSource]) -> Bool { + !hasRetainedHardwareRetry && fundingSources.count > 1 + } + + /// What Confirm's FROM control shows and does. + struct FromControl: Equatable { + let titleKey: String + let isSwitchable: Bool + } + + /// A Spark leg pays from Spending, and the payer can still move a fixed request to Savings or a hardware wallet, + /// which reroutes it. + static func sparkRouteFromControl(canSwitchFundingSource: Bool) -> FromControl { + FromControl(titleKey: "wallet__spending__title", isSwitchable: canSwitchFundingSource) } var canSwitchWallet: Bool { @@ -760,13 +776,13 @@ struct SendConfirmationView: View { VStack(alignment: .leading, spacing: 16) { HStack { SendSectionView(t("wallet__send_from")) { - // The payer can still move a fixed request to Savings or a hardware wallet here, which reroutes it. + let control = Self.sparkRouteFromControl(canSwitchFundingSource: canSwitchFundingSource) NumberPadActionButton( - text: t("wallet__spending__title"), - imageName: canSwitchFundingSource ? "arrow-up-down" : nil, + text: t(control.titleKey), + imageName: control.isSwitchable ? "arrow-up-down" : nil, color: .purpleAccent, - variant: canSwitchFundingSource ? .primary : .secondary, - disabled: !canSwitchFundingSource + variant: control.isSwitchable ? .primary : .secondary, + disabled: !control.isSwitchable ) { selectNextFundingSource() } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 5106daf28..928d61346 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -563,6 +563,24 @@ final class PaykitRouteTests: XCTestCase { XCTAssertNil(SendConfirmationView.route(nil, afterSelecting: .hardware(walletId: "hw"))) } + func testTheFromControlStaysSwitchableOnASparkRoute() { + let sources = SendConfirmationView.fundingSources( + hasLightningInvoice: false, + hasOnchainInvoice: true, + offersSparkLeg: true, + hardwareWalletIds: [] + ) + let canSwitch = SendConfirmationView.canSwitchFundingSource(hasRetainedHardwareRetry: false, fundingSources: sources) + + let control = SendConfirmationView.sparkRouteFromControl(canSwitchFundingSource: canSwitch) + + XCTAssertTrue(control.isSwitchable, "After picking Spark, FROM still moves a fixed request to Savings") + XCTAssertEqual(control.titleKey, "wallet__spending__title") + // A retained hardware retry pins the source, on a Spark route too. + let pinned = SendConfirmationView.canSwitchFundingSource(hasRetainedHardwareRetry: true, fundingSources: sources) + XCTAssertFalse(SendConfirmationView.sparkRouteFromControl(canSwitchFundingSource: pinned).isSwitchable) + } + func testARefusedRerouteOnConfirmKeepsTheSwipeOffUntilAnotherSourceIsPicked() { let refused = SendConfirmationView.refusedRouteSource(after: .refused, on: .lightning, previous: nil) From 70853cee8e582cf8d0da851f2c49dd1be8c4a91e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:30:56 +0100 Subject: [PATCH 244/378] fix: keep an uncertain spark send pending until spark syncs --- Bitkit/Managers/SparkManager.swift | 8 +++++ Bitkit/Services/LightningService.swift | 47 +++++++++++++++++++++++++- BitkitTests/ChannelFreeRailTests.swift | 28 +++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 69839aff2..cc99d0e25 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -368,6 +368,14 @@ final class SparkManager: ChannelFreeLightningRail { return ChannelFreeRailSendRefused(reason: t("other__scan__error__expired")) } + nonisolated func hasSynced(after seconds: UInt64?) async -> Bool { + guard let info = try? await requireSdk().getInfo(request: GetInfoRequest(ensureSynced: false)), + info.lastSyncError == nil + else { return false } + guard let seconds else { return true } + return info.lastSyncedAt.map { $0 > seconds } ?? false + } + nonisolated func lightningPayments() async throws -> [ChannelFreeRailPayment] { let payments = try await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments return payments.compactMap { payment in diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 219c32cb4..7cab25ff7 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -17,6 +17,9 @@ protocol ChannelFreeLightningRail: AnyObject { func estimateRoutingFees(bolt11: String, amountSats: UInt64?) async throws -> UInt64 /// Throws when the rail could not list its payments, so a duplicate-payment check can tell that from none. func lightningPayments() async throws -> [ChannelFreeRailPayment] + /// Whether the rail's last wallet sync reached it, and started after `seconds` since 1970 when given. The rail + /// finishes an interrupted send only on such a sync, so before one its list can lack a send that went out. + func hasSynced(after seconds: UInt64?) async -> Bool } @MainActor @@ -130,6 +133,8 @@ class LightningService: NodeLifecycleService { private var attemptRails: [String: LightningPaymentRail] = [:] /// Payment hashes inside a rail send, which Spark lists only once it returns. private var railSendsInFlight: Set = [] + /// Rail sends that failed after they were submitted, by hash, with the time they failed in seconds since 1970. + private var uncertainRailSends: [String: UInt64] = [:] /// Counts attempts recorded in this run, so a listing drops a hash just when the hash was recorded before the /// listing was read, never a retry recorded while it was read. private var attemptRecords = 0 @@ -1091,6 +1096,12 @@ class LightningService: NodeLifecycleService { releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) } throw refusal + } catch { + // A send that failed once submitted may still go out, so its outcome waits for a later sync. + if let paymentHash { + markRailSendUncertain(paymentHash: paymentHash, since: mark) + } + throw error } } if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.channelFree]) { @@ -1285,6 +1296,7 @@ class LightningService: NodeLifecycleService { /// Called when the rail's wallet is wiped, as its payments go with it. func forgetRailAttempts() { eventCallbackLock.withLock { + uncertainRailSends = [:] railAttemptsStore.removeObject(forKey: Self.railAttemptsKey) railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.railAttemptsKey)) attemptRecordedAt[Self.railAttemptsKey] = nil @@ -1398,9 +1410,14 @@ class LightningService: NodeLifecycleService { if payment == nil || payment?.status == .failed { if live.contains(paymentHash.lowercased()) || isRailSendInFlight(paymentHash: paymentHash) { statuses.append(.pending) + } else if await !railSyncedSinceUncertainSend(paymentHash: paymentHash) { + Logger.info("Held '\(paymentHash)' until Spark syncs after its unknown send", context: "LightningService") + statuses.append(.pending) } else { releaseRailAttempt(paymentHash: paymentHash) } + } else { + endRailSendUncertainty(paymentHash: paymentHash) } } else if hasRailAttempt(paymentHash: paymentHash) { Logger.warn("Refused paying '\(paymentHash)', Spark can't list the earlier attempt now", context: "LightningService") @@ -1420,7 +1437,35 @@ class LightningService: NodeLifecycleService { /// Nil while the rail's payments can't be read: listing them failed, or the rail runs here but isn't connected. private func readableRailPayments() async -> [ChannelFreeRailPayment]? { - try? await channelFreeRailPayments() + // A restored wallet fills its list on its first sync, so before a sync reaches Spark a missing send proves + // nothing. + if let rail = channelFreeRail, await !rail.hasSynced(after: nil) { + return nil + } + return try? await channelFreeRailPayments() + } + + /// A rail send that failed after it was submitted, with the time it failed, until a sync after it settles it. + private func markRailSendUncertain(paymentHash: String, since mark: AttemptMark) { + let hash = paymentHash.lowercased() + let failedAt = UInt64(max(0, now().timeIntervalSince1970)) + eventCallbackLock.withLock { + guard attemptsRecorded(since: mark.records, key: Self.railAttemptsKey).contains(hash) else { return } + uncertainRailSends[hash] = failedAt + } + } + + private func endRailSendUncertainty(paymentHash: String) { + _ = eventCallbackLock.withLock { uncertainRailSends.removeValue(forKey: paymentHash.lowercased()) } + } + + /// True unless the hash's last rail send failed after it was submitted and no sync has reached the rail since. + private func railSyncedSinceUncertainSend(paymentHash: String) async -> Bool { + let hash = paymentHash.lowercased() + guard let failedAt = eventCallbackLock.withLock({ uncertainRailSends[hash] }) else { return true } + guard let rail = channelFreeRail, await rail.hasSynced(after: failedAt) else { return false } + endRailSendUncertainty(paymentHash: hash) + return true } private static func pendingDuplicate(paymentHash: String?) -> NodeError { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 22b8f514f..4c58e910e 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -636,6 +636,23 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } + func testASparkSendWithAnUnknownOutcomeHoldsTheInvoiceUntilSparkSyncsAgain() async { + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + // The connection drops inside Spark's send, after it may have committed the payment. + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Spark last synced before the send failed, so its list, which lacks the send, proves nothing yet. + rail.syncedAfter = { ($0 ?? 0) < 1000 } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + + rail.syncedAfter = { _ in true } + await assertReachesTheNode() + } + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" @@ -1138,6 +1155,10 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { var onList: (() -> Void)? /// Runs as a send is about to be submitted, before `beforeSubmission`. var beforeSubmit: (() -> Void)? + /// Thrown after the send was submitted, as when the connection drops inside Spark's send. + var errorAfterSubmission: Error? + /// Whether the rail's last sync reached it, after the given seconds when there are any. + var syncedAfter: (UInt64?) -> Bool = { _ in true } func receive(amountSats: UInt64?, description: String, expirySecs: UInt32) async throws -> String { "lnbcrt1fake" @@ -1152,6 +1173,9 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { if let refusalAfterSubmission { throw refusalAfterSubmission } + if let errorAfterSubmission { + throw errorAfterSubmission + } await sendGate?() sentFeeLimitSats = maxFeeSats sentBolt11 = bolt11 @@ -1165,6 +1189,10 @@ private final class FakeRail: Bitkit.ChannelFreeLightningRail { return routingFeeSats } + func hasSynced(after seconds: UInt64?) async -> Bool { + syncedAfter(seconds) + } + func lightningPayments() async throws -> [Bitkit.ChannelFreeRailPayment] { onList?() if let listError { From e559574c5e080e0ea6c1bb4226d0c91fa3da26f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:30:56 +0100 Subject: [PATCH 245/378] fix: fail quickpay on a spark refusal --- .../Utilities/QuickPayPaymentCoordinator.swift | 4 ++++ .../QuickPayPaymentCoordinatorTests.swift | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index 88c292344..a51ac2ba0 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -198,6 +198,10 @@ final class QuickPayPaymentCoordinator { } static func isHardReject(_ error: Error) -> Bool { + // Spark refuses only before anything leaves the wallet. + if error is ChannelFreeRailSendRefused || (error as? AppError)?.underlyingError is ChannelFreeRailSendRefused { + return true + } guard let nodeError = nodeError(from: error) else { return false } diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index 7c3857e2a..fd327bbf6 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -103,6 +103,22 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { XCTAssertEqual(store.spentCentsToday(), 0) } + func testSparkRefusalReleasesSpendAndFails() async throws { + let invoiceHash = try Self.invoiceHash + // Spark refuses only before anything leaves the wallet, as when its balance can't cover the payment. + let route = await firstRoute( + sendBolt11: { _ in + throw Bitkit.ChannelFreeRailSendRefused(reason: "Spark balance can't cover the payment") + } + ) + + guard case .failure = route else { + return XCTFail("Expected failure, got \(String(describing: route))") + } + XCTAssertNil(store.record(matching: invoiceHash)) + XCTAssertEqual(store.spentCentsToday(), 0) + } + func testWrappedDuplicateDispatchWithSucceededLdkReleasesFreshReserveAndFails() async throws { let invoiceHash = try Self.invoiceHash let route = await firstRoute( From 5fbaeb5b66ef41d768dd151ebc4879855b8c2c01 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:30:56 +0100 Subject: [PATCH 246/378] fix: keep the transfer flag on a restored spark funding --- Bitkit/Models/PaykitPaymentStateBackup.swift | 6 +++++- .../PaykitPaymentStateBackupTests.swift | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 9e631a952..d1143b40c 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -225,6 +225,8 @@ struct PaykitPaymentStateBackup: Codable { let candidateMiningFees: [String: String]? let followup: Followup? let transfer: Transfer? + /// A transfer to Spark's deposit address pays no order, so only this keeps it a transfer through a restore. + let sparkFunding: Bool? init(_ attempt: OnchainSendAttempt, wallet: Wallet, billingPeriod: PaykitBillingPeriod? = nil) throws { guard attempt.walletId == wallet.originalWalletId else { throw invalidBackup("On-chain attempt belongs to another backup wallet") } @@ -277,6 +279,7 @@ struct PaykitPaymentStateBackup: Codable { return Transfer(txTotalSats: String($0.txTotalSats), preTransferOnchainSats: String($0.preTransferOnchainSats), originalOrderClientBalanceSats: String($0.clientBalanceSats), originalOrderFeeSats: String(fee)) } + sparkFunding = attempt.isSparkFunding ? true : nil } func restored(wallet destination: Wallet, proofs: [PendingPaykitPaymentProof]) throws -> (OnchainSendAttempt, [PendingPaykitPaymentProof]) { @@ -369,7 +372,8 @@ struct PaykitPaymentStateBackup: Codable { recoveryContext: OnchainSendRecoveryContext(inputs: inputs ?? [], satsPerVbyte: rate, paymentIdentity: payerIdentity, candidateTxids: candidateTxids, candidateFeeRates: rates, - candidateFeeSats: miningFees)) + candidateFeeSats: miningFees), + sparkFunding: sparkFunding) return (attempt, restoredProofs) } diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 821dbf3dc..4bcb56b95 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -927,6 +927,25 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertThrowsError(try restored(object)) } + func testSparkFundingBackupKeepsItsTransferFlag() throws { + let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) + let wire = try XCTUnwrap(envelope.paykitPaymentState?.activeOnchainAttempt) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(wire)) as? [String: Any]) + object["requestId"] = nil + object["payerIdentity"] = nil + object["status"] = "pending" + let plain = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, + from: JSONSerialization.data(withJSONObject: object)) + var funding = try plain.restored(wallet: Self.goldenWallet(), proofs: []).0 + XCTAssertFalse(funding.isSparkFunding) + funding.sparkFunding = true + + let backup = try PaykitPaymentStateBackup.ActiveOnchainAttempt(funding, wallet: Self.goldenWallet()) + let decoded = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, from: JSONEncoder().encode(backup)) + + XCTAssertTrue(try decoded.restored(wallet: Self.goldenWallet(), proofs: []).0.isSparkFunding) + } + func testCandidateFeeRatesRoundtripAndRejectForeignOrMalformedRates() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let state = try XCTUnwrap(envelope.paykitPaymentState) From df96f3cd821738a462c9ed6297d32af17e7fb9a2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:30:57 +0100 Subject: [PATCH 247/378] fix: show payment pending for a spark funding with an unknown outcome --- Bitkit/ViewModels/TransferViewModel.swift | 24 ++++++++++++++++++--- Bitkit/Views/Transfer/SpendingConfirm.swift | 18 ++++++++++++++++ BitkitTests/SparkTransferFlowTests.swift | 12 +++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index c419ac876..3cb62f00f 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -483,9 +483,7 @@ class TransferViewModel: ObservableObject { ), isSparkFunding: true ) - guard case let .accepted(txid) = result else { - throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark funding outcome: \(result)") - } + let txid = try Self.fundedTxid(result) _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: walletId, observedTxid: txid) await SparkFundingRecovery.handOff(txid: txid, amountSats: amountSats) Logger.info("Funded Spark wallet with \(amountSats) sats in \(txid)", context: "TransferViewModel") @@ -493,6 +491,19 @@ class TransferViewModel: ObservableObject { return txid } + /// The txid of an accepted funding. A rejected or unknown one is kept and broadcast again, so it is unresolved, not + /// failed, and is never swiped a second time. + static func fundedTxid(_ result: OnchainSendResult) throws -> String { + switch result { + case let .accepted(txid): + return txid + case let .rejected(txid, reason): + throw SparkFundingUnresolved(txid: txid, rejectionReason: reason) + case let .unknown(txid): + throw SparkFundingUnresolved(txid: txid) + } + } + func closeChannels(channels: [ChannelDetails]) async throws -> [ChannelDetails] { var failedChannels: [ChannelDetails] = [] var successfulChannels: [ChannelDetails] = [] @@ -1061,3 +1072,10 @@ private enum SparkWithdrawalPrice: Equatable { case fee(UInt64) case belowMinimum } + +/// A Spark funding the network rejected or whose broadcast outcome is unknown. It is kept and broadcast again, so it is +/// never sent a second time. +struct SparkFundingUnresolved: Error { + let txid: String + var rejectionReason: String? +} diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 0826969ca..28e09f3ff 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -5,6 +5,7 @@ struct SpendingConfirm: View { @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var transfer: TransferViewModel @EnvironmentObject var wallet: WalletViewModel @Environment(SparkTransferFlow.self) private var flow @@ -198,6 +199,22 @@ struct SpendingConfirm: View { ) } throw SpendingFeesIncreasedError() + } catch let unresolved as SparkFundingUnresolved { + // The funding may be on the network and is kept, so Payment Pending shows it, as for any on-chain send. + if let attemptId = await OnchainSendAttemptService.shared.unresolvedSparkFundingAttemptId() { + flow.bindApproval(to: attemptId) + } + Logger.warn("Spark funding \(unresolved.txid) is unresolved", context: "SpendingConfirm") + if let reason = unresolved.rejectionReason { + app.toast(type: .error, title: t("wallet__onchain_broadcast_rejected"), + description: t("wallet__onchain_broadcast_rejected_note", variables: ["reason": reason])) + } else { + app.toast(type: .warning, title: t("wallet__onchain_broadcast_unconfirmed"), + description: t("wallet__onchain_broadcast_unconfirmed_note")) + } + let route = await SendConfirmationView.onchainPendingRoute(txid: unresolved.txid, requestId: nil) + sheets.showSheet(.send, data: SendConfig(view: route)) + throw unresolved } catch { if let attemptId = await OnchainSendAttemptService.shared.unresolvedSparkFundingAttemptId() { flow.bindApproval(to: attemptId, after: error) @@ -348,6 +365,7 @@ private func spendingConfirmPreview(_ flow: SparkTransferFlow) -> some View { .environmentObject(FeeEstimatesManager()) .environmentObject(NavigationViewModel()) .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) .environmentObject(TransferViewModel()) .environmentObject(WalletViewModel()) .environment(flow) diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 88d190840..c29c1a35b 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import LDKNode import XCTest final class SparkTransferFlowTests: XCTestCase { @@ -331,6 +332,17 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .added, "The funding this swipe left unresolved lost the approval it gave") } + @MainActor + func testAFundingWhoseOutcomeIsUnknownIsKeptNotFailed() { + XCTAssertEqual(try TransferViewModel.fundedTxid(.accepted(txid: "txid")), "txid") + XCTAssertThrowsError(try TransferViewModel.fundedTxid(.unknown(txid: "txid"))) { error in + XCTAssertEqual((error as? SparkFundingUnresolved)?.txid, "txid", "A funding that may be on the network read as failed") + } + XCTAssertThrowsError(try TransferViewModel.fundedTxid(.rejected(txid: "txid", reason: "fee too low"))) { error in + XCTAssertEqual((error as? SparkFundingUnresolved)?.rejectionReason, "fee too low") + } + } + @MainActor func testARecoveredFundingWithNoApprovalAsksBeforeAnEarlyClaim() async { let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) From 87fee7267129fc92ef9d121efe167869fa9114ec Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:30:57 +0100 Subject: [PATCH 248/378] fix: follow a kept funding that completes off the confirm screen --- Bitkit/MainNavView.swift | 5 +++++ Bitkit/ViewModels/SparkTransferFlow.swift | 8 ++++++++ Bitkit/Views/Transfer/SpendingConfirm.swift | 12 ++++++++++-- BitkitTests/SparkTransferFlowTests.swift | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 0b7cf1540..03f2ef74b 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -82,6 +82,11 @@ struct MainNavView: View { navigationContent } .environment(sparkTransfer) + .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in + // The swipe's funding can resolve while another screen of the transfer shows. + guard resolution.isSparkFunding else { return } + _ = sparkTransfer.followIfOwned(txid: resolution.txid, attemptId: resolution.attemptId) + } .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in guard resumeState.shouldResume(didResume: &didResumePendingPubkyProfileSetup) else { return } navigation.navigate(.createProfile) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index 3ca2fa80b..f6a94454f 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -344,6 +344,14 @@ final class SparkTransferFlow { } } + /// Follows a recovered funding a swipe of this flow approved, whichever screen shows when it resolves, so its claim + /// starts and a later swipe can't fund it again. False for one it doesn't own or already follows. + func followIfOwned(txid: String, attemptId: UUID) -> Bool { + guard approvals[attemptId] != nil, self.txid != txid else { return false } + onRecoveredBroadcast(txid: txid, attemptId: attemptId) + return true + } + /// Starts the claim of a funding whose outcome was unknown at the swipe, at the ceiling and speed then approved; with /// nothing approved for that funding, an early claim asks first. func onRecoveredBroadcast(txid: String, attemptId: UUID) { diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 28e09f3ff..1e433db8f 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -96,8 +96,11 @@ struct SpendingConfirm: View { .onDisappear { SparkFundingRecovery.confirmScreenOpen = false } .onReceive(OnchainSendAttemptService.localResolutionPublisher.receive(on: DispatchQueue.main)) { resolution in guard Self.followsRecovered(resolution, isPaying: isPaying), !hideSwipeButton else { return } - // This screen owns the recovered funding: follow it at the chosen speed, as a swipe would have. - flow.onRecoveredBroadcast(txid: resolution.txid, attemptId: resolution.attemptId) + // This screen owns the recovered funding: follow it at the chosen speed, as a swipe would have, unless the + // flow already follows it. + if flow.txid != resolution.txid { + flow.onRecoveredBroadcast(txid: resolution.txid, attemptId: resolution.attemptId) + } hideSwipeButton = true navigation.navigate(.settingUp) } @@ -130,6 +133,11 @@ struct SpendingConfirm: View { } private func onConfirm() async throws { + // A funding of this transfer resolved while another screen showed, so it is followed, not sent again. + if flow.txid != nil { + navigation.navigate(.settingUp) + return + } guard let shown = flow.quote, !isPaying else { return } isPaying = true defer { isPaying = false } diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index c29c1a35b..98fe31c00 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -332,6 +332,23 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertEqual(flow.claimState, .added, "The funding this swipe left unresolved lost the approval it gave") } + @MainActor + func testAFundingThatResolvesOffTheConfirmScreenIsFollowedOnce() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let attemptId = UUID() + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: attemptId, after: OnchainSendAttemptError.unresolved) + + // The swipe's funding resolves while the Amount screen shows, so no confirm screen follows it. + XCTAssertTrue(flow.followIfOwned(txid: "txid", attemptId: attemptId), "A funding this flow approved went unfollowed") + await flow.waitForClaim() + + XCTAssertEqual(flow.txid, "txid") + XCTAssertEqual(flow.claimState, .added) + XCTAssertFalse(flow.followIfOwned(txid: "txid", attemptId: attemptId), "A followed funding was followed again") + } + @MainActor func testAFundingWhoseOutcomeIsUnknownIsKeptNotFailed() { XCTAssertEqual(try TransferViewModel.fundedTxid(.accepted(txid: "txid")), "txid") From 48b2252f9e588a7ce5ed5df690db0bc614167046 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 20:03:06 +0100 Subject: [PATCH 249/378] fix: approve the refused savings fee on the next fee bump confirm --- Bitkit/Managers/SparkExitManager.swift | 15 ++++++++++++++- .../Views/Settings/Advanced/SparkExitViews.swift | 1 + BitkitTests/SparkExitFeeBumpTests.swift | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index c487e7e29..308c4ace2 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -63,6 +63,8 @@ final class SparkExitManager { @ObservationIgnored var fundFromSavings = false @ObservationIgnored var savingsFeeRate: UInt32 = 0 @ObservationIgnored var savingsFeeSats: UInt64 = 0 + /// The savings fee a higher-fee payment was refused for, by rate. + @ObservationIgnored private var feeBumpRefusedFees: [UInt64: UInt64] = [:] @ObservationIgnored private var walletIndex = 0 @ObservationIgnored private var isTicking = false /// The exit a start is still settling: no tick or background run drives it until its optimization wait, requote @@ -633,7 +635,18 @@ final class SparkExitManager { /// Notes what savings pays in fees to send the extra funding of `rate`. func setFeeBumpPaymentFee(rate: UInt64, sats: UInt64) { guard let i = feeBumpOptions.firstIndex(where: { $0.rate == rate }) else { return } - feeBumpOptions[i].paymentFeeSats = sats + feeBumpOptions[i].paymentFeeSats = Self.feeBumpPaymentFee(priced: sats, refusedFeeSats: feeBumpRefusedFees[rate]) + } + + /// Notes the savings fee a higher-fee payment at `rate` was refused for, so the next confirm approves it. + func noteFeeBumpRefused(rate: UInt64, preparedFeeSats: UInt64?) { + feeBumpRefusedFees[rate] = preparedFeeSats + } + + /// The fee a higher-fee payment is confirmed at: coin selection can need more than the estimate, so after a refusal + /// at least the fee it was refused for. + nonisolated static func feeBumpPaymentFee(priced: UInt64, refusedFeeSats: UInt64?) -> UInt64 { + max(priced, refusedFeeSats ?? 0) } /// Moves the exit to `option`'s rate: pays its extra funding from savings at `savingsFeeRate` first, then rebuilds diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 10bc08b9b..ab57a6b82 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -1013,6 +1013,7 @@ struct SparkExitFeeBumpView: View { } catch let OnchainSendAttemptError.preDispatch(error) where error is SparkFundingChanged { // Savings never pays more than the fee confirmed: the rates are priced again for another confirm. app.toast(type: .info, title: t("lightning__spending_confirm__fees_changed_title")) + exit.noteFeeBumpRefused(rate: selected.rate, preparedFeeSats: (error as? SparkFundingChanged)?.preparedFeeSats) await exit.loadFeeBumpOptions() await priceSavingsPayments() selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift index 64401c87d..f571c2d4f 100644 --- a/BitkitTests/SparkExitFeeBumpTests.swift +++ b/BitkitTests/SparkExitFeeBumpTests.swift @@ -354,6 +354,13 @@ final class SparkExitFeeBumpTests: XCTestCase { } /// A failed-leaf retry that built a recovery of its own can be sped up like any live recovery; its timing stays. + /// Coin selection needed more than the estimate: the next confirm approves the fee the refused one asked. + func testTheNextConfirmAfterARefusedHigherFeeApprovesTheFeeItWasRefusedFor() { + XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 420, refusedFeeSats: 600), 600, "the same refused fee was confirmed again") + XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 700, refusedFeeSats: 600), 700) + XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 420, refusedFeeSats: nil), 420) + } + func testAFailedLeafRetryThatBuiltCanPayAHigherFee() async throws { var record = doneWithFailedLeaf() sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])] From 7968138f674b3981a961f3bf10d0af4aceefa009 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 20:04:20 +0100 Subject: [PATCH 250/378] fix: keep the exit notification note above its buttons --- .../Settings/Advanced/SparkExitViews.swift | 352 +++++++++--------- 1 file changed, 185 insertions(+), 167 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index ab57a6b82..55ab2816e 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -641,197 +641,215 @@ struct SparkExitProgressView: View { NavigationBar(title: t("lightning__spark_exit__nav_title")) .padding(.bottom, 16) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - DisplayText(title, accentColor: .brandAccent) - .padding(.top, 32) + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(title, accentColor: .brandAccent) + .padding(.top, 32) + + BodyMText(text, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, SparkExitLayout.headlineGap) + .padding(.bottom, 24) + + if let record { + SparkExitSteps(record: record, tipHeight: exit.tipHeight) + + if let sweepTxid = SparkExitMath.received(record)?.sweepTxid { + ActivityInfoSection( + title: t("wallet__activity_tx_id"), + content: sweepTxid, + testId: "SparkExitSweepTxid", + usesCustomDivider: true, + bottomSpacing: 0 + ) + .padding(.top, 24) + } - BodyMText(text, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, SparkExitLayout.headlineGap) - .padding(.bottom, 24) - - if let record { - SparkExitSteps(record: record, tipHeight: exit.tipHeight) - - if let sweepTxid = SparkExitMath.received(record)?.sweepTxid { - ActivityInfoSection( - title: t("wallet__activity_tx_id"), - content: sweepTxid, - testId: "SparkExitSweepTxid", - usesCustomDivider: true, - bottomSpacing: 0 - ) - .padding(.top, 24) - } + if exit.isSavingsPaymentRefused { + BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent) + .padding(.top, 24) + .accessibilityIdentifier("SparkExitSavingsRefused") + } - if exit.isSavingsPaymentRefused { - BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent) - .padding(.top, 24) - .accessibilityIdentifier("SparkExitSavingsRefused") - } + if let address = record.funding.first?.address { + switch fundingState { + case let .ask(sats, receivedSats): + // The ask counts the input fee of each payment, so it is what is missing; the hint + // names the full total, not the dust-floored ask added to what arrived. + SparkExitFundingAddress( + address: address, + requiredSats: sats, + receivedSats: receivedSats, + targetSats: exit.fundingAskTotal ?? sats + receivedSats, + showsSeen: false + ) + .padding(.top, 24) + case let .paid(txid): + SparkExitWaitingPayment( + text: t("lightning__spark_exit__fund_paid"), + txid: txid, + testId: "SparkExitFundingPaid" + ) + .padding(.top, 24) + case let .seen(txid): + SparkExitWaitingPayment( + text: t("lightning__spark_exit__fund_pending"), + txid: txid, + testId: "SparkExitFundingSeen" + ) + .padding(.top, 24) + case nil: + EmptyView() + } + } - if let address = record.funding.first?.address { - switch fundingState { - case let .ask(sats, receivedSats): - // The ask counts the input fee of each payment, so it is what is missing; the hint - // names the full total, not the dust-floored ask added to what arrived. - SparkExitFundingAddress( - address: address, - requiredSats: sats, - receivedSats: receivedSats, - targetSats: exit.fundingAskTotal ?? sats + receivedSats, - showsSeen: false - ) - .padding(.top, 24) - case let .paid(txid): - SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_paid"), txid: txid, testId: "SparkExitFundingPaid") - .padding(.top, 24) - case let .seen(txid): - SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_pending"), txid: txid, testId: "SparkExitFundingSeen") - .padding(.top, 24) - case nil: - EmptyView() - } - } + if record.isActive, record.isRebuiltFromChain == true { + BodySText(t("lightning__spark_exit__rebuilt_from_chain")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitRebuiltFromChain") + } - if record.isActive, record.isRebuiltFromChain == true { - BodySText(t("lightning__spark_exit__rebuilt_from_chain")) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitRebuiltFromChain") - } + if record.isWaitingForOperators { + BodySText(t( + "lightning__spark_exit__waiting_operators", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingOperators") + } - if record.isWaitingForOperators { - BodySText(t( - "lightning__spark_exit__waiting_operators", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))] - ), accentColor: .white, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitWaitingOperators") - } + if record.isActive, record.isWaitingForLeaves { + BodySText(t("lightning__spark_exit__waiting_leaves")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingLeaves") + } - if record.isActive, record.isWaitingForLeaves { - BodySText(t("lightning__spark_exit__waiting_leaves")) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitWaitingLeaves") - } + if let rejection = record.surfacedRejection { + BodySText(t("lightning__spark_exit__not_moving", variables: ["error": rejection]), textColor: .redAccent) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitNotMoving") + } - if let rejection = record.surfacedRejection { - BodySText(t("lightning__spark_exit__not_moving", variables: ["error": rejection]), textColor: .redAccent) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitNotMoving") - } + if record.showsFeeLow(tip: exit.tipHeight) { + BodySText(t("lightning__spark_exit__fee_low", variables: ["rate": "\(record.feeRateSatPerVbyte)"])) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFeeLow") + } else if exit.canPayHigherFee { + Button { + navigation.navigate(.sparkExitFeeBump) + } label: { + BodySText(t("lightning__spark_exit__fee_rose"), textColor: .yellowAccent) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(PlainButtonStyle()) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFeeRose") + } - if record.showsFeeLow(tip: exit.tipHeight) { - BodySText(t("lightning__spark_exit__fee_low", variables: ["rate": "\(record.feeRateSatPerVbyte)"])) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitFeeLow") - } else if exit.canPayHigherFee { - Button { - navigation.navigate(.sparkExitFeeBump) - } label: { - BodySText(t("lightning__spark_exit__fee_rose"), textColor: .yellowAccent) - .fixedSize(horizontal: false, vertical: true) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) + if let outsideSats = SparkExitMath + .outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) + { + BodySText(t( + "lightning__spark_exit__outside_exit", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: outsideSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitOutsideExit") + } + + if let lostText { + BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLostLeaves") + } + + if record.leftBehindSats > 0 { + BodySText(t( + "lightning__spark_exit__left_behind_leaves", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLeftBehind") + } + + if record.isActive { + SparkExitBackgroundNotes() + .padding(.top, 16) + } + + if !record.isActive, !record.leftover.isEmpty || !(record.sweptLeftover ?? []).isEmpty, + let address = record.funding.first?.address + { + SparkExitLeftoverStatus(record: record, address: address) + .padding(.top, 16) + } } - .buttonStyle(PlainButtonStyle()) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitFeeRose") } + .padding(.bottom, 32) - if let outsideSats = SparkExitMath.outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) { - BodySText(t( - "lightning__spark_exit__outside_exit", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: outsideSats))] - ), accentColor: .white, accentFont: Fonts.bold) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitOutsideExit") - } + // The buttons scroll with the content once it outgrows the screen, so nothing ends behind them. + Spacer(minLength: 0) - if let lostText { - BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitLostLeaves") + if record?.isActive == true, notifications.authorizationStatus == .denied { + CustomButton(title: t("lightning__spark_exit__note_notifications_button"), variant: .secondary) { + if let url = URL(string: UIApplication.openSettingsURLString) { + await UIApplication.shared.open(url) + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitNotificationSettings") } - if record.leftBehindSats > 0 { - BodySText(t( - "lightning__spark_exit__left_behind_leaves", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))] - ), accentColor: .white, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitLeftBehind") + if exit.isSavingsPaymentRefused { + CustomButton(title: t("lightning__spark_exit__fund_savings"), variant: .secondary) { + do { + try await exit.retrySavingsPayment() + } catch { + app.toast(error) + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitRetrySavings") } - if record.isActive { - SparkExitBackgroundNotes() - .padding(.top, 16) + if exit.canPayHigherFee { + CustomButton(title: t("lightning__spark_exit__fee_bump_button"), variant: .secondary) { + navigation.navigate(.sparkExitFeeBump) + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitFeeBump") } - if !record.isActive, !record.leftover.isEmpty || !(record.sweptLeftover ?? []).isEmpty, - let address = record.funding.first?.address - { - SparkExitLeftoverStatus(record: record, address: address) - .padding(.top, 16) + if exit.canCancel { + CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { + isConfirmingCancel = true + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitCancel") } - } - } - .padding(.bottom, 32) - } - .refreshable { - await SparkManager.shared.refreshState() - await exit.tick() - } - - if record?.isActive == true, notifications.authorizationStatus == .denied { - CustomButton(title: t("lightning__spark_exit__note_notifications_button"), variant: .secondary) { - if let url = URL(string: UIApplication.openSettingsURLString) { - await UIApplication.shared.open(url) - } - } - .padding(.bottom, 16) - .accessibilityIdentifier("SparkExitNotificationSettings") - } - if exit.isSavingsPaymentRefused { - CustomButton(title: t("lightning__spark_exit__fund_savings"), variant: .secondary) { - do { - try await exit.retrySavingsPayment() - } catch { - app.toast(error) + CustomButton(title: t("common__ok")) { + navigation.reset() + } + .accessibilityIdentifier("SparkExitProgressOk") } + .frame(minHeight: geometry.size.height) } - .padding(.bottom, 16) - .accessibilityIdentifier("SparkExitRetrySavings") - } - - if exit.canPayHigherFee { - CustomButton(title: t("lightning__spark_exit__fee_bump_button"), variant: .secondary) { - navigation.navigate(.sparkExitFeeBump) + .refreshable { + await SparkManager.shared.refreshState() + await exit.tick() } - .padding(.bottom, 16) - .accessibilityIdentifier("SparkExitFeeBump") - } - - if exit.canCancel { - CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { - isConfirmingCancel = true - } - .padding(.bottom, 16) - .accessibilityIdentifier("SparkExitCancel") - } - - CustomButton(title: t("common__ok")) { - navigation.reset() } - .accessibilityIdentifier("SparkExitProgressOk") } .navigationBarHidden(true) .padding(.horizontal, 16) From 3311ff97594a7c8a9dc73838dc085fc9da1d20c7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 20:13:19 +0100 Subject: [PATCH 251/378] revert: keep the exit notification note above its buttons --- .../Settings/Advanced/SparkExitViews.swift | 352 +++++++++--------- 1 file changed, 167 insertions(+), 185 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 55ab2816e..ab57a6b82 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -641,215 +641,197 @@ struct SparkExitProgressView: View { NavigationBar(title: t("lightning__spark_exit__nav_title")) .padding(.bottom, 16) - GeometryReader { geometry in - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 0) { - DisplayText(title, accentColor: .brandAccent) - .padding(.top, 32) - - BodyMText(text, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, SparkExitLayout.headlineGap) - .padding(.bottom, 24) - - if let record { - SparkExitSteps(record: record, tipHeight: exit.tipHeight) - - if let sweepTxid = SparkExitMath.received(record)?.sweepTxid { - ActivityInfoSection( - title: t("wallet__activity_tx_id"), - content: sweepTxid, - testId: "SparkExitSweepTxid", - usesCustomDivider: true, - bottomSpacing: 0 - ) - .padding(.top, 24) - } - - if exit.isSavingsPaymentRefused { - BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent) - .padding(.top, 24) - .accessibilityIdentifier("SparkExitSavingsRefused") - } - - if let address = record.funding.first?.address { - switch fundingState { - case let .ask(sats, receivedSats): - // The ask counts the input fee of each payment, so it is what is missing; the hint - // names the full total, not the dust-floored ask added to what arrived. - SparkExitFundingAddress( - address: address, - requiredSats: sats, - receivedSats: receivedSats, - targetSats: exit.fundingAskTotal ?? sats + receivedSats, - showsSeen: false - ) - .padding(.top, 24) - case let .paid(txid): - SparkExitWaitingPayment( - text: t("lightning__spark_exit__fund_paid"), - txid: txid, - testId: "SparkExitFundingPaid" - ) - .padding(.top, 24) - case let .seen(txid): - SparkExitWaitingPayment( - text: t("lightning__spark_exit__fund_pending"), - txid: txid, - testId: "SparkExitFundingSeen" - ) - .padding(.top, 24) - case nil: - EmptyView() - } - } - - if record.isActive, record.isRebuiltFromChain == true { - BodySText(t("lightning__spark_exit__rebuilt_from_chain")) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitRebuiltFromChain") - } - - if record.isWaitingForOperators { - BodySText(t( - "lightning__spark_exit__waiting_operators", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))] - ), accentColor: .white, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitWaitingOperators") - } + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DisplayText(title, accentColor: .brandAccent) + .padding(.top, 32) - if record.isActive, record.isWaitingForLeaves { - BodySText(t("lightning__spark_exit__waiting_leaves")) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitWaitingLeaves") - } + BodyMText(text, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, SparkExitLayout.headlineGap) + .padding(.bottom, 24) + + if let record { + SparkExitSteps(record: record, tipHeight: exit.tipHeight) + + if let sweepTxid = SparkExitMath.received(record)?.sweepTxid { + ActivityInfoSection( + title: t("wallet__activity_tx_id"), + content: sweepTxid, + testId: "SparkExitSweepTxid", + usesCustomDivider: true, + bottomSpacing: 0 + ) + .padding(.top, 24) + } - if let rejection = record.surfacedRejection { - BodySText(t("lightning__spark_exit__not_moving", variables: ["error": rejection]), textColor: .redAccent) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitNotMoving") - } + if exit.isSavingsPaymentRefused { + BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent) + .padding(.top, 24) + .accessibilityIdentifier("SparkExitSavingsRefused") + } - if record.showsFeeLow(tip: exit.tipHeight) { - BodySText(t("lightning__spark_exit__fee_low", variables: ["rate": "\(record.feeRateSatPerVbyte)"])) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitFeeLow") - } else if exit.canPayHigherFee { - Button { - navigation.navigate(.sparkExitFeeBump) - } label: { - BodySText(t("lightning__spark_exit__fee_rose"), textColor: .yellowAccent) - .fixedSize(horizontal: false, vertical: true) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(PlainButtonStyle()) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitFeeRose") - } + if let address = record.funding.first?.address { + switch fundingState { + case let .ask(sats, receivedSats): + // The ask counts the input fee of each payment, so it is what is missing; the hint + // names the full total, not the dust-floored ask added to what arrived. + SparkExitFundingAddress( + address: address, + requiredSats: sats, + receivedSats: receivedSats, + targetSats: exit.fundingAskTotal ?? sats + receivedSats, + showsSeen: false + ) + .padding(.top, 24) + case let .paid(txid): + SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_paid"), txid: txid, testId: "SparkExitFundingPaid") + .padding(.top, 24) + case let .seen(txid): + SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_pending"), txid: txid, testId: "SparkExitFundingSeen") + .padding(.top, 24) + case nil: + EmptyView() + } + } - if let outsideSats = SparkExitMath - .outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) - { - BodySText(t( - "lightning__spark_exit__outside_exit", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: outsideSats))] - ), accentColor: .white, accentFont: Fonts.bold) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitOutsideExit") - } + if record.isActive, record.isRebuiltFromChain == true { + BodySText(t("lightning__spark_exit__rebuilt_from_chain")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitRebuiltFromChain") + } - if let lostText { - BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitLostLeaves") - } + if record.isWaitingForOperators { + BodySText(t( + "lightning__spark_exit__waiting_operators", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingOperators") + } - if record.leftBehindSats > 0 { - BodySText(t( - "lightning__spark_exit__left_behind_leaves", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))] - ), accentColor: .white, accentFont: Fonts.bold) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - .accessibilityIdentifier("SparkExitLeftBehind") - } + if record.isActive, record.isWaitingForLeaves { + BodySText(t("lightning__spark_exit__waiting_leaves")) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitWaitingLeaves") + } - if record.isActive { - SparkExitBackgroundNotes() - .padding(.top, 16) - } + if let rejection = record.surfacedRejection { + BodySText(t("lightning__spark_exit__not_moving", variables: ["error": rejection]), textColor: .redAccent) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitNotMoving") + } - if !record.isActive, !record.leftover.isEmpty || !(record.sweptLeftover ?? []).isEmpty, - let address = record.funding.first?.address - { - SparkExitLeftoverStatus(record: record, address: address) - .padding(.top, 16) - } + if record.showsFeeLow(tip: exit.tipHeight) { + BodySText(t("lightning__spark_exit__fee_low", variables: ["rate": "\(record.feeRateSatPerVbyte)"])) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFeeLow") + } else if exit.canPayHigherFee { + Button { + navigation.navigate(.sparkExitFeeBump) + } label: { + BodySText(t("lightning__spark_exit__fee_rose"), textColor: .yellowAccent) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(PlainButtonStyle()) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitFeeRose") } - .padding(.bottom, 32) - - // The buttons scroll with the content once it outgrows the screen, so nothing ends behind them. - Spacer(minLength: 0) - if record?.isActive == true, notifications.authorizationStatus == .denied { - CustomButton(title: t("lightning__spark_exit__note_notifications_button"), variant: .secondary) { - if let url = URL(string: UIApplication.openSettingsURLString) { - await UIApplication.shared.open(url) - } - } - .padding(.bottom, 16) - .accessibilityIdentifier("SparkExitNotificationSettings") + if let outsideSats = SparkExitMath.outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) { + BodySText(t( + "lightning__spark_exit__outside_exit", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: outsideSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitOutsideExit") } - if exit.isSavingsPaymentRefused { - CustomButton(title: t("lightning__spark_exit__fund_savings"), variant: .secondary) { - do { - try await exit.retrySavingsPayment() - } catch { - app.toast(error) - } - } - .padding(.bottom, 16) - .accessibilityIdentifier("SparkExitRetrySavings") + if let lostText { + BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLostLeaves") } - if exit.canPayHigherFee { - CustomButton(title: t("lightning__spark_exit__fee_bump_button"), variant: .secondary) { - navigation.navigate(.sparkExitFeeBump) - } - .padding(.bottom, 16) - .accessibilityIdentifier("SparkExitFeeBump") + if record.leftBehindSats > 0 { + BodySText(t( + "lightning__spark_exit__left_behind_leaves", + variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))] + ), accentColor: .white, accentFont: Fonts.bold) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .accessibilityIdentifier("SparkExitLeftBehind") } - if exit.canCancel { - CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { - isConfirmingCancel = true - } - .padding(.bottom, 16) - .accessibilityIdentifier("SparkExitCancel") + if record.isActive { + SparkExitBackgroundNotes() + .padding(.top, 16) } - CustomButton(title: t("common__ok")) { - navigation.reset() + if !record.isActive, !record.leftover.isEmpty || !(record.sweptLeftover ?? []).isEmpty, + let address = record.funding.first?.address + { + SparkExitLeftoverStatus(record: record, address: address) + .padding(.top, 16) } - .accessibilityIdentifier("SparkExitProgressOk") } - .frame(minHeight: geometry.size.height) } - .refreshable { - await SparkManager.shared.refreshState() - await exit.tick() + .padding(.bottom, 32) + } + .refreshable { + await SparkManager.shared.refreshState() + await exit.tick() + } + + if record?.isActive == true, notifications.authorizationStatus == .denied { + CustomButton(title: t("lightning__spark_exit__note_notifications_button"), variant: .secondary) { + if let url = URL(string: UIApplication.openSettingsURLString) { + await UIApplication.shared.open(url) + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitNotificationSettings") + } + + if exit.isSavingsPaymentRefused { + CustomButton(title: t("lightning__spark_exit__fund_savings"), variant: .secondary) { + do { + try await exit.retrySavingsPayment() + } catch { + app.toast(error) + } + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitRetrySavings") + } + + if exit.canPayHigherFee { + CustomButton(title: t("lightning__spark_exit__fee_bump_button"), variant: .secondary) { + navigation.navigate(.sparkExitFeeBump) } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitFeeBump") + } + + if exit.canCancel { + CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) { + isConfirmingCancel = true + } + .padding(.bottom, 16) + .accessibilityIdentifier("SparkExitCancel") + } + + CustomButton(title: t("common__ok")) { + navigation.reset() } + .accessibilityIdentifier("SparkExitProgressOk") } .navigationBarHidden(true) .padding(.horizontal, 16) From 27b2ce1001ff4290e2646a6b1c34ebff6517ec4e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 20:13:35 +0100 Subject: [PATCH 252/378] fix: keep the exit notification note above its buttons --- Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index ab57a6b82..1332c7caf 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -789,6 +789,13 @@ struct SparkExitProgressView: View { await SparkManager.shared.refreshState() await exit.tick() } + // Over the bottom inset: content cut at the edge fades out above the buttons, and scrolled to the end the + // last line clears it. + .overlay(alignment: .bottom) { + LinearGradient(colors: [.customBlack.opacity(0), .customBlack], startPoint: .top, endPoint: .bottom) + .frame(height: 32) + .allowsHitTesting(false) + } if record?.isActive == true, notifications.authorizationStatus == .denied { CustomButton(title: t("lightning__spark_exit__note_notifications_button"), variant: .secondary) { From d74b3f13f70af5136d56f008001ee0033b1ae32a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 20:57:06 +0100 Subject: [PATCH 253/378] fix: read spark's payments only after its first sync --- Bitkit/Services/LightningService.swift | 15 +++++++-------- BitkitTests/ChannelFreeRailTests.swift | 9 +++++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 7cab25ff7..3962e8951 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1437,12 +1437,7 @@ class LightningService: NodeLifecycleService { /// Nil while the rail's payments can't be read: listing them failed, or the rail runs here but isn't connected. private func readableRailPayments() async -> [ChannelFreeRailPayment]? { - // A restored wallet fills its list on its first sync, so before a sync reaches Spark a missing send proves - // nothing. - if let rail = channelFreeRail, await !rail.hasSynced(after: nil) { - return nil - } - return try? await channelFreeRailPayments() + try? await channelFreeRailPayments() } /// A rail send that failed after it was submitted, with the time it failed, until a sync after it settles it. @@ -2075,10 +2070,14 @@ extension LightningService { return isStaleFailure(paymentHash: hash, from: rail) } - /// The rail's Lightning payments, none where the rail doesn't run; throws when the rail could not list them, or runs - /// here but isn't connected, so a payment it may hold is never read as one it never made. + /// The rail's Lightning payments, none where the rail doesn't run; throws when the rail could not list them, runs + /// here but isn't connected, or no sync has reached it yet, so a payment it may hold is never read as one it never + /// made. func channelFreeRailPayments() async throws -> [ChannelFreeRailPayment] { if let rail = channelFreeRail { + // A restored wallet fills its list on its first sync, so before a sync reaches Spark a missing send proves + // nothing. + guard await rail.hasSynced(after: nil) else { throw ChannelFreeRailUnavailable() } return try await rail.lightningPayments() } if await channelFreeRailSupported() { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 4c58e910e..cb6942313 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -653,6 +653,15 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } + func testPaykitReadsSparkBeforeItsFirstSyncAsUnavailable() async { + // A restored wallet fills Spark's list on its first sync, so the list before it lacks sends Spark made. + rail.syncedAfter = { _ in false } + + let status = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: "aa") + + XCTAssertEqual(status, .unavailable, "Spark's unsynced list was read as complete") + } + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" From ddac6c09aeb523bab73e8811a7ab40188a818446 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 20:57:06 +0100 Subject: [PATCH 254/378] fix: keep an unknown spark send pending past an older failed row --- Bitkit/Services/LightningService.swift | 16 +++++++++++++++- BitkitTests/ChannelFreeRailTests.swift | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 3962e8951..f9e723367 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1450,6 +1450,19 @@ class LightningService: NodeLifecycleService { } } + /// Rail sends whose outcome stayed unknown, listed as pending until a sync after them reaches the rail: Spark may + /// still finish one, while its own list shows only an earlier attempt, or nothing. + private func unsettledUncertainRailSends() async -> [ChannelFreeRailPayment] { + let hashes = eventCallbackLock.withLock { Array(uncertainRailSends.keys) } + var pending: [ChannelFreeRailPayment] = [] + for hash in hashes { + if await !railSyncedSinceUncertainSend(paymentHash: hash) { + pending.append(ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .pending, preimage: nil)) + } + } + return pending + } + private func endRailSendUncertainty(paymentHash: String) { _ = eventCallbackLock.withLock { uncertainRailSends.removeValue(forKey: paymentHash.lowercased()) } } @@ -2078,7 +2091,8 @@ extension LightningService { // A restored wallet fills its list on its first sync, so before a sync reaches Spark a missing send proves // nothing. guard await rail.hasSynced(after: nil) else { throw ChannelFreeRailUnavailable() } - return try await rail.lightningPayments() + let payments = try await rail.lightningPayments() + return await payments + unsettledUncertainRailSends() } if await channelFreeRailSupported() { throw ChannelFreeRailUnavailable() diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index cb6942313..a18497168 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -662,6 +662,22 @@ extension ChannelFreeRailTests { XCTAssertEqual(status, .unavailable, "Spark's unsynced list was read as complete") } + func testPaykitReadsAnOlderFailedAttemptOfAnUnknownSparkSendAsPending() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Spark lists only an earlier attempt that failed, and has not synced since this send failed. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.syncedAfter = { ($0 ?? 0) < 1000 } + + let status = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: hash) + + XCTAssertEqual(status, .pending, "an earlier attempt's failure ended a send Spark may still finish") + } + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" From 2e9524cf2ae8ae8f40aac64649b98c6b2807d60a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 20:57:07 +0100 Subject: [PATCH 255/378] fix: end a spark send's unknown outcome on its payment event --- Bitkit/Services/LightningService.swift | 9 ++++++++- BitkitTests/ChannelFreeRailTests.swift | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index f9e723367..8267465bf 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -2040,9 +2040,16 @@ extension LightningService { /// Spark runs before the node starts, so its events wait until the node's event callback is set rather than being lost. func emitChannelFreeRailEvent(_ event: Event) { - if case let .paymentFailed(_, paymentHash?, _) = event { + switch event { + case let .paymentFailed(_, paymentHash?, _): // The failure of the send itself, which may arrive while its send call is still returning. releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) + // The event settles a send whose outcome was unknown, so retries of its invoice are no longer held. + endRailSendUncertainty(paymentHash: paymentHash) + case let .paymentSuccessful(_, paymentHash, _, _): + endRailSendUncertainty(paymentHash: paymentHash) + default: + break } if isStaleFailure(event, from: .channelFree) { Logger.info("Ignored a Spark failure for a payment retried over a channel: \(event)", context: "LightningService") diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index a18497168..b72d2d246 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -678,6 +678,22 @@ extension ChannelFreeRailTests { XCTAssertEqual(status, .pending, "an earlier attempt's failure ended a send Spark may still finish") } + func testASparkFailureEventOfAnUnknownSendLetsTheNodeRetry() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Spark's syncs keep failing, but it resumes the send and reports that it failed. + rail.syncedAfter = { ($0 ?? 0) < 1000 } + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: nil)) + rail.balanceSats = 0 + + await assertReachesTheNode() + } + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" From 5a56f12449ef61b946bc1f54c5c1398225515434 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 21:25:33 +0100 Subject: [PATCH 256/378] fix: keep a missing spark send pending until its invoice expires --- Bitkit/Services/LightningService.swift | 26 +++++++++++++++++-------- BitkitTests/ChannelFreeRailTests.swift | 27 +++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 8267465bf..39613af66 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -134,7 +134,7 @@ class LightningService: NodeLifecycleService { /// Payment hashes inside a rail send, which Spark lists only once it returns. private var railSendsInFlight: Set = [] /// Rail sends that failed after they were submitted, by hash, with the time they failed in seconds since 1970. - private var uncertainRailSends: [String: UInt64] = [:] + private var uncertainRailSends: [String: UncertainRailSend] = [:] /// Counts attempts recorded in this run, so a listing drops a hash just when the hash was recorded before the /// listing was read, never a retry recorded while it was read. private var attemptRecords = 0 @@ -1099,7 +1099,7 @@ class LightningService: NodeLifecycleService { } catch { // A send that failed once submitted may still go out, so its outcome waits for a later sync. if let paymentHash { - markRailSendUncertain(paymentHash: paymentHash, since: mark) + markRailSendUncertain(paymentHash: paymentHash, since: mark, expiresAt: expiresAt) } throw error } @@ -1440,13 +1440,19 @@ class LightningService: NodeLifecycleService { try? await channelFreeRailPayments() } - /// A rail send that failed after it was submitted, with the time it failed, until a sync after it settles it. - private func markRailSendUncertain(paymentHash: String, since mark: AttemptMark) { + /// A rail send whose outcome stayed unknown: when it failed, and when its invoice expires, in seconds since 1970. + private struct UncertainRailSend { + let failedAt: UInt64 + let expiresAt: Int? + } + + /// A rail send that failed after it was submitted, until a sync after it and its invoice's expiry settle it. + private func markRailSendUncertain(paymentHash: String, since mark: AttemptMark, expiresAt: Int?) { let hash = paymentHash.lowercased() let failedAt = UInt64(max(0, now().timeIntervalSince1970)) eventCallbackLock.withLock { guard attemptsRecorded(since: mark.records, key: Self.railAttemptsKey).contains(hash) else { return } - uncertainRailSends[hash] = failedAt + uncertainRailSends[hash] = UncertainRailSend(failedAt: failedAt, expiresAt: expiresAt) } } @@ -1467,11 +1473,15 @@ class LightningService: NodeLifecycleService { _ = eventCallbackLock.withLock { uncertainRailSends.removeValue(forKey: paymentHash.lowercased()) } } - /// True unless the hash's last rail send failed after it was submitted and no sync has reached the rail since. + /// True unless the hash's last rail send failed after it was submitted and may still go out: no sync has reached the + /// rail since, or its invoice hasn't expired. private func railSyncedSinceUncertainSend(paymentHash: String) async -> Bool { let hash = paymentHash.lowercased() - guard let failedAt = eventCallbackLock.withLock({ uncertainRailSends[hash] }) else { return true } - guard let rail = channelFreeRail, await rail.hasSynced(after: failedAt) else { return false } + guard let uncertain = eventCallbackLock.withLock({ uncertainRailSends[hash] }) else { return true } + guard let rail = channelFreeRail, await rail.hasSynced(after: uncertain.failedAt) else { return false } + // Spark keeps a send it committed but didn't finish in a cache no call exposes, and a sync that reached Spark + // can still fail to resume it, so only an expired invoice shows a missing send will never go out. + guard let expiresAt = uncertain.expiresAt, Int(now().timeIntervalSince1970) >= expiresAt else { return false } endRailSendUncertainty(paymentHash: hash) return true } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index b72d2d246..e2e54034a 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -636,7 +636,7 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } - func testASparkSendWithAnUnknownOutcomeHoldsTheInvoiceUntilSparkSyncsAgain() async { + func testASparkSendWithAnUnknownOutcomeHoldsTheInvoiceUntilSparkSyncsAgain() async throws { Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } rail.balanceSats = 100_000 // The connection drops inside Spark's send, after it may have committed the payment. @@ -649,10 +649,35 @@ extension ChannelFreeRailTests { await assertRefusedAsDuplicate() + // A sync after the failure reached Spark and the invoice expired, so the send can no longer go out. rail.syncedAfter = { _ in true } + let expiresAt = try Self.invoiceExpiresAt() + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt) } await assertReachesTheNode() } + func testAnUnknownSparkSendMissingAfterAFreshSyncIsHeldUntilItsInvoiceExpires() async throws { + let expiresAt = try Self.invoiceExpiresAt() + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt - 600) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Every sync after the failure reaches Spark, yet Spark lists no such send: resuming it may have failed. + rail.syncedAfter = { _ in true } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt) } + await assertReachesTheNode() + } + + private static func invoiceExpiresAt() throws -> TimeInterval { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: invoice) + return TimeInterval(invoice.secondsSinceEpoch() + invoice.expiryTimeSeconds()) + } + func testPaykitReadsSparkBeforeItsFirstSyncAsUnavailable() async { // A restored wallet fills Spark's list on its first sync, so the list before it lacks sends Spark made. rail.syncedAfter = { _ in false } From 536144ca719c2ebcc41888ff084aebd679105358 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 21:43:39 +0100 Subject: [PATCH 257/378] fix: approve the refused savings fee on the next exit start --- Bitkit/Managers/SparkExitManager.swift | 9 +++++++ .../Settings/Advanced/SparkExitViews.swift | 14 ++++++++-- BitkitTests/SparkExitReviewTests.swift | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 308c4ace2..459243b10 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -65,6 +65,8 @@ final class SparkExitManager { @ObservationIgnored var savingsFeeSats: UInt64 = 0 /// The savings fee a higher-fee payment was refused for, by rate. @ObservationIgnored private var feeBumpRefusedFees: [UInt64: UInt64] = [:] + /// The savings fee the start's funding payment was refused for: the next funding prices and approves at least that. + @ObservationIgnored private(set) var savingsFundingRefusedFeeSats: UInt64? @ObservationIgnored private var walletIndex = 0 @ObservationIgnored private var isTicking = false /// The exit a start is still settling: no tick or background run drives it until its optimization wait, requote @@ -213,6 +215,7 @@ final class SparkExitManager { fundFromSavings = false savingsFeeRate = 0 savingsFeeSats = 0 + savingsFundingRefusedFeeSats = nil fundingSeenSats = 0 fundingSeenCount = 0 Self.clearReminders() @@ -638,6 +641,11 @@ final class SparkExitManager { feeBumpOptions[i].paymentFeeSats = Self.feeBumpPaymentFee(priced: sats, refusedFeeSats: feeBumpRefusedFees[rate]) } + /// Notes the savings fee the start's funding payment was refused for, so the next funding prices and approves it. + func noteSavingsFundingRefused(preparedFeeSats: UInt64?) { + savingsFundingRefusedFeeSats = preparedFeeSats + } + /// Notes the savings fee a higher-fee payment at `rate` was refused for, so the next confirm approves it. func noteFeeBumpRefused(rate: UInt64, preparedFeeSats: UInt64?) { feeBumpRefusedFees[rate] = preparedFeeSats @@ -849,6 +857,7 @@ final class SparkExitManager { } self.record = started setFunding(SparkExitMath.fundingState(started, utxos: nil), for: started) + savingsFundingRefusedFeeSats = nil } Task { await tick() } } diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 1332c7caf..3eb5c551b 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -273,6 +273,7 @@ struct SparkExitFundingView: View { let priced = await Self.priceSavingsFee( address: exit.fundingAddress, fundingSats: fundingSats, + refusedFeeSats: exit.savingsFundingRefusedFeeSats, estimates: { await feeEstimatesManager.getEstimates(refresh: true) }, calculate: { try await wallet.calculateTotalFee(address: $0, amountSats: $1, satsPerVByte: $2) } ) @@ -299,6 +300,7 @@ struct SparkExitFundingView: View { nonisolated static func priceSavingsFee( address: String?, fundingSats: UInt64, + refusedFeeSats: UInt64? = nil, estimates: () async -> FeeRates?, calculate: (String, UInt64, UInt32) async throws -> UInt64 ) async -> SavingsFee { @@ -310,7 +312,9 @@ struct SparkExitFundingView: View { } let rate = TransactionSpeed.fast.getFeeRate(from: feeRates) do { - return try await .priced(rate: rate, feeSats: calculate(address, fundingSats, rate)) + // Coin selection can need more fee than the estimate: after a refused start, at least the fee it asked. + let feeSats = try await calculate(address, fundingSats, rate) + return .priced(rate: rate, feeSats: max(feeSats, refusedFeeSats ?? 0)) } catch { Logger.warn("Failed to price the Spark exit funding from savings: \(error)", context: "SparkExitFundingView") return .failed @@ -531,7 +535,13 @@ struct SparkExitConfirmView: View { // Cancelled while the start waited: nothing was paid, nothing to say. Rethrown so the swipe resets. throw error } catch { - app.toast(error) + if case let OnchainSendAttemptError.preDispatch(refused) = error, let changed = refused as? SparkFundingChanged { + // Savings never pays more than the fee confirmed: the next funding asks for the refused one. + exit.noteSavingsFundingRefused(preparedFeeSats: changed.preparedFeeSats) + app.toast(type: .info, title: t("lightning__spending_confirm__fees_changed_title")) + } else { + app.toast(error) + } // The exit started but paying its fee funding failed: the progress screen shows where to fund it. if exit.isExiting { navigation.navigate(.sparkExitProgress) diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index c5efcbb4c..abefaa8d0 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -819,6 +819,32 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(priced, .priced(rate: TransactionSpeed.fast.getFeeRate(from: rates), feeSats: 300)) } + /// A start whose savings payment was refused for a higher prepared fee: the next funding prices at least that fee, + /// so Funding shows it and the next swipe approves it. + @MainActor + func testTheNextFundingAfterAStartRefusedForAHigherSavingsFeePricesAtLeastThatFee() async { + let rates = FeeRates(fast: 9, mid: 6, slow: 1) + let exit = SparkExitManager(walletIndex: 9999) + func price() async -> SparkExitFundingView.SavingsFee { + await SparkExitFundingView.priceSavingsFee( + address: "bcrt1qfunding", + fundingSats: 20000, + refusedFeeSats: exit.savingsFundingRefusedFeeSats, + estimates: { rates }, + calculate: { _, _, _ in 420 } + ) + } + let rate = TransactionSpeed.fast.getFeeRate(from: rates) + let first = await price() + XCTAssertEqual(first, .priced(rate: rate, feeSats: 420)) + + exit.noteSavingsFundingRefused(preparedFeeSats: 600) + let next = await price() + + // Savings' coin selection needs 600 sats where the estimate says 420. + XCTAssertEqual(next, .priced(rate: rate, feeSats: 600), "the same refused estimate was confirmed again") + } + /// Savings never pays more than the funding the user confirmed; the rest is asked for on Progress once the payment /// confirms. func testSavingsPaysAtMostTheConfirmedFundingAndProgressAsksForTheRest() throws { From 80394fcea3346e8e1a6fadfcebde37b138855a27 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 21:44:38 +0100 Subject: [PATCH 258/378] fix: keep the kept spark balance from the exit start --- Bitkit/Managers/SparkExitManager.swift | 2 ++ Bitkit/Managers/SparkManager.swift | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 459243b10..c31320543 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -792,6 +792,8 @@ final class SparkExitManager { startingCreatedAt = record.createdAt defer { startingCreatedAt = nil } self.record = record + // In the same step the exit shows: a refresh before the reconnect below would read Spark as exiting. + SparkManager.shared.holdKeptBalanceForExit() SparkManager.shared.detachForExit() // Reconnecting cuts an optimization short, which would leave its counter-swap unclaimed for a while. await SparkManager.shared.waitForOptimization() diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 959563b8b..86ff529c8 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -142,13 +142,19 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Saves the kept balance as an exit starts, before a refresh reads Spark as exiting and drops it: a cancelled + /// exit shows it again. + func holdKeptBalanceForExit() { + keptBeforeExit = keptBalanceSats ?? keptBeforeExit + keptBalanceSats = nil + } + /// Connects Spark again so its leaf optimization follows whether an exit runs: off when one starts, back on /// once it ends. An exit cancelled before it paid anything (`isCancel`) shows the kept balance again. func reconnectForExit(isCancel: Bool = false) async { guard let network = Self.sparkNetwork, !isWiping else { return } if isExiting { - keptBeforeExit = keptBalanceSats ?? keptBeforeExit - keptBalanceSats = nil + holdKeptBalanceForExit() } else { keptBalanceSats = Self.keptBalanceSats(afterExitKept: keptBeforeExit, read: balanceSats, isCancel: isCancel) ?? keptBalanceSats From 56b7ea0a3d4c80da13e466e81f2425a00b8f99b6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 21:45:30 +0100 Subject: [PATCH 259/378] fix: forget the kept spark balance on a wipe --- Bitkit/Managers/SparkManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 86ff529c8..71124a246 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -501,6 +501,7 @@ final class SparkManager: ChannelFreeLightningRail { private func deleteWipedData(walletIndex: Int) throws { balanceSats = 0 keptBalanceSats = nil + keptBeforeExit = nil withdrawalFailedAt = nil pendingDepositSats = 0 recoverableFundsSats = 0 From 67a070702f3e26a942018a81159e68afd435af5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 22:09:43 +0100 Subject: [PATCH 260/378] fix: count spark as synced only after a sync this run --- Bitkit/Managers/SparkManager.swift | 33 ++++++++++++++++++----- BitkitTests/SparkPaymentSafetyTests.swift | 13 +++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index cc99d0e25..83a7f234e 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -62,6 +62,8 @@ final class SparkManager: ChannelFreeLightningRail { /// Payments whose activity failed to save, written again at the next refresh. @ObservationIgnored private var unsavedPayments: Set = [] @ObservationIgnored private var sdk: BreezSdk? + /// Whether a wallet sync ended since this run connected, so the SDK's sync error describes this run. + @ObservationIgnored private var syncedThisRun = false /// The last withdrawal quote, which the withdrawal reuses while it holds. @ObservationIgnored private var preparedWithdrawal: (quote: SparkWithdrawalQuote, response: PrepareSendPaymentResponse)? /// Withdrawals this app sent, known before Spark lists them. @@ -143,7 +145,10 @@ final class SparkManager: ChannelFreeLightningRail { activitiesSynced = try await Self.startConnected( activitiesSynced: activitiesSynced, refresh: { try await refresh() }, - syncWallet: { _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) }, + syncWallet: { + _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) + syncedThisRun = true + }, syncActivities: { try await syncActivities() } ) } catch { @@ -188,6 +193,7 @@ final class SparkManager: ChannelFreeLightningRail { if let chainServiceUrl = Env.sparkChainServiceUrl(for: Env.network) { await builder.withRestChainService(url: chainServiceUrl, apiType: .esplora, credentials: nil) } + syncedThisRun = false let connected = try await builder.build() guard generation == connectGeneration else { try? await connected.disconnect() @@ -223,6 +229,7 @@ final class SparkManager: ChannelFreeLightningRail { /// keep the wiped wallet; throws when the data could not be deleted, so the wipe is retried. func wipe(walletIndex: Int) async throws { connectGeneration += 1 + syncedThisRun = false if let connectTask { _ = try? await connectTask.value } @@ -369,11 +376,21 @@ final class SparkManager: ChannelFreeLightningRail { } nonisolated func hasSynced(after seconds: UInt64?) async -> Bool { - guard let info = try? await requireSdk().getInfo(request: GetInfoRequest(ensureSynced: false)), - info.lastSyncError == nil - else { return false } + guard let info = try? await requireSdk().getInfo(request: GetInfoRequest(ensureSynced: false)) else { return false } + return await Self.hasSynced( + after: seconds, + syncedThisRun: syncedThisRun, + lastSyncFailed: info.lastSyncError != nil, + lastSyncedAt: info.lastSyncedAt + ) + } + + /// The SDK keeps `lastSyncedAt` across launches but not `lastSyncError`, which is unset after each connect until that + /// run's first sync ends, so only a sync this run shows Spark's list is complete. + nonisolated static func hasSynced(after seconds: UInt64?, syncedThisRun: Bool, lastSyncFailed: Bool, lastSyncedAt: UInt64?) -> Bool { + guard syncedThisRun, !lastSyncFailed else { return false } guard let seconds else { return true } - return info.lastSyncedAt.map { $0 > seconds } ?? false + return lastSyncedAt.map { $0 > seconds } ?? false } nonisolated func lightningPayments() async throws -> [ChannelFreeRailPayment] { @@ -645,6 +662,7 @@ final class SparkManager: ChannelFreeLightningRail { Task { do { _ = try await requireSdk().syncWallet(request: SyncWalletRequest()) + syncedThisRun = true } catch { Logger.warn("Failed to sync Spark after deposit \(txid): \(error)", context: "SparkManager") } @@ -881,7 +899,10 @@ final class SparkManager: ChannelFreeLightningRail { private func onSdkEvent(_ event: SdkEvent) async { switch event { - case .synced, .newDeposits, .claimedDeposits: + case .synced: + syncedThisRun = true + await refreshState() + case .newDeposits, .claimedDeposits: await refreshState() case let .unclaimedDeposits(deposits): Logger.warn("Found '\(deposits.count)' unclaimed Spark deposits", context: "SparkManager") diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index 96d350554..a32616901 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -358,3 +358,16 @@ private actor FeeQuotes { fees.count > 1 ? fees.removeFirst() : fees[0] } } + +extension SparkPaymentSafetyTests { + func testSparkCountsAsSyncedOnlyAfterASyncThisRun() { + // After a relaunch the SDK reports no sync error until this run's first sync ends, and keeps the last sync time. + XCTAssertFalse( + SparkManager.hasSynced(after: nil, syncedThisRun: false, lastSyncFailed: false, lastSyncedAt: 2000), + "a relaunched wallet's unsynced list was read as complete" + ) + XCTAssertFalse(SparkManager.hasSynced(after: 1000, syncedThisRun: false, lastSyncFailed: false, lastSyncedAt: 2000)) + XCTAssertTrue(SparkManager.hasSynced(after: 1000, syncedThisRun: true, lastSyncFailed: false, lastSyncedAt: 2000)) + XCTAssertFalse(SparkManager.hasSynced(after: nil, syncedThisRun: true, lastSyncFailed: true, lastSyncedAt: 2000)) + } +} From ef4ff8a318155550e4c99fe60092b6c576586148 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 22:09:44 +0100 Subject: [PATCH 261/378] fix: read spark's sync state before its payment list --- Bitkit/Services/LightningService.swift | 5 +++-- BitkitTests/ChannelFreeRailTests.swift | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 39613af66..8bf06e888 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -2108,8 +2108,9 @@ extension LightningService { // A restored wallet fills its list on its first sync, so before a sync reaches Spark a missing send proves // nothing. guard await rail.hasSynced(after: nil) else { throw ChannelFreeRailUnavailable() } - let payments = try await rail.lightningPayments() - return await payments + unsettledUncertainRailSends() + // Read before the listing, so a sync that ends while it is read can't settle a send the listing predates. + let unsettled = await unsettledUncertainRailSends() + return try await rail.lightningPayments() + unsettled } if await channelFreeRailSupported() { throw ChannelFreeRailUnavailable() diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index e2e54034a..dcf99ea36 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -673,6 +673,26 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } + func testASyncEndingWhileSparksListIsReadKeepsAnUnknownSendHeld() async throws { + let expiresAt = try Self.invoiceExpiresAt() + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt - 600) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: expiresAt) } + // Spark synced only before the failure, and a sync after it ends while the listing that lacks the send is read. + rail.syncedAfter = { $0 == nil } + let fake: FakeRail = rail + fake.onList = { + fake.onList = nil + fake.syncedAfter = { _ in true } + } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + } + private static func invoiceExpiresAt() throws -> TimeInterval { let invoice = try Bolt11Invoice.fromStr(invoiceStr: invoice) return TimeInterval(invoice.secondsSinceEpoch() + invoice.expiryTimeSeconds()) From 1d9b173fb001d4ded6ab214f745f86725f5a74d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 22:09:45 +0100 Subject: [PATCH 262/378] fix: start a new spark transfer when its amount changes --- Bitkit/ViewModels/SparkTransferFlow.swift | 2 ++ BitkitTests/SparkTransferFlowTests.swift | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Bitkit/ViewModels/SparkTransferFlow.swift b/Bitkit/ViewModels/SparkTransferFlow.swift index f6a94454f..7d8d1320f 100644 --- a/Bitkit/ViewModels/SparkTransferFlow.swift +++ b/Bitkit/ViewModels/SparkTransferFlow.swift @@ -211,6 +211,8 @@ final class SparkTransferFlow { if self.amountSats != amountSats || self.isMaxAmount != isMaxAmount || self.walletId != walletId { quote = nil fixedFeeFloorSats = nil + // A funding followed for the earlier transfer never stands in for this one's swipe. + txid = nil } self.amountSats = amountSats self.isMaxAmount = isMaxAmount diff --git a/BitkitTests/SparkTransferFlowTests.swift b/BitkitTests/SparkTransferFlowTests.swift index 98fe31c00..a816c1283 100644 --- a/BitkitTests/SparkTransferFlowTests.swift +++ b/BitkitTests/SparkTransferFlowTests.swift @@ -349,6 +349,24 @@ final class SparkTransferFlowTests: XCTestCase { XCTAssertFalse(flow.followIfOwned(txid: "txid", attemptId: attemptId), "A followed funding was followed again") } + @MainActor + func testANewAmountAfterAFollowedFundingIsANewTransfer() async { + let flow = pollingFlow(offers: [choice(earlyFee: 800)], claims: [.claimed]) + let attemptId = UUID() + flow.start(amountSats: 40000, isMaxAmount: false) + flow.quote = quote(instantMax: 3010) + flow.approveSwipe() + flow.bindApproval(to: attemptId, after: OnchainSendAttemptError.unresolved) + XCTAssertTrue(flow.followIfOwned(txid: "txid", attemptId: attemptId)) + await flow.waitForClaim() + + flow.start(amountSats: 40000, isMaxAmount: false) + XCTAssertEqual(flow.txid, "txid", "the transfer lost the funding it follows") + // The funding resolved while Spending Amount showed, and a new amount is a new transfer. + flow.start(amountSats: 50000, isMaxAmount: false) + XCTAssertNil(flow.txid, "an earlier funding stood in for a new transfer's swipe") + } + @MainActor func testAFundingWhoseOutcomeIsUnknownIsKeptNotFailed() { XCTAssertEqual(try TransferViewModel.fundedTxid(.accepted(txid: "txid")), "txid") From 2eea8e5842d42f44462ae70ad6a47150b3068528 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 22:16:01 +0100 Subject: [PATCH 263/378] fix: keep a failed spark withdrawal open after a sync that never reached spark --- Bitkit/Managers/SparkManager.swift | 47 +++++++++++++++++++++++++++--- BitkitTests/PaykitRouteTests.swift | 27 +++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index bcce16282..e5a7595a5 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -384,6 +384,14 @@ final class SparkManager: ChannelFreeLightningRail { return ChannelFreeRailSendRefused(reason: t("other__scan__error__expired")) } + nonisolated func hasSynced(after seconds: UInt64?) async -> Bool { + guard let info = try? await requireSdk().getInfo(request: GetInfoRequest(ensureSynced: false)), + info.lastSyncError == nil + else { return false } + guard let seconds else { return true } + return info.lastSyncedAt.map { $0 > seconds } ?? false + } + nonisolated func lightningPayments() async throws -> [ChannelFreeRailPayment] { let payments = try await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments return payments.compactMap { payment in @@ -654,10 +662,14 @@ final class SparkManager: ChannelFreeLightningRail { )) } catch { Logger.warn("Spark withdrawal to a contact failed or its outcome is unknown: \(error)", context: "SparkManager") + let failedAt = UInt64(Date().timeIntervalSince1970) Task { - // Only a sync that succeeded now can tell whether this withdrawal went out. - guard await (try? requireSdk().syncWallet(request: SyncWalletRequest())) != nil else { return } - await settleContactWithdrawal(allowClear: true) + await Self.settleAfterFailedWithdrawal( + failedAt: failedAt, + syncWallet: { _ = try await self.requireSdk().syncWallet(request: SyncWalletRequest()) }, + hasSynced: { await self.hasSynced(after: $0) }, + settle: { await self.settleContactWithdrawal(allowClear: $0) } + ) } throw error } @@ -702,6 +714,29 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Settles a withdrawal whose send failed or has an unknown outcome from the sync after it. That sync returns even + /// when it never reached Spark, so only one that completed after `failedAt` may close an unlisted one as unsent. + static func settleAfterFailedWithdrawal( + failedAt: UInt64, + syncWallet: () async throws -> Void, + hasSynced: (UInt64) async -> Bool, + settle: (_ allowClear: Bool) async -> Void + ) async { + guard await (try? syncWallet()) != nil else { return } + await settleAfterSync(since: failedAt, hasSynced: hasSynced, settle: settle) + } + + /// Spark reports a sync that never reached it as done too, its `.synced` event included, so only one that completed + /// after `since` may close an unlisted withdrawal as unsent. + static func settleAfterSync( + since: UInt64, + hasSynced: (UInt64) async -> Bool, + settle: (_ allowClear: Bool) async -> Void + ) async { + let synced = await hasSynced(since) + await settle(synced) + } + /// Writes the attempt's journal before `beforeSend` starts the request's proof, so a kill between the two leaves a /// journal that recovery closes as unsent once it expires, releasing the proof; a refusal in `beforeSend`, or a /// `paymentDeadline` passed by then, closes it at once, releasing what it started. @@ -1222,7 +1257,11 @@ final class SparkManager: ChannelFreeLightningRail { switch event { case .synced: await refreshState() - await settleContactWithdrawal(allowClear: true) + await Self.settleAfterSync( + since: Self.pendingContactWithdrawal?.startedAt ?? 0, + hasSynced: { await self.hasSynced(after: $0) }, + settle: { await self.settleContactWithdrawal(allowClear: $0) } + ) case .newDeposits, .claimedDeposits: await refreshState() case let .unclaimedDeposits(deposits): diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 928d61346..16a2ebfe9 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -182,6 +182,33 @@ final class PaykitRouteTests: XCTestCase { ) } + func testAFailedWithdrawalStaysOpenAfterASyncThatNeverReachedSpark() async { + var allowedClears: [Bool] = [] + // The sync call returns, but Spark keeps its failed sync in `lastSyncError`, so it never synced after the failure. + await SparkManager.settleAfterFailedWithdrawal( + failedAt: 1000, + syncWallet: {}, + hasSynced: { _ in false }, + settle: { allowedClears.append($0) } + ) + XCTAssertEqual(allowedClears, [false], "A sync that never reached Spark can't show the withdrawal was never sent") + + var syncedAfter: UInt64? + await SparkManager.settleAfterFailedWithdrawal( + failedAt: 1000, + syncWallet: {}, + hasSynced: { syncedAfter = $0; return true }, + settle: { allowedClears.append($0) } + ) + XCTAssertEqual(syncedAfter, 1000) + XCTAssertEqual(allowedClears, [false, true]) + + // Spark's `.synced` event also follows a sync that never reached it. + await SparkManager.settleAfterSync(since: 900, hasSynced: { syncedAfter = $0; return false }, settle: { allowedClears.append($0) }) + XCTAssertEqual(syncedAfter, 900) + XCTAssertEqual(allowedClears, [false, true, false], "A synced event alone can't show the withdrawal was never sent") + } + func testJournalStaysOpenUntilEveryLocalRecordIsSaved() async { let saved = await SparkManager.recordWithdrawalLocally(saveProof: { true }, saveActivity: { true }, saveTags: { true }) XCTAssertTrue(saved) From cae1c29cad9ff7bb5a6d723aba60d4c1d4ccb80d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 22:39:22 +0100 Subject: [PATCH 264/378] fix: hold an unlisted spark send after a relaunch until its invoice expires --- Bitkit/Services/LightningService.swift | 18 +++++++++++++++++- BitkitTests/ChannelFreeRailTests.swift | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 8bf06e888..df791387d 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1469,6 +1469,17 @@ class LightningService: NodeLifecycleService { return pending } + /// Spark's kept sends whose invoice is unexpired. A sync can still resume one Spark committed but lists no row for, + /// also after a relaunch, so it is pending until its invoice expires, the rule `railSyncedSinceUncertainSend` holds. + private func unexpiredRailAttempts() -> Set { + let seconds = Int(now().timeIntervalSince1970) + return eventCallbackLock.withLock { + let expiries = attemptExpiries(key: Self.railAttemptsKey) + let hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + return Set(hashes.filter { hash in expiries[hash].map { $0 > seconds } ?? false }) + } + } + private func endRailSendUncertainty(paymentHash: String) { _ = eventCallbackLock.withLock { uncertainRailSends.removeValue(forKey: paymentHash.lowercased()) } } @@ -2110,7 +2121,12 @@ extension LightningService { guard await rail.hasSynced(after: nil) else { throw ChannelFreeRailUnavailable() } // Read before the listing, so a sync that ends while it is read can't settle a send the listing predates. let unsettled = await unsettledUncertainRailSends() - return try await rail.lightningPayments() + unsettled + let kept = unexpiredRailAttempts() + let payments = try await rail.lightningPayments() + let listed = Set((payments + unsettled).filter { $0.direction == .outbound }.map { $0.paymentHash.lowercased() }) + let unlisted = kept.subtracting(listed).sorted() + .map { ChannelFreeRailPayment(paymentHash: $0, direction: .outbound, status: .pending, preimage: nil) } + return payments + unsettled + unlisted } if await channelFreeRailSupported() { throw ChannelFreeRailUnavailable() diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index dcf99ea36..2ce463941 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -693,6 +693,24 @@ extension ChannelFreeRailTests { await assertRefusedAsDuplicate() } + func testAnUnlistedSparkSendKeptFromAnEarlierLaunchHoldsTheNodeUntilItsInvoiceExpires() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let hash = String(invoice.paymentHash()) + let expiresAt = Int(invoice.secondsSinceEpoch() + invoice.expiryTimeSeconds()) + let service = Bitkit.LightningService.shared + // After a relaunch only the kept hash is left, and Spark synced since but lists no row for the send. + service.recordRailAttempt(paymentHash: hash, expiresAt: expiresAt) + service.now = { Date(timeIntervalSince1970: TimeInterval(expiresAt - 600)) } + rail.balanceSats = 0 + + let status = await PaykitLightningPaymentProofLookup.railStatus(paymentHash: hash) + + XCTAssertEqual(status, .pending, "a send Spark can still resume was read as never made") + await assertRefusedAsDuplicate() + service.now = { Date(timeIntervalSince1970: TimeInterval(expiresAt)) } + await assertReachesTheNode() + } + private static func invoiceExpiresAt() throws -> TimeInterval { let invoice = try Bolt11Invoice.fromStr(invoiceStr: invoice) return TimeInterval(invoice.secondsSinceEpoch() + invoice.expiryTimeSeconds()) From a21a20d5a9690df58af9b2a4af4ab93d44b4b194 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:20:00 +0100 Subject: [PATCH 265/378] fix: drop a paykit route quote once its source or amount changed --- Bitkit/Services/PaykitRouteService.swift | 16 ++++-- Bitkit/ViewModels/AppViewModel.swift | 2 + BitkitTests/PaykitRouteTests.swift | 62 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 3c434617d..37f0ec50e 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -260,12 +260,18 @@ extension AppViewModel { var unrouted = route unrouted.serviceLeg = nil let contactId = contactPaymentContext?.id + paykitRoutingGeneration += 1 + let generation = paykitRoutingGeneration + // A dismissed or replaced payment, a newer routing request (a new amount or source) or a source picked since + // makes the answer stale, so it never lands on what followed it. + let isCurrent = { + self.contactPaymentContext?.id == contactId && self.paykitRoutingGeneration == generation && + (self.selectedWalletToPayFrom == .lightning) == (source == .spending) && + (self.paykitRoute.map { $0.endpoints == route.endpoints } ?? true) + } do { let picked = try await router(route, amountSats, source, lightningSpendableSats, onchainSpendableSats) - // A dismissed or replaced payment's route must never land on the one that followed it. - guard contactPaymentContext?.id == contactId, paykitRoute.map({ $0.endpoints == route.endpoints }) ?? true else { - return .stale - } + guard isCurrent() else { return .stale } switch picked { case let .direct(endpoint): paykitRoute = unrouted @@ -292,7 +298,7 @@ extension AppViewModel { } } catch { Logger.error("Failed to route Paykit payment: \(error)", context: "PaykitRoute") - guard contactPaymentContext?.id == contactId else { return .stale } + guard isCurrent() else { return .stale } showNoPaykitRouteToast(isBalanceShort: false) return .refused } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f4f1932a6..b86db9063 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -172,6 +172,8 @@ class AppViewModel: ObservableObject { private(set) var didRejectScannedPayment = false @Published var paykitUsesUsdt = false @Published var paykitRoute: PaykitRouteUi? + /// Bumped by each routing request, so only the latest one's answer lands. + var paykitRoutingGeneration = 0 @Published var paykitReviewedAmount: PaykitAmount? @Published var paykitPaymentTerms: PaykitRequestPricing.Payment? diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 16a2ebfe9..8468af73e 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -438,6 +438,68 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(app.paykitRoute, routeB, "contact A's leg must never pay contact B") } + @MainActor + func testARouteThatReturnsAfterTheSourceChangedNeverRestoresIt() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.paykitRoute = route + app.selectedWalletToPayFrom = .lightning + + let outcome = await app.routeContactPayment( + route, + amountSats: 20000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in + // The user goes back to Savings while Spark quotes the Spending leg. + app.selectedWalletToPayFrom = .onchain + return .service(leg: self.sparkLeg(amountSats: 20000, feeSats: 300)) + } + + XCTAssertEqual(outcome, .stale) + XCTAssertEqual(app.selectedWalletToPayFrom, .onchain, "a superseded Spending quote must not take the source back") + XCTAssertNil(app.paykitRoute?.serviceLeg) + } + + @MainActor + func testARouteSupersededByANewerOneIsDropped() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.paykitRoute = route + app.selectedWalletToPayFrom = .lightning + var newer: PaykitRouteOutcome? + + let older = await app.routeContactPayment( + route, + amountSats: 20000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in + // A new amount asks the router again before the first quote returns. + newer = await app.routeContactPayment( + route, + amountSats: 30000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in .service(leg: self.sparkLeg(amountSats: 30000, feeSats: 300)) } + return .service(leg: self.sparkLeg(amountSats: 20000, feeSats: 300)) + } + + XCTAssertEqual(newer, .leg) + XCTAssertEqual(older, .stale) + XCTAssertEqual(app.paykitRoute?.serviceLeg?.deliveredSat, 30000, "the older amount's leg must not replace the newer one") + } + @MainActor func testAStaleRecoveryNeverClearsANewerWithdrawalsJournal() async { snapshotAppDefaultsDomain() From 6957828af5ee45188064182e502f53040a0bcd82 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:20:00 +0100 Subject: [PATCH 266/378] fix: settle an unsent spark withdrawal's allowance before dropping its proof --- Bitkit/Managers/SparkManager.swift | 30 +++++++------- Bitkit/Services/PaykitAllowanceExecutor.swift | 24 +++++++---- .../Services/PaykitPaymentProofService.swift | 6 ++- .../PaykitPaymentProofServiceTests.swift | 41 +++++++++++++++++++ BitkitTests/PaykitRouteTests.swift | 32 +++++++++++---- 5 files changed, 98 insertions(+), 35 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index e5a7595a5..517a5646e 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -839,22 +839,16 @@ final class SparkManager: ChannelFreeLightningRail { /// allowance attempt, left unknown when the send's outcome was, then fails, so the ledger lets it be paid again. static func closeUnsentWithdrawal( _ pending: SparkContactWithdrawal, - releaseProof: (SparkContactWithdrawal.PaykitRequest) async -> SparkProofRelease, - failAllowance: (SparkContactWithdrawal.PaykitRequest) async -> Void + releaseProof: (SparkContactWithdrawal.PaykitRequest, _ failAllowance: () async -> Bool) async -> SparkProofRelease, + failAllowance: @escaping (SparkContactWithdrawal.PaykitRequest) async -> Bool ) async { // The proof and allowance attempt match by request, so a stale close would release a retry's. guard isOpenJournal(pending) else { return } - if let request = pending.request { - switch await releaseProof(request) { - case .failed: - Logger.warn("Kept the Spark contact withdrawal journal: its unsent proof is not released yet", context: "SparkManager") - return - case .released: - // The allowance attempt begins only once the proof started, so only then is there one to fail. - await failAllowance(request) - case .nothingStarted: - break - } + // The allowance attempt begins only once the proof started, so only a started proof fails one, and it fails + // before that proof goes, so a restart in between still finds the proof to settle it from. + if let request = pending.request, await releaseProof(request, { await failAllowance(request) }) == .failed { + Logger.warn("Kept the Spark contact withdrawal journal: its unsent proof is not released yet", context: "SparkManager") + return } if clearJournal(pending) { resolveContactWithdrawal(pending, txId: nil) @@ -880,15 +874,19 @@ final class SparkManager: ChannelFreeLightningRail { contactWithdrawalResolutionSubject.send(resolution) } - private static func releaseUnsentProof(_ request: SparkContactWithdrawal.PaykitRequest) async -> SparkProofRelease { + private static func releaseUnsentProof( + _ request: SparkContactWithdrawal.PaykitRequest, + failAllowance: () async -> Bool + ) async -> SparkProofRelease { await PaykitPaymentProofService.shared.failSparkOnchainPayment( requestId: request.id, identity: request.identity, - unstartedPaymentList: request.paymentList + unstartedPaymentList: request.paymentList, + settleAllowance: failAllowance ) } - private static func failAllowance(_ request: SparkContactWithdrawal.PaykitRequest) async { + private static func failAllowance(_ request: SparkContactWithdrawal.PaykitRequest) async -> Bool { await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .failed) } diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index fa20526ff..d69eb7eeb 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -1358,8 +1358,10 @@ actor PaykitAllowanceExecutor { setStage(.sending, attemptId: attemptId, identity: identity) } - func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async { - guard let identity = activeIdentity else { return } + /// Returns whether the outcome was recorded. + @discardableResult + func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async -> Bool { + guard let identity = activeIdentity else { return false } if let transactionId { updateLocalState(identity: identity) { state in guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } @@ -1368,20 +1370,24 @@ actor PaykitAllowanceExecutor { } do { try await record(attemptId: attemptId, outcome: outcome, identity: identity) + return true } catch { Logger.warn("Failed to record a manual payment outcome: \(error)", context: "PaykitAllowance") + return false } } /// Settles the manual attempt still open for a request once its outcome is learned later, as a Spark withdrawal's /// journal learns it, so an unsent send no longer blocks the request on the ledger. - func settleManualPayment(requestId: PaykitPaymentRequest.ID, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async { - guard let identity = activeIdentity, - let entry = localState(identity: identity).journal.last(where: { - !$0.isAutomatic && $0.requestId == requestId && !$0.stage.isTerminal - }) - else { return } - await finishManualPayment(attemptId: entry.attemptId, outcome: outcome, transactionId: transactionId) + /// Returns whether no attempt is left open for the request. + @discardableResult + func settleManualPayment(requestId: PaykitPaymentRequest.ID, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async -> Bool { + guard let identity = activeIdentity else { return false } + guard let entry = localState(identity: identity).journal.last(where: { + !$0.isAutomatic && $0.requestId == requestId && !$0.stage.isTerminal + }) + else { return true } + return await finishManualPayment(attemptId: entry.attemptId, outcome: outcome, transactionId: transactionId) } // MARK: Helpers diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 1a6a472b7..168175245 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1374,7 +1374,8 @@ actor PaykitPaymentProofService { requestId: PaykitPaymentRequest.ID, identity: String, unstartedPaymentList: SparkContactWithdrawal.PaymentList? = nil, - privatePaykitService: PrivatePaykitService = .shared + privatePaykitService: PrivatePaykitService = .shared, + settleAllowance: () async -> Bool = { true } ) async -> SparkProofRelease { do { return try await mutationLock.withLock { @@ -1395,6 +1396,9 @@ actor PaykitPaymentProofService { } return .nothingStarted } + // The allowance attempt is settled before the proof goes: a restart in between, or a failed ledger + // write, leaves the proof and so the journal, which retry the settlement. + guard await settleAllowance() else { return .failed } for proof in unsent { guard let version = proof.privatePaymentListVersion else { continue } try await privatePaykitService.releaseUnsentPaymentListVersion( diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 0a847fb51..b261b51cb 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1926,6 +1926,47 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remaining.isEmpty) } + func testAnUnsentSparkWithdrawalKeepsItsProofUntilItsAllowanceAttemptIsSettled() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, sparkWithdrawal: true) + + let unsettled = await service.failSparkOnchainPayment(requestId: request.id, identity: identity, settleAllowance: { false }) + XCTAssertEqual(unsettled, .failed, "A failed allowance write keeps the journal open for a retry") + let kept = await store.snapshot() + XCTAssertEqual(kept.count, 1, "The proof stays until its allowance attempt is settled, so a restart still settles it") + + var proofsWhileSettling: Int? + let released = await service.failSparkOnchainPayment(requestId: request.id, identity: identity, settleAllowance: { + proofsWhileSettling = await store.snapshot().count + return true + }) + XCTAssertEqual(released, .released) + XCTAssertEqual(proofsWhileSettling, 1, "The allowance attempt is settled before its proof is removed") + let remaining = await store.snapshot() + XCTAssertTrue(remaining.isEmpty) + } + + func testAnUnstartedSparkWithdrawalSettlesNoAllowanceAttempt() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: PaymentProofMemoryStore()) + var settled = false + + let release = await service.failSparkOnchainPayment(requestId: request.id, identity: identity, settleAllowance: { + settled = true + return true + }) + + XCTAssertEqual(release, .nothingStarted) + XCTAssertFalse(settled, "Only a started Spark proof has an allowance attempt to settle") + } + func testClosingASparkJournalLeavesADirectOnchainProofForTheSameRequest() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 8468af73e..6a54a0663 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -245,15 +245,22 @@ final class PaykitRouteTests: XCTestCase { SparkManager.pendingContactWithdrawal = pending var failedAllowances: [SparkContactWithdrawal.PaykitRequest] = [] - await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in .failed }, failAllowance: { failedAllowances.append($0) }) + await SparkManager.closeUnsentWithdrawal( + pending, + releaseProof: { _, _ in .failed }, + failAllowance: { failedAllowances.append($0); return true } + ) XCTAssertEqual(SparkManager.pendingContactWithdrawal, pending, "A proof that wasn't released keeps the journal, so the next sync retries it") XCTAssertEqual(failedAllowances, [], "The attempt stays unknown while the journal is still open") var released: SparkContactWithdrawal.PaykitRequest? await SparkManager.closeUnsentWithdrawal( pending, - releaseProof: { released = $0; return .released }, - failAllowance: { failedAllowances.append($0) } + releaseProof: { request, failAllowance in + released = request + return await failAllowance() ? .released : .failed + }, + failAllowance: { failedAllowances.append($0); return true } ) XCTAssertEqual(released, request) XCTAssertEqual(failedAllowances, [request], "An unsent withdrawal fails its allowance attempt, so the ledger lets the request be paid again") @@ -262,7 +269,11 @@ final class PaykitRouteTests: XCTestCase { // A kill before the proof started leaves no Spark attempt, so a direct send's attempt for the request stays. SparkManager.pendingContactWithdrawal = pending failedAllowances = [] - await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in .nothingStarted }, failAllowance: { failedAllowances.append($0) }) + await SparkManager.closeUnsentWithdrawal( + pending, + releaseProof: { _, _ in .nothingStarted }, + failAllowance: { failedAllowances.append($0); return true } + ) XCTAssertEqual(failedAllowances, [], "Only the Spark withdrawal's own allowance attempt fails") XCTAssertNil(SparkManager.pendingContactWithdrawal) } @@ -339,7 +350,7 @@ final class PaykitRouteTests: XCTestCase { let subscription = SparkManager.contactWithdrawalResolutionPublisher.sink { resolutions.append($0) } defer { subscription.cancel() } - await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _ in .released }, failAllowance: { _ in }) + await SparkManager.closeUnsentWithdrawal(pending, releaseProof: { _, _ in .released }, failAllowance: { _ in true }) let unsent = SparkContactWithdrawalResolution(attempt: pending.attempt, txId: nil) XCTAssertEqual(resolutions, [unsent]) @@ -508,7 +519,7 @@ final class PaykitRouteTests: XCTestCase { // A recovery captured the older withdrawal; another one closed it and the newer withdrawal wrote its journal. SparkManager.pendingContactWithdrawal = newer - await SparkManager.closeUnsentWithdrawal(older, releaseProof: { _ in .nothingStarted }, failAllowance: { _ in }) + await SparkManager.closeUnsentWithdrawal(older, releaseProof: { _, _ in .nothingStarted }, failAllowance: { _ in true }) XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer, "Losing the newer journal could pay it twice") } @@ -529,7 +540,7 @@ final class PaykitRouteTests: XCTestCase { let subscription = SparkManager.contactWithdrawalResolutionPublisher.sink { resolutions.append($0) } defer { subscription.cancel() } - await SparkManager.closeUnsentWithdrawal(older, releaseProof: { _ in .nothingStarted }, failAllowance: { _ in }) + await SparkManager.closeUnsentWithdrawal(older, releaseProof: { _, _ in .nothingStarted }, failAllowance: { _ in true }) XCTAssertEqual(SparkManager.pendingContactWithdrawal, newer) XCTAssertEqual(resolutions, [], "An unsent resolution for the newer send's attempt would offer Try Again while it is in flight") @@ -550,8 +561,11 @@ final class PaykitRouteTests: XCTestCase { await SparkManager.closeUnsentWithdrawal( older, - releaseProof: { released.append($0); return .released }, - failAllowance: { failedAllowances.append($0) } + releaseProof: { request, failAllowance in + released.append(request) + return await failAllowance() ? .released : .failed + }, + failAllowance: { failedAllowances.append($0); return true } ) XCTAssertEqual(released, [], "The proof matches by request, so a stale close would release the retry's started proof") From 01bfbd8078e7204b7cf3ee523f50e518610b9a42 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:27:06 +0100 Subject: [PATCH 267/378] fix: cap the leftover sweep fee at bitkit's own rate --- Bitkit/Managers/SparkExitManager.swift | 11 ++++-- .../Services/SparkExit/SparkExitDriver.swift | 18 ++++++++-- BitkitTests/SparkExitSweepTests.swift | 34 +++++++++++++++++-- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index c31320543..b8360d636 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -429,7 +429,8 @@ final class SparkExitManager { chain: chain, recovering: SparkManager.shared.exitRecovering, makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, - save: saveRecord + save: saveRecord, + trustedFeeRate: Self.trustedFeeRate ) do { tipHeight = try? await chain.tipHeight() @@ -982,6 +983,11 @@ final class SparkExitManager { "sparkExitNextFundingIndex-\(Env.network)-\(walletIndex)" } + /// The fast rate of Bitkit's own fee source, the ceiling of a leftover sweep with the exit's own rate. + nonisolated static func trustedFeeRate() async -> UInt64? { + await (try? CoreService.shared.blocktank.fees())?.map { UInt64($0.fast) } + } + /// Pays the fee funding from savings. It shows as an ordinary payment, not as a transfer to spending. @discardableResult func payFunding(address: String, sats: UInt64, satsPerVbyte: UInt32, feeSats: UInt64) async throws -> String { @@ -1097,7 +1103,8 @@ final class SparkExitManager { chain: chain, recovering: recovering, makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) }, - save: saveRecord + save: saveRecord, + trustedFeeRate: Self.trustedFeeRate ) }, chain: chain, diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index ad0251f63..c4b4ec41c 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -31,6 +31,9 @@ struct SparkExitDriver { let makeSigner: (UInt32) throws -> CpfpSigner let save: (SparkExitRecord) throws -> Void var now: () -> Date = Date.init + /// The fast rate of Bitkit's own fee source, nil when it cannot be read: with the exit's own rate, the most a + /// leftover sweep pays, whatever the chain host asks. + var trustedFeeRate: () async -> UInt64? = { nil } /// A ready transaction accepted in this generation is sent again after this long, in case a node dropped it. static let rebroadcastInterval: TimeInterval = 600 @@ -932,10 +935,12 @@ struct SparkExitDriver { // A node that dropped it answers not found: that is unconfirmed too. isReplaceDue = await (try? chain.txStatus(txid: oldTxid))?.confirmed != true } - if isReplaceDue { + // At the ceiling already: sent again as it is until it confirms. + let ceiling = isReplaceDue ? await sweepFeeCeiling(record) : 0 + if isReplaceDue, oldRate < ceiling { // A host with no fee estimates still gets a replacement one sat/vB over the old rate. let fastest = try? await chain.fastestFeeRate() - let rate = max(fastest ?? 0, oldRate + 1) + let rate = min(max(fastest ?? 0, oldRate + 1), ceiling) let utxos = SparkExitMath.economicalUtxos(record.leftover.map { EsploraUtxo(txid: $0.txid, vout: $0.vout, value: $0.valueSats, status: EsploraTxStatus(confirmed: true, blockHeight: nil)) }, rate: rate) @@ -1154,9 +1159,10 @@ struct SparkExitDriver { let fees = try? await chain.networkFees() // A rate a node refused stays refused: the least it asked for lifts the economy rate, up to the next block's or // the exit's rate. A node asking more refuses again, and the sweep waits a block for fees to drop. + let ceiling = await sweepFeeCeiling(record) let refusedFloor = SparkExitMath.refusedSweepFloor( waiting?.refusedFeeRate ?? 0, - nextBlock: fees?.fastest, + nextBlock: fees.map { min($0.fastest, ceiling) }, exitRate: record.feeRateSatPerVbyte ) let rate = max(SparkExitMath.sweepFeeRate(economy: fees?.economy, exitRate: record.feeRateSatPerVbyte), refusedFloor) @@ -1192,6 +1198,12 @@ struct SparkExitDriver { try save(record) } + /// The most a leftover sweep pays per vbyte: the exit's own rate, which the user confirmed, or Bitkit's own fast rate + /// when higher. The chain host's estimates and refusals are not trusted past it. + private func sweepFeeCeiling(_ record: SparkExitRecord) async -> UInt64 { + await max(record.feeRateSatPerVbyte, trustedFeeRate() ?? 0) + } + // MARK: Broadcast /// A unilateral step the SDK could not read is never sent: a rebuild settles it. A cooperative one is, since nodes diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 79516cb96..355c3ad86 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -24,12 +24,13 @@ final class SparkExitSweepTests: XCTestCase { try singleKeyCpfpSigner(secretKeyBytes: key.secretKey) } - private func driver() -> SparkExitDriver { + private func driver(trustedFeeRate: UInt64? = 10) -> SparkExitDriver { SparkExitDriver( chain: chain, recovering: nil, makeSigner: { [unowned self] _ in try signer() }, - save: { [unowned self] in saved.append($0) } + save: { [unowned self] in saved.append($0) }, + trustedFeeRate: { trustedFeeRate } ) } @@ -281,7 +282,7 @@ final class SparkExitSweepTests: XCTestCase { accepted.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4800, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000) chain.confirmedTxids = [] chain.fastestFee = 50 - accepted = try await driver().tick(accepted) + accepted = try await driver(trustedFeeRate: 50).tick(accepted) XCTAssertEqual(accepted.leftoverSweep?.txid, "s") XCTAssertTrue(accepted.needsDriving) } @@ -597,6 +598,33 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(sweep.feeRateSatPerVbyte, 5) } + /// The host's next block asks 5,000 sat/vB and its refusal 550,000 sats for ~110 vB of a 2M-sat leftover: the sweep + /// pays Bitkit's own fast rate at most, and is not replaced above it. + func testAHostQuotingAnAbsurdFeeRateCannotPushTheSweepPastBitkitsOwnRate() async throws { + var record = endedRecord() + let utxo = SparkExitFixtures.utxo(String(repeating: "1", count: 64), value: 2_000_000) + record.leftover = [.init(txid: utxo.txid, vout: 0, valueSats: 2_000_000)] + chain.utxosByAddress[key.address] = [utxo] + chain.economyFee = 1 + chain.fastestFee = 5000 + chain.outcome = .rejected("min relay fee not met, 110 < 550000") + + record = try await driver().tick(record) + chain.tip = 1001 + chain.outcome = .accepted + record = try await driver().tick(record) + + let sweep = try XCTUnwrap(record.leftoverSweep) + XCTAssertEqual(sweep.isAccepted, true) + XCTAssertEqual(sweep.feeRateSatPerVbyte, 10, "the refused sweep went out at the host's rate") + + // A day unconfirmed: no replacement above the ceiling, the same sweep is sent again. + chain.tip = 1145 + record = try await driver().tick(record) + XCTAssertEqual(record.leftoverSweep?.txid, sweep.txid) + XCTAssertEqual(record.leftoverSweep?.feeRateSatPerVbyte, 10) + } + /// The result names the leftover once, with its total, whatever state it is in. func testTheLeftoverIsOneLineWithTheTotal() { var record = endedRecord() From 2a203b403e63c2104c32767c6c8afc3cbb444bfd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:43:22 +0100 Subject: [PATCH 268/378] fix: forget a savings payment only after three blocks unseen --- .../Services/SparkExit/SparkExitDriver.swift | 5 +-- BitkitTests/SparkExitReviewTests.swift | 31 +++++++++++++++++-- BitkitTests/SparkExitSweepTests.swift | 2 +- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index c4b4ec41c..7e14f2b7c 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -67,8 +67,9 @@ struct SparkExitDriver { /// About four weeks: then the change too small to send stays at the funding address. static let sweepWaitBlocks: UInt32 = 4032 /// A savings payment Bitkit sent that no host knows for this many blocks, or that the exit did not adopt within - /// `ownPaymentTimeoutBlocks`, is forgotten: the exit asks for the funding, and reminds, again. - static let ownPaymentMissingBlocks: UInt32 = 1 + /// `ownPaymentTimeoutBlocks`, is forgotten: the exit asks for the funding, and reminds, again. Long enough that a + /// host still indexing it, or one false miss, never asks for the funding twice. + static let ownPaymentMissingBlocks: UInt32 = 3 static let ownPaymentTimeoutBlocks: UInt32 = 144 @discardableResult diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index abefaa8d0..4980966e4 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -108,7 +108,7 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertTrue(reminders.contains { $0.id.hasPrefix("1-") }, "the live steps keep their reminders") } - /// A payment no host knows for a block is forgotten: the ask and the funding reminder come back. + /// A payment no host knows for three blocks is forgotten: the ask and the funding reminder come back. func testADroppedSavingsPaymentBringsTheAskBack() async throws { var record = bumpedRecord() sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 3) @@ -117,11 +117,36 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(record.fundingPaymentMissingSince, 1000) XCTAssertEqual(record.fundingPaymentTxid, "bump", "one read is not enough") - chain.tip = 1001 + chain.tip = 1003 record = try await driver().tick(record) XCTAssertNil(record.fundingPaymentTxid) XCTAssertFalse(record.isAwaitingOwnPayment) - XCTAssertTrue(SparkExitMath.reminders(record, tip: 1001, now: clock, network: .regtest).contains { $0.isFunding }) + XCTAssertTrue(SparkExitMath.reminders(record, tip: 1003, now: clock, network: .regtest).contains { $0.isFunding }) + } + + /// A payment one host misses just before a block and just after it, seconds apart, may still be indexing: it is + /// kept. A read that finds it starts the count over; only a miss that lasts three blocks forgets it. + func testASavingsPaymentMissedForABlockOrASingleReadIsKept() async throws { + var record = bumpedRecord() + sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 6) + + record = try await driver().tick(record) + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, "bump", "a one-block miss asked for the funding again") + + chain.mempoolTxids = ["bump"] + chain.tip = 1002 + record = try await driver().tick(record) + chain.mempoolTxids = [] + chain.tip = 1003 + record = try await driver().tick(record) + chain.tip = 1005 + record = try await driver().tick(record) + XCTAssertEqual(record.fundingPaymentTxid, "bump") + chain.tip = 1006 + record = try await driver().tick(record) + XCTAssertNil(record.fundingPaymentTxid) } /// One still unadopted after a day is forgotten too, known or not. diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 355c3ad86..e93f16242 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -852,7 +852,7 @@ final class SparkExitSweepTests: XCTestCase { record = try await driver().tick(record) XCTAssertNotNil(record.fundingPaymentTxid) - chain.tip = 1001 + chain.tip = 1003 record = try await driver().tick(record) XCTAssertNil(record.fundingPaymentTxid) XCTAssertFalse(record.needsDriving) From f352f9de6d022b919306b9fd74d9af95da34d4db Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:51:05 +0100 Subject: [PATCH 269/378] fix: withdraw spark to savings only when both read one chain --- Bitkit/ViewModels/TransferViewModel.swift | 10 ++++++++++ BitkitTests/SparkPaymentSafetyTests.swift | 1 + BitkitTests/SparkWithdrawalTests.swift | 17 +++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 3cb62f00f..7ae00b3d4 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -55,6 +55,11 @@ class TransferViewModel: ObservableObject { var sparkBalanceSats: @MainActor () -> UInt64 = { SparkManager.shared.balanceSats } /// Sends Spark's balance to the address, refusing above the shown fee; tests replace it. var withdrawSpark: @MainActor (String, UInt64) async throws -> Void = { try await SparkManager.shared.withdrawAll(to: $0, maxFeeSats: $1) } + /// Whether Spark reads Savings' chain, so its withdrawal reaches Savings; tests replace it. + var sparkReadsSavingsChain: @MainActor () -> Bool = { + SparkManager.readsBitkitChain(network: Env.network, chainServiceUrl: Env.sparkChainServiceUrl(for: Env.network)) + } + /// How long the confirm screen waits for Spark's withdrawal fee before offering to retry; a full-balance quote can /// take minutes. var sparkFeeTimeout: TimeInterval = 180 @@ -454,6 +459,8 @@ class TransferViewModel: ObservableObject { guard let confirmedFeeSats = confirmedSparkFeeSats, let address = sparkWithdrawAddress else { throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced") } + // On regtest Spark may read another chain than Savings, where Savings' address would take Spark's funds. + guard sparkReadsSavingsChain() else { throw SparkReadsAnotherChain() } try await withdrawSpark(address, confirmedFeeSats) sparkWithdrawAddress = nil sparkFeeBalanceSats = nil @@ -1079,3 +1086,6 @@ struct SparkFundingUnresolved: Error { let txid: String var rejectionReason: String? } + +/// Spark reads another chain than Savings, so a withdrawal to Savings would never arrive. +private struct SparkReadsAnotherChain: Error {} diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index a32616901..6d71a2f2e 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -175,6 +175,7 @@ final class SparkPaymentSafetyTests: XCTestCase { transfer.sparkBalanceSats = { 99901 } var sent: (String, UInt64)? transfer.withdrawSpark = { sent = ($0, $1) } + transfer.sparkReadsSavingsChain = { true } transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { try await Task.sleep(nanoseconds: 20_000_000) diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index 736b27404..ae2d77869 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -223,9 +223,26 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertEqual(withdrawnFeeSats, 1470, "Spark withdrew at a fee priced after close instead") } + @MainActor + func testSparkIsNotWithdrawnToASavingsAddressOnAnotherChain() async throws { + let transfer = await pricedTransfer() + XCTAssertTrue(transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close)) + // A regtest Savings wallet reads its own chain unless Spark is pointed at it. + transfer.sparkReadsSavingsChain = { false } + let withdrawn = WithdrawnFee() + transfer.sparkBalanceSats = { 40000 } + transfer.withdrawSpark = { _, feeSats in withdrawn.feeSats = feeSats } + + _ = try await transfer.closeSelectedChannels() + + XCTAssertNil(withdrawn.feeSats, "Spark sent its balance to a Savings address on another chain") + XCTAssertNotNil(transfer.sparkWithdrawError, "the withdrawal was skipped without telling the user") + } + @MainActor private func pricedTransfer() async -> TransferViewModel { let transfer = TransferViewModel() + transfer.sparkReadsSavingsChain = { true } transfer.priceSparkWithdrawal = { _ in 1470 } transfer.loadSparkWithdrawFee(balanceSats: 40000, savingsAddress: "bcrt1q-savings") await waitUntilPriced(transfer) From eb434dc8f21ec0074d6e1c1a3a413538f798c4d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:51:05 +0100 Subject: [PATCH 270/378] fix: note a payment's rail only once its send is admitted --- Bitkit/Services/LightningService.swift | 5 +++-- BitkitTests/ChannelFreeRailTests.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index df791387d..d4d4cedcb 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1077,7 +1077,6 @@ class LightningService: NodeLifecycleService { throw ChannelFreeRailSendRefused(reason: t("wallet__max_possible_fee_rate")) } } - noteAttempt(on: .channelFree, bolt11: bolt11) do { return try await rail.send( bolt11: bolt11, @@ -1087,6 +1086,8 @@ class LightningService: NodeLifecycleService { beforeSubmission: { [weak self] in try beforeSubmission() try self?.admitSend(paymentHash: paymentHash, on: .channelFree, since: mark) + // Noted once admitted, so a refused attempt never takes the place of the one that went out. + self?.noteAttempt(on: .channelFree, bolt11: bolt11) self?.recordRailAttempt(paymentHash: paymentHash, expiresAt: expiresAt) } ) @@ -1112,7 +1113,6 @@ class LightningService: NodeLifecycleService { } Logger.info("Paying bolt11: \(bolt11)") - noteAttempt(on: .ldk, bolt11: bolt11) let sendKey = paymentHash?.lowercased() defer { endLdkSend(paymentHash: sendKey) } @@ -1121,6 +1121,7 @@ class LightningService: NodeLifecycleService { try beforeSubmission() guard let self else { return } try admitSend(paymentHash: sendKey, on: .ldk, since: mark) + noteAttempt(on: .ldk, bolt11: bolt11) recordLdkAttempt(paymentHash: paymentHash, expiresAt: expiresAt) }) { if let sats { diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 2ce463941..782ca1f00 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -476,6 +476,20 @@ extension ChannelFreeRailTests { await assertRefusedAsDuplicate() } + func testASparkAttemptRefusedAtAdmissionKeepsTheNodeAttemptAsTheLatest() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + // The node's send of the invoice went out, and Spark's checks don't see it before Spark's submission. + service.noteAttempt(on: .ldk, paymentHash: hash) + service.beginLdkSend(paymentHash: hash) + defer { service.endLdkSend(paymentHash: hash) } + rail.balanceSats = 100_000 + + await assertRefusedAsDuplicate() + + XCTAssertEqual(service.attemptRail(paymentHash: hash), .ldk, "a refused Spark attempt took the node's place") + } + func testSendsNoListingShowsAreDroppedOnceTheirInvoiceExpiredPastTheSettleWindow() async throws { let service = Bitkit.LightningService.shared let nodePayments = service.nodePayments From 5eab42d2686abd6a2e2965123b24f64d9a2b3a97 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:51:06 +0100 Subject: [PATCH 271/378] fix: count spark as synced only once a wallet sync succeeded --- Bitkit/Managers/SparkManager.swift | 7 ++++--- BitkitTests/SparkPaymentSafetyTests.swift | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 83a7f234e..dd8267218 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -386,11 +386,12 @@ final class SparkManager: ChannelFreeLightningRail { } /// The SDK keeps `lastSyncedAt` across launches but not `lastSyncError`, which is unset after each connect until that - /// run's first sync ends, so only a sync this run shows Spark's list is complete. + /// run's first sync ends, so only a sync this run, after a wallet sync that ever succeeded, shows Spark's list is complete. nonisolated static func hasSynced(after seconds: UInt64?, syncedThisRun: Bool, lastSyncFailed: Bool, lastSyncedAt: UInt64?) -> Bool { - guard syncedThisRun, !lastSyncFailed else { return false } + // The SDK sets `lastSyncedAt` only on a wallet sync that succeeded, while it reports Synced for partial ones too. + guard syncedThisRun, !lastSyncFailed, let lastSyncedAt else { return false } guard let seconds else { return true } - return lastSyncedAt.map { $0 > seconds } ?? false + return lastSyncedAt > seconds } nonisolated func lightningPayments() async throws -> [ChannelFreeRailPayment] { diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index 6d71a2f2e..af6b93ae2 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -371,4 +371,13 @@ extension SparkPaymentSafetyTests { XCTAssertTrue(SparkManager.hasSynced(after: 1000, syncedThisRun: true, lastSyncFailed: false, lastSyncedAt: 2000)) XCTAssertFalse(SparkManager.hasSynced(after: nil, syncedThisRun: true, lastSyncFailed: true, lastSyncedAt: 2000)) } + + func testSparkCountsAsSyncedOnlyOnceAWalletSyncSucceeded() { + // The SDK reports no error before any sync ends, and sets its sync time only on a wallet sync that succeeded, + // while it also reports Synced for a partial or skipped sync. + XCTAssertFalse( + SparkManager.hasSynced(after: nil, syncedThisRun: true, lastSyncFailed: false, lastSyncedAt: nil), + "a list no wallet sync filled was read as complete" + ) + } } From fb90070f3c53dfcf8da747bb4a27edff02f537d9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 01:08:16 +0100 Subject: [PATCH 272/378] fix: pay between spark wallets over lightning on regtest --- Bitkit/Managers/SparkManager.swift | 4 ++-- BitkitTests/SparkConfigTests.swift | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index dd8267218..762d852a9 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -108,8 +108,8 @@ final class SparkManager: ChannelFreeLightningRail { sdk == nil ? nil : Self.exitStateGeneration } - /// Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a - /// Spark transfer, which lets two dev builds pay each other's invoices. + /// On regtest, an invoice that carries a Spark address is paid as a Spark transfer. Bitkit's own invoices carry none, + /// so two dev builds pay each other over Lightning through Spark's provider, as on mainnet. nonisolated static var prefersSpark: Bool { Env.network == .regtest } diff --git a/BitkitTests/SparkConfigTests.swift b/BitkitTests/SparkConfigTests.swift index 426673a4d..b6504ecda 100644 --- a/BitkitTests/SparkConfigTests.swift +++ b/BitkitTests/SparkConfigTests.swift @@ -14,6 +14,12 @@ final class SparkConfigTests: XCTestCase { } } + func testInvoicesCarryNoSparkAddressOnAnyNetworkSoEveryPaymentIsALightningPaymentBitkitRecords() { + for network in [BreezSdkSpark.Network.mainnet, .regtest] { + XCTAssertFalse(SparkManager.sdkConfig(network: network, apiKey: nil).preferSparkOverLightning, "\(network)") + } + } + func testChainServiceIsBitkitElectrsOnMainnetOnly() { XCTAssertEqual(Env.sparkChainServiceUrl(for: .bitcoin), "https://bitkit.to/electrs") XCTAssertNil(Env.sparkChainServiceUrl(for: .regtest)) From a1397f3efd1060a432b2fccc33dbe2c25b3bbc4c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 09:10:29 +0100 Subject: [PATCH 273/378] fix: keep an unresolved spark retry held across restarts --- Bitkit/Services/LightningService.swift | 47 ++++++++++++++-------- BitkitTests/ChannelFreeRailTests.swift | 54 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index d4d4cedcb..4b096d965 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -133,8 +133,6 @@ class LightningService: NodeLifecycleService { private var attemptRails: [String: LightningPaymentRail] = [:] /// Payment hashes inside a rail send, which Spark lists only once it returns. private var railSendsInFlight: Set = [] - /// Rail sends that failed after they were submitted, by hash, with the time they failed in seconds since 1970. - private var uncertainRailSends: [String: UncertainRailSend] = [:] /// Counts attempts recorded in this run, so a listing drops a hash just when the hash was recorded before the /// listing was read, never a retry recorded while it was read. private var attemptRecords = 0 @@ -1100,7 +1098,7 @@ class LightningService: NodeLifecycleService { } catch { // A send that failed once submitted may still go out, so its outcome waits for a later sync. if let paymentHash { - markRailSendUncertain(paymentHash: paymentHash, since: mark, expiresAt: expiresAt) + markRailSendUncertain(paymentHash: paymentHash, since: mark) } throw error } @@ -1152,6 +1150,9 @@ class LightningService: NodeLifecycleService { /// The clock a trim checks invoice expiries against; tests set it. var now: () -> Date = Date.init private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" + /// Rail sends that failed after they were submitted, by hash, with the time they failed in seconds since 1970. Kept + /// across launches, as Spark may still resume one after a relaunch while its list shows an earlier failed attempt. + private static let uncertainRailSendsKey = "channelFreeRailUncertainSendFailedAt" /// A listing drops the hashes it shows failed only, or paid once the invoice has expired, so the other rail can't /// pay it again, and ones it doesn't show once the invoice expired `unlistedAttemptSettleSeconds` ago. Paid or /// unlisted hashes with no known expiry (kept before expiries were, or whose invoice can't be parsed) are dropped @@ -1213,6 +1214,7 @@ class LightningService: NodeLifecycleService { let nowSeconds = Int(now().timeIntervalSince1970) // Against now minus the window, as an invoice that never expires is kept at Int.max. let isExpired = { (hash: String, settleSeconds: Int) in expiries[hash].map { $0 <= nowSeconds - settleSeconds } == true } + let live = key == Self.railAttemptsKey ? live.union(uncertainRailSendFailedAt().keys) : live let unsettled = hashes.filter { hash in live.contains(hash) || !(failed.contains(hash) || paid.contains(hash) && isExpired(hash, 0) || !listed.contains(hash) && isExpired(hash, Self.unlistedAttemptSettleSeconds)) @@ -1241,6 +1243,8 @@ class LightningService: NodeLifecycleService { let hash = paymentHash.lowercased() eventCallbackLock.withLock { guard !unlessInFlight || !railSendsInFlight.contains(hash) else { return } + // A send whose outcome is unknown may still go out, whatever an earlier attempt's state. + guard uncertainRailSendFailedAt()[hash] == nil else { return } if let mark, attemptsLive(since: mark, key: Self.railAttemptsKey).contains(hash) { return } @@ -1297,7 +1301,7 @@ class LightningService: NodeLifecycleService { /// Called when the rail's wallet is wiped, as its payments go with it. func forgetRailAttempts() { eventCallbackLock.withLock { - uncertainRailSends = [:] + railAttemptsStore.removeObject(forKey: Self.uncertainRailSendsKey) railAttemptsStore.removeObject(forKey: Self.railAttemptsKey) railAttemptsStore.removeObject(forKey: Self.expiriesKey(Self.railAttemptsKey)) attemptRecordedAt[Self.railAttemptsKey] = nil @@ -1441,26 +1445,28 @@ class LightningService: NodeLifecycleService { try? await channelFreeRailPayments() } - /// A rail send whose outcome stayed unknown: when it failed, and when its invoice expires, in seconds since 1970. - private struct UncertainRailSend { - let failedAt: UInt64 - let expiresAt: Int? + /// Callers hold `eventCallbackLock`. + private func uncertainRailSendFailedAt() -> [String: Int] { + railAttemptsStore.dictionary(forKey: Self.uncertainRailSendsKey) as? [String: Int] ?? [:] } - /// A rail send that failed after it was submitted, until a sync after it and its invoice's expiry settle it. - private func markRailSendUncertain(paymentHash: String, since mark: AttemptMark, expiresAt: Int?) { + /// A rail send that failed after it was submitted, until a sync after it and its invoice's expiry settle it. The + /// expiry is the one kept beside the hash. + private func markRailSendUncertain(paymentHash: String, since mark: AttemptMark) { let hash = paymentHash.lowercased() - let failedAt = UInt64(max(0, now().timeIntervalSince1970)) + let failedAt = Int(max(0, now().timeIntervalSince1970)) eventCallbackLock.withLock { guard attemptsRecorded(since: mark.records, key: Self.railAttemptsKey).contains(hash) else { return } - uncertainRailSends[hash] = UncertainRailSend(failedAt: failedAt, expiresAt: expiresAt) + var uncertain = uncertainRailSendFailedAt() + uncertain[hash] = failedAt + railAttemptsStore.set(uncertain, forKey: Self.uncertainRailSendsKey) } } /// Rail sends whose outcome stayed unknown, listed as pending until a sync after them reaches the rail: Spark may /// still finish one, while its own list shows only an earlier attempt, or nothing. private func unsettledUncertainRailSends() async -> [ChannelFreeRailPayment] { - let hashes = eventCallbackLock.withLock { Array(uncertainRailSends.keys) } + let hashes = eventCallbackLock.withLock { Array(uncertainRailSendFailedAt().keys) } var pending: [ChannelFreeRailPayment] = [] for hash in hashes { if await !railSyncedSinceUncertainSend(paymentHash: hash) { @@ -1482,14 +1488,21 @@ class LightningService: NodeLifecycleService { } private func endRailSendUncertainty(paymentHash: String) { - _ = eventCallbackLock.withLock { uncertainRailSends.removeValue(forKey: paymentHash.lowercased()) } + eventCallbackLock.withLock { + var uncertain = uncertainRailSendFailedAt() + guard uncertain.removeValue(forKey: paymentHash.lowercased()) != nil else { return } + railAttemptsStore.set(uncertain, forKey: Self.uncertainRailSendsKey) + } } /// True unless the hash's last rail send failed after it was submitted and may still go out: no sync has reached the /// rail since, or its invoice hasn't expired. private func railSyncedSinceUncertainSend(paymentHash: String) async -> Bool { let hash = paymentHash.lowercased() - guard let uncertain = eventCallbackLock.withLock({ uncertainRailSends[hash] }) else { return true } + let uncertain = eventCallbackLock.withLock { + uncertainRailSendFailedAt()[hash].map { (failedAt: UInt64($0), expiresAt: attemptExpiries(key: Self.railAttemptsKey)[hash]) } + } + guard let uncertain else { return true } guard let rail = channelFreeRail, await rail.hasSynced(after: uncertain.failedAt) else { return false } // Spark keeps a send it committed but didn't finish in a cache no call exposes, and a sync that reached Spark // can still fail to resume it, so only an expired invoice shows a missing send will never go out. @@ -2064,10 +2077,10 @@ extension LightningService { func emitChannelFreeRailEvent(_ event: Event) { switch event { case let .paymentFailed(_, paymentHash?, _): - // The failure of the send itself, which may arrive while its send call is still returning. - releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) // The event settles a send whose outcome was unknown, so retries of its invoice are no longer held. endRailSendUncertainty(paymentHash: paymentHash) + // The failure of the send itself, which may arrive while its send call is still returning. + releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) case let .paymentSuccessful(_, paymentHash, _, _): endRailSendUncertainty(paymentHash: paymentHash) default: diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 782ca1f00..f105ad798 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -771,6 +771,60 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } + func testAnUnknownSparkSendStaysHeldAfterARelaunchWhenSparkListsOnlyAnOlderFailedAttempt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await service.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + try simulateRelaunch() + + // The launch's sync reads Spark's list, which shows only an earlier attempt of the invoice that failed. + await SparkManager.releaseFailedSendHolds { [Self.sparkPayment(hash: hash, status: .failed)] } + + let kept = service.railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + XCTAssertTrue(kept.contains(hash), "an earlier attempt's failure released a send Spark may still resume") + // Spark has not synced since the send failed, so it may still finish it. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.syncedAfter = { ($0 ?? 0) < 1000 } + rail.balanceSats = 0 + await assertRefusedAsDuplicate() + } + + /// Keeps only what the service stores across launches: the rail's kept keys survive, everything in memory goes. + private func simulateRelaunch() throws { + let service = Bitkit.LightningService.shared + let kept = service.railAttemptsStore.dictionaryRepresentation().filter { $0.key.hasPrefix("channelFreeRail") } + XCTAssertNotNil(kept[Self.railAttemptsKey], "the send left no kept hash to survive the relaunch") + service.forgetRailAttempts() + kept.forEach { service.railAttemptsStore.set($0.value, forKey: $0.key) } + } + + private static func sparkPayment(hash: String, status: BreezSdkSpark.PaymentStatus) -> Payment { + Payment( + id: hash, + paymentType: .send, + status: status, + amount: 20000, + fees: 0, + timestamp: 1, + method: .lightning, + details: .lightning( + description: nil, + invoice: invoice, + destinationPubkey: "pubkey", + htlcDetails: SparkHtlcDetails(paymentHash: hash, preimage: nil, expiryTime: 0, status: .returned), + lnurlPayInfo: nil, + lnurlWithdrawInfo: nil, + lnurlReceiveMetadata: nil, + conversionInfo: nil + ), + conversionDetails: nil + ) + } + private static let ldkAttemptsKey = "ldkAttemptedPaymentHashes" private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" From 88fb385036e3d586a9652cc9ce8e558ecbf83645 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 11:26:19 +0100 Subject: [PATCH 274/378] fix: hide boost on a pending spark withdrawal --- Bitkit/Managers/SparkManager.swift | 24 +++++++++++++++++++ .../Wallets/Activity/ActivityItemView.swift | 5 +++- BitkitTests/SparkWithdrawalTests.swift | 21 ++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index f1ccc071c..d30b8582b 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -575,6 +575,30 @@ final class SparkManager: ChannelFreeLightningRail { sent.contains(txid) || withdrawals.contains { $0.txid == txid } } + /// Whether `txid` is a Spark withdrawal, which LDK never held and so can't boost: one this app sent, to Savings or a + /// contact, or one Spark lists. + func isSparkWithdrawal(txid: String) async -> Bool { + let known = sentWithdrawalTxIds.union(Self.contactWithdrawalTxIds).union(withdrawals.map(\.txid)) + if Self.isSparkWithdrawal(txid: txid, known: known, payments: []) { + return true + } + guard isReady else { return false } + do { + let payments = try await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments + return Self.isSparkWithdrawal(txid: txid, known: known, payments: payments) + } catch { + Logger.warn("Failed to list Spark payments for \(txid): \(error)", context: "SparkManager") + return false + } + } + + nonisolated static func isSparkWithdrawal(txid: String, known: Set, payments: [Payment]) -> Bool { + known.contains(txid) || payments.contains { payment in + guard case let .withdraw(txId, _, _) = payment.details else { return false } + return txId == txid + } + } + /// Reuses the provider's withdrawal quote while it holds for the same address and balance, so the withdrawal /// pays the fee the confirm screen showed; a second full-balance quote can take minutes. private func prepareWithdrawAll(to address: String) async throws -> PrepareSendPaymentResponse { diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index ce4422c4d..3d62f6849 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -19,6 +19,8 @@ struct ActivityItemView: View { @StateObject private var viewModel: ActivityItemViewModel @State private var boostTxDoesExist: [String: Bool] = [:] // Maps boostTxId -> doesExist @State private var isCpfpChild: Bool = false + /// Spark sent this transaction, so LDK holds no input of it to boost. + @State private var isSparkWithdrawal = false private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled @@ -148,7 +150,7 @@ struct ActivityItemView: View { case .lightning: return true case let .onchain(activity): - if isCpfpChild { + if isCpfpChild || isSparkWithdrawal { return true } if !activity.doesExist { @@ -257,6 +259,7 @@ struct ActivityItemView: View { // Check if this is a CPFP child transaction if case let .onchain(activity) = viewModel.activity { isCpfpChild = await CoreService.shared.activity.isCpfpChildTransaction(txId: activity.txId, walletId: activity.walletId) + isSparkWithdrawal = await SparkManager.shared.isSparkWithdrawal(txid: activity.txId) } // Load boostTxIds doesExist status to determine RBF vs CPFP diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index ae2d77869..166ab522e 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -295,6 +295,27 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertFalse(SparkManager.isWithdrawal(txid: "payment", sent: ["sent"], withdrawals: listed)) } + func testASparkWithdrawalIsNeverBoosted() { + let pending = Payment( + id: "pending", + paymentType: .send, + status: .pending, + amount: 20000, + fees: 300, + timestamp: 1, + method: .withdraw, + details: .withdraw(txId: "listed", vout: 0, destinationAddress: "bcrt1qcontact"), + conversionDetails: nil + ) + + XCTAssertTrue(SparkManager.isSparkWithdrawal(txid: "contact", known: ["contact"], payments: [])) + XCTAssertTrue( + SparkManager.isSparkWithdrawal(txid: "listed", known: [], payments: [pending]), + "a pending withdrawal Spark lists can't be boosted" + ) + XCTAssertFalse(SparkManager.isSparkWithdrawal(txid: "savings", known: ["contact"], payments: [pending])) + } + @MainActor private func waitUntilPriced(_ transfer: TransferViewModel) async { for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { From b96e53efe29cd00e38318615501b7c1dc370ffb5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 11:26:19 +0100 Subject: [PATCH 275/378] fix: restore the endpoint a paykit reroute picks --- Bitkit/Services/PaykitRouteService.swift | 41 ++++++++----- BitkitTests/PaykitRouteTests.swift | 74 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 37f0ec50e..4acda6ce2 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -3,11 +3,13 @@ import Foundation import Paykit /// A contact payment bitkit-core's Paykit router picks the way for: the contact's bitcoin endpoints, whether a Spark leg -/// may reach its on-chain ones, and the leg once the router picked one. +/// may reach its on-chain ones, the leg once the router picked one, and the decoded invoice and LNURL a Direct pick pays. struct PaykitRouteUi: Equatable { let endpoints: [PaykitEndpoint] let offersSparkLeg: Bool var serviceLeg: ServiceRouteLeg? + var invoice: LightningInvoice? + var lnurl: LnurlPayData? var deliveredSat: UInt64? { serviceLeg?.deliveredSat @@ -259,6 +261,8 @@ extension AppViewModel { ) async -> PaykitRouteOutcome { var unrouted = route unrouted.serviceLeg = nil + unrouted.invoice = route.invoice ?? scannedLightningInvoice + unrouted.lnurl = route.lnurl ?? lnurlPayData let contactId = contactPaymentContext?.id paykitRoutingGeneration += 1 let generation = paykitRoutingGeneration @@ -275,13 +279,13 @@ extension AppViewModel { switch picked { case let .direct(endpoint): paykitRoute = unrouted - guard await payDirectly(endpoint) else { return .refused } + guard await payDirectly(endpoint, route: unrouted) else { return .refused } if let request { paykitPaymentTerms = try request.payment(using: paykitPaymentMethod) } return .direct case let .service(leg): - var routed = route + var routed = unrouted routed.serviceLeg = leg paykitRoute = routed selectedWalletToPayFrom = .lightning @@ -304,8 +308,9 @@ extension AppViewModel { } } - /// Switches the send to the endpoint the router picked, when the scan opened another one. - private func payDirectly(_ endpoint: PaykitEndpoint) async -> Bool { + /// Switches the send to exactly the endpoint the router picked, clearing the other Lightning one. An earlier pick may + /// have cleared the picked one, so it comes back from `route`. + private func payDirectly(_ endpoint: PaykitEndpoint, route: PaykitRouteUi) async -> Bool { if endpoint.isOnchain { selectedWalletToPayFrom = .onchain if scannedOnchainInvoice?.address != endpoint.value { @@ -315,17 +320,27 @@ extension AppViewModel { } selectedWalletToPayFrom = .lightning if endpoint.isLnurl { - return lnurlPayData != nil || scannedLightningInvoice != nil - } - if scannedLightningInvoice?.bolt11.caseInsensitiveCompare(endpoint.value) == .orderedSame { + guard let lnurl = lnurlPayData ?? route.lnurl else { + Logger.warn("Found no LNURL for the endpoint the router picked", context: "PaykitRoute") + showNoPaykitRouteToast(isBalanceShort: false) + return false + } + lnurlPayData = lnurl + scannedLightningInvoice = nil return true } - guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value) else { - Logger.warn("Failed to decode the Lightning endpoint the router picked", context: "PaykitRoute") - showNoPaykitRouteToast(isBalanceShort: false) - return false + let known = [scannedLightningInvoice, route.invoice].compactMap { $0 } + .first { $0.bolt11.caseInsensitiveCompare(endpoint.value) == .orderedSame } + if let known { + scannedLightningInvoice = known + } else { + guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value) else { + Logger.warn("Failed to decode the Lightning endpoint the router picked", context: "PaykitRoute") + showNoPaykitRouteToast(isBalanceShort: false) + return false + } + scannedLightningInvoice = invoice } - scannedLightningInvoice = invoice lnurlPayData = nil return true } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 6a54a0663..86e528eb0 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -511,6 +511,80 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(app.paykitRoute?.serviceLeg?.deliveredSat, 30000, "the older amount's leg must not replace the newer one") } + @MainActor + func testADirectRerouteFromBolt11BackToLnurlPaysTheLnurl() async { + let (app, route, bolt11, lnurl) = openLightningContact() + + let first = await routeDirectly(app, route, to: bolt11) + XCTAssertEqual(first, .direct) + XCTAssertNil(app.lnurlPayData) + + let second = await routeDirectly(app, app.paykitRoute ?? route, to: lnurl) + XCTAssertEqual(second, .direct) + XCTAssertEqual(app.lnurlPayData?.uri, lnurl.value, "the LNURL pick must pay the LNURL, not the skipped invoice") + XCTAssertNil(app.scannedLightningInvoice) + } + + @MainActor + func testADirectRerouteFromLnurlBackToBolt11PaysTheInvoice() async { + let (app, route, bolt11, lnurl) = openLightningContact() + + let first = await routeDirectly(app, route, to: lnurl) + XCTAssertEqual(first, .direct) + XCTAssertNil(app.scannedLightningInvoice, "the LNURL pick must not leave the invoice to pay") + + let second = await routeDirectly(app, app.paykitRoute ?? route, to: bolt11) + XCTAssertEqual(second, .direct) + XCTAssertEqual(app.scannedLightningInvoice?.bolt11, bolt11.value) + XCTAssertNil(app.lnurlPayData) + } + + @MainActor + private func openLightningContact() -> (AppViewModel, PaykitRouteUi, PaykitEndpoint, PaykitEndpoint) { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let bolt11 = PaykitEndpoint( + methodId: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + value: "lnbcrt1contactreroute" + ) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.scannedLightningInvoice = LightningInvoice( + bolt11: bolt11.value, + paymentHash: Data(), + amountSatoshis: 0, + timestampSeconds: 1, + expirySeconds: 3600, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + app.lnurlPayData = LnurlPayData( + uri: lightning.value, + callback: "https://example.com/pay", + minSendable: 1000, + maxSendable: 100_000_000, + metadataStr: "[]", + commentAllowed: nil, + allowsNostr: false, + nostrPubkey: nil + ) + app.selectedWalletToPayFrom = .lightning + return (app, PaykitRouteUi(endpoints: [bolt11, lightning], offersSparkLeg: false), bolt11, lightning) + } + + @MainActor + private func routeDirectly(_ app: AppViewModel, _ route: PaykitRouteUi, to endpoint: PaykitEndpoint) async -> PaykitRouteOutcome { + await app.routeContactPayment( + route, + amountSats: 2000, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) { _, _, _, _, _ in .direct(endpoint: endpoint) } + } + @MainActor func testAStaleRecoveryNeverClearsANewerWithdrawalsJournal() async { snapshotAppDefaultsDomain() From 2cbaa9e751b473c2b18aeac0c5b900790a880b6b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 11:52:29 +0100 Subject: [PATCH 276/378] fix: hold an unknown spark send until it settles Store the uncertain mark right before Spark's send is submitted, so a process that ends inside the call still holds the invoice after a relaunch, and clear it once the send returns or is refused. The duplicate check ends the uncertainty only for a settled row, as a pending row may be the synthetic one listed for the uncertain send itself, so a refused retry no longer erases the hold. --- Bitkit/Services/LightningService.swift | 23 +++++++++--- BitkitTests/ChannelFreeRailTests.swift | 51 ++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 4b096d965..664160d8a 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1076,7 +1076,7 @@ class LightningService: NodeLifecycleService { } } do { - return try await rail.send( + let sent = try await rail.send( bolt11: bolt11, sats: sats, maxFeeSats: params?.maxTotalRoutingFeeMsat.map { $0 / 1000 } @@ -1087,11 +1087,20 @@ class LightningService: NodeLifecycleService { // Noted once admitted, so a refused attempt never takes the place of the one that went out. self?.noteAttempt(on: .channelFree, bolt11: bolt11) self?.recordRailAttempt(paymentHash: paymentHash, expiresAt: expiresAt) + // Kept until the call returns, so a send cut off by process death stays held after a relaunch. + if let paymentHash { + self?.markRailSendUncertain(paymentHash: paymentHash, since: mark) + } } ) + if let paymentHash { + endRailSendUncertainty(paymentHash: paymentHash) + } + return sent } catch let refusal as ChannelFreeRailSendRefused { // Nothing left the wallet, and the checks above leave no earlier live attempt under this hash. if let paymentHash { + endRailSendUncertainty(paymentHash: paymentHash) releaseRailAttempt(paymentHash: paymentHash, unlessInFlight: false) } throw refusal @@ -1150,8 +1159,9 @@ class LightningService: NodeLifecycleService { /// The clock a trim checks invoice expiries against; tests set it. var now: () -> Date = Date.init private static let railAttemptsKey = "channelFreeRailAttemptedPaymentHashes" - /// Rail sends that failed after they were submitted, by hash, with the time they failed in seconds since 1970. Kept - /// across launches, as Spark may still resume one after a relaunch while its list shows an earlier failed attempt. + /// Rail sends submitted but not returned, or that failed after they were submitted, by hash, with the time they + /// were submitted or failed in seconds since 1970. Kept across launches, as Spark may still resume one after a + /// relaunch while its list shows an earlier failed attempt. private static let uncertainRailSendsKey = "channelFreeRailUncertainSendFailedAt" /// A listing drops the hashes it shows failed only, or paid once the invoice has expired, so the other rail can't /// pay it again, and ones it doesn't show once the invoice expired `unlistedAttemptSettleSeconds` ago. Paid or @@ -1421,7 +1431,8 @@ class LightningService: NodeLifecycleService { } else { releaseRailAttempt(paymentHash: paymentHash) } - } else { + } else if payment?.status == .succeeded { + // Only a settled row ends the unknown send: a pending one may be the synthetic row it is listed as. endRailSendUncertainty(paymentHash: paymentHash) } } else if hasRailAttempt(paymentHash: paymentHash) { @@ -1450,8 +1461,8 @@ class LightningService: NodeLifecycleService { railAttemptsStore.dictionary(forKey: Self.uncertainRailSendsKey) as? [String: Int] ?? [:] } - /// A rail send that failed after it was submitted, until a sync after it and its invoice's expiry settle it. The - /// expiry is the one kept beside the hash. + /// A rail send about to be submitted, or that failed after it was, until a sync after it and its invoice's expiry + /// settle it. The expiry is the one kept beside the hash. private func markRailSendUncertain(paymentHash: String, since mark: AttemptMark) { let hash = paymentHash.lowercased() let failedAt = Int(max(0, now().timeIntervalSince1970)) diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index f105ad798..e95167967 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -755,6 +755,45 @@ extension ChannelFreeRailTests { XCTAssertEqual(status, .pending, "an earlier attempt's failure ended a send Spark may still finish") } + func testARefusedRetryKeepsAnUnknownSparkSendHeldPastAnOlderFailedAttempt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + rail.errorAfterSubmission = NSError(domain: "spark", code: 2) + _ = try? await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + rail.errorAfterSubmission = nil + // Spark lists only an earlier attempt that failed, and has not synced since this send failed. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.syncedAfter = { ($0 ?? 0) < 1000 } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + // A refused retry must not end the hold: Spark may still finish the unknown send. + await assertRefusedAsDuplicate() + } + + func testASparkSendCutOffByTheProcessEndingStaysHeldPastAnOlderFailedAttempt() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + // The process ends inside Spark's send, after submission: only what was stored by then survives. + var stored: [String: Any] = [:] + let store = service.railAttemptsStore + rail.sendGate = { stored = store.dictionaryRepresentation().filter { $0.key.hasPrefix("channelFreeRail") } } + _ = try? await service.send(bolt11: Self.invoice) + rail.sendGate = nil + rail.sentBolt11 = nil + service.forgetRailAttempts() + stored.forEach { service.railAttemptsStore.set($0.value, forKey: $0.key) } + // After the relaunch Spark has synced and lists only an earlier attempt of the invoice that failed. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.syncedAfter = { _ in true } + rail.balanceSats = 0 + + await assertRefusedAsDuplicate() + } + func testASparkFailureEventOfAnUnknownSendLetsTheNodeRetry() async throws { let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } @@ -793,6 +832,18 @@ extension ChannelFreeRailTests { await assertRefusedAsDuplicate() } + func testASparkSendThatReturnedLetsTheNodeRetryOnceSparkListsItFailed() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + Bitkit.LightningService.shared.now = { Date(timeIntervalSince1970: 1000) } + rail.balanceSats = 100_000 + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + // Spark returned the send, then reports that it failed. + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .failed, preimage: nil)] + rail.balanceSats = 0 + + await assertReachesTheNode() + } + /// Keeps only what the service stores across launches: the rail's kept keys survive, everything in memory goes. private func simulateRelaunch() throws { let service = Bitkit.LightningService.shared From 6429e7be6011a32b75dbf345c05d255881f018c8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:00:51 +0100 Subject: [PATCH 277/378] fix: refuse a spark send once an exit holds spark A send or withdrawal checks the exit hold after its last await, right before the SDK send, and is counted while it runs. An exit start waits for the counted sends, for up to two minutes, before it quotes again. --- Bitkit/Managers/SparkExitManager.swift | 2 + Bitkit/Managers/SparkManager.swift | 77 ++++++++++++++++++----- BitkitTests/SparkPaymentSafetyTests.swift | 50 +++++++++++++++ 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index b8360d636..0b0ad39f8 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -798,6 +798,8 @@ final class SparkExitManager { SparkManager.shared.detachForExit() // Reconnecting cuts an optimization short, which would leave its counter-swap unclaimed for a while. await SparkManager.shared.waitForOptimization() + // A send that passed its exit check before the hold may still spend leaves: the requote comes after it. + await SparkManager.shared.waitForSpends() // The rest runs under the ticks' lock, on this exit as stored: one cancelled during the wait above is never // reconnected for or paid to. while isTicking { diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 4edcc3e7e..de48f5652 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -90,6 +90,8 @@ final class SparkManager: ChannelFreeLightningRail { @ObservationIgnored private var connectTask: Task? /// A leaf optimization is running, as its events tell. @ObservationIgnored private var isOptimizing = false + /// Sends and withdrawals past their exit check, which an exit starting waits for. + let spends = SparkSpends { SparkExitManager.shared.isExiting } /// Waiting before an exit quote for value an interrupted leaf optimization still owes the wallet. private(set) var isSettlingForExit = false /// When a leaf optimization last moved without completing, so a counter-swap it left may still be unclaimed. @@ -261,6 +263,12 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// Waits for the sends and withdrawals that passed their exit check before the exit held Spark, for up to two + /// minutes, so the exit quotes the leaves they leave. + func waitForSpends() async { + await spends.waitUntilIdle(timeout: Self.optimizationWaitSeconds) + } + private func noteOptimization(_ event: AutoOptimizationEvent) { switch event { case .started, .roundCompleted: @@ -577,16 +585,18 @@ final class SparkManager: ChannelFreeLightningRail { } // The fee limit is checked on the quote that is sent, not only on the earlier estimate. try Self.checkSendFee(feeSats, maxFeeSats: maxFeeSats) - // A deadline that passed while the quote was prepared stops the payment here, before anything leaves. - try beforeSubmission() - let response: SendPaymentResponse - do { - response = try await sdk.sendPayment(request: SendPaymentRequest( - prepareResponse: prepared, - options: Self.bolt11SendOptions - )) - } catch { - throw Self.sendPaymentFailure(error) + // An exit that started while the quote was prepared refuses it here, before its hash is recorded. + let response: SendPaymentResponse = try await spends.run { + // A deadline that passed while the quote was prepared stops the payment here, before anything leaves. + try beforeSubmission() + do { + return try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: Self.bolt11SendOptions + )) + } catch { + throw Self.sendPaymentFailure(error) + } } Logger.info("Sent Spark payment '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") return invoiceDetails.paymentHash @@ -750,10 +760,14 @@ final class SparkManager: ChannelFreeLightningRail { do { let prepared = try await prepareWithdrawAll(to: address) try Self.checkWithdrawalFee(Self.withdrawalFeeSats(prepared), shownFeeSats: maxFeeSats) - let response = try await requireSdk().sendPayment(request: SendPaymentRequest( - prepareResponse: prepared, - options: .bitcoinAddress(confirmationSpeed: .medium) - )) + let sdk = try await requireSdk() + // Preparing suspends, so an exit can start meanwhile. + let response = try await spends.run { + try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium) + )) + } preparedWithdrawal = nil if case let .withdraw(txId, _, _) = response.payment.details { sentWithdrawalTxIds.insert(txId) @@ -1463,6 +1477,41 @@ private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked } } +/// The Spark sends and withdrawals past their exit check. An exit holds Spark before it waits for them, so every later +/// one refuses, and it quotes the leaves they leave once they end. +@MainActor +final class SparkSpends { + private let isExiting: () -> Bool + private(set) var inFlight = 0 + + init(isExiting: @escaping () -> Bool) { + self.isExiting = isExiting + } + + /// Runs `send` counted, unless an exit holds Spark. Called after the send's last await but the one inside `send`, + /// so an exit starting meanwhile either refuses it here or waits for it. + func run(_ send: () async throws -> T) async throws -> T { + guard !isExiting() else { + Logger.info("Refused a Spark send: an emergency exit holds Spark", context: "SparkManager") + throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) + } + inFlight += 1 + defer { inFlight -= 1 } + return try await send() + } + + /// Waits for the counted sends to end, for up to `timeout`. + func waitUntilIdle(timeout: TimeInterval) async { + let deadline = Date().addingTimeInterval(timeout) + while inFlight > 0, !Task.isCancelled, Date() < deadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } + if inFlight > 0 { + Logger.warn("Gave up waiting for Spark sends in flight to end", context: "SparkManager") + } + } +} + /// Lets the first of two racing tasks resume a continuation, once. private final class SparkFirstResume: @unchecked Sendable { private let lock = NSLock() diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index af6b93ae2..56a47ebe2 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -381,3 +381,53 @@ extension SparkPaymentSafetyTests { ) } } + +// MARK: - Exit hold + +extension SparkPaymentSafetyTests { + func testAnExitStartingWhileASendPreparesSendsNothing() async { + var isExiting = false + let spends = SparkSpends { isExiting } + var sent = false + + // The exit starts while the send prepares, after any check the send made before it. + isExiting = true + do { + try await spends.run { sent = true } + XCTFail("a send went out once an exit held Spark") + } catch { + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused) + } + + XCTAssertFalse(sent) + XCTAssertEqual(spends.inFlight, 0) + } + + func testAnExitStartWaitsForASendAlreadyPastItsExitCheck() async throws { + var isExiting = false + let spends = SparkSpends { isExiting } + var release: CheckedContinuation? + let send = Task { @MainActor in + try await spends.run { + await withCheckedContinuation { release = $0 } + } + } + while release == nil { + await Task.yield() + } + + isExiting = true + var isIdle = false + let idle = Task { @MainActor in + await spends.waitUntilIdle(timeout: 120) + isIdle = true + } + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertFalse(isIdle, "the exit start went on while a send past its check still spent leaves") + release?.resume() + try await send.value + await idle.value + XCTAssertTrue(isIdle) + } +} From 270ae4cce52f35ecfa49b589508778b8ec2a402e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:13:37 +0100 Subject: [PATCH 278/378] fix: show spark exit funding amounts in bitcoin The amounts the user sends from another wallet (the funding text, the address text, the partial-payment line and the Progress ask) always show in bitcoin: the exit needs them exact. Other exit amounts keep the primary unit. --- Bitkit/ViewModels/CurrencyViewModel.swift | 6 ++++++ Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 12 ++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Bitkit/ViewModels/CurrencyViewModel.swift b/Bitkit/ViewModels/CurrencyViewModel.swift index e2472fff2..cfd569a64 100644 --- a/Bitkit/ViewModels/CurrencyViewModel.swift +++ b/Bitkit/ViewModels/CurrencyViewModel.swift @@ -169,6 +169,12 @@ extension CurrencyViewModel { Self.primaryAmountText(sats: sats, converted: convert(sats: sats), primaryDisplay: primaryDisplay, displayUnit: displayUnit) } + /// An amount the user sends from another wallet, always in bitcoin: it must be exact, which a fiat figure converted + /// at another wallet's rate is not. + func bitcoinAmountText(sats: UInt64) -> String { + Self.primaryAmountText(sats: sats, converted: convert(sats: sats), primaryDisplay: .bitcoin, displayUnit: displayUnit) + } + nonisolated static func primaryAmountText( sats: UInt64, converted: ConvertedAmount?, diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 3eb5c551b..4bc837b56 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -202,7 +202,7 @@ struct SparkExitFundingView: View { .padding(.top, 32) BodyMText( - t("lightning__spark_exit__funding_text", variables: ["amount": currency.primaryAmountText(sats: fundingSats)]), + t("lightning__spark_exit__funding_text", variables: ["amount": currency.bitcoinAmountText(sats: fundingSats)]), accentFont: Fonts.bold ) .fixedSize(horizontal: false, vertical: true) @@ -345,7 +345,7 @@ struct SparkExitFundingAddress: View { BodySText( t("lightning__spark_exit__fund_external_text", - variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: requiredSats))]), + variables: ["amount": sparkExitAmountAccent(currency.bitcoinAmountText(sats: requiredSats))]), accentColor: .white, accentFont: Fonts.bold ) @@ -365,9 +365,9 @@ struct SparkExitFundingAddress: View { if let missingSats = SparkExitMath.partialFundingSats(receivedSats: receivedSats, requiredSats: targetSats) { BodySText(t("lightning__spark_exit__fund_partial", variables: [ - "received": sparkExitAmountAccent(currency.primaryAmountText(sats: receivedSats)), - "required": sparkExitAmountAccent(currency.primaryAmountText(sats: targetSats)), - "missing": sparkExitAmountAccent(currency.primaryAmountText(sats: missingSats)), + "received": sparkExitAmountAccent(currency.bitcoinAmountText(sats: receivedSats)), + "required": sparkExitAmountAccent(currency.bitcoinAmountText(sats: targetSats)), + "missing": sparkExitAmountAccent(currency.bitcoinAmountText(sats: missingSats)), ]), accentColor: .white, accentFont: Fonts.bold) .accessibilityIdentifier("SparkExitFundingPartial") } else if showsSeen, receivedSats >= targetSats, targetSats > 0 { @@ -640,7 +640,7 @@ struct SparkExitProgressView: View { case .needsFunding where !isFundingOnItsWay: t( "lightning__spark_exit__needs_funding_text", - variables: ["amount": currency.primaryAmountText(sats: record.flatMap { SparkExitMath.neededSats($0, state: fundingState) } ?? 0)] + variables: ["amount": currency.bitcoinAmountText(sats: record.flatMap { SparkExitMath.neededSats($0, state: fundingState) } ?? 0)] ) default: t("lightning__spark_exit__progress_text") } From 9a259f7f9fc8296a336830df491bef4bb5e2bd07 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:13:38 +0100 Subject: [PATCH 279/378] fix: keep counting what stays on spark during an exit The start and the requote save Spark's balance beyond the quote's leaves as outsideSats. Spending shows the larger of the live figure and the saved one, so what stays on Spark survives published leaves leaving the balance, and a restart. --- Bitkit/Managers/SparkExitManager.swift | 8 +++++--- Bitkit/Services/SparkExit/SparkExitFunding.swift | 9 ++++++--- Bitkit/Services/SparkExit/SparkExitRecord.swift | 7 +++++++ BitkitTests/SparkExitReviewTests.swift | 9 ++++++--- BitkitTests/SparkExitTests.swift | 16 ++++++++++++++++ 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 0b0ad39f8..98910c939 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -778,6 +778,7 @@ final class SparkExitManager { skippedLeafCount: quote.prepared.skipped.count ) record.fundingStrategy = SparkExitRecord.perNodeStrategy + record.outsideSats = SparkExitMath.staysOnSparkSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) let transfers = TransferService(lightningService: .shared) let store = store record = try await Self.writeStart( @@ -822,7 +823,7 @@ final class SparkExitManager { destination: destination, lostLeafIds: (try? lostLeafStore.load()) ?? [] ), - let requoted = Self.requoted(started, fresh: fresh) + let requoted = Self.requoted(started, fresh: fresh, sparkBalanceSats: SparkManager.shared.balanceSats) { let transfers = TransferService(lightningService: .shared) let store = store @@ -887,8 +888,8 @@ final class SparkExitManager { } } - /// The started exit over the fresh quote's leaves, amount and funding. - nonisolated static func requoted(_ record: SparkExitRecord, fresh: SparkExitQuote) -> SparkExitRecord? { + /// The started exit over the fresh quote's leaves, amount and funding, and what `sparkBalanceSats` holds beyond them. + nonisolated static func requoted(_ record: SparkExitRecord, fresh: SparkExitQuote, sparkBalanceSats: UInt64) -> SparkExitRecord? { guard case .awaitingFunding = record.phase else { return nil } var record = record record.phase = .awaitingFunding(requiredSats: fresh.fundingSats) @@ -896,6 +897,7 @@ final class SparkExitManager { record.leafTotalSats = fresh.leafTotalSats record.expectedSats = fresh.incomingSats record.skippedLeafCount = fresh.prepared.skipped.count + record.outsideSats = SparkExitMath.staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) return record } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index b1df81e5a..971d4b10a 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -620,11 +620,14 @@ enum SparkExitMath { return record.expectedSats.saturatingSub(received).saturatingSub(unpaid).saturatingSub(record.lostSats) } - /// What the Spark balance holds beyond the exit's leaves: value that reached Spark after the quote, such as a - /// counter-swap claimed late, which this exit leaves on Spark. Nil when nothing is beyond them. + /// What stays on Spark while the exit runs: the Spark balance beyond the exit's leaves, such as a counter-swap + /// claimed late, or, once published leaves left the balance, what the start saw beyond them (`outsideSats`), + /// whichever is more. Nil when nothing is beyond them. static func outsideExitSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { guard record.isActive else { return nil } - return staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) + let live = staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) ?? 0 + let outside = max(live, record.outsideSats ?? 0) + return outside > 0 ? outside : nil } /// What the Spark balance holds beyond the exit's leaves, in any phase: what a failed exit leaves on Spark. Lost diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 185d47e19..996db587c 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -186,6 +186,9 @@ struct SparkExitRecord: Codable, Equatable { var leafIds: [String] /// What the quote's leaves hold together, for telling how much of the Spark balance is outside the exit. var leafTotalSats: UInt64? + /// Spark's balance beyond the quote's leaves when the exit started: what stays on Spark, also once published leaves + /// left the balance. + var outsideSats: UInt64? var funding: [Funding] /// Bumped on every rebuild. Broadcasts are logged per generation and a rebuilt recovery replaces the old one /// entirely, so older hex is never sent again. @@ -697,6 +700,7 @@ enum SparkExitRecordCoder { record.exitStateSnapshot = kept.exitStateSnapshot record.fundingStrategy = kept.fundingStrategy record.earlierReceivedSats = kept.earlierReceivedSats + record.outsideSats = kept.outsideSats // Change and fan-out outputs of earlier generations at the funding address stay the exit's own, not new funding. let chained = (kept.earlierCpfpTxids ?? []) + (kept.recovery.map { SparkExitTxid.chainedTxids($0.response) } ?? []) record.earlierCpfpTxids = chained.isEmpty ? nil : chained @@ -722,10 +726,12 @@ enum SparkExitRecordCoder { let recovery: StoredRecovery? let fundingStrategy: String? let earlierReceivedSats: [String: UInt64]? + let outsideSats: UInt64? private enum CodingKeys: String, CodingKey { case network, createdAt, phase, feeRateSatPerVbyte, destination, leafIds, leafTotalSats, funding, transferId, expectedSats case lostLeaves, leftBehindLeaves, exitStateSnapshot, earlierCpfpTxids, recovery, fundingStrategy, earlierReceivedSats + case outsideSats } init(from decoder: Decoder) throws { @@ -747,6 +753,7 @@ enum SparkExitRecordCoder { recovery = try? c.decodeIfPresent(StoredRecovery.self, forKey: .recovery) fundingStrategy = try? c.decodeIfPresent(String.self, forKey: .fundingStrategy) earlierReceivedSats = try? c.decodeIfPresent([String: UInt64].self, forKey: .earlierReceivedSats) + outsideSats = try? c.decodeIfPresent(UInt64.self, forKey: .outsideSats) } } } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 4980966e4..21dd072ff 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -495,9 +495,11 @@ final class SparkExitReviewTests: XCTestCase { record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) record.fundingStrategy = SparkExitRecord.perNodeStrategy record.earlierReceivedSats = ["sweep-1": 40000] + record.outsideSats = 14167 let salvaged = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(record))) XCTAssertEqual(salvaged.fundingStrategy, SparkExitRecord.perNodeStrategy) XCTAssertEqual(salvaged.earlierReceivedSats, ["sweep-1": 40000]) + XCTAssertEqual(salvaged.outsideSats, 14167) } /// A salvaged exit imports the exit data it kept before it is prepared again: the SDK may no longer hold a leaf @@ -612,12 +614,13 @@ final class SparkExitReviewTests: XCTestCase { let requote = await SparkExitManager.requote(sdk, confirmed: confirmed, feeRate: 2, destination: "bcrt1qdestination", lostLeafIds: []) let fresh = try XCTUnwrap(requote) - let record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: fresh)) + let record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: fresh, sparkBalanceSats: 214_167)) XCTAssertEqual(record.leafIds, ["leaf-2", "leaf-3"]) XCTAssertEqual(record.phase, .awaitingFunding(requiredSats: 35000)) XCTAssertEqual(record.expectedSats, fresh.incomingSats) XCTAssertEqual(record.leafTotalSats, 200_000) + XCTAssertEqual(record.outsideSats, 14167, "what stays on Spark beyond the fresh leaves") XCTAssertEqual(sdk.prepareRequests.last?.destination, "bcrt1qdestination") } @@ -645,7 +648,7 @@ final class SparkExitReviewTests: XCTestCase { struct WriteFailed: Error {} let started = SparkExitFixtures.record() let fresh = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 25000, leaves: ["leaf-2"])) - let requoted = try XCTUnwrap(SparkExitManager.requoted(started, fresh: fresh)) + let requoted = try XCTUnwrap(SparkExitManager.requoted(started, fresh: fresh, sparkBalanceSats: 0)) var settled: [String] = [] let noTransfer = await SparkExitManager.writeRequote( @@ -879,7 +882,7 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: higher), 20000) XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: lower), 15000) - var record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: higher)) + var record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: higher, sparkBalanceSats: 0)) record.fundingPaymentTxid = "paid" let paid = [SparkExitFixtures.utxo("paid", value: 20000)] guard case let .ask(sats, receivedSats) = SparkExitMath.fundingState(record, utxos: paid) else { diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index bff8bd3da..53f05c250 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -315,6 +315,7 @@ final class SparkExitRecordTests: XCTestCase { record.fundingPaymentTxid = "pay" record.spentStepStrikes = ["r1": 1] record.lostLeaves = [.init(leafId: "leaf-1", valueSats: 5000)] + record.outsideSats = 14167 XCTAssertEqual(try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record)), record) } @@ -2403,6 +2404,21 @@ final class SparkExitMathTests: XCTestCase { XCTAssertNil(SparkExitMath.outsideExitSats(sparkBalanceSats: 49865, record: record)) } + /// The hosted-regtest run: 14,167 sats stay on Spark, and the exit's 100,000 sats of leaves are published, so they + /// left the balance. + func testSpendingKeepsWhatStaysOnSparkOnceTheExitsPublishedLeavesLeftItsBalance() { + var record = SparkExitFixtures.record(phase: .running) + record.leafTotalSats = 100_000 + record.outsideSats = 14167 + + let outside = SparkExitMath.outsideExitSats(sparkBalanceSats: 14167, record: record) + + XCTAssertEqual(outside, 14167) + XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 14167, outsideExitSats: outside), 14167) + // While the leaves still count in the balance, a late claim beyond them still shows. + XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 120_000, record: record), 20000) + } + /// A failed exit claims only what Spark still holds beyond its leaves, never the leaves it lost. func testFailedExitNamesWhatStaysOnSpark() { var record = SparkExitFixtures.record(phase: .failed(reason: "Claimed by an older state")) From 246140b7bc7feee45048b56584800ed0a950a734 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:33 +0100 Subject: [PATCH 280/378] fix: keep the valid generation over an unverified failed-only rebuild --- .../Services/SparkExit/SparkExitDriver.swift | 10 ++++++ BitkitTests/SparkExitTests.swift | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 7e14f2b7c..b3869c11a 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -554,7 +554,17 @@ struct SparkExitDriver { Logger.warn("Spark exit has a step the SDK could not read, rebuilding over it", context: "SparkExitDriver") record.unverifiedRebuiltAtHeight = tip try save(record) + let valid = record try await build(&record, recovering: recovering) + // A rebuild of only failed cooperative leaves beside the leaf it could not read drops that leaf's signed + // steps, which a matured step needs sent within the operators' window: the valid generation stays, as + // it was before the build, and is what the tick saves. + if record.generation != valid.generation, record.recovery?.response.transactions.isEmpty == true, + !(record.waitingLeafIds ?? []).isEmpty + { + Logger.warn("Spark exit keeps its generation: the rebuild built only failed leaves", context: "SparkExitDriver") + record = valid + } return } // The SDK sees no divergence when another wallet's CPFP child confirmed a node our leaves share. diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 53f05c250..db2c0f65d 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1570,6 +1570,38 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2b"], "the unverified step is still not sent") } + /// An unverified rebuild that builds only failed cooperative leaves, beside the leaf it could not read, keeps the + /// valid generation: its signed steps are still sent once the chain reads again. + func testAnUnverifiedRebuildOfOnlyFailedLeavesKeepsTheValidGeneration() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "coop-1"] + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down")) + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + ], failed: [failure]) + let readable = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"]), + ], failed: [failure]) + record.adopt(unread, at: clock) + sdk.checks = [ + CheckRecoverFundsResponse(recovery: unread, verdict: .valid), + CheckRecoverFundsResponse(recovery: readable, verdict: .valid), + ] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = Array(repeating: SparkExitFixtures.recovery([], failed: [failure]), count: 2) + + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1, "rebuild adopted a recovery without leaf-1's signed steps") + + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") + } + /// R2: while a unilateral step is ready, a rebuild would conflict with it, so nothing rebuilds; a cooperative step /// the SDK could not read is still sent. func testAReadyStepHoldsTheUnverifiedRebuildAndCooperativeStepsAreStillSent() async throws { From d840671a0daa58736d7ceb1e56c345f4bbb37939 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:34 +0100 Subject: [PATCH 281/378] fix: never take a timelock reminder early without the tip --- Bitkit/Managers/SparkExitManager.swift | 31 ++++++++++++------- .../Services/SparkExit/SparkExitFunding.swift | 4 ++- BitkitTests/SparkExitTests.swift | 19 ++++++++++++ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 98910c939..57a9a0a62 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -433,7 +433,7 @@ final class SparkExitManager { trustedFeeRate: Self.trustedFeeRate ) do { - tipHeight = try? await chain.tipHeight() + tipHeight = await (try? chain.tipHeight()) ?? tipHeight unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress() await refreshRefusedSavingsPayment() await refreshNetworkFees(chain: chain) @@ -1116,7 +1116,7 @@ final class SparkExitManager { connect: { timeout in await SparkManager.shared.connectForExit(walletIndex: walletIndex, timeout: timeout) }, deadline: deadline ) - tipHeight = try? await chain.tipHeight() + tipHeight = await (try? chain.tipHeight()) ?? tipHeight let outcome = await run.run() let seconds = SparkExitBackgroundRun.Step.allCases.compactMap { step in outcome.seconds[step].map { "\(step.rawValue)=\(String(format: "%.2f", $0))s" } @@ -1209,15 +1209,9 @@ final class SparkExitManager { var requests: [UNNotificationRequest] = [] for reminder in planned { let id = Self.notificationId(reminder, record: record) - // A reminder that fired is not posted again. One still ahead keeps its earliest date, so runs that do not - // publish its step never push it out. - let rememberedAt = remembered[id].map(Date.init(timeIntervalSince1970:)) - if let rememberedAt, rememberedAt <= now { - kept[id] = rememberedAt.timeIntervalSince1970 - continue - } - let fireAt = max(now.addingTimeInterval(1), min(reminder.fireAt, rememberedAt ?? reminder.fireAt)) - kept[id] = fireAt.timeIntervalSince1970 + let date = Self.reminderDate(reminder, rememberedAt: remembered[id].map(Date.init(timeIntervalSince1970:)), now: now) + kept[id] = date.remembered?.timeIntervalSince1970 + guard let fireAt = date.fireAt else { continue } let content = UNMutableNotificationContent() content.userInfo = ["bitkit_action": Self.notificationAction] content.title = t("lightning__spark_exit__notification_title") @@ -1241,6 +1235,21 @@ final class SparkExitManager { UserDefaults.standard.set(Self.keepingFired(kept, remembered: remembered, now: now), forKey: Self.remindersKey) } + /// When `reminder` posts, nil once it fired, and the date remembered for it. A reminder that fired is not posted + /// again. One still ahead keeps its earliest date, so runs that do not publish its step never push it out. + /// A date guessed without the tip keeps the remembered one and is never remembered itself, so a later run with the + /// tip still plans the reminder at maturity. + nonisolated static func reminderDate(_ reminder: SparkExitReminder, rememberedAt: Date?, now: Date) -> (fireAt: Date?, remembered: Date?) { + if let rememberedAt, rememberedAt <= now { + return (nil, rememberedAt) + } + if reminder.isEstimated { + return (max(now.addingTimeInterval(1), rememberedAt ?? reminder.fireAt), rememberedAt) + } + let fireAt = max(now.addingTimeInterval(1), min(reminder.fireAt, rememberedAt ?? reminder.fireAt)) + return (fireAt, fireAt) + } + /// How long a fired reminder stays remembered after it left the plan, so a plan that drops an id for one run (a /// tip that could not be read, a phase that flickered) never posts it again. nonisolated static let firedReminderMemory: TimeInterval = 30 * 24 * 60 * 60 diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 971d4b10a..a902cb5a7 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -214,6 +214,8 @@ struct SparkExitReminder: Equatable { var isLostLeaves = false var isFailed = false var isNotMoving = false + /// Planned without the chain tip, so its date is a guess that never takes the place of one planned with it. + var isEstimated = false } /// What the exit shows about its fee funding while it lacks some. @@ -347,7 +349,7 @@ enum SparkExitMath { } for height in heights.sorted().prefix(maxTimelockReminders) { let wait = tip.map { secondsUntil(height: height, tip: $0, network: network) } ?? reminderSeconds(network: network) - reminders.append(SparkExitReminder(id: "\(generation)-h\(height)", fireAt: now.addingTimeInterval(wait + grace))) + reminders.append(SparkExitReminder(id: "\(generation)-h\(height)", fireAt: now.addingTimeInterval(wait + grace), isEstimated: tip == nil)) } if let readyTxid { reminders.append(SparkExitReminder(id: "\(generation)-ready-\(readyTxid)", fireAt: now.addingTimeInterval(grace))) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index db2c0f65d..1317b2ae2 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -2567,6 +2567,25 @@ final class SparkExitMathTests: XCTestCase { XCTAssertFalse(SparkExitMath.isUnexitableLeafError("operators unavailable")) } + /// A run that cannot read the tip guesses each timelock's reminder about 6 hours out: the guess never takes the + /// date of the reminder at maturity, which a run with the tip plans. + func testARunWithoutTheTipNeverTakesATimelocksReminderEarly() throws { + var record = SparkExitFixtures.record(phase: .running) + record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("r1", kind: .refund, status: .waitingForTimelock(spendableAtHeight: 3000))])) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let id = "\(record.generation)-h3000" + let guessed = try XCTUnwrap(SparkExitMath.reminders(record, tip: nil, now: now, network: .bitcoin).first { $0.id == id }) + let known = try XCTUnwrap(SparkExitMath.reminders(record, tip: 2000, now: now, network: .bitcoin).first { $0.id == id }) + + let first = SparkExitManager.reminderDate(guessed, rememberedAt: nil, now: now) + let second = SparkExitManager.reminderDate(known, rememberedAt: first.remembered, now: now) + let third = SparkExitManager.reminderDate(guessed, rememberedAt: second.remembered, now: now) + + XCTAssertNil(first.remembered, "a date guessed without the tip was kept") + XCTAssertEqual(second.fireAt, known.fireAt, "the reminder at maturity was taken early") + XCTAssertEqual(third.fireAt, known.fireAt) + } + /// With the operators down an older state's refund can take a leaf 100 blocks after ours matures, and the /// watchtower may publish after 50: reminders land well inside the smaller window. func testRemindersLandWellInsideTheDeadline() { From b4a5944d5830e0bca28f5abf9629829d471ca9ff Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:35 +0100 Subject: [PATCH 282/378] fix: read a step already in the utxo set as accepted --- Bitkit/Services/SparkExit/SparkExitBroadcaster.swift | 7 +++++-- BitkitTests/SparkExitTests.swift | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index 595d30391..d8afa7d84 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -286,6 +286,8 @@ enum SparkExitBroadcastClassifier { "txn-already-known", "txn-same-nonwitness-data-in-mempool", "already in block chain", + // Core's newer wording, which esplora hosts pass on as plain text. + "already in utxo set", ] private static let notMatureMessages = ["non-BIP68-final", "non-final"] private static let missingInputMessages = ["bad-txns-inputs-missingorspent", "missing-inputs", "missingorspent"] @@ -393,8 +395,9 @@ enum SparkExitBroadcastClassifier { return .rejected(text) } - /// The RPC code, also when the node's error sits JSON-escaped inside mempool's own error object. + /// The RPC code, also when the node's error sits JSON-escaped inside mempool's own error object, or as esplora's + /// plain text (`sendrawtransaction RPC error -27: …`). private static func hasRpcCode(_ code: Int, in text: String) -> Bool { - text.range(of: #"code\\?"\s*:\s*\#(code)\b"#, options: .regularExpression) != nil + text.range(of: #"(code\\?"\s*:\s*|RPC error )\#(code)\b"#, options: .regularExpression) != nil } } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 1317b2ae2..7424ccb64 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -2789,6 +2789,13 @@ final class SparkExitBroadcasterTests: XCTestCase { guard case .rejected = outcome else { return XCTFail("Expected the refusal, got \(outcome)") } } + /// Esplora hosts (bitkit.to, blockstream.info) answer a step that already confirmed in Core's newer plain-text form. + func testReadsAStepAlreadyInTheUtxoSetAsAccepted() { + let text = "sendrawtransaction RPC error -27: Transaction outputs already in utxo set" + XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 400, body: Data(text.utf8)), .accepted) + XCTAssertEqual(SparkExitBroadcastClassifier.classify(error: "sendrawtransaction RPC error -27: unknown"), .accepted) + } + func testClassifiesNodeAnswers() { XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 400, body: Data(#"sendrawtransaction RPC error: {"code":-27}"#.utf8)), .accepted) XCTAssertEqual( From 49cd36fb8593c2d19ff90f9e6a40069ccf7c58d8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:36 +0100 Subject: [PATCH 283/378] fix: stop driving a diverged generation during a top-up --- Bitkit/Services/SparkExit/SparkExitDriver.swift | 5 ++++- BitkitTests/SparkExitReviewTests.swift | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index b3869c11a..35aa8bcfd 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -283,7 +283,10 @@ struct SparkExitDriver { // A payment still on its way is followed after the end and swept back with the leftover. record.phase = .running case .redo: - break + // The diverged generation is never sent again: its rebuild, a Redo one, waits for the top-up. + Logger.warn("Spark exit diverged on-chain while a top-up is awaited; rebuilding once it confirms", context: "SparkExitDriver") + record.isRebuilding = true + record.isRedoRebuild = true } } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 21dd072ff..c75882166 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -108,6 +108,21 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertTrue(reminders.contains { $0.id.hasPrefix("1-") }, "the live steps keep their reminders") } + /// A Redo while a higher fee's top-up is awaited: the diverged generation is never sent again, and its rebuild + /// waits for the top-up as a Redo rebuild. + func testARedoWhileAHigherFeesTopUpIsAwaitedStopsDrivingTheDivergedGeneration() async throws { + var record = bumpedRecord() + chain.mempoolTxids = ["bump"] + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))] + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 25680)) + XCTAssertTrue(record.isRebuilding) + XCTAssertEqual(record.isRedoRebuild, true) + XCTAssertFalse(record.isDrivingWhileFunding, "the diverged generation would be sent again") + } + /// A payment no host knows for three blocks is forgotten: the ask and the funding reminder come back. func testADroppedSavingsPaymentBringsTheAskBack() async throws { var record = bumpedRecord() From 0dcda74ea2f0ac31bc3f55363992fd5a9d67d735 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:36 +0100 Subject: [PATCH 284/378] fix: keep the value of leaves the first build leaves behind --- Bitkit/Managers/SparkExitManager.swift | 2 ++ Bitkit/Services/SparkExit/SparkExitDriver.swift | 2 ++ Bitkit/Services/SparkExit/SparkExitFunding.swift | 5 +++++ Bitkit/Services/SparkExit/SparkExitRecord.swift | 2 ++ BitkitTests/SparkExitReviewTests.swift | 1 + BitkitTests/SparkExitTests.swift | 14 ++++++++++++++ 6 files changed, 26 insertions(+) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 57a9a0a62..d55418191 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -779,6 +779,7 @@ final class SparkExitManager { ) record.fundingStrategy = SparkExitRecord.perNodeStrategy record.outsideSats = SparkExitMath.staysOnSparkSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) + record.leafSats = quote.leafSats let transfers = TransferService(lightningService: .shared) let store = store record = try await Self.writeStart( @@ -897,6 +898,7 @@ final class SparkExitManager { record.leafTotalSats = fresh.leafTotalSats record.expectedSats = fresh.incomingSats record.skippedLeafCount = fresh.prepared.skipped.count + record.leafSats = fresh.leafSats record.outsideSats = SparkExitMath.staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) return record } diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 35aa8bcfd..fa7d00344 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -442,7 +442,9 @@ struct SparkExitDriver { private func leaveBehind(_ leafIds: [String], reason: String, in record: inout SparkExitRecord) { guard !leafIds.isEmpty else { return } + // The first build has no recovery yet: the quote's values price its leaves. let values = Dictionary((record.recovery?.response.leaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first }) + .merging(record.leafSats ?? [:]) { recovered, _ in recovered } Logger.warn("Spark exit leaves left behind: \(leafIds): \(reason)", context: "SparkExitDriver") record.leftBehindLeaves = (record.leftBehindLeaves ?? []) + leafIds.map { SparkExitRecord.LeftBehindLeaf(leafId: $0, valueSats: values[$0] ?? 0, reason: reason) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index a902cb5a7..bdfc353ca 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -160,6 +160,11 @@ struct SparkExitQuote: Equatable { prepared.leaves.reduce(0) { $0 + $1.valueSats } } + /// Each leaf's value, kept with the exit for a leaf its first build leaves behind. + var leafSats: [String: UInt64] { + Dictionary(prepared.leaves.map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first }) + } + /// Breez's rule: only exit when the value is more than its fees. var isWorthExiting: Bool { !prepared.leaves.isEmpty && exitSats > networkFeeSats diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 996db587c..b851b76ab 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -189,6 +189,8 @@ struct SparkExitRecord: Codable, Equatable { /// Spark's balance beyond the quote's leaves when the exit started: what stays on Spark, also once published leaves /// left the balance. var outsideSats: UInt64? + /// Each quoted leaf's value, so a leaf the first build leaves behind, before any recovery lists it, keeps its value. + var leafSats: [String: UInt64]? var funding: [Funding] /// Bumped on every rebuild. Broadcasts are logged per generation and a rebuilt recovery replaces the old one /// entirely, so older hex is never sent again. diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index c75882166..2058cdc1f 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -636,6 +636,7 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(record.expectedSats, fresh.incomingSats) XCTAssertEqual(record.leafTotalSats, 200_000) XCTAssertEqual(record.outsideSats, 14167, "what stays on Spark beyond the fresh leaves") + XCTAssertEqual(record.leafSats?.keys.sorted(), ["leaf-2", "leaf-3"]) XCTAssertEqual(sdk.prepareRequests.last?.destination, "bcrt1qdestination") } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 7424ccb64..460088eac 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1397,6 +1397,20 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.exitLeafIds, ["leaf-2"]) } + /// The first build has no recovery to price a leaf it leaves behind from: the quote's value is kept. + func testALeafTheFirstBuildLeavesBehindKeepsItsValue() async throws { + var record = twoLeafRecord() + record.leafSats = ["leaf-1": 100_000, "leaf-2": 50000] + record.isRebuilding = true + sdk.prepareErrors = [SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee at this fee rate")] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"]) + XCTAssertEqual(record.leftBehindSats, 100_000, "the leaf left behind counted as 0 sats") + } + /// An error that names no leaf falls back to `RecoverableOnly`, and what it leaves out stays behind. func testARebuildFallsBackToRecoverableOnlyWhenTheErrorNamesNoLeaf() async throws { var record = twoLeafRecord() From 267c8c9c362895046060131a36e0e3cbacacf180 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:37 +0100 Subject: [PATCH 285/378] fix: leave left-behind leaves out of the exit transfer --- Bitkit/Services/SparkExit/SparkExitFunding.swift | 4 +++- BitkitTests/SparkExitSweepTests.swift | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index bdfc353ca..530e8f7c8 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -612,7 +612,8 @@ enum SparkExitMath { /// What the exit still brings to savings: what it expects, less what already reached savings, less the first fee /// funding nobody paid yet (the expected amount counts it as coming back), less leaves an older state claimed, - /// which never arrive. Only confirmed steps count as reached: the sweep is in the plan from its first build. + /// which never arrive, and leaves the exit left behind, which stay on Spark. Only confirmed steps count as reached: the sweep is in the plan from + /// its first build. static func inTransferSats(_ record: SparkExitRecord, fundingSeenSats: UInt64) -> UInt64 { let confirmed = (record.recovery?.response.transactions ?? []).filter { guard case .confirmed = $0.status else { return false } @@ -625,6 +626,7 @@ enum SparkExitMath { unpaid = requiredSats.saturatingSub(max(record.fundedSats, fundingSeenSats)) } return record.expectedSats.saturatingSub(received).saturatingSub(unpaid).saturatingSub(record.lostSats) + .saturatingSub(record.leftBehindSats) } /// What stays on Spark while the exit runs: the Spark balance beyond the exit's leaves, such as a counter-swap diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index e93f16242..51680b6a4 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -919,6 +919,13 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 10000) } + /// A leaf the exit left behind stays on Spark: it leaves the transfer to savings. + func testInTransferLeavesOutALeafLeftBehind() { + var record = endedRecord(phase: .running) + record.leftBehindLeaves = [.init(leafId: "leaf-2", valueSats: 30000, reason: "fee rate")] + XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 60000, "the left-behind leaf is still counted in transfer") + } + /// The first fee funding nobody paid yet is not in transfer; once paid, or once the exit pays it, it is. func testInTransferLeavesOutUnpaidFirstFunding() { var record = endedRecord(phase: .awaitingFunding(requiredSats: 20000)) From 8f724d9de01caa52ab2fd436643429103c1e6f66 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:38 +0100 Subject: [PATCH 286/378] fix: reconnect spark for a stalled exit --- Bitkit/Managers/SparkExitManager.swift | 9 ++++++++- Bitkit/Managers/SparkManager.swift | 27 ++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index d55418191..af8199963 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -22,6 +22,8 @@ final class SparkExitManager { /// The `bitkit_action` of the exit's notifications, so tapping one opens the exit. nonisolated static let notificationAction = "spark_exit" private static let foregroundTickSeconds: UInt64 = 60 + /// How long a foreground tick waits for Spark to connect again before it goes on without the SDK. + private static let reconnectTimeoutSeconds: TimeInterval = 20 private(set) var record: SparkExitRecord? private(set) var quote: SparkExitQuote? @@ -425,6 +427,10 @@ final class SparkExitManager { defer { isTicking = false } let store = store let walletIndex = walletIndex + // A reconnect for the exit that failed leaves Spark ready but disconnected: the tick connects it again. + if SparkManager.shared.isReady, !SparkManager.shared.isConnected { + _ = await SparkManager.shared.connectForExit(walletIndex: walletIndex, timeout: Self.reconnectTimeoutSeconds) + } let driver = SparkExitDriver( chain: chain, recovering: SparkManager.shared.exitRecovering, @@ -509,7 +515,8 @@ final class SparkExitManager { private func reattachSpark(isCancel: Bool = false) async { let spark = SparkManager.shared await spark.reconnectForExit(isCancel: isCancel) - if LightningService.shared.channelFreeRail == nil, spark.isReady { + // A Spark the reconnect left disconnected stays off the rail, so Lightning keeps paying through LDK. + if LightningService.shared.channelFreeRail == nil, spark.isReady, spark.isConnected { LightningService.shared.channelFreeRail = spark } spark.onStateChanged?() diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index de48f5652..c327a6c84 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -164,6 +164,11 @@ final class SparkManager: ChannelFreeLightningRail { ?? keptBalanceSats keptBeforeExit = nil } + // A connect still running, such as the launch's, builds an SDK on the same storage: it ends first and is + // disconnected below, so no second SDK keeps running. + if let connecting = connectTask { + _ = try? await connecting.value + } noteOptimizationCutShort() if let sdk { if let listenerId { @@ -174,7 +179,16 @@ final class SparkManager: ChannelFreeLightningRail { sdk = nil listenerId = nil do { - try await connect(walletIndex: walletIndex, network: network, generation: connectGeneration) + let generation = connectGeneration + let walletIndex = walletIndex + let connecting = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) } + connectTask = connecting + defer { + if connectTask == connecting { + connectTask = nil + } + } + try await connecting.value try await refresh() } catch { Logger.error("Failed to reconnect Spark for the exit: \(error)", context: "SparkManager") @@ -349,8 +363,14 @@ final class SparkManager: ChannelFreeLightningRail { let generation = connectGeneration connectTask = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) } } - try await connectTask?.value - connectTask = nil + let task = connectTask + // A reconnect may have replaced the task while this one was awaited: only the task it awaited is cleared. + defer { + if connectTask == task { + connectTask = nil + } + } + try await task?.value } // Registered before the first refresh marks Spark ready, so Lightning calls never see it ready while they // still go to LDK, even when that refresh or the activity sync fails. An emergency exit keeps it out. @@ -368,7 +388,6 @@ final class SparkManager: ChannelFreeLightningRail { syncActivities: { try await syncActivities() } ) } catch { - connectTask = nil Logger.error("Failed to start Spark wallet: \(error)", context: "SparkManager") } } From 599b963eea83a292c2c457686e3e7f3def533589 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:39 +0100 Subject: [PATCH 287/378] fix: pay from savings only what the funding address lacks --- Bitkit/Managers/SparkExitManager.swift | 13 ++++++++----- Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 11 ++++++++--- BitkitTests/SparkExitReviewTests.swift | 13 +++++++++++-- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index af8199963..03c94dfad 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -91,8 +91,10 @@ final class SparkExitManager { /// What paying the fee funding from savings costs: that payment's own fee, and the total that leaves savings. Nil /// when another wallet pays, or there is nothing to fund. var savingsPayment: (feeSats: UInt64, totalSats: UInt64)? { - guard fundFromSavings, let quote, quote.fundingSats > 0 else { return nil } - return (savingsFeeSats, quote.fundingSats + savingsFeeSats) + guard fundFromSavings, let quote else { return nil } + let sats = quote.fundingSats.saturatingSub(fundingSeenSats) + guard sats > 0 else { return nil } + return (savingsFeeSats, sats + savingsFeeSats) } var isExiting: Bool { @@ -852,7 +854,7 @@ final class SparkExitManager { await SparkManager.shared.reconnectForExit() Logger.info("Started Spark exit of \(exitQuote.exitSats) sats over \(exitQuote.leafIds.count) leaves", context: "SparkExitManager") - let savingsSats = Self.savingsFundingSats(confirmed: quote, fresh: exitQuote) + let savingsSats = Self.savingsFundingSats(confirmed: quote, fresh: exitQuote, seenSats: fundingSeenSats) if fundFromSavings, savingsSats > 0 { let txid: String do { @@ -972,8 +974,9 @@ final class SparkExitManager { /// Savings pays at most the funding the user confirmed: a higher fresh amount goes through the shortfall flow on /// Progress, which asks for the rest. - nonisolated static func savingsFundingSats(confirmed: SparkExitQuote, fresh: SparkExitQuote) -> UInt64 { - min(confirmed.fundingSats, fresh.fundingSats) + /// Funding another wallet already sent to the address (`seenSats`) is not paid again. + nonisolated static func savingsFundingSats(confirmed: SparkExitQuote, fresh: SparkExitQuote, seenSats: UInt64) -> UInt64 { + min(confirmed.fundingSats, fresh.fundingSats).saturatingSub(seenSats) } /// The exit a start began, still stored and active (not cancelled meanwhile); nil otherwise. The store keeps diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 4bc837b56..383652982 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -172,6 +172,11 @@ struct SparkExitFundingView: View { exit.quote?.fundingSats ?? 0 } + /// What savings pays: the funding less what another wallet already sent to the address, as the start pays it. + private var savingsSats: UInt64 { + fundingSats.saturatingSub(exit.fundingSeenSats) + } + /// What the funding address has to hold for the payments seen so far, and what the next one should bring it to. private var prestartFunding: (covered: UInt64, toSend: UInt64) { SparkExitMath.prestartFunding( @@ -186,7 +191,7 @@ struct SparkExitFundingView: View { guard let savingsFeeSats else { return false } return SparkExitMath.canFundFromSavings( spendableSats: UInt64(max(0, wallet.spendableOnchainBalanceSats)), - fundingSats: fundingSats, + fundingSats: savingsSats, paymentFeeSats: savingsFeeSats ) } @@ -258,7 +263,7 @@ struct SparkExitFundingView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .bottomSafeAreaPadding() - .task { + .task(id: savingsSats) { await estimateSavingsFee() } .task(id: showsAddress) { @@ -272,7 +277,7 @@ struct SparkExitFundingView: View { let feeEstimatesManager = feeEstimatesManager let priced = await Self.priceSavingsFee( address: exit.fundingAddress, - fundingSats: fundingSats, + fundingSats: savingsSats, refusedFeeSats: exit.savingsFundingRefusedFeeSats, estimates: { await feeEstimatesManager.getEstimates(refresh: true) }, calculate: { try await wallet.calculateTotalFee(address: $0, amountSats: $1, satsPerVByte: $2) } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 2058cdc1f..333f895c7 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -20,6 +20,15 @@ final class SparkExitReviewTests: XCTestCase { SparkExitDriver(chain: chain, recovering: withSdk ? sdk : nil, makeSigner: { _ in StubCpfpSigner() }, save: { _ in }) } + // MARK: Review 518bf31e5 + + /// Funding sent from another wallet before the user picked savings is not paid twice. + func testSavingsPaysOnlyWhatTheFundingAddressDoesNotHoldYet() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 8000), 12000) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 25000), 0) + } + // MARK: H1 cancel func testCancelIsOffAfterAPaymentFromSavings() { @@ -895,8 +904,8 @@ final class SparkExitReviewTests: XCTestCase { let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) let higher = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 35000, leaves: ["leaf-1", "leaf-2"])) let lower = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 15000, leaves: ["leaf-2"])) - XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: higher), 20000) - XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: lower), 15000) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: higher, seenSats: 0), 20000) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: lower, seenSats: 0), 15000) var record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: higher, sparkBalanceSats: 0)) record.fundingPaymentTxid = "paid" From 92c4f410860c0fbc9dec60d99a9600f252a8f885 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:40 +0100 Subject: [PATCH 288/378] fix: drop a tapped exit notification with no result --- Bitkit/AppScene.swift | 20 ++++++++++++++++---- Bitkit/Managers/SparkExitManager.swift | 22 +++++++++++++++++++++- BitkitTests/SparkExitReviewTests.swift | 8 ++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 642cba40a..cf3eb1328 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -616,15 +616,27 @@ struct AppScene: View { .task(id: [ SparkExitManager.shared.opensProgress, SparkExitManager.shared.showsResult, + SparkExitManager.shared.hasLoadedRecord, isLockedForDeepLinks, wallet.walletExists == true, ]) { // A tapped exit notification opens the exit once the wallet is unlocked and its record is loaded. let exit = SparkExitManager.shared - guard exit.opensProgress, exit.showsResult, wallet.walletExists == true, !isLockedForDeepLinks else { return } - exit.opensProgress = false - navigation.reset() - navigation.navigate(.sparkExitProgress) + guard exit.opensProgress, wallet.walletExists == true, !isLockedForDeepLinks else { return } + switch SparkExitManager.notificationOpen( + opensProgress: exit.opensProgress, + showsResult: exit.showsResult, + hasLoadedRecord: exit.hasLoadedRecord + ) { + case .wait: + return + case .drop: + exit.opensProgress = false + case .open: + exit.opensProgress = false + navigation.reset() + navigation.navigate(.sparkExitProgress) + } } .task(id: foregroundContactSession) { shopContactImport?.task.cancel() diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 03c94dfad..d39e1347b 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -58,8 +58,10 @@ final class SparkExitManager { private var fundingExitCreatedAt: Date? private(set) var fundingAddress: String? private(set) var lastError: String? - /// Set when the user tapped an exit notification, until the app shows the exit. + /// Set when the user tapped an exit notification, until the app shows the exit or the loaded record shows none. var opensProgress = false + /// The record of this wallet was read (there may be none), so a tapped notification no longer waits for it. + private(set) var hasLoadedRecord = false /// The funding choice made on the funding screen, used when the exit starts. @ObservationIgnored var fundFromSavings = false @@ -156,6 +158,7 @@ final class SparkExitManager { self.walletIndex = walletIndex do { record = try Self.loadRecord(store: store, lostLeafStore: lostLeafStore) + hasLoadedRecord = true } catch is SparkExitStore.Unavailable { // Before the first unlock the file is there but cannot be read yet: it is read again on the next load. Logger.warn("The Spark exit record cannot be read yet", context: "SparkExitManager") @@ -165,6 +168,7 @@ final class SparkExitManager { Logger.error("Failed to read the Spark exit record: \(error)", context: "SparkExitManager") try? store.setAside() record = nil + hasLoadedRecord = true } if isExiting { SparkManager.shared.detachForExit() @@ -210,6 +214,7 @@ final class SparkExitManager { try? store.delete() try? lostLeafStore.delete() record = nil + hasLoadedRecord = false quote = nil // The quote's destination and funding belong to the wiped wallet: the next wallet's exit never pays to them. pendingDestination = nil @@ -334,6 +339,21 @@ final class SparkExitManager { } /// What cancelling does, given what the funding address holds now (nil when it could not be read). + /// What a tapped exit notification does now. + enum NotificationOpen: Equatable { + case wait, open, drop + } + + /// It waits for the record to load, opens the exit while it shows a result, and is let go once the loaded record + /// shows none, so a later result never jumps to Progress. + nonisolated static func notificationOpen(opensProgress: Bool, showsResult: Bool, hasLoadedRecord: Bool) -> NotificationOpen { + guard opensProgress else { return .drop } + if showsResult { + return .open + } + return hasLoadedRecord ? .drop : .wait + } + nonisolated static func cancelOutcome(utxos: [EsploraUtxo]?) -> CancelOutcome { guard let utxos else { return .keep } guard !utxos.isEmpty else { return .forget } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 333f895c7..72ea8a759 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -29,6 +29,14 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 25000), 0) } + /// A tapped notification for an exit whose result was seen already is let go once the record loaded, so a later + /// exit's result never jumps to Progress. + func testATappedNotificationIsLetGoOnceTheLoadedExitShowsNoResult() { + XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: false, hasLoadedRecord: false), .wait) + XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: true, hasLoadedRecord: true), .open) + XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: false, hasLoadedRecord: true), .drop) + } + // MARK: H1 cancel func testCancelIsOffAfterAPaymentFromSavings() { From b6195364c7f4ef80ef85d4647f540fc623a06bb6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:41 +0100 Subject: [PATCH 289/378] fix: report an exit that is no longer cancellable --- Bitkit/Managers/SparkExitManager.swift | 6 +++++- Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 1 + BitkitTests/SparkExitReviewTests.swift | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index d39e1347b..3326d112f 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -336,6 +336,8 @@ final class SparkExitManager { case sweepFunding([SparkExitRecord.Leftover]) /// The address could not be read: nothing changes. case keep + /// The exit can no longer be cancelled, such as once a tick adopted its funding: nothing changes. + case notCancellable } /// What cancelling does, given what the funding address holds now (nil when it could not be read). @@ -388,13 +390,15 @@ final class SparkExitManager { record = stored } unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress() - guard canCancel, var record, let address = record.funding.first?.address else { return .keep } + guard canCancel, var record, let address = record.funding.first?.address else { return .notCancellable } let utxos = try? await chain?.utxos(address: address) let outcome = Self.cancelOutcome(utxos: utxos) switch outcome { case .keep: Logger.warn("Spark exit not cancelled: its funding address could not be read", context: "SparkExitManager") return outcome + case .notCancellable: + return outcome case let .sweepFunding(leftover): record.phase = .failed(reason: SparkExitRecord.cancelledReason) record.isCancelled = true diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index 383652982..d6bd4b1cc 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -867,6 +867,7 @@ struct SparkExitProgressView: View { // Funding that reached the address is swept back on this screen, as the exit's leftover. case .sweepFunding: break case .forget: navigation.reset() + case .notCancellable: break } } } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 72ea8a759..76bdbeb6d 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -37,6 +37,13 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: false, hasLoadedRecord: true), .drop) } + /// A cancel of an exit that can no longer be cancelled says so, not that the address could not be read. + @MainActor func testACancelOfAnExitNoLongerCancellableIsNotAFailedRead() async { + let exit = SparkExitManager(walletIndex: 9999) + let outcome = await exit.cancel() + XCTAssertEqual(outcome, .notCancellable) + } + // MARK: H1 cancel func testCancelIsOffAfterAPaymentFromSavings() { From 2ef82a842dc4546c98e0093955455d1d7ace8b9f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:42 +0100 Subject: [PATCH 290/378] fix: give a forgotten exit's funding address back --- Bitkit/Managers/SparkExitManager.swift | 9 +++++++++ BitkitTests/SparkExitReviewTests.swift | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 3326d112f..f3ff02fcb 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -356,6 +356,12 @@ final class SparkExitManager { return hasLoadedRecord ? .drop : .wait } + /// The funding index counter once an exit using `index` is forgotten. + /// Given back when it is the last one used, so the next exit watches that address again. + nonisolated static func fundingCounterAfterForget(_ counter: Int, index: UInt32) -> Int { + counter == Int(index) + 1 ? Int(index) : counter + } + nonisolated static func cancelOutcome(utxos: [EsploraUtxo]?) -> CancelOutcome { guard let utxos else { return .keep } guard !utxos.isEmpty else { return .forget } @@ -425,6 +431,9 @@ final class SparkExitManager { try? store.delete() self.record = nil Self.clearReminders() + // A payment that lands at the forgotten address late is then seen by the next exit, which uses it again. + let counter = UserDefaults.standard.integer(forKey: fundingIndexKey) + UserDefaults.standard.set(Self.fundingCounterAfterForget(counter, index: record.fundingIndex), forKey: fundingIndexKey) if let transferId = record.transferId { try? await TransferService(lightningService: .shared).markSettled(id: transferId) } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 76bdbeb6d..46cc408f6 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -44,6 +44,12 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(outcome, .notCancellable) } + /// A forgotten exit gives its funding address back, so a payment that lands there late is seen by the next exit. + func testAForgottenExitGivesItsFundingAddressBack() { + XCTAssertEqual(SparkExitManager.fundingCounterAfterForget(4, index: 3), 3) + XCTAssertEqual(SparkExitManager.fundingCounterAfterForget(6, index: 3), 6, "a later address is in use") + } + // MARK: H1 cancel func testCancelIsOffAfterAPaymentFromSavings() { From 5e211721b1fdabc771a12ed2a24c411ea48e07ad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:43 +0100 Subject: [PATCH 291/378] fix: delete the exit record's copies on wipe --- Bitkit/Managers/SparkExitManager.swift | 6 +++++- Bitkit/Managers/SparkManager.swift | 4 ++++ Bitkit/Services/SparkExit/SparkExitRecord.swift | 12 ++++++++++++ BitkitTests/SparkExitReviewTests.swift | 13 +++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index f3ff02fcb..658e4264d 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -211,7 +211,11 @@ final class SparkExitManager { try? await Task.sleep(nanoseconds: 100_000_000) } writeGate = SparkExitWriteGate() - try? store.delete() + do { + try store.deleteWithCopies() + } catch { + Logger.error("Failed to delete the wiped wallet's Spark exit record: \(error)", context: "SparkExitManager") + } try? lostLeafStore.delete() record = nil hasLoadedRecord = false diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index c327a6c84..5643c93fc 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -549,6 +549,10 @@ final class SparkManager: ChannelFreeLightningRail { onStateChanged?() try Self.removeIfPresent(Env.sparkStorage(walletIndex: walletIndex)) try Self.removeIfPresent(Env.sparkExitRestore(walletIndex: walletIndex)) + // The exit's own wipe only logs a failed delete: here it throws, so the wipe is retried, and covers the wiped + // wallet's index even when the exit manager holds another one. + try SparkExitStore(walletIndex: walletIndex).deleteWithCopies() + try Self.removeIfPresent(Env.sparkExitLostLeaves(walletIndex: walletIndex)) } nonisolated static func removeIfPresent(_ url: URL) throws { diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index b851b76ab..bd83a755b 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -603,6 +603,18 @@ struct SparkExitStore { guard FileManager.default.fileExists(atPath: url.path) else { return } try FileManager.default.removeItem(at: url) } + + /// Deletes the record and the copies kept aside for support (`keepCopyAside`, `setAside`), for a wallet being + /// wiped: they hold its addresses and signed transactions. + func deleteWithCopies() throws { + try delete() + let dir = url.deletingLastPathComponent() + guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return } + let prefix = url.deletingPathExtension().lastPathComponent + ".unreadable-" + for name in names where name.hasPrefix(prefix) { + try FileManager.default.removeItem(at: dir.appendingPathComponent(name)) + } + } } /// The ids of the leaves an older state of the wallet claimed on-chain, across exits. The operators keep listing such diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 46cc408f6..001b2030e 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -50,6 +50,19 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(SparkExitManager.fundingCounterAfterForget(6, index: 3), 6, "a later address is in use") } + /// A wipe deletes the copies of an unreadable record kept aside, which hold the wiped wallet's addresses. + func testAWipeDeletesTheRecordsCopiesKeptAside() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")) + try store.save(SparkExitFixtures.record()) + try store.keepCopyAside() + + try store.deleteWithCopies() + + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: dir.path), []) + } + // MARK: H1 cancel func testCancelIsOffAfterAPaymentFromSavings() { From 2fe70af91dec853da81451177326aa92c2eebd47 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:44 +0100 Subject: [PATCH 292/378] fix: show kept change recovery details once --- .../Services/SparkExit/SparkExitDriver.swift | 2 ++ .../Services/SparkExit/SparkExitRecord.swift | 10 ++++++++-- BitkitTests/SparkExitSweepTests.swift | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index fa7d00344..0b53661d0 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -1146,6 +1146,8 @@ struct SparkExitDriver { if tip.saturatingSub(since) >= Self.sweepWaitBlocks { Logger.info("Spark exit keeps its fee change at the funding address after waiting for fees", context: "SparkExitDriver") record.leftoverSweep?.isDustKept = true + // Shown once more with its path, even after the result was opened: nothing else reaches that address. + record.isResultSeen = nil try save(record) return } diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index bd83a755b..6816b56b3 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -301,9 +301,15 @@ struct SparkExitRecord: Codable, Equatable { return false } - /// An ended exit that failed or lost leaves, whose result the user has not opened yet. + /// An ended exit that failed, lost leaves or kept change at the funding address, whose result the user has not + /// opened yet. var hasUnseenResult: Bool { - !isActive && (isFailed || lostSats > 0) && isResultSeen != true + !isActive && (isFailed || lostSats > 0 || isChangeKept) && isResultSeen != true + } + + /// The fee change stays at the funding address for good, shown with the path that spends it. + var isChangeKept: Bool { + !leftover.isEmpty && leftoverSweep?.isDustKept == true } /// Whether Emergency exit opens this exit rather than a new one: it runs, its leftover is still on its way to diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 51680b6a4..162bcf0f0 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -663,6 +663,25 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(SparkExitFeeBumpOption.options(exitRate: 2, fees: fees).map(\.rate), [3], "a bump still offers a higher rate") } + /// Change kept at the funding address shows its path once, even when the result was opened before, so the user + /// can spend it; after that a new exit may start. + func testKeptChangeShowsItsRecoveryDetailsOnce() async throws { + var record = endedRecord() + record.isResultSeen = true + record.feeRateSatPerVbyte = 20 + chain.economyFee = 20 + chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 600)] + record = try await driver().tick(record) + chain.tip = 1000 + SparkExitDriver.sweepWaitBlocks + + record = try await driver().tick(record) + + XCTAssertEqual(record.leftoverLine, .kept(sats: 600)) + XCTAssertTrue(record.showsResult, "the funding address and its path were out of reach") + record.isResultSeen = true + XCTAssertFalse(record.showsResult, "a new exit may start") + } + /// After about four weeks (4,032 blocks) the change stays at the funding address and the exit is let go. func testChangeTooSmallForFourWeeksIsKept() async throws { var record = endedRecord() From 5f8e05487d9d1e5ed2e89190383aac3ff9c687a5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:33:45 +0100 Subject: [PATCH 293/378] fix: cap the refused sweep rate on overflow --- Bitkit/Services/SparkExit/SparkExitFunding.swift | 7 +++++-- BitkitTests/SparkExitSweepTests.swift | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 530e8f7c8..a74900d27 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -540,8 +540,11 @@ enum SparkExitMath { guard let match = refusal.firstMatch(of: #/(?:min relay fee|mempool min fee) not met[^0-9]*(\d+)\s*<\s*(\d+)/#), let paid = UInt64(match.1), let least = UInt64(match.2), paid > 0 else { return bumped } - let leastRate = (least * max(paidRate, 1) + paid - 1) / paid - return max(leastRate, bumped) + // A host's number can be anything: one too big asks for more than any cap, which the caller applies. + let (scaled, isScaledOver) = least.multipliedReportingOverflow(by: max(paidRate, 1)) + let (rounded, isRoundedOver) = scaled.addingReportingOverflow(paid - 1) + guard !isScaledOver, !isRoundedOver else { return .max } + return max(rounded / paid, bumped) } /// What a refused rate lifts the sweep to: never above the next block's rate or the exit's own, whichever is higher, diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 162bcf0f0..afe28e3d5 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -567,6 +567,8 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(SparkExitMath.refusedSweepRate("min relay fee not met, 450 < 674", paidRate: 1), 2) XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 900 < 2700", paidRate: 2), 6) XCTAssertEqual(SparkExitMath.refusedSweepRate("insufficient fee", paidRate: 3), 4, "no numbers: one sat/vB more") + // A host's number past any real rate asks for more than every cap, and never traps. + XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 450 < 10000000000000000000", paidRate: 2), .max) let absurd = SparkExitMath.refusedSweepRate("min relay fee not met, 110 < 22000", paidRate: 1) XCTAssertEqual(absurd, 200) XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: 5, exitRate: 2), 5, "capped at the next block's rate") From daec38b4dc5f7b9c58ad7397290f83f0c05f26e4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:34:01 +0100 Subject: [PATCH 294/378] fix: validate a spark withdrawal against its debit and spark's balance --- Bitkit/Services/PaykitRouteService.swift | 11 ++++++ .../Views/Wallets/Send/SendAmountView.swift | 27 ++++++++++++-- BitkitTests/PaykitRouteTests.swift | 35 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 4acda6ce2..f2029554a 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -134,6 +134,17 @@ enum PaykitRouteService { return legs } + /// Whether a Spark withdrawal carries `amountSats` on `route`, or nil when Lightning's limit decides. A withdrawal + /// is paid from Spark's whole balance, so Lightning's limit never caps it: a quoted leg fits when its debit, fees + /// included, does, and before the quote an amount the balance covers lets Continue ask for one. + static func sparkWithdrawalValidity(route: PaykitRouteUi?, amountSats: UInt64, sparkBalanceSats: UInt64) -> Bool? { + guard let route, route.offersSparkLeg, amountSats > 0 else { return nil } + if let leg = route.serviceLeg, leg.deliveredSat == amountSats { + return sparkBalanceSats >= leg.debitSat + } + return sparkBalanceSats >= amountSats ? true : nil + } + /// Whether a None route is a short balance rather than a contact Bitkit can't pay. static func isBalanceShort(amountSats: UInt64, lightningSpendableSats: UInt64, onchainSpendableSats: UInt64) -> Bool { amountSats > lightningSpendableSats.saturatingAdd(onchainSpendableSats) diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index dddfa14d8..2554e4e3b 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -121,6 +121,9 @@ struct SendAmountView: View { } let minAmount = app.selectedWalletToPayFrom == .lightning ? 1 : Env.dustLimit + if !hwSend.isActive, app.selectedWalletToPayFrom == .lightning, let valid = sparkWithdrawalValidity { + return valid + } if app.selectedWalletToPayFrom == .lightning, let data = app.lnurlPayData, !data.accepts(amountSats: amountSats) { @@ -129,6 +132,26 @@ struct SendAmountView: View { return amountSats >= minAmount && amountSats <= availableAmount } + /// Spark's whole balance pays a withdrawal to the contact, so Lightning's limit never caps it. + private var sparkWithdrawalBalanceSats: UInt64 { + guard app.contactPaymentContext != nil, app.paykitRoute?.offersSparkLeg == true, SparkManager.shared.isReady else { return 0 } + return SparkManager.shared.balanceSats + } + + private var sparkWithdrawalValidity: Bool? { + PaykitRouteService.sparkWithdrawalValidity( + route: app.paykitRoute, + amountSats: amountSats, + sparkBalanceSats: sparkWithdrawalBalanceSats + ) + } + + /// The pad's cap: what Spending can send, or Spark's whole balance when a withdrawal can carry more. + private var inputCapSats: UInt64 { + guard !hwSend.isActive, app.selectedWalletToPayFrom == .lightning else { return availableAmount } + return max(availableAmount, sparkWithdrawalBalanceSats) + } + /// Determines if the current amount is a max amount send var isMaxAmountSend: Bool { guard app.selectedWalletToPayFrom == .onchain, app.contactPaymentContext?.incomingPaymentRequest == nil else { return false } @@ -339,7 +362,7 @@ struct SendAmountView: View { } } } - .onChange(of: availableAmount, initial: true) { updateInputCap() } + .onChange(of: inputCapSats, initial: true) { updateInputCap() } .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } @@ -478,7 +501,7 @@ struct SendAmountView: View { private func updateInputCap() { // Don't cap when nothing is sendable, so the pad stays usable (Continue stays disabled instead). - amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil + amountViewModel.maxAmountOverride = inputCapSats > 0 ? inputCapSats : nil } private var fundingSourceMenu: some View { diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 86e528eb0..5b616a7af 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -139,6 +139,41 @@ final class PaykitRouteTests: XCTestCase { XCTAssertNil(PaykitRouteService.routeUi(for: usdtOnly, canRouteThroughSpark: true, canPayDirectly: false)) } + func testASparkWithdrawalIsWeighedAgainstSparksBalanceNotTheLightningLimit() { + let route = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + var quoted = route + quoted.serviceLeg = sparkLeg(amountSats: 999_000, feeSats: 500) + + // Lightning's limit of 997,506 sats never caps a withdrawal from Spark's 1,000,000. + XCTAssertEqual( + PaykitRouteService.sparkWithdrawalValidity(route: route, amountSats: 999_000, sparkBalanceSats: 1_000_000), + true, + "continue asks spark for a quote" + ) + XCTAssertEqual( + PaykitRouteService.sparkWithdrawalValidity(route: quoted, amountSats: 999_000, sparkBalanceSats: 1_000_000), + true, + "the quoted debit fits spark's balance" + ) + XCTAssertEqual( + PaykitRouteService.sparkWithdrawalValidity(route: quoted, amountSats: 999_000, sparkBalanceSats: 999_499), + false, + "a debit past spark's balance can't be sent" + ) + XCTAssertNil( + PaykitRouteService.sparkWithdrawalValidity(route: route, amountSats: 1_000_001, sparkBalanceSats: 1_000_000), + "past spark's balance, lightning's limit decides" + ) + XCTAssertNil( + PaykitRouteService.sparkWithdrawalValidity( + route: PaykitRouteUi(endpoints: [onchain], offersSparkLeg: false), + amountSats: 999_000, + sparkBalanceSats: 1_000_000 + ), + "without a spark leg, lightning's limit decides" + ) + } + // MARK: - Contact withdrawal journal func testJournaledWithdrawalMatchesOnlyItsOwnAttempt() { From ec8daf97220fb85e17ff324ed6f77cc8f299497e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:02:24 +0100 Subject: [PATCH 295/378] fix: check a rerouted lnurl's minimum before confirm --- Bitkit/Services/PaykitRouteService.swift | 17 +++++++++ .../Views/Wallets/Send/SendAmountView.swift | 4 ++- BitkitTests/PaykitRouteTests.swift | 35 ++++++++++++++++--- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index f2029554a..911d11035 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -364,4 +364,21 @@ extension AppViewModel { accessibilityIdentifier: isBalanceShort ? "PaykitRouteBalanceToast" : "PaykitRouteNoneToast" ) } + + /// The route can restore an LNURL a BOLT11 pick cleared, so Amount checks its minimum after routing. + func validateRoutedLnurlMinimum(amountSats: UInt64) -> Bool { + guard let lnurlPayData else { return true } + let minimumAmount = max(1, lnurlPayData.minSendableSat) + guard amountSats < minimumAmount else { return true } + toast( + type: .error, + title: t("wallet__lnurl_pay__error_min__title"), + description: t( + "wallet__lnurl_pay__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(minimumAmount)] + ), + accessibilityIdentifier: "LnurlPayAmountTooLowToast" + ) + return false + } } diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 2554e4e3b..b87d9d0a0 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -439,7 +439,9 @@ struct SendAmountView: View { return } - navigationPath.append(app.lnurlPayData != nil && app.scannedLightningInvoice == nil ? .lnurlPayConfirm : .confirm) + let paysLnurl = app.lnurlPayData != nil && app.scannedLightningInvoice == nil + guard !paysLnurl || app.validateRoutedLnurlMinimum(amountSats: amountSats) else { return } + navigationPath.append(paysLnurl ? .lnurlPayConfirm : .confirm) return } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 5b616a7af..09da11823 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -575,7 +575,29 @@ final class PaykitRouteTests: XCTestCase { } @MainActor - private func openLightningContact() -> (AppViewModel, PaykitRouteUi, PaykitEndpoint, PaykitEndpoint) { + func testARerouteFromBolt11ToLnurlBelowItsMinimumShowsTheMinimumToast() async { + let (app, route, bolt11, lnurl) = openLightningContact(lnurlMinSendableMsats: 1_000_000) + defer { ToastWindowManager.shared.hideToast() } + + let first = await routeDirectly(app, route, to: bolt11) + XCTAssertEqual(first, .direct) + XCTAssertNil(app.lnurlPayData) + + let second = await routeDirectly(app, app.paykitRoute ?? route, to: lnurl, amountSats: 500) + XCTAssertEqual(second, .direct) + XCTAssertEqual(app.lnurlPayData?.uri, lnurl.value) + XCTAssertFalse(app.validateRoutedLnurlMinimum(amountSats: 500), "the restored LNURL's minimum must stop Confirm") + let toast = ToastWindowManager.shared.currentToast + XCTAssertEqual(toast?.accessibilityIdentifier, "LnurlPayAmountTooLowToast") + XCTAssertEqual(toast?.title, t("wallet__lnurl_pay__error_min__title")) + XCTAssertEqual(toast?.description, t( + "wallet__lnurl_pay__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(1000)] + )) + } + + @MainActor + private func openLightningContact(lnurlMinSendableMsats: UInt64 = 1000) -> (AppViewModel, PaykitRouteUi, PaykitEndpoint, PaykitEndpoint) { snapshotAppDefaultsDomain() let app = AppViewModel() let bolt11 = PaykitEndpoint( @@ -597,7 +619,7 @@ final class PaykitRouteTests: XCTestCase { app.lnurlPayData = LnurlPayData( uri: lightning.value, callback: "https://example.com/pay", - minSendable: 1000, + minSendable: lnurlMinSendableMsats, maxSendable: 100_000_000, metadataStr: "[]", commentAllowed: nil, @@ -609,10 +631,15 @@ final class PaykitRouteTests: XCTestCase { } @MainActor - private func routeDirectly(_ app: AppViewModel, _ route: PaykitRouteUi, to endpoint: PaykitEndpoint) async -> PaykitRouteOutcome { + private func routeDirectly( + _ app: AppViewModel, + _ route: PaykitRouteUi, + to endpoint: PaykitEndpoint, + amountSats: UInt64 = 2000 + ) async -> PaykitRouteOutcome { await app.routeContactPayment( route, - amountSats: 2000, + amountSats: amountSats, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 0, From f0d17cdac9cc64f2b19476f6ee1df66a89f7547c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:05:47 +0100 Subject: [PATCH 296/378] fix: let spark pay while the node is down --- Bitkit/Views/Wallets/Send/SendSheet.swift | 34 +++++++++++++---------- BitkitTests/ChannelFreeRailTests.swift | 33 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index ab25990ea..fe64d37c0 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -304,26 +304,30 @@ struct SendSheet: View { return false } - // Node must be running - guard wallet.nodeLifecycleState == .running else { return true } - - // For lightning payments, also need usable channels (peer connected) let isLightningPayment = app.scannedLightningInvoice != nil || app.lnurlPayData != nil || app.selectedWalletToPayFrom == .lightning + return Self.waitsForNode( + isNodeRunning: wallet.nodeLifecycleState == .running, + isLightningPayment: isLightningPayment, + hasAnyChannels: (wallet.channels?.isEmpty == false) || wallet.channelCount > 0, + hasUsableChannels: wallet.hasUsableChannels, + sparkCanPay: sparkCanPayScannedInvoice + ) + } - if isLightningPayment { - // If there are no channels at all, don't show the sync overlay – - // there is nothing to \"sync into\". Let validation/UX handle this as - // an \"insufficient capacity / no channels\" case instead of a sync wait. - let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 - guard hasAnyChannels, !sparkCanPayScannedInvoice else { return false } - - // We have channels but none are usable yet → show sync overlay - return !wallet.hasUsableChannels + /// A Lightning payment Spark can pay goes ahead without the node. Otherwise the node must run, and a Lightning + /// payment over channels waits for a usable one. With no channels at all there is nothing to sync into, so + /// validation reports the missing capacity instead. + static func waitsForNode( + isNodeRunning: Bool, isLightningPayment: Bool, hasAnyChannels: Bool, hasUsableChannels: Bool, sparkCanPay: Bool + ) -> Bool { + if isLightningPayment, sparkCanPay { + return false } - - return false + guard isNodeRunning else { return true } + guard isLightningPayment, hasAnyChannels, !sparkCanPay else { return false } + return !hasUsableChannels } /// Spark pays without channels, so a channel that is still reconnecting is no reason to wait. diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index e95167967..85ed3d367 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -1226,6 +1226,39 @@ extension ChannelFreeRailTests { XCTAssertTrue(SendSheet.sparkCanPay(amountSats: 99750, isReady: true, balanceSats: 100_000)) } + func testTheSendSheetLetsSparkPayWhileTheNodeIsDown() { + let payable = SendSheet.sparkCanPay(amountSats: 99750, isReady: true, balanceSats: 100_000) + XCTAssertFalse( + SendSheet.waitsForNode( + isNodeRunning: false, isLightningPayment: true, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: payable + ), + "A Lightning payment Spark can pay waited for the node to start" + ) + XCTAssertFalse(SendSheet.waitsForNode( + isNodeRunning: false, isLightningPayment: true, hasAnyChannels: false, hasUsableChannels: false, sparkCanPay: payable + )) + + let unpayable = [ + SendSheet.sparkCanPay(amountSats: 99750, isReady: false, balanceSats: 100_000), + SendSheet.sparkCanPay(amountSats: 100_000, isReady: true, balanceSats: 100_000), + SendSheet.sparkCanPay(amountSats: 0, isReady: true, balanceSats: 100_000), + ] + for sparkCanPay in unpayable { + XCTAssertTrue(SendSheet.waitsForNode( + isNodeRunning: false, isLightningPayment: true, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: sparkCanPay + )) + } + XCTAssertTrue(SendSheet.waitsForNode( + isNodeRunning: false, isLightningPayment: false, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: payable + ), "An on-chain payment went ahead without the node") + XCTAssertTrue(SendSheet.waitsForNode( + isNodeRunning: true, isLightningPayment: true, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: false + )) + XCTAssertFalse(SendSheet.waitsForNode( + isNodeRunning: true, isLightningPayment: true, hasAnyChannels: true, hasUsableChannels: false, sparkCanPay: payable + )) + } + func testInsufficientFundsInsideTheSparkSendIsARefusal() { let failure = Bitkit.SparkManager.sendPaymentFailure(SdkError.InsufficientFunds(tokenIdentifier: nil)) From f595d0dac1012565a75ef9ce0f31ecd0a50069d0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:05:47 +0100 Subject: [PATCH 297/378] fix: drop the spending balance after a full spark withdrawal --- Bitkit/Managers/SparkManager.swift | 17 +++++++++++++---- BitkitTests/SparkTransferActivityTests.swift | 19 +++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 762d852a9..d40f47cc9 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -71,6 +71,8 @@ final class SparkManager: ChannelFreeLightningRail { /// When a withdrawal last failed, in seconds since 1970, until Spark reads a balance again or syncs after it /// (`keptBalanceSats`). @ObservationIgnored private var withdrawalFailedAt: UInt64? + /// Whether a withdrawal moved the whole balance, so a read of 0 is real until Spark reads a balance again. + @ObservationIgnored private var withdrewAll = false @ObservationIgnored private var listenerId: String? @ObservationIgnored private var connectTask: Task? /// Bumped by `wipe`, so a connect that started before it disconnects instead of keeping the wiped wallet. @@ -258,6 +260,7 @@ final class SparkManager: ChannelFreeLightningRail { balanceSats = 0 keptBalanceSats = nil withdrawalFailedAt = nil + withdrewAll = false pendingDepositSats = 0 deposits = [] withdrawals = [] @@ -509,6 +512,7 @@ final class SparkManager: ChannelFreeLightningRail { sentWithdrawalTxIds.insert(txId) } withdrawalFailedAt = response.payment.status == .failed ? Self.nowSecs() : nil + withdrewAll = response.payment.status != .failed try await Self.settleWithdrawal(status: response.payment.status) { try await self.refresh() } } catch { withdrawalFailedAt = Self.nowSecs() @@ -724,8 +728,12 @@ final class SparkManager: ChannelFreeLightningRail { withdrawalFailedAt = Self.withdrawalFailedAt( withdrawalFailedAt, read: info.balanceSats, lastSyncedAt: info.lastSyncedAt, syncFailed: info.lastSyncError != nil ) + withdrewAll = withdrewAll && info.balanceSats == 0 let kept = Self.keptBalanceSats( - read: info.balanceSats, lastShown: shownBalanceSats, isStale: withdrawalFailedAt != nil || info.lastSyncError != nil + read: info.balanceSats, + lastShown: shownBalanceSats, + isStale: withdrawalFailedAt != nil || info.lastSyncError != nil, + withdrewAll: withdrewAll ) let changed = !isReady || Self.balancesChanged( from: SparkBalances(readSats: balanceSats, keptSats: keptBalanceSats, pendingSats: pendingDepositSats), @@ -759,9 +767,10 @@ final class SparkManager: ChannelFreeLightningRail { } /// After a failed withdrawal, or while Spark's last sync failed (`isStale`), Spark can read 0 with the funds still - /// there, so the last shown balance stays shown until it reads one again. - nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool) -> UInt64? { - read == 0 && isStale && lastShown > 0 ? lastShown : nil + /// there, so the last shown balance stays shown until it reads one again. After a withdrawal of the whole balance + /// (`withdrewAll`) the 0 is real. + nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool, withdrewAll: Bool) -> UInt64? { + read == 0 && isStale && !withdrewAll && lastShown > 0 ? lastShown : nil } /// A failed withdrawal stops keeping the balance once Spark reads one, or reads 0 after a sync that ended after it. diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index 1eb19ed07..986f99182 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -58,10 +58,21 @@ final class SparkTransferActivityTests: XCTestCase { } func testAZeroReadAfterAFailedWithdrawalOrSyncKeepsTheLastKnownBalance() { - XCTAssertEqual(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true), 9000, "The balance read 0 while stale") - XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false)) - XCTAssertNil(SparkManager.keptBalanceSats(read: 4000, lastShown: 9000, isStale: true)) - XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 0, isStale: true)) + XCTAssertEqual( + SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: false), + 9000, + "The balance read 0 while stale" + ) + XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false, withdrewAll: false)) + XCTAssertNil(SparkManager.keptBalanceSats(read: 4000, lastShown: 9000, isStale: true, withdrewAll: false)) + XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 0, isStale: true, withdrewAll: false)) + } + + func testAZeroReadAfterASuccessfulWithdrawalDropsTheLastShownBalance() { + XCTAssertNil( + SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: true), + "The withdrawn balance stayed shown while Spark's last sync had failed" + ) } func testASyncedZeroReadAfterAFailedWithdrawalClearsTheKeptBalance() { From f0f06df5efb2cfa9ef20736b9a920f0a1c41ba9e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:16:33 +0100 Subject: [PATCH 298/378] fix: check a rerouted lnurl's maximum before confirm --- Bitkit/Services/PaykitRouteService.swift | 34 ++++++++++++------- .../Views/Wallets/Send/SendAmountView.swift | 2 +- BitkitTests/PaykitRouteTests.swift | 28 +++++++++++++-- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 911d11035..a847d5720 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -365,20 +365,28 @@ extension AppViewModel { ) } - /// The route can restore an LNURL a BOLT11 pick cleared, so Amount checks its minimum after routing. - func validateRoutedLnurlMinimum(amountSats: UInt64) -> Bool { - guard let lnurlPayData else { return true } + /// The route can restore an LNURL a BOLT11 pick cleared, so Amount checks its limits after routing. + func validateRoutedLnurlAmount(amountSats: UInt64) -> Bool { + guard let lnurlPayData, !lnurlPayData.accepts(amountSats: amountSats) else { return true } let minimumAmount = max(1, lnurlPayData.minSendableSat) - guard amountSats < minimumAmount else { return true } - toast( - type: .error, - title: t("wallet__lnurl_pay__error_min__title"), - description: t( - "wallet__lnurl_pay__error_min__description", - variables: ["amount": CurrencyFormatter.formatSats(minimumAmount)] - ), - accessibilityIdentifier: "LnurlPayAmountTooLowToast" - ) + if amountSats < minimumAmount { + toast( + type: .error, + title: t("wallet__lnurl_pay__error_min__title"), + description: t( + "wallet__lnurl_pay__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(minimumAmount)] + ), + accessibilityIdentifier: "LnurlPayAmountTooLowToast" + ) + } else { + toast( + type: .error, + title: t("wallet__lnurl_pay__error_max__title"), + description: t("wallet__lnurl_pay__error_max__description"), + accessibilityIdentifier: "LnurlPayAmountTooHighToast" + ) + } return false } } diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index b87d9d0a0..3ffb8d65f 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -440,7 +440,7 @@ struct SendAmountView: View { } let paysLnurl = app.lnurlPayData != nil && app.scannedLightningInvoice == nil - guard !paysLnurl || app.validateRoutedLnurlMinimum(amountSats: amountSats) else { return } + guard !paysLnurl || app.validateRoutedLnurlAmount(amountSats: amountSats) else { return } navigationPath.append(paysLnurl ? .lnurlPayConfirm : .confirm) return } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 09da11823..e46c6b370 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -586,7 +586,7 @@ final class PaykitRouteTests: XCTestCase { let second = await routeDirectly(app, app.paykitRoute ?? route, to: lnurl, amountSats: 500) XCTAssertEqual(second, .direct) XCTAssertEqual(app.lnurlPayData?.uri, lnurl.value) - XCTAssertFalse(app.validateRoutedLnurlMinimum(amountSats: 500), "the restored LNURL's minimum must stop Confirm") + XCTAssertFalse(app.validateRoutedLnurlAmount(amountSats: 500), "the restored LNURL's minimum must stop Confirm") let toast = ToastWindowManager.shared.currentToast XCTAssertEqual(toast?.accessibilityIdentifier, "LnurlPayAmountTooLowToast") XCTAssertEqual(toast?.title, t("wallet__lnurl_pay__error_min__title")) @@ -597,7 +597,29 @@ final class PaykitRouteTests: XCTestCase { } @MainActor - private func openLightningContact(lnurlMinSendableMsats: UInt64 = 1000) -> (AppViewModel, PaykitRouteUi, PaykitEndpoint, PaykitEndpoint) { + func testARerouteFromBolt11ToLnurlAboveItsMaximumShowsTheMaximumToast() async { + let (app, route, bolt11, lnurl) = openLightningContact(lnurlMaxSendableMsats: 3_000_000) + defer { ToastWindowManager.shared.hideToast() } + + let first = await routeDirectly(app, route, to: bolt11) + XCTAssertEqual(first, .direct) + XCTAssertNil(app.lnurlPayData) + + let second = await routeDirectly(app, app.paykitRoute ?? route, to: lnurl, amountSats: 4000) + XCTAssertEqual(second, .direct) + XCTAssertEqual(app.lnurlPayData?.uri, lnurl.value) + XCTAssertFalse(app.validateRoutedLnurlAmount(amountSats: 4000), "the restored LNURL's maximum must stop Confirm") + let toast = ToastWindowManager.shared.currentToast + XCTAssertEqual(toast?.accessibilityIdentifier, "LnurlPayAmountTooHighToast") + XCTAssertEqual(toast?.title, t("wallet__lnurl_pay__error_max__title")) + XCTAssertEqual(toast?.description, t("wallet__lnurl_pay__error_max__description")) + } + + @MainActor + private func openLightningContact( + lnurlMinSendableMsats: UInt64 = 1000, + lnurlMaxSendableMsats: UInt64 = 100_000_000 + ) -> (AppViewModel, PaykitRouteUi, PaykitEndpoint, PaykitEndpoint) { snapshotAppDefaultsDomain() let app = AppViewModel() let bolt11 = PaykitEndpoint( @@ -620,7 +642,7 @@ final class PaykitRouteTests: XCTestCase { uri: lightning.value, callback: "https://example.com/pay", minSendable: lnurlMinSendableMsats, - maxSendable: 100_000_000, + maxSendable: lnurlMaxSendableMsats, metadataStr: "[]", commentAllowed: nil, allowsNostr: false, From f51344357d3f63fa053470fcf52329cada595700 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:31:32 +0100 Subject: [PATCH 299/378] fix: keep kept fee change's recovery details reachable --- Bitkit/Services/SparkExit/SparkExitRecord.swift | 7 ++++--- BitkitTests/SparkExitSweepTests.swift | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 6816b56b3..a4f9a504a 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -313,10 +313,11 @@ struct SparkExitRecord: Codable, Equatable { } /// Whether Emergency exit opens this exit rather than a new one: it runs, its leftover is still on its way to - /// savings, the leftover sweep was refused and the leftover waits at the funding address, or it failed or lost - /// leaves and the user has not seen that yet. + /// savings, the leftover waits at the funding address for good (the sweep was refused, or the change was kept), or + /// it failed or lost leaves and the user has not seen that yet. Coins left at the funding address keep the result, + /// with the address and the path that spends them, and no new exit replaces it. var showsResult: Bool { - needsDriving || (!leftover.isEmpty && leftoverSweep?.rejection != nil) || hasUnseenResult + needsDriving || (!leftover.isEmpty && leftoverSweep?.rejection != nil) || isChangeKept || hasUnseenResult } /// The leaves builds select: the quote's, less those an older state claimed and those left behind. diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index afe28e3d5..a8ed10928 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -665,9 +665,9 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(SparkExitFeeBumpOption.options(exitRate: 2, fees: fees).map(\.rate), [3], "a bump still offers a higher rate") } - /// Change kept at the funding address shows its path once, even when the result was opened before, so the user - /// can spend it; after that a new exit may start. - func testKeptChangeShowsItsRecoveryDetailsOnce() async throws { + /// Change kept at the funding address keeps its address and path reachable, even once the result was opened, so + /// the user can spend it and no new exit overwrites the record. + func testKeptChangeKeepsItsRecoveryDetailsReachable() async throws { var record = endedRecord() record.isResultSeen = true record.feeRateSatPerVbyte = 20 @@ -681,7 +681,7 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(record.leftoverLine, .kept(sats: 600)) XCTAssertTrue(record.showsResult, "the funding address and its path were out of reach") record.isResultSeen = true - XCTAssertFalse(record.showsResult, "a new exit may start") + XCTAssertTrue(record.showsResult, "the funding address and its path went out of reach once the result was opened") } /// After about four weeks (4,032 blocks) the change stays at the funding address and the exit is let go. From d745ec9c458bb0f9bfd8e16904f5405fe5e7298f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:31:33 +0100 Subject: [PATCH 300/378] fix: refuse an exit start while a spark send is still active --- Bitkit/Managers/SparkExitManager.swift | 7 +++-- Bitkit/Managers/SparkManager.swift | 25 +++++++++++++---- BitkitTests/SparkPaymentSafetyTests.swift | 33 +++++++++++++++++++++-- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 658e4264d..e146e161e 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -811,6 +811,11 @@ final class SparkExitManager { throw AppError(message: t("lightning__spark_exit__quote_error"), debugMessage: "No quote to start from") } let key = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex) + // A send that passed its exit check before the hold may still spend leaves. One still sending after the wait + // refuses the start before anything is saved, so the quote stays and the user starts again once it ended. + // The record holds Spark from its save on. + defer { SparkManager.shared.releaseSpendsHold() } + try await SparkManager.shared.holdSpendsForExit() var record = SparkExitRecord( network: "\(network)", createdAt: Date(), @@ -846,8 +851,6 @@ final class SparkExitManager { SparkManager.shared.detachForExit() // Reconnecting cuts an optimization short, which would leave its counter-swap unclaimed for a while. await SparkManager.shared.waitForOptimization() - // A send that passed its exit check before the hold may still spend leaves: the requote comes after it. - await SparkManager.shared.waitForSpends() // The rest runs under the ticks' lock, on this exit as stored: one cancelled during the wait above is never // reconnected for or paid to. while isTicking { diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 5643c93fc..77b9da83c 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -279,8 +279,12 @@ final class SparkManager: ChannelFreeLightningRail { /// Waits for the sends and withdrawals that passed their exit check before the exit held Spark, for up to two /// minutes, so the exit quotes the leaves they leave. - func waitForSpends() async { - await spends.waitUntilIdle(timeout: Self.optimizationWaitSeconds) + func holdSpendsForExit() async throws { + try await spends.holdForExit(timeout: Self.optimizationWaitSeconds) + } + + func releaseSpendsHold() { + spends.release() } private func noteOptimization(_ event: AutoOptimizationEvent) { @@ -1506,6 +1510,8 @@ private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked final class SparkSpends { private let isExiting: () -> Bool private(set) var inFlight = 0 + /// An exit start holds Spark while it waits for the sends, before its record exists. + private(set) var isHeld = false init(isExiting: @escaping () -> Bool) { self.isExiting = isExiting @@ -1514,7 +1520,7 @@ final class SparkSpends { /// Runs `send` counted, unless an exit holds Spark. Called after the send's last await but the one inside `send`, /// so an exit starting meanwhile either refuses it here or waits for it. func run(_ send: () async throws -> T) async throws -> T { - guard !isExiting() else { + guard !isExiting(), !isHeld else { Logger.info("Refused a Spark send: an emergency exit holds Spark", context: "SparkManager") throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) } @@ -1523,16 +1529,25 @@ final class SparkSpends { return try await send() } - /// Waits for the counted sends to end, for up to `timeout`. - func waitUntilIdle(timeout: TimeInterval) async { + /// Holds Spark for an exit start and waits for the counted sends to end, for up to `timeout`. Throws, and lets go, + /// when one is still sending then: the exit must not start while a send can spend its leaves, and the user starts + /// it again once the send ended. The start lets go (`release`) once its record holds Spark. + func holdForExit(timeout: TimeInterval) async throws { + isHeld = true let deadline = Date().addingTimeInterval(timeout) while inFlight > 0, !Task.isCancelled, Date() < deadline { try? await Task.sleep(nanoseconds: 100_000_000) } if inFlight > 0 { Logger.warn("Gave up waiting for Spark sends in flight to end", context: "SparkManager") + isHeld = false + throw AppError(message: t("other__try_again"), debugMessage: "A Spark payment is still sending") } } + + func release() { + isHeld = false + } } /// Lets the first of two racing tasks resume a continuation, once. diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift index 56a47ebe2..c900a9a92 100644 --- a/BitkitTests/SparkPaymentSafetyTests.swift +++ b/BitkitTests/SparkPaymentSafetyTests.swift @@ -419,7 +419,7 @@ extension SparkPaymentSafetyTests { isExiting = true var isIdle = false let idle = Task { @MainActor in - await spends.waitUntilIdle(timeout: 120) + try await spends.holdForExit(timeout: 120) isIdle = true } try await Task.sleep(nanoseconds: 300_000_000) @@ -427,7 +427,36 @@ extension SparkPaymentSafetyTests { XCTAssertFalse(isIdle, "the exit start went on while a send past its check still spent leaves") release?.resume() try await send.value - await idle.value + try await idle.value XCTAssertTrue(isIdle) } + + /// A send still sending after the wait refuses the exit start before its record exists; Spark is let go, so the + /// user can start again once the send ended. + func testAnExitStartIsRefusedWhileASendStaysActivePastTheWait() async throws { + let spends = SparkSpends { false } + var release: CheckedContinuation? + let send = Task { @MainActor in + try await spends.run { + await withCheckedContinuation { release = $0 } + } + } + while release == nil { + await Task.yield() + } + + var isRefused = false + do { + try await spends.holdForExit(timeout: 0.3) + } catch { + isRefused = true + } + + XCTAssertTrue(isRefused, "the exit start went on while a send could still spend its leaves") + XCTAssertFalse(spends.isHeld, "Spark stayed held after the refused start") + release?.resume() + try await send.value + try await spends.holdForExit(timeout: 1) + XCTAssertTrue(spends.isHeld) + } } From 9b4f8d163a929efa7efa841d254ffb3640bc1c8e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:31:33 +0100 Subject: [PATCH 301/378] fix: leave lost leaves out of what stays on spark during an exit --- Bitkit/Managers/SparkExitManager.swift | 4 ++-- Bitkit/Services/SparkExit/SparkExitFunding.swift | 7 +++++++ BitkitTests/SparkExitReviewTests.swift | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index e146e161e..05e1e3631 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -829,7 +829,7 @@ final class SparkExitManager { skippedLeafCount: quote.prepared.skipped.count ) record.fundingStrategy = SparkExitRecord.perNodeStrategy - record.outsideSats = SparkExitMath.staysOnSparkSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) + record.outsideSats = SparkExitMath.outsideSats(sparkBalanceSats: SparkManager.shared.balanceSats, quote: quote) record.leafSats = quote.leafSats let transfers = TransferService(lightningService: .shared) let store = store @@ -948,7 +948,7 @@ final class SparkExitManager { record.expectedSats = fresh.incomingSats record.skippedLeafCount = fresh.prepared.skipped.count record.leafSats = fresh.leafSats - record.outsideSats = SparkExitMath.staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) + record.outsideSats = SparkExitMath.outsideSats(sparkBalanceSats: sparkBalanceSats, quote: fresh) return record } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index a74900d27..fa42607a8 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -587,6 +587,13 @@ enum SparkExitMath { return max(UInt32(clamping: max(0, counter)), afterRecord) } + /// What stays on Spark once a quote's exit took its leaves: the balance beyond them, skipped leaves included, less + /// the lost leaves it still counts until a sync drops them. Nil when nothing. + static func outsideSats(sparkBalanceSats: UInt64, quote: SparkExitQuote) -> UInt64? { + let outside = beyondLostLeaves(sparkBalanceSats.saturatingSub(quote.leafTotalSats), lostLeafSats: quote.lostLeafSats) + return outside > 0 ? outside : nil + } + /// The Spark balance a quote cannot exit: the leaves it skips, and what the balance holds beyond every leaf the /// SDK lists, such as a counter-swap still unclaimed. Nil when there is none. static func unexitableSats(sparkBalanceSats: UInt64, quote: SparkExitQuote) -> UInt64? { diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 001b2030e..bdd8b43f9 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -663,6 +663,21 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(exit.fundingSeenCount, 0) } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) + + /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats + /// selected, 32,768 lost and 1,696 skipped save 1,696. + func testAStartSavesWhatStaysOnSparkWithoutTheLostLeavesSparkStillCounts() throws { + var prepared = SparkExitFixtures.prepared() + prepared.skipped = [SkippedLeaf(leafId: "dust", valueSats: 1696, reason: .feeExceedsValue)] + let quote = SparkExitQuote(prepared: prepared, lostLeafSats: [32768]) + XCTAssertEqual(quote.leafTotalSats, 100_000) + + let record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: quote, sparkBalanceSats: 134_464)) + + XCTAssertEqual(record.outsideSats, 1696, "the lost leaf was kept as staying on Spark") + } + // MARK: Requote at the start (review 1ac10966a, Android 64004d7) /// An optimization swapped the leaves while the start waited: the exit is quoted again over the leaves Spark holds now. From 39cb0714d22978cb1fce4669bc7e918a4c6b9c5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:31:34 +0100 Subject: [PATCH 302/378] fix: count left-behind leaves on spark --- Bitkit/Services/SparkExit/SparkExitFunding.swift | 5 +++-- BitkitTests/SparkExitReviewTests.swift | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index fa42607a8..a0ac0d525 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -650,10 +650,11 @@ enum SparkExitMath { } /// What the Spark balance holds beyond the exit's leaves, in any phase: what a failed exit leaves on Spark. Lost - /// leaves count only while the balance still holds them (`beyondLostLeaves`). + /// leaves count only while the balance still holds them (`beyondLostLeaves`); a leaf the exit left behind stays on + /// Spark, so it counts here, not in the transfer. static func staysOnSparkSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { guard let leafTotal = record.leafTotalSats else { return nil } - let liveLeafTotal = leafTotal.saturatingSub(record.lostSats) + let liveLeafTotal = leafTotal.saturatingSub(record.lostSats).saturatingSub(record.leftBehindSats) guard sparkBalanceSats > liveLeafTotal else { return nil } let stays = beyondLostLeaves(sparkBalanceSats - liveLeafTotal, lostLeafSats: (record.lostLeaves ?? []).map(\.valueSats)) return stays > 0 ? stays : nil diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index bdd8b43f9..50524db25 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -678,6 +678,17 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(record.outsideSats, 1696, "the lost leaf was kept as staying on Spark") } + /// A leaf the exit left behind stays on Spark: Spending counts it while it is out of the transfer. + func testALeafTheExitLeftBehindCountsOnSpark() { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + record.leafTotalSats = 108_192 + record.leftBehindLeaves = [.init(leafId: "leaf-2", valueSats: 8192, reason: "its refund cannot pay")] + + XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 108_192, record: record), 8192, "the leaf is in neither total") + XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 108_192, record: record), 8192) + } + // MARK: Requote at the start (review 1ac10966a, Android 64004d7) /// An optimization swapped the leaves while the start waited: the exit is quoted again over the leaves Spark holds now. From c097edb8963d135109e4d65785de0d1706b03e0d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:54:24 +0100 Subject: [PATCH 303/378] fix: hold node sends of pre-restore invoices until spark lists its sends No backup keeps which invoices Spark paid, so after a restore a node send of an older invoice waits until Spark has synced and listed its sends. Invoices made after the restore, and wallets never restored, pay as before. --- Bitkit/Managers/SparkManager.swift | 1 + Bitkit/Services/LightningService.swift | 43 +++++++++++- .../Views/Onboarding/RestoreWalletView.swift | 3 + BitkitTests/ChannelFreeRailTests.swift | 65 +++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index d40f47cc9..545189b4a 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -911,6 +911,7 @@ final class SparkManager: ChannelFreeLightningRail { switch event { case .synced: syncedThisRun = true + await LightningService.shared.listRailSendsIfUnseeded() await refreshState() case .newDeposits, .claimedDeposits: await refreshState() diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 664160d8a..bc55e95ee 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1112,7 +1112,9 @@ class LightningService: NodeLifecycleService { throw error } } - if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.channelFree]) { + if let paid = try await settledEarlierAttempt( + paymentHash: paymentHash, on: [.channelFree], invoiceCreatedAt: invoice?.secondsSinceEpoch() + ) { return paid } guard let node else { @@ -1178,6 +1180,29 @@ class LightningService: NodeLifecycleService { /// Set once the node's own list has filled `ldkAttemptsKey`, as node payments made before it was kept carry no hash /// there. Until then an unlistable node refuses every Spark payment. static let ldkAttemptsSeededKey = "ldkAttemptedPaymentHashesSeeded" + /// When the wallet was restored, in seconds since 1970, until Spark lists its sends after a sync since. No backup + /// keeps `railAttemptsKey`, so until then an invoice made before the restore may be one Spark already paid. + static let sparkHistoryUnseededSinceKey = "sparkHistoryUnseededSince" + + /// Called as a wallet is restored, before Spark starts. + func markSparkHistoryUnseeded() { + let since = Int(now().timeIntervalSince1970) + eventCallbackLock.withLock { railAttemptsStore.set(since, forKey: Self.sparkHistoryUnseededSinceKey) } + } + + func clearSparkHistoryUnseeded() { + eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.sparkHistoryUnseededSinceKey) } + } + + var sparkHistoryUnseededSince: Int? { + eventCallbackLock.withLock { railAttemptsStore.object(forKey: Self.sparkHistoryUnseededSinceKey) as? Int } + } + + /// Reads Spark's sends after its sync, which ends the hold on invoices made before a restore. + func listRailSendsIfUnseeded() async { + guard sparkHistoryUnseededSince != nil else { return } + _ = try? await channelFreeRailPayments() + } /// `expiresAt` is the invoice's expiry in seconds since 1970, kept beside each key's hashes. func recordRailAttempt(paymentHash: String?, expiresAt: Int? = nil) { @@ -1386,8 +1411,13 @@ class LightningService: NodeLifecycleService { /// An invoice must never go out on both rails: an earlier attempt still pending on `rails` refuses the payment as a /// duplicate, as LDK does its own, and a settled one returns its hash as paid. A failed or unknown one lets the - /// payment go. While either rail can't list its payments, an invoice it was sent over is refused as pending too. - private func settledEarlierAttempt(paymentHash: String?, on rails: [LightningPaymentRail]) async throws -> PaymentHash? { + /// payment go. While either rail can't list its payments, an invoice it was sent over is refused as pending too, and + /// while Spark hasn't listed its sends since a restore, one created (`invoiceCreatedAt`) before it is refused. + private func settledEarlierAttempt( + paymentHash: String?, + on rails: [LightningPaymentRail], + invoiceCreatedAt: UInt64? = nil + ) async throws -> PaymentHash? { guard let paymentHash else { return nil } var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] if rails.contains(.ldk) { @@ -1438,6 +1468,10 @@ class LightningService: NodeLifecycleService { } else if hasRailAttempt(paymentHash: paymentHash) { Logger.warn("Refused paying '\(paymentHash)', Spark can't list the earlier attempt now", context: "LightningService") throw Self.pendingDuplicate(paymentHash: paymentHash) + } else if let invoiceCreatedAt, let since = sparkHistoryUnseededSince, Int(clamping: invoiceCreatedAt) < since { + // Nothing left the wallet, and an invoice made after the restore can't be one Spark paid before it. + Logger.warn("Refused paying '\(paymentHash)' until Spark lists its sends since the restore", context: "LightningService") + throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) } } if statuses.contains(.succeeded) { @@ -2148,6 +2182,9 @@ extension LightningService { let unsettled = await unsettledUncertainRailSends() let kept = unexpiredRailAttempts() let payments = try await rail.lightningPayments() + if let since = sparkHistoryUnseededSince, await rail.hasSynced(after: UInt64(clamping: since)) { + clearSparkHistoryUnseeded() + } let listed = Set((payments + unsettled).filter { $0.direction == .outbound }.map { $0.paymentHash.lowercased() }) let unlisted = kept.subtracting(listed).sorted() .map { ChannelFreeRailPayment(paymentHash: $0, direction: .outbound, status: .pending, preimage: nil) } diff --git a/Bitkit/Views/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 9d3b9a3f4..854f32bb2 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -278,6 +278,8 @@ struct RestoreWalletView: View { // it on the Get Started tap left a window where replayed txs could still pop a sheet. #588 SettingsViewModel.shared.pendingRestoreActivitySeenSince = UInt64(Date().timeIntervalSince1970) SettingsViewModel.shared.restoreSyncedBlockHeight = 0 + // No backup keeps which invoices Spark paid, so node sends of older invoices wait for Spark's list. + LightningService.shared.markSparkHistoryUnseeded() // When restoring a wallet, monitor all address types to catch any existing funds SettingsViewModel.shared.monitorAllAddressTypes() @@ -288,6 +290,7 @@ struct RestoreWalletView: View { // The node never started, so no sync will lift the hold. Left in place it would silence // every later on-chain receive, across retries and relaunches. #588 SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0 + LightningService.shared.clearSparkHistoryUnseeded() BackupService.shared.setRestoring(false) app.toast(error) } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 85ed3d367..6368c4c66 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -25,6 +25,7 @@ final class ChannelFreeRailTests: XCTestCase { service.railAttemptsStore = UserDefaults(suiteName: "ChannelFreeRailTests")! service.forgetRailAttempts() service.forgetLdkAttempts() + service.clearSparkHistoryUnseeded() previousRailSupported = service.channelFreeRailSupported service.channelFreeRailSupported = { true } previousNow = service.now @@ -34,6 +35,7 @@ final class ChannelFreeRailTests: XCTestCase { let service = Bitkit.LightningService.shared service.forgetRailAttempts() service.forgetLdkAttempts() + service.clearSparkHistoryUnseeded() service.railAttemptsStore = previousAttemptsStore service.channelFreeRailSupported = previousRailSupported service.now = previousNow @@ -1127,6 +1129,69 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } + // MARK: - Restore + + /// Marks the wallet restored `offset` seconds after the fixture invoice was created. + private func restore(secondsAfterTheInvoice offset: Int) throws { + let createdAt = try Int(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).secondsSinceEpoch()) + let service = Bitkit.LightningService.shared + service.now = { Date(timeIntervalSince1970: TimeInterval(createdAt + offset)) } + service.markSparkHistoryUnseeded() + service.now = previousNow + } + + /// Spark can't list its sends and has no balance to pay the invoice, so the send goes to the node. + private func leaveSparkUnsyncedAndEmpty() { + rail.syncedAfter = { _ in false } + rail.balanceSats = 0 + } + + private func assertRefusedUntilSparkLists(file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("A node send of an invoice made before the restore went out before Spark listed its sends", file: file, line: line) + } catch { + XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused, "\(error)", file: file, line: line) + } + XCTAssertNil(rail.sentBolt11, file: file, line: line) + } + + func testAnInvoiceMadeBeforeARestoreIsRefusedOverTheNodeUntilSparkSyncs() async throws { + try restore(secondsAfterTheInvoice: 60) + leaveSparkUnsyncedAndEmpty() + + await assertRefusedUntilSparkLists() + } + + func testAnInvoiceMadeAfterARestorePaysOverTheNodeWhileSparkIsUnsynced() async throws { + try restore(secondsAfterTheInvoice: -60) + leaveSparkUnsyncedAndEmpty() + + await assertReachesTheNode() + } + + func testAWalletNeverRestoredIsNeverHeldWhileSparkIsUnsynced() async { + leaveSparkUnsyncedAndEmpty() + + await assertReachesTheNode() + XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) + } + + func testSparksListingAfterASyncEndsTheRestoreHold() async throws { + try restore(secondsAfterTheInvoice: 60) + leaveSparkUnsyncedAndEmpty() + await assertRefusedUntilSparkLists() + + // What Spark's synced event does once its sync reached it. + rail.syncedAfter = { _ in true } + await Bitkit.LightningService.shared.listRailSendsIfUnseeded() + // A later outage no longer holds the invoice, as Spark's list showed no send of it. + rail.syncedAfter = { _ in false } + + XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) + await assertReachesTheNode() + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) From 238abe5795e5d5df4fed040e10b4e00560f006ab Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:39:59 +0100 Subject: [PATCH 304/378] fix: settle pending spark withdrawals and guard contact routing --- Bitkit/Managers/SparkManager.swift | 115 ++++++++++---- Bitkit/Services/PaykitRouteService.swift | 8 +- .../Wallets/Activity/ActivityItemView.swift | 2 +- .../Views/Wallets/Send/SendAmountView.swift | 26 +++- Bitkit/Views/Wallets/Send/SendSheet.swift | 8 +- BitkitTests/PaykitRouteTests.swift | 141 ++++++++++++++++++ BitkitTests/SparkWithdrawalTests.swift | 13 +- 7 files changed, 272 insertions(+), 41 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index d30b8582b..39d1dbf23 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -577,23 +577,25 @@ final class SparkManager: ChannelFreeLightningRail { /// Whether `txid` is a Spark withdrawal, which LDK never held and so can't boost: one this app sent, to Savings or a /// contact, or one Spark lists. - func isSparkWithdrawal(txid: String) async -> Bool { + func isSparkWithdrawal(txid: String, isSent: Bool) async -> Bool { let known = sentWithdrawalTxIds.union(Self.contactWithdrawalTxIds).union(withdrawals.map(\.txid)) - if Self.isSparkWithdrawal(txid: txid, known: known, payments: []) { + if Self.isSparkWithdrawal(txid: txid, isSent: isSent, known: known, payments: []) { return true } guard isReady else { return false } do { let payments = try await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments - return Self.isSparkWithdrawal(txid: txid, known: known, payments: payments) + return Self.isSparkWithdrawal(txid: txid, isSent: isSent, known: known, payments: payments) } catch { Logger.warn("Failed to list Spark payments for \(txid): \(error)", context: "SparkManager") return false } } - nonisolated static func isSparkWithdrawal(txid: String, known: Set, payments: [Payment]) -> Bool { - known.contains(txid) || payments.contains { payment in + nonisolated static func isSparkWithdrawal(txid: String, isSent: Bool, known: Set, payments: [Payment]) -> Bool { + // Only the sent side was Spark's; Savings owns the output a withdrawal to it pays, so its receipt can be boosted. + guard isSent else { return false } + return known.contains(txid) || payments.contains { payment in guard case let .withdraw(txId, _, _) = payment.details else { return false } return txId == txid } @@ -661,9 +663,12 @@ final class SparkManager: ChannelFreeLightningRail { let prepared: PrepareSendPaymentResponse let earlierPayments: [Payment] do { - guard Self.pendingContactWithdrawal == nil else { - throw AppError(message: "wallet__payment_failed_description", debugMessage: "A Spark withdrawal to a contact is still settling") - } + try claimContactWithdrawal() + } catch { + throw SparkWithdrawalNotSent(underlying: error) + } + defer { releaseContactWithdrawalClaim() } + do { sdk = try await requireSdk() prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats) if let maxFeeSats, case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod { @@ -688,8 +693,6 @@ final class SparkManager: ChannelFreeLightningRail { tags: tags, earlierPaymentIds: Self.withdrawalIds(to: address, amountSats: amountSats, in: earlierPayments) ) - isWithdrawingToContact = true - defer { isWithdrawingToContact = false } let journaled = pending try await Self.startJournaledWithdrawal( writeJournal: { @@ -709,20 +712,26 @@ final class SparkManager: ChannelFreeLightningRail { options: .bitcoinAddress(confirmationSpeed: .medium) )) } catch { - Logger.warn("Spark withdrawal to a contact failed or its outcome is unknown: \(error)", context: "SparkManager") - let failedAt = UInt64(Date().timeIntervalSince1970) - Task { - await Self.settleAfterFailedWithdrawal( - failedAt: failedAt, - syncWallet: { - _ = try await self.requireSdk().syncWallet(request: SyncWalletRequest()) - self.syncedThisRun = true - }, - hasSynced: { await self.hasSynced(after: $0) }, - settle: { await self.settleContactWithdrawal(allowClear: $0) } - ) - } - throw error + throw await Self.withdrawalSendFailure( + error, + closeUnsent: { + await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) + }, + settleLater: { + let failedAt = UInt64(Date().timeIntervalSince1970) + Task { + await Self.settleAfterFailedWithdrawal( + failedAt: failedAt, + syncWallet: { + _ = try await self.requireSdk().syncWallet(request: SyncWalletRequest()) + self.syncedThisRun = true + }, + hasSynced: { await self.hasSynced(after: $0) }, + settle: { await self.settleContactWithdrawal(allowClear: $0) } + ) + } + } + ) } Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") pending.paymentId = response.payment.id @@ -734,6 +743,19 @@ final class SparkManager: ChannelFreeLightningRail { return try Self.sentWithdrawalId(response.payment) } + /// Claims the one contact withdrawal slot before anything awaits, so a second send started meanwhile is refused + /// instead of overwriting the first one's journal. + func claimContactWithdrawal() throws { + guard Self.pendingContactWithdrawal == nil, !isWithdrawingToContact else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "A Spark withdrawal to a contact is still settling") + } + isWithdrawingToContact = true + } + + func releaseContactWithdrawalClaim() { + isWithdrawingToContact = false + } + /// What `withdraw` returns for the payment Spark sent: its txid, or its id while Spark gives none yet. A withdrawal /// Spark returned as failed paid nothing, so the call ends as a refusal: the caller releases the request's proof, /// private list and allowance attempt and shows a failure, never a success. @@ -748,6 +770,25 @@ final class SparkManager: ChannelFreeLightningRail { return txId } + /// What a failed `sendPayment` of a journaled withdrawal means. A refusal Spark makes before anything reaches the + /// operators, as on the Lightning rail, closes the journal at once; an outcome that may have sent it stays journaled + /// and settles from the sync after it. + static func withdrawalSendFailure( + _ error: Error, + closeUnsent: () async -> Void, + settleLater: () -> Void + ) async -> Error { + let failure = sendPaymentFailure(error) + if failure is ChannelFreeRailSendRefused { + Logger.warn("Spark refused a withdrawal to a contact before sending it: \(error)", context: "SparkManager") + await closeUnsent() + return SparkWithdrawalNotSent(underlying: failure) + } + Logger.warn("Spark withdrawal to a contact failed or its outcome is unknown: \(error)", context: "SparkManager") + settleLater() + return error + } + /// Settles the open contact withdrawal from Spark's payments: its own payment, once Spark returned it, or else a /// withdrawal to its address and amount that wasn't listed before it started. With `allowClear` (after a sync that /// succeeded), a list without one closes it as never sent and releases the request's proof, but only once the @@ -835,14 +876,29 @@ final class SparkManager: ChannelFreeLightningRail { } } + /// What an open withdrawal's journal does with the payment Spark returned or lists for it. Spark keeps a withdrawal + /// pending until the provider accepts its payout, and one it never accepts still fails, so only a completed one paid. + nonisolated static func settlement(of status: BreezSdkSpark.PaymentStatus) -> SparkWithdrawalSettlement { + switch status { + case .completed: .record + case .failed: .closeUnsent + case .pending: .wait + } + } + /// Writes the withdrawal's sent activity, tags and request proof, and closes the journal only once all of them are /// saved, so a failed or interrupted write is retried from the journal. private func recordContactWithdrawal(_ payment: Payment, txId: String, pending: SparkContactWithdrawal) async { // A settle that raced this one already saved everything; its request's proof and allowance may be a retry's now. guard Self.isOpenJournal(pending) else { return } - guard payment.status != .failed else { + switch Self.settlement(of: payment.status) { + case .wait: + return + case .closeUnsent: await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) return + case .record: + break } Self.contactWithdrawalTxIds.insert(txId) let isDurable = await Self.recordWithdrawalLocally( @@ -1572,6 +1628,15 @@ enum SparkErrorCopy { /// A Spark withdrawal to a contact's on-chain address, journaled before it is sent. /// A contact withdrawal refused before its journal was written: nothing was sent. +enum SparkWithdrawalSettlement: Equatable { + /// Paid: its records are saved and its journal closes. + case record + /// Never paid: its journal closes as unsent, releasing what it started. + case closeUnsent + /// Still in flight: its journal stays open until Spark settles it. + case wait +} + struct SparkWithdrawalNotSent: Error { let underlying: Error } diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index a847d5720..62e472d70 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -66,7 +66,8 @@ enum PaykitRouteService { return PaykitRouteUi(endpoints: endpoints, offersSparkLeg: offersSparkLeg) } - /// What the router is asked from `source`: Spending offers every endpoint, Savings only the on-chain ones. + /// What the router is asked from `source`: Spending offers every endpoint but an LNURL whose limits refuse the amount, + /// Savings only the on-chain ones. static func request( route: PaykitRouteUi, amountSats: UInt64, @@ -79,7 +80,7 @@ enum PaykitRouteService { case .spending: PaykitRouteRequest( amountSat: amountSats, - contactEndpoints: route.endpoints, + contactEndpoints: route.endpoints.filter { !$0.isLnurl || route.lnurl?.accepts(amountSats: amountSats) != false }, lightningSpendableSat: lightningSpendableSats, onchainSpendableSat: 0, serviceLegs: serviceLegs @@ -285,7 +286,8 @@ extension AppViewModel { (self.paykitRoute.map { $0.endpoints == route.endpoints } ?? true) } do { - let picked = try await router(route, amountSats, source, lightningSpendableSats, onchainSpendableSats) + // The known invoice and LNURL go to the router too, so it never picks an LNURL whose limits refuse the amount. + let picked = try await router(unrouted, amountSats, source, lightningSpendableSats, onchainSpendableSats) guard isCurrent() else { return .stale } switch picked { case let .direct(endpoint): diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 3d62f6849..821dd7d42 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -259,7 +259,7 @@ struct ActivityItemView: View { // Check if this is a CPFP child transaction if case let .onchain(activity) = viewModel.activity { isCpfpChild = await CoreService.shared.activity.isCpfpChildTransaction(txId: activity.txId, walletId: activity.walletId) - isSparkWithdrawal = await SparkManager.shared.isSparkWithdrawal(txid: activity.txId) + isSparkWithdrawal = await SparkManager.shared.isSparkWithdrawal(txid: activity.txId, isSent: activity.txType == .sent) } // Load boostTxIds doesExist status to determine RBF vs CPFP diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 3ffb8d65f..42dc012b3 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -624,14 +624,8 @@ struct SendAmountView: View { conversionError = nil app.paykitUsesUsdt = source == .usdt switch source { - case .usdt: - hwSend.selectWallet(nil) - case .spending: + case .usdt, .spending, .savings: hwSend.selectWallet(nil) - app.selectedWalletToPayFrom = .lightning - case .savings: - hwSend.selectWallet(nil) - app.selectedWalletToPayFrom = .onchain case let .hardware(walletId): let balance = hwWalletManager.fundingBalance(walletId: walletId) let reserve = HwFundingSigner.feeReserve( @@ -643,11 +637,27 @@ struct SendAmountView: View { initialAvailableSats: balance > reserve ? balance - reserve : 0, showsLoading: true ) - app.selectedWalletToPayFrom = .onchain } + Self.selectWallet(of: source, app: app) updateFixedLightningAmount() } + /// Points the send at the wallet `source` pays from. A hardware wallet skips the router on Continue, so a Spark leg + /// routed for Spending is dropped here, as Confirm's own switch does. + static func selectWallet(of source: SendFundingSource, app: AppViewModel) { + switch source { + case .usdt: + break + case .spending: + app.selectedWalletToPayFrom = .lightning + case .savings: + app.selectedWalletToPayFrom = .onchain + case .hardware: + app.paykitRoute = SendConfirmationView.route(app.paykitRoute, afterSelecting: source) + app.selectedWalletToPayFrom = .onchain + } + } + private func updateFixedLightningAmount() { guard app.contactPaymentContext?.incomingPaymentRequest == nil, let amount = app.fixedLightningAmountSats else { return } amountViewModel.updateFromSats(amount, currency: currency) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 074f79b77..0cafa3a9d 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -934,6 +934,12 @@ struct SendSheet: View { } } + /// Whether a request Savings and the hardware wallets can't cover is rejected before Confirm. One Spark can carry + /// reaches Confirm, which routes it from Spending, as a pure on-chain payment's check does. + static func rejectsUnfundedOnchainRequest(route: PaykitRouteUi?) -> Bool { + route?.offersSparkLeg != true + } + private func selectHardwareFundingSourceIfNeeded( amountSats: UInt64, requestId: PaykitPaymentRequest.ID @@ -1045,7 +1051,7 @@ struct SendSheet: View { // A failed estimate is not proof of insufficient funds. Keep the sheet open so the normal // confirmation path can retry instead of rejecting a payable request. - guard !hasUnavailableSource else { return true } + guard !hasUnavailableSource, Self.rejectsUnfundedOnchainRequest(route: app.paykitRoute) else { return true } let maximumAvailable = max(savingsAvailable ?? 0, hardwareSources.map(\.available).max() ?? 0) _ = validateOnchainBalanceAndDismissIfInsufficient( invoiceAmount: amountSats, diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index e46c6b370..1462a292b 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -455,6 +455,147 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(steps, ["journal", "proof started", "closed"], "The refusal closes the journal, releasing the started proof") } + func testAPendingWithdrawalStaysOpenUntilSparkAcceptsItsPayout() { + XCTAssertEqual( + SparkManager.settlement(of: .pending), + .wait, + "Spark keeps a withdrawal pending until the provider accepts its payout, and it can still fail" + ) + XCTAssertEqual(SparkManager.settlement(of: .completed), .record) + XCTAssertEqual(SparkManager.settlement(of: .failed), .closeUnsent) + } + + @MainActor + func testADefiniteSparkRefusalClosesTheWithdrawalAtOnce() async { + var steps: [String] = [] + let refused = await SparkManager.withdrawalSendFailure( + SdkError.InsufficientFunds(tokenIdentifier: nil), + closeUnsent: { steps.append("closed") }, + settleLater: { steps.append("settle later") } + ) + XCTAssertTrue(refused is SparkWithdrawalNotSent, "Spark picks leaves before anything reaches the operators") + XCTAssertEqual(steps, ["closed"], "A refusal releases the journal at once instead of after the expiry") + + steps = [] + let unknown = await SparkManager.withdrawalSendFailure( + SdkError.SparkError("operators"), + closeUnsent: { steps.append("closed") }, + settleLater: { steps.append("settle later") } + ) + XCTAssertFalse(unknown is SparkWithdrawalNotSent) + XCTAssertEqual(steps, ["settle later"], "An outcome that may have sent it settles from the sync after it") + } + + @MainActor + func testASecondContactWithdrawalIsRefusedWhileTheFirstPrepares() throws { + snapshotAppDefaultsDomain() + SparkManager.pendingContactWithdrawal = nil + let spark = SparkManager.shared + try spark.claimContactWithdrawal() + defer { spark.releaseContactWithdrawalClaim() } + + XCTAssertThrowsError(try spark.claimContactWithdrawal(), "A second send must never overwrite the first one's journal") + spark.releaseContactWithdrawalClaim() + XCTAssertNoThrow(try spark.claimContactWithdrawal()) + } + + func testAnUnfundedRequestReachesConfirmWhileSparkCanCarryIt() throws { + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] + ) + let offered = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true, canPayDirectly: false)) + let direct = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false, canPayDirectly: false)) + + XCTAssertFalse(SendSheet.rejectsUnfundedOnchainRequest(route: offered), "Confirm routes it from Spending through Spark") + XCTAssertTrue(SendSheet.rejectsUnfundedOnchainRequest(route: direct)) + XCTAssertTrue(SendSheet.rejectsUnfundedOnchainRequest(route: nil)) + } + + @MainActor + func testAHardwareWalletPickOnAmountDropsTheSparkLeg() { + snapshotAppDefaultsDomain() + let app = AppViewModel() + var routed = PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true) + routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) + app.paykitRoute = routed + app.selectedWalletToPayFrom = .lightning + + SendAmountView.selectWallet(of: .hardware(walletId: "trezor"), app: app) + + XCTAssertEqual(app.selectedWalletToPayFrom, .onchain) + XCTAssertNil(app.paykitRoute?.serviceLeg, "Confirm must never describe the Spark leg for a hardware wallet's send") + XCTAssertEqual(app.paykitRoute?.endpoints, routed.endpoints) + + app.paykitRoute = routed + SendAmountView.selectWallet(of: .savings, app: app) + XCTAssertEqual(app.paykitRoute, routed, "Savings asks the router again on Continue") + } + + func testAnLnurlOutsideItsLimitsLeavesTheSparkLegToTheRouter() { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true, lnurl: lnurlData(minSendableMsats: 5_000_000)) + let leg = sparkLeg(amountSats: 1000, feeSats: 2190) + + let below = PaykitRouteService.request( + route: route, + amountSats: 1000, + source: .spending, + lightningSpendableSats: 4000, + onchainSpendableSats: 0, + serviceLegs: [leg] + ) + XCTAssertEqual(below.contactEndpoints, [onchain], "An LNURL that refuses the amount must never win over the Spark leg") + + let within = PaykitRouteService.request( + route: route, + amountSats: 6000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + serviceLegs: [] + ) + XCTAssertEqual(within.contactEndpoints, [lightning, onchain]) + } + + @MainActor + func testTheRouterIsAskedWithTheLnurlItsLimitsComeFrom() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.lnurlPayData = lnurlData(minSendableMsats: 5_000_000) + app.selectedWalletToPayFrom = .lightning + var asked: PaykitRouteUi? + + _ = await app.routeContactPayment( + route, + amountSats: 1000, + source: .spending, + lightningSpendableSats: 4000, + onchainSpendableSats: 0, + request: nil + ) { route, _, _, _, _ in + asked = route + return .service(leg: self.sparkLeg(amountSats: 1000, feeSats: 2190)) + } + + XCTAssertEqual(asked?.lnurl, app.lnurlPayData, "The router must see the LNURL's limits") + } + + private func lnurlData(minSendableMsats: UInt64) -> LnurlPayData { + LnurlPayData( + uri: lightning.value, + callback: "https://example.com/pay", + minSendable: minSendableMsats, + maxSendable: 100_000_000, + metadataStr: "[]", + commentAllowed: nil, + allowsNostr: false, + nostrPubkey: nil + ) + } + @MainActor func testARouteThatReturnsAfterItsPaymentChangedNeverLandsOnTheNextOne() async { snapshotAppDefaultsDomain() diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index 166ab522e..4091cee0c 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -308,12 +308,19 @@ final class SparkWithdrawalTests: XCTestCase { conversionDetails: nil ) - XCTAssertTrue(SparkManager.isSparkWithdrawal(txid: "contact", known: ["contact"], payments: [])) + XCTAssertTrue(SparkManager.isSparkWithdrawal(txid: "contact", isSent: true, known: ["contact"], payments: [])) XCTAssertTrue( - SparkManager.isSparkWithdrawal(txid: "listed", known: [], payments: [pending]), + SparkManager.isSparkWithdrawal(txid: "listed", isSent: true, known: [], payments: [pending]), "a pending withdrawal Spark lists can't be boosted" ) - XCTAssertFalse(SparkManager.isSparkWithdrawal(txid: "savings", known: ["contact"], payments: [pending])) + XCTAssertFalse(SparkManager.isSparkWithdrawal(txid: "savings", isSent: true, known: ["contact"], payments: [pending])) + } + + func testASparkWithdrawalsSavingsReceiptCanStillBeBoosted() { + XCTAssertFalse( + SparkManager.isSparkWithdrawal(txid: "transfer", isSent: false, known: ["transfer"], payments: []), + "Savings owns the output a Spark withdrawal pays it, so its receipt keeps CPFP" + ) } @MainActor From 412ddd736e317cf4a908451acd03f571fcb7e718 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:39:59 +0100 Subject: [PATCH 305/378] fix: offer the spark leg when lightning pays the lnurl minimum --- Bitkit/Services/PaykitRouteService.swift | 12 +-- Bitkit/ViewModels/AppViewModel.swift | 20 ++--- .../Views/Wallets/Send/SendAmountView.swift | 2 +- .../Wallets/Send/SendConfirmationView.swift | 2 +- BitkitTests/PaykitRouteTests.swift | 77 ++++++++++++++++--- 5 files changed, 79 insertions(+), 34 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 62e472d70..b3072f704 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -51,18 +51,14 @@ enum PaykitRouteService { } /// The route for a contact payment, or nil without bitcoin endpoints. A Spark leg is offered to a contact with an - /// on-chain endpoint while Spark can route and the payment can't go directly over Lightning; a request it pays gets - /// its on-chain proof from the withdrawal's txid. - static func routeUi( - for context: ContactPaymentContext, - canRouteThroughSpark: Bool, - canPayDirectly: Bool - ) -> PaykitRouteUi? { + /// on-chain endpoint while Spark can route, whatever Lightning can pay, since the router still picks a direct payment + /// that works; a request it pays gets its on-chain proof from the withdrawal's txid. + static func routeUi(for context: ContactPaymentContext, canRouteThroughSpark: Bool) -> PaykitRouteUi? { let endpoints = context.endpoints .filter { $0.methodId != .usdtArbitrum } .map { PaykitEndpoint(methodId: $0.methodId.rawValue, value: $0.value) } guard !endpoints.isEmpty else { return nil } - let offersSparkLeg = canRouteThroughSpark && !canPayDirectly && endpoints.contains(where: \.isOnchain) + let offersSparkLeg = canRouteThroughSpark && endpoints.contains(where: \.isOnchain) return PaykitRouteUi(endpoints: endpoints, offersSparkLeg: offersSparkLeg) } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index b86db9063..1ba6248ba 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -117,6 +117,7 @@ struct ScanPaymentOperations { let state: () -> ScanPaymentState let canSendLightning: (_ amountSats: UInt64) -> Bool var decodeInvoice: (String) async throws -> BitkitCore.Scanner = { try await decode(invoice: $0) } + var canRouteThroughSpark: @MainActor () -> Bool = { PaykitRouteService.canRouteThroughSpark } @MainActor static func live(lightningService: LightningService) -> ScanPaymentOperations { @@ -842,13 +843,11 @@ extension AppViewModel { isUsdtEnabled: SettingsViewModel.shared.isUsdtEnabled ) || context.incomingPaymentRequest.map { PaykitUsdtPaymentService.shared.hasBinding(for: $0.id) } == true selectedWalletToPayFrom = canUseLightning || (hasLightning && scannedOnchainInvoice == nil) ? .lightning : .onchain - // A Spark leg can pay the on-chain endpoint from Spending here too, when Lightning can't pay directly. - if !canUseLightning, scannedOnchainInvoice != nil, - let route = PaykitRouteService.routeUi( - for: context, - canRouteThroughSpark: PaykitRouteService.canRouteThroughSpark, - canPayDirectly: false - ), route.offersSparkLeg + // A Spark leg can pay the on-chain endpoint from Spending here too, whatever Lightning can pay now: an amount + // entered later can pass Lightning's limit, and the router still picks a direct payment that works. + if scannedOnchainInvoice != nil, + let route = PaykitRouteService.routeUi(for: context, canRouteThroughSpark: scanPaymentOperations.canRouteThroughSpark()), + route.offersSparkLeg { paykitRoute = route // A request keeps its source here; its Confirm asks the router. @@ -1075,11 +1074,8 @@ extension AppViewModel { // An on-chain contact payment opens with Spending as a source while Spark can carry it there, whether or // not Savings covers it; Spending is the default when Savings holds no more than dust. if let context = contactPaymentContext, scope != .onchainPayments, - let route = PaykitRouteService.routeUi( - for: context, - canRouteThroughSpark: PaykitRouteService.canRouteThroughSpark, - canPayDirectly: false - ), route.offersSparkLeg + let route = PaykitRouteService.routeUi(for: context, canRouteThroughSpark: scanPaymentOperations.canRouteThroughSpark()), + route.offersSparkLeg { handleScannedOnchainInvoice(invoice) paykitRoute = route diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 42dc012b3..5bf522324 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -415,7 +415,7 @@ struct SendAmountView: View { } if !app.paykitUsesUsdt, let context = app.contactPaymentContext, - let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false, canPayDirectly: true) + let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: PaykitRouteService.canRouteThroughSpark) { switch await routePaykitPayment(route, request: context.incomingPaymentRequest) { case .leg: diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 19532db2f..1ffe8834e 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1443,7 +1443,7 @@ struct SendConfirmationView: View { if !sourceChanged { guard let request, routedIncomingRequestId != request.id else { return } } - guard let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false, canPayDirectly: true) + guard let route = app.paykitRoute ?? PaykitRouteService.routeUi(for: context, canRouteThroughSpark: PaykitRouteService.canRouteThroughSpark) else { return } let amountSats: UInt64 if let request { diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 1462a292b..7b1b3d735 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -114,7 +114,7 @@ final class PaykitRouteTests: XCTestCase { // MARK: - Spark leg offer - func testSparkLegIsOfferedForAnOnchainContactLightningCantPay() { + func testSparkLegIsOfferedForAnOnchainContactWhileSparkCanRoute() { let onchainOnly = ContactPaymentContext( publicKey: "contact", privatePaymentContext: nil, @@ -122,21 +122,17 @@ final class PaykitRouteTests: XCTestCase { ) XCTAssertEqual( - PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: true, canPayDirectly: false)?.offersSparkLeg, + PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: true)?.offersSparkLeg, true ) XCTAssertEqual( - PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: false, canPayDirectly: false)?.offersSparkLeg, - false - ) - XCTAssertEqual( - PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: true, canPayDirectly: true)?.offersSparkLeg, + PaykitRouteService.routeUi(for: onchainOnly, canRouteThroughSpark: false)?.offersSparkLeg, false ) let usdtOnly = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: [ Self.endpoint(.usdtArbitrum, "0x0000000000000000000000000000000000000001"), ]) - XCTAssertNil(PaykitRouteService.routeUi(for: usdtOnly, canRouteThroughSpark: true, canPayDirectly: false)) + XCTAssertNil(PaykitRouteService.routeUi(for: usdtOnly, canRouteThroughSpark: true)) } func testASparkWithdrawalIsWeighedAgainstSparksBalanceNotTheLightningLimit() { @@ -505,8 +501,8 @@ final class PaykitRouteTests: XCTestCase { privatePaymentContext: nil, endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] ) - let offered = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true, canPayDirectly: false)) - let direct = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false, canPayDirectly: false)) + let offered = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true)) + let direct = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: false)) XCTAssertFalse(SendSheet.rejectsUnfundedOnchainRequest(route: offered), "Confirm routes it from Spending through Spark") XCTAssertTrue(SendSheet.rejectsUnfundedOnchainRequest(route: direct)) @@ -583,6 +579,63 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(asked?.lnurl, app.lnurlPayData, "The router must see the LNURL's limits") } + @MainActor + func testAContactWhoseLnurlMinimumLightningPaysStillOffersSparkPastTheLightningLimit() async throws { + snapshotAppDefaultsDomain() + let lnurl = LnurlPayData( + uri: lightning.value, + callback: "https://example.com/pay", + minSendable: 1_000_000, + maxSendable: 2_000_000_000, + metadataStr: "[]", + commentAllowed: nil, + allowsNostr: false, + nostrPubkey: nil + ) + let address = onchain.value + let app = AppViewModel( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 0, + totalLightningBalanceSats: 997_506, + hasChannels: false, + hasUsableChannels: false + ) + }, + canSendLightning: { $0 <= 997_506 }, + decodeInvoice: { value in + value == address + ? .onChain(invoice: OnChainInvoice(address: address, amountSatoshis: 0, label: nil, message: nil, params: nil)) + : .lnurlPay(data: lnurl) + }, + canRouteThroughSpark: { true } + ) + ) + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [ + Self.endpoint(.bitcoinLightningLnurl, lightning.value), + Self.endpoint(.regtestOnchainP2wpkh, address), + Self.endpoint(.usdtArbitrum, "0x1111111111111111111111111111111111111111"), + ] + ) + XCTAssertTrue(app.claimContactPaymentContext(context)) + + try await app.handleScannedData(lightning.value, claimedContactPaymentContext: context) + + XCTAssertEqual(app.lnurlPayData, lnurl) + XCTAssertEqual( + app.paykitRoute?.offersSparkLeg, + true, + "Lightning pays the LNURL's minimum, but a 999,000-sat payment past its 997,506 limit still needs Spark" + ) + } + private func lnurlData(minSendableMsats: UInt64) -> LnurlPayData { LnurlPayData( uri: lightning.value, @@ -930,7 +983,7 @@ final class PaykitRouteTests: XCTestCase { privatePaymentContext: nil, endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] ) - let offered = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true, canPayDirectly: false)) + let offered = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true)) var routed = offered routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) @@ -948,7 +1001,7 @@ final class PaykitRouteTests: XCTestCase { privatePaymentContext: nil, endpoints: [Self.endpoint(.regtestOnchainP2wpkh, onchain.value)] ) - var routed = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true, canPayDirectly: false)) + var routed = try XCTUnwrap(PaykitRouteService.routeUi(for: context, canRouteThroughSpark: true)) routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) XCTAssertEqual( From 9f7afa9921b76a73b86a35a063fcc4258bd31172 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:58:01 +0100 Subject: [PATCH 306/378] fix: skip the spark quote when lightning pays a contact --- Bitkit/Services/PaykitRouteService.swift | 42 ++++++++++++++---------- BitkitTests/PaykitRouteTests.swift | 23 +++++++++++++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index b3072f704..6bdc4a16d 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -92,29 +92,33 @@ enum PaykitRouteService { } } - /// Asks the router how to pay `amountSats` from `source`, quoting a Spark leg to each on-chain endpoint first when - /// the route offers one. + /// Asks the router how to pay `amountSats` from `source`. The router pays directly before it weighs any leg, so a + /// Spark leg to each on-chain endpoint is quoted only when the route offers one and no balance pays. static func route( _ route: PaykitRouteUi, amountSats: UInt64, source: Source, lightningSpendableSats: UInt64, - onchainSpendableSats: UInt64 + onchainSpendableSats: UInt64, + quoteLegs: ([PaykitEndpoint], UInt64) async -> [ServiceRouteLeg] = { await sparkLegs(to: $0, amountSats: $1) } ) async throws -> PaykitRoute { - let serviceLegs = source == .spending && route.offersSparkLeg - ? await sparkLegs(to: route.onchainEndpoints, amountSats: amountSats) - : [] - return try paykitRoutePayment( - request: request( - route: route, - amountSats: amountSats, - source: source, - lightningSpendableSats: lightningSpendableSats, - onchainSpendableSats: onchainSpendableSats, - serviceLegs: serviceLegs - ), - network: Env.bitkitCoreNetwork - ) + func pick(_ serviceLegs: [ServiceRouteLeg]) throws -> PaykitRoute { + try paykitRoutePayment( + request: request( + route: route, + amountSats: amountSats, + source: source, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats, + serviceLegs: serviceLegs + ), + network: Env.bitkitCoreNetwork + ) + } + let direct = try pick([]) + guard case .none = direct, source == .spending, route.offersSparkLeg else { return direct } + let serviceLegs = await quoteLegs(route.onchainEndpoints, amountSats) + return serviceLegs.isEmpty ? direct : try pick(serviceLegs) } private static func sparkLegs(to endpoints: [PaykitEndpoint], amountSats: UInt64) async -> [ServiceRouteLeg] { @@ -265,7 +269,9 @@ extension AppViewModel { lightningSpendableSats: UInt64, onchainSpendableSats: UInt64, request: PaykitPaymentRequest?, - router: (PaykitRouteUi, UInt64, PaykitRouteService.Source, UInt64, UInt64) async throws -> PaykitRoute = PaykitRouteService.route + router: (PaykitRouteUi, UInt64, PaykitRouteService.Source, UInt64, UInt64) async throws -> PaykitRoute = { + try await PaykitRouteService.route($0, amountSats: $1, source: $2, lightningSpendableSats: $3, onchainSpendableSats: $4) + } ) async -> PaykitRouteOutcome { var unrouted = route unrouted.serviceLeg = nil diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 7b1b3d735..846c1fcf2 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -636,6 +636,29 @@ final class PaykitRouteTests: XCTestCase { ) } + func testALightningPayableContactRoutesDirectWithoutASparkQuote() async throws { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + var quotes = 0 + + let direct = try await PaykitRouteService.route( + route, amountSats: 2000, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 0 + ) { _, _ in quotes += 1; return [] } + XCTAssertEqual(direct, .direct(endpoint: lightning)) + XCTAssertEqual(quotes, 0, "Spark's quote is never fetched for a payment Lightning makes") + + let leg = sparkLeg(amountSats: 60000, feeSats: 300) + let routed = try await PaykitRouteService.route( + route, amountSats: 60000, source: .spending, lightningSpendableSats: 50000, onchainSpendableSats: 0 + ) { endpoints, amountSats in + quotes += 1 + XCTAssertEqual(endpoints, [self.onchain]) + XCTAssertEqual(amountSats, 60000) + return [leg] + } + XCTAssertEqual(routed, .service(leg: leg), "An amount past Lightning's limit still takes the quoted Spark leg") + XCTAssertEqual(quotes, 1) + } + private func lnurlData(minSendableMsats: UInt64) -> LnurlPayData { LnurlPayData( uri: lightning.value, From 8f66a5dcc56fb8bb1674979be935af42f595ca0a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:58:02 +0100 Subject: [PATCH 307/378] fix: name the lnurl limit when no route pays a contact --- Bitkit/Services/PaykitRouteService.swift | 16 ++++++++++++--- BitkitTests/PaykitRouteTests.swift | 26 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 6bdc4a16d..b1b2e5986 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -308,11 +308,15 @@ extension AppViewModel { case let .none(reason): Logger.warn("Found no Paykit route: '\(reason)'", context: "PaykitRoute") paykitRoute = unrouted - showNoPaykitRouteToast(isBalanceShort: PaykitRouteService.isBalanceShort( + let isBalanceShort = PaykitRouteService.isBalanceShort( amountSats: amountSats, lightningSpendableSats: lightningSpendableSats, onchainSpendableSats: onchainSpendableSats - )) + ) + // The router never saw an LNURL whose limits refuse the amount, so its limit says why. + if isBalanceShort || unrouted.lnurl.map({ validateLnurlAmount($0, amountSats: amountSats) }) != false { + showNoPaykitRouteToast(isBalanceShort: isBalanceShort) + } return .refused } } catch { @@ -371,7 +375,13 @@ extension AppViewModel { /// The route can restore an LNURL a BOLT11 pick cleared, so Amount checks its limits after routing. func validateRoutedLnurlAmount(amountSats: UInt64) -> Bool { - guard let lnurlPayData, !lnurlPayData.accepts(amountSats: amountSats) else { return true } + guard let lnurlPayData else { return true } + return validateLnurlAmount(lnurlPayData, amountSats: amountSats) + } + + /// Shows `lnurl`'s too-low or too-high toast when `amountSats` is outside its limits; true when it accepts it. + func validateLnurlAmount(_ lnurlPayData: LnurlPayData, amountSats: UInt64) -> Bool { + guard !lnurlPayData.accepts(amountSats: amountSats) else { return true } let minimumAmount = max(1, lnurlPayData.minSendableSat) if amountSats < minimumAmount { toast( diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 846c1fcf2..80eb21a5e 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -659,6 +659,32 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(quotes, 1) } + @MainActor + func testANoneRouteNamesTheLnurlLimitTheAmountMissed() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + defer { ToastWindowManager.shared.hideToast() } + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.lnurlPayData = lnurlData(minSendableMsats: 1_000_000) + app.selectedWalletToPayFrom = .lightning + + let outcome = await app.routeContactPayment( + PaykitRouteUi(endpoints: [lightning], offersSparkLeg: false), + amountSats: 500, + source: .spending, + lightningSpendableSats: 50000, + onchainSpendableSats: 0, + request: nil + ) + + XCTAssertEqual(outcome, .refused) + XCTAssertEqual( + ToastWindowManager.shared.currentToast?.accessibilityIdentifier, + "LnurlPayAmountTooLowToast", + "A contact with a working LNURL is payable, only not at this amount" + ) + } + private func lnurlData(minSendableMsats: UInt64) -> LnurlPayData { LnurlPayData( uri: lightning.value, From a2d84a7585b67719cd6610bd4c3c6e616d487a4d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:58:03 +0100 Subject: [PATCH 308/378] fix: hold a pending spark withdrawal until spark completes it --- Bitkit/Managers/SparkManager.swift | 6 +++++- BitkitTests/PaykitRouteTests.swift | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 39d1dbf23..0f5eea0ab 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -758,7 +758,8 @@ final class SparkManager: ChannelFreeLightningRail { /// What `withdraw` returns for the payment Spark sent: its txid, or its id while Spark gives none yet. A withdrawal /// Spark returned as failed paid nothing, so the call ends as a refusal: the caller releases the request's proof, - /// private list and allowance attempt and shows a failure, never a success. + /// private list and allowance attempt and shows a failure, never a success. One still pending can fail until the + /// provider accepts its payout, so the caller holds it as unknown, on the pending screen, until its journal settles. nonisolated static func sentWithdrawalId(_ payment: Payment) throws -> String { guard payment.status != .failed else { throw SparkWithdrawalNotSent(underlying: AppError( @@ -766,6 +767,9 @@ final class SparkManager: ChannelFreeLightningRail { debugMessage: "Spark returned withdrawal '\(payment.id)' as failed" )) } + guard payment.status == .completed else { + throw AppError(message: "wallet__payment_failed_description", debugMessage: "Spark withdrawal '\(payment.id)' is still pending") + } guard case let .withdraw(txId, _, _) = payment.details else { return payment.id } return txId } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 80eb21a5e..84e159523 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -685,6 +685,24 @@ final class PaykitRouteTests: XCTestCase { ) } + @MainActor + func testAPendingWithdrawalSendIsHeldUntilItsJournalSettles() { + var pending = Self.withdrawal(id: "sent", to: onchain.value, amountSats: 20000, timestamp: 1001) + pending.status = .pending + + XCTAssertThrowsError(try SparkManager.sentWithdrawalId(pending), "A pending withdrawal can still fail, so it never shows success") { + XCTAssertFalse($0 is SparkWithdrawalNotSent, "It may still pay, so its proof and allowance attempt stay") + XCTAssertEqual( + SendConfirmationView.serviceLegFailureRoute( + $0, + attempt: SparkWithdrawalAttempt(address: self.onchain.value, amountSats: 20000, requestId: nil), + routingCacheResetAttempted: false + ), + .sparkWithdrawalPending(SparkWithdrawalAttempt(address: self.onchain.value, amountSats: 20000, requestId: nil)) + ) + } + } + private func lnurlData(minSendableMsats: UInt64) -> LnurlPayData { LnurlPayData( uri: lightning.value, From b2614727fbe01b18f89b3c483108a1a36069bd27 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 14:07:22 +0100 Subject: [PATCH 309/378] fix: route a mixed bitcoin contact through spark when no endpoint pays --- .../Utilities/PaymentNavigationHelper.swift | 6 +-- Bitkit/ViewModels/AppViewModel.swift | 17 ++++++- Bitkit/Views/Wallets/Send/SendSheet.swift | 4 +- BitkitTests/PaykitRouteTests.swift | 49 +++++++++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index cf5fcab78..b7ca2c3c2 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -50,7 +50,7 @@ struct PaymentNavigationHelper { sheetViewModel.showSheet(.send, data: SendConfig(view: route)) return } - if app.contactPaymentContext?.requiresAssetSelection == true { + if app.contactPaymentPicksSource { sheetViewModel.showSheet(.send, data: SendConfig(view: .amount)) return } @@ -117,7 +117,7 @@ struct PaymentNavigationHelper { spendStore: QuickPaySpendStore = .shared, coordinator: QuickPayPaymentCoordinator? = nil ) -> SendRoute? { - if app.contactPaymentContext?.requiresAssetSelection == true, app.paykitPaymentTerms == nil { + if app.contactPaymentPicksSource, app.paykitPaymentTerms == nil { return .amount } if let lnurlWithdrawData = app.lnurlWithdrawData { @@ -218,7 +218,7 @@ struct PaymentNavigationHelper { spendStore: QuickPaySpendStore = .shared, coordinator: QuickPayPaymentCoordinator? = nil ) -> SendRoute? { - if app.contactPaymentContext?.requiresAssetSelection == true { + if app.contactPaymentPicksSource { if app.paykitPaymentTerms == nil { return .amount } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1ba6248ba..6a1fae05a 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -37,6 +37,12 @@ struct ContactPaymentContext: Equatable { incomingPaymentRequest.map { $0.amount.asset != .btc || $0.pricing.conversion != nil } == true } + /// An on-chain and a Lightning endpoint, between which the router picks, Spark's leg to the on-chain one included. + var hasMixedBitcoinEndpoints: Bool { + endpoints.contains { $0.methodId.onchainNetwork != nil } && + endpoints.contains { $0.methodId == .bitcoinLightningLnurl || $0.methodId == .bitcoinLightningBolt11 } + } + func initialUsdtSubscriptionPayment(at date: Date = Date()) -> PaykitRequestPricing.Payment? { guard isInitialSubscriptionPayment, let request = incomingPaymentRequest, request.billingPeriod != nil, Set(request.acceptedPaymentEndpointIdentifiers) == [PublicPaykitService.MethodId.usdtArbitrum.rawValue], @@ -769,6 +775,13 @@ extension AppViewModel { /// The method a send prices with. Spending with no Lightning payment to make funds the Spark leg to the on-chain /// endpoint, so it takes that endpoint's price, the one the leg and its proof pay. + /// Amount picks the source and the asset: for a contact that lists USDT or is priced in another asset, and for a mixed + /// Bitcoin contact while Spark can carry a leg, so an amount no Lightning endpoint takes still reaches the router. + var contactPaymentPicksSource: Bool { + guard let context = contactPaymentContext else { return false } + return context.requiresAssetSelection || (context.hasMixedBitcoinEndpoints && paykitRoute?.offersSparkLeg == true) + } + func paykitBitcoinMethod(for wallet: WalletType) -> PublicPaykitService.MethodId { if wallet == .lightning { // A routed Spark leg pays its on-chain endpoint, whatever Lightning endpoint the contact also has. @@ -817,7 +830,9 @@ extension AppViewModel { } } - if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments { + if let context = claimedContactPaymentContext, scope != .onchainPayments, + context.requiresAssetSelection || context.hasMixedBitcoinEndpoints && scanPaymentOperations.canRouteThroughSpark() + { resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) expectedGeneration = sendStateGeneration for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 0cafa3a9d..04a8de7f0 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -300,7 +300,7 @@ struct SendSheet: View { /// For onchain: only need node running. private var shouldShowSyncOverlay: Bool { if isPreparingRequest || hwSend.isActive || flow.isResumingAcceptedOrdinarySend || app.paykitUsesUsdt || - (app.contactPaymentContext?.requiresAssetSelection == true && currentRoot == .amount && flow.navigationPath.isEmpty) + (app.contactPaymentPicksSource && currentRoot == .amount && flow.navigationPath.isEmpty) { return false } @@ -761,7 +761,7 @@ struct SendSheet: View { return .ready } if app - .paykitUsesUsdt || (app.contactPaymentContext?.requiresAssetSelection == true && currentRoot == .amount && flow.navigationPath.isEmpty) + .paykitUsesUsdt || (app.contactPaymentPicksSource && currentRoot == .amount && flow.navigationPath.isEmpty) { flow.hasValidatedAfterSync = true return .ready diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 84e159523..8edae0e7d 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -703,6 +703,55 @@ final class PaykitRouteTests: XCTestCase { } } + @MainActor + func testAMixedBitcoinContactWithoutUsdtOpensOnAmountWithTheSparkLeg() async throws { + snapshotAppDefaultsDomain() + let lnurl = lnurlData(minSendableMsats: 5_000_000) + let address = onchain.value + let sheets = SheetViewModel() + let app = AppViewModel( + sheetViewModel: sheets, + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 0, + totalLightningBalanceSats: 10000, + hasChannels: false, + hasUsableChannels: false + ) + }, + canSendLightning: { $0 <= 10000 }, + decodeInvoice: { value in + value == address + ? .onChain(invoice: OnChainInvoice(address: address, amountSatoshis: 0, label: nil, message: nil, params: nil)) + : .lnurlPay(data: lnurl) + }, + canRouteThroughSpark: { true } + ) + ) + let context = ContactPaymentContext( + publicKey: "contact", + privatePaymentContext: nil, + endpoints: [Self.endpoint(.bitcoinLightningLnurl, lightning.value), Self.endpoint(.regtestOnchainP2wpkh, address)] + ) + XCTAssertFalse(context.requiresAssetSelection) + XCTAssertTrue(app.claimContactPaymentContext(context)) + + try await app.handleScannedData(lightning.value, claimedContactPaymentContext: context) + PaymentNavigationHelper.openPaymentSheet( + app: app, + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, + sheetViewModel: sheets + ) + + XCTAssertEqual(app.paykitRoute?.offersSparkLeg, true, "2,000 sats below the LNURL's 5,000 minimum can still go through Spark") + XCTAssertEqual(app.scannedOnchainInvoice?.address, address) + XCTAssertEqual(sheets.sendSheetItem?.initialRoute, .amount, "Amount picks the source, so the amount reaches the router") + } + private func lnurlData(minSendableMsats: UInt64) -> LnurlPayData { LnurlPayData( uri: lightning.value, From f8c896b9b735acb2db829dca65febe5d61c784ab Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 14:27:27 +0100 Subject: [PATCH 310/378] fix: refresh wallet totals when the exit's amounts change --- Bitkit/Managers/SparkExitManager.swift | 49 ++++++++++++++++++++++++-- BitkitTests/SparkExitReviewTests.swift | 32 +++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 05e1e3631..489dccb9b 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -25,13 +25,19 @@ final class SparkExitManager { /// How long a foreground tick waits for Spark to connect again before it goes on without the SDK. private static let reconnectTimeoutSeconds: TimeInterval = 20 - private(set) var record: SparkExitRecord? + private(set) var record: SparkExitRecord? { + didSet { noteBalanceInputs() } + } + private(set) var quote: SparkExitQuote? private(set) var quoteError: String? private(set) var isQuoting = false private(set) var tipHeight: UInt32? /// Confirmed or not, what reached the funding address and is not a funding input yet. - private(set) var fundingSeenSats: UInt64 = 0 + private(set) var fundingSeenSats: UInt64 = 0 { + didSet { noteBalanceInputs() } + } + /// How many payments make up `fundingSeenSats` on the pre-start funding screen. private(set) var fundingSeenCount = 0 /// The network's rates now, read on every tick: the SDK's recommendation, or the chain host's without the SDK. @@ -80,11 +86,18 @@ final class SparkExitManager { @ObservationIgnored private var pendingFeeRate: UInt64? @ObservationIgnored private(set) var pendingFundingIndex: UInt32? + /// Recomputes the wallet's totals, through Spark's state hook, when what they read from the exit changed. + @ObservationIgnored private let balanceWatch = SparkExitBalanceWatch { SparkManager.shared.onStateChanged?() } + /// The app uses `shared`; tests make their own, on a wallet index no wallet uses. init(walletIndex: Int = 0) { self.walletIndex = walletIndex } + private func noteBalanceInputs() { + balanceWatch.update(record: record, fundingSeenSats: fundingSeenSats, sparkBalanceSats: SparkManager.shared.balanceSats) + } + /// The fee rate of the quote on screen. var quoteFeeRate: UInt64? { pendingFeeRate @@ -1373,3 +1386,35 @@ final class SparkExitWriteGate: @unchecked Sendable { lock.withLock { closed = true } } } + +/// What the wallet's totals read from the exit: its transfer, what is still in transfer to savings and what stays on +/// Spark. A funding payment seen at the address or a record write changes them without any Spark state change, so +/// the watch asks the wallet to recompute when they changed, and only then. +struct SparkExitBalanceInputs: Equatable { + let transferId: String? + let inTransferSats: UInt64? + let outsideExitSats: UInt64? + + init(record: SparkExitRecord?, fundingSeenSats: UInt64, sparkBalanceSats: UInt64) { + transferId = record?.transferId + inTransferSats = record.map { SparkExitMath.inTransferSats($0, fundingSeenSats: fundingSeenSats) } + outsideExitSats = record.flatMap { SparkExitMath.outsideExitSats(sparkBalanceSats: sparkBalanceSats, record: $0) } + } +} + +@MainActor +final class SparkExitBalanceWatch { + private var last: SparkExitBalanceInputs? + private let onChange: () -> Void + + init(onChange: @escaping () -> Void) { + self.onChange = onChange + } + + func update(record: SparkExitRecord?, fundingSeenSats: UInt64, sparkBalanceSats: UInt64) { + let inputs = SparkExitBalanceInputs(record: record, fundingSeenSats: fundingSeenSats, sparkBalanceSats: sparkBalanceSats) + guard inputs != last else { return } + last = inputs + onChange() + } +} diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 50524db25..44cc3ede7 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -663,6 +663,38 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(exit.fundingSeenCount, 0) } + // MARK: Wallet totals (pwltr, android#1782) + + /// Funding seen at the address changes what is in transfer to savings: the wallet recomputes its totals. + @MainActor func testAFundingSeenOnlyChangeRefreshesTheWalletBalance() { + var refreshes = 0 + let watch = SparkExitBalanceWatch { refreshes += 1 } + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 20000)) + record.transferId = "transfer-1" + watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0) + let before = refreshes + + watch.update(record: record, fundingSeenSats: 5000, sparkBalanceSats: 0) + + XCTAssertEqual(refreshes, before + 1, "the wallet kept the in-transfer amount from before the funding was seen") + } + + /// A record write that leaves the totals' amounts alone does not recompute them. + @MainActor func testAnExitWriteThatLeavesTheTotalsAloneDoesNotRefreshTheWalletBalance() { + var refreshes = 0 + let watch = SparkExitBalanceWatch { refreshes += 1 } + var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 20000)) + record.transferId = "transfer-1" + watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0) + let before = refreshes + + record.isResultSeen = true + record.lastBuildAt = Date(timeIntervalSince1970: 1_800_000_000) + watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0) + + XCTAssertEqual(refreshes, before) + } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats From 32b33794d22dc7af6a8f326e79f6bec78bfa3b69 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 14:33:15 +0100 Subject: [PATCH 311/378] fix: show the balance copy when spark's fee takes a contact payment past it --- Bitkit/Managers/SparkManager.swift | 40 +++++++++++++++---- Bitkit/Services/PaykitRouteService.swift | 51 +++++++++++++++++------- BitkitTests/PaykitRouteTests.swift | 36 +++++++++++++++++ 3 files changed, 105 insertions(+), 22 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 2cb7181c0..82cc48aac 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -623,22 +623,42 @@ final class SparkManager: ChannelFreeLightningRail { /// A Paykit route that withdraws exactly `amountSats` to the contact's on-chain `endpoint` at medium speed, with /// Spark's fee on top, or nil while Spark cannot send it. bitkit-core's Paykit router weighs it against the contact's - /// other endpoints. + /// other endpoints. Throws `SparkLegUnaffordable` when the balance can't cover the amount and its fee. func withdrawalRouteLeg(endpoint: PaykitEndpoint, amountSats: UInt64) async throws -> ServiceRouteLeg? { - guard isReady, balanceSats >= amountSats else { return nil } + guard isReady else { return nil } + guard balanceSats >= amountSats else { throw SparkLegUnaffordable(debitSats: amountSats, balanceSats: balanceSats) } let prepared = try await prepareSend(paymentRequest: endpoint.value, amountSats: amountSats) guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else { throw AppError(message: "Unexpected Spark payment method", debugMessage: "\(prepared.paymentMethod)") } - let debitSats = Self.withdrawalDebitSats(amountSats: amountSats, fee: feeQuote.speedMedium) - guard !feeQuote.isEstimate, balanceSats >= debitSats else { return nil } + guard !feeQuote.isEstimate else { return nil } + return try Self.withdrawalRouteLeg( + endpoint: endpoint, + amountSats: amountSats, + fee: feeQuote.speedMedium, + validUntilUnix: feeQuote.expiresAt, + balanceSats: balanceSats + ) + } + + /// The leg that withdraws `amountSats` to `endpoint` with `fee` on top. Throws `SparkLegUnaffordable` when + /// `balanceSats` can't cover both. + nonisolated static func withdrawalRouteLeg( + endpoint: PaykitEndpoint, + amountSats: UInt64, + fee: SendOnchainSpeedFeeQuote, + validUntilUnix: UInt64, + balanceSats: UInt64 + ) throws -> ServiceRouteLeg { + let debitSats = withdrawalDebitSats(amountSats: amountSats, fee: fee) + guard balanceSats >= debitSats else { throw SparkLegUnaffordable(debitSats: debitSats, balanceSats: balanceSats) } return ServiceRouteLeg( - service: Self.routeService, + service: routeService, endpoint: endpoint, deliveredSat: amountSats, debitSat: debitSats, - atomicity: Self.routeAtomicity, - validUntilUnix: feeQuote.expiresAt + atomicity: routeAtomicity, + validUntilUnix: validUntilUnix ) } @@ -1655,6 +1675,12 @@ struct SparkWithdrawalNotSent: Error { let underlying: Error } +/// Spark's balance can't cover a contact withdrawal's amount and fee, so the route offers no Spark leg. +struct SparkLegUnaffordable: Error { + let debitSats: UInt64 + let balanceSats: UInt64 +} + struct SparkContactWithdrawal: Codable, Equatable { /// The Paykit request a withdrawal pays, and the identity its proof belongs to. struct PaykitRequest: Codable, Equatable { diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index b1b2e5986..29d3bddc3 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -100,7 +100,7 @@ enum PaykitRouteService { source: Source, lightningSpendableSats: UInt64, onchainSpendableSats: UInt64, - quoteLegs: ([PaykitEndpoint], UInt64) async -> [ServiceRouteLeg] = { await sparkLegs(to: $0, amountSats: $1) } + quoteLegs: ([PaykitEndpoint], UInt64) async throws -> [ServiceRouteLeg] = { try await sparkLegs(to: $0, amountSats: $1) } ) async throws -> PaykitRoute { func pick(_ serviceLegs: [ServiceRouteLeg]) throws -> PaykitRoute { try paykitRoutePayment( @@ -117,21 +117,34 @@ enum PaykitRouteService { } let direct = try pick([]) guard case .none = direct, source == .spending, route.offersSparkLeg else { return direct } - let serviceLegs = await quoteLegs(route.onchainEndpoints, amountSats) + let serviceLegs = try await quoteLegs(route.onchainEndpoints, amountSats) return serviceLegs.isEmpty ? direct : try pick(serviceLegs) } - private static func sparkLegs(to endpoints: [PaykitEndpoint], amountSats: UInt64) async -> [ServiceRouteLeg] { + /// Spark's leg to each of `endpoints`, from `quote`. Throws `SparkLegUnaffordable` when no leg is quoted and one + /// was dropped because Spark's balance can't cover the amount and its fee. + static func sparkLegs( + to endpoints: [PaykitEndpoint], + amountSats: UInt64, + quote: (PaykitEndpoint, UInt64) async throws -> ServiceRouteLeg? = { + try await SparkManager.shared.withdrawalRouteLeg(endpoint: $0, amountSats: $1) + } + ) async throws -> [ServiceRouteLeg] { var legs: [ServiceRouteLeg] = [] + var unaffordable: SparkLegUnaffordable? for endpoint in endpoints { do { - if let leg = try await SparkManager.shared.withdrawalRouteLeg(endpoint: endpoint, amountSats: amountSats) { + if let leg = try await quote(endpoint, amountSats) { legs.append(leg) } } catch { + unaffordable = unaffordable ?? error as? SparkLegUnaffordable Logger.warn("No Spark route to \(endpoint.methodId): \(error)", context: "PaykitRoute") } } + if legs.isEmpty, let unaffordable { + throw unaffordable + } return legs } @@ -282,6 +295,20 @@ extension AppViewModel { let generation = paykitRoutingGeneration // A dismissed or replaced payment, a newer routing request (a new amount or source) or a source picked since // makes the answer stale, so it never lands on what followed it. + // A short balance names itself first, then the limit of an LNURL that refused the amount (the router never saw + // it), then a Spark leg the balance can't cover with its fee. + func refuse(sparkLegShort: Bool) -> PaykitRouteOutcome { + paykitRoute = unrouted + let isBalanceShort = PaykitRouteService.isBalanceShort( + amountSats: amountSats, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats + ) + if isBalanceShort || unrouted.lnurl.map({ validateLnurlAmount($0, amountSats: amountSats) }) != false { + showNoPaykitRouteToast(isBalanceShort: isBalanceShort || sparkLegShort) + } + return .refused + } let isCurrent = { self.contactPaymentContext?.id == contactId && self.paykitRoutingGeneration == generation && (self.selectedWalletToPayFrom == .lightning) == (source == .spending) && @@ -307,18 +334,12 @@ extension AppViewModel { return .leg case let .none(reason): Logger.warn("Found no Paykit route: '\(reason)'", context: "PaykitRoute") - paykitRoute = unrouted - let isBalanceShort = PaykitRouteService.isBalanceShort( - amountSats: amountSats, - lightningSpendableSats: lightningSpendableSats, - onchainSpendableSats: onchainSpendableSats - ) - // The router never saw an LNURL whose limits refuse the amount, so its limit says why. - if isBalanceShort || unrouted.lnurl.map({ validateLnurlAmount($0, amountSats: amountSats) }) != false { - showNoPaykitRouteToast(isBalanceShort: isBalanceShort) - } - return .refused + return refuse(sparkLegShort: false) } + } catch let error as SparkLegUnaffordable { + Logger.warn("Found no Paykit route: \(error)", context: "PaykitRoute") + guard isCurrent() else { return .stale } + return refuse(sparkLegShort: true) } catch { Logger.error("Failed to route Paykit payment: \(error)", context: "PaykitRoute") guard isCurrent() else { return .stale } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 8edae0e7d..33754a3c6 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -685,6 +685,42 @@ final class PaykitRouteTests: XCTestCase { ) } + @MainActor + func testASparkLegItsFeeTakesPastTheBalanceShowsTheBalanceCopy() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + defer { ToastWindowManager.shared.hideToast() } + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.selectedWalletToPayFrom = .lightning + let fee = SendOnchainSpeedFeeQuote(userFeeSat: 750, l1BroadcastFeeSat: 1440) + + let outcome = await app.routeContactPayment( + PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true), + amountSats: 9000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil + ) { route, amountSats, source, lightningSats, onchainSats in + try await PaykitRouteService.route( + route, amountSats: amountSats, source: source, lightningSpendableSats: lightningSats, onchainSpendableSats: onchainSats + ) { endpoints, amountSats in + try await PaykitRouteService.sparkLegs(to: endpoints, amountSats: amountSats) { endpoint, amountSats in + try SparkManager.withdrawalRouteLeg( + endpoint: endpoint, amountSats: amountSats, fee: fee, validUntilUnix: 0, balanceSats: 10000 + ) + } + } + } + + XCTAssertEqual(outcome, .refused) + XCTAssertEqual( + ToastWindowManager.shared.currentToast?.accessibilityIdentifier, + "PaykitRouteBalanceToast", + "Spark holds 10,000 but the 9,000 withdrawal takes 11,190 with its fee, so the balance is what falls short" + ) + } + @MainActor func testAPendingWithdrawalSendIsHeldUntilItsJournalSettles() { var pending = Self.withdrawal(id: "sent", to: onchain.value, amountSats: 20000, timestamp: 1001) From ca16cb575d40ba06a99e2ef4ddc2c3801c683111 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 15:47:03 +0100 Subject: [PATCH 312/378] fix: keep spark's listed sends when the restore hold ends Spark's first sync after a restore ended the hold without keeping the outbound sends it listed, so after a relaunch, before Spark synced again, the node could pay an invoice Spark had already paid. The listed non-failed outbound hashes now go into the rail attempts before the hold is cleared. --- Bitkit/Services/LightningService.swift | 7 +++++++ BitkitTests/ChannelFreeRailTests.swift | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index bc55e95ee..3e9db501d 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -2183,6 +2183,13 @@ extension LightningService { let kept = unexpiredRailAttempts() let payments = try await rail.lightningPayments() if let since = sparkHistoryUnseededSince, await rail.hasSynced(after: UInt64(clamping: since)) { + // Kept, so after a relaunch the sends Spark listed hold the node until Spark syncs again. + let seeded = payments.filter { $0.direction == .outbound && $0.status != .failed }.map { $0.paymentHash.lowercased() } + eventCallbackLock.withLock { + let hashes = railAttemptsStore.stringArray(forKey: Self.railAttemptsKey) ?? [] + let added = Array(Set(seeded).subtracting(hashes)).sorted() + railAttemptsStore.set(added + hashes, forKey: Self.railAttemptsKey) + } clearSparkHistoryUnseeded() } let listed = Set((payments + unsettled).filter { $0.direction == .outbound }.map { $0.paymentHash.lowercased() }) diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 6368c4c66..682bf4663 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -1192,6 +1192,27 @@ extension ChannelFreeRailTests { await assertReachesTheNode() } + func testAnInvoiceSparkListedAsPaidAfterARestoreStaysHeldOverTheNodeAfterARelaunch() async throws { + let hash = try String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash()) + try restore(secondsAfterTheInvoice: 60) + leaveSparkUnsyncedAndEmpty() + // Spark's first sync since the restore lists the send the other device made. + rail.syncedAfter = { _ in true } + rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash, direction: .outbound, status: .succeeded, preimage: nil)] + await Bitkit.LightningService.shared.listRailSendsIfUnseeded() + XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) + + // A relaunch: Spark hasn't synced this run. + rail.syncedAfter = { _ in false } + + do { + _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) + XCTFail("A node send of an invoice Spark listed as paid went out after a relaunch") + } catch { + XCTAssertFalse("\(error)".contains("nodeNotSetup"), "Reached the node: \(error)") + } + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) From f08a0a97449780154665fc761e2abd70739bff56 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 15:47:04 +0100 Subject: [PATCH 313/378] fix: hold spark sends of pre-restore invoices until spark lists its sends The Spark branch checked earlier attempts without the invoice creation time, so a funded, ready Spark that had not yet synced since a restore could pay an unexpired invoice made before it. The Spark branch now passes the invoice time, as the node branch does, and is refused the same way. --- Bitkit/Services/LightningService.swift | 4 +++- BitkitTests/ChannelFreeRailTests.swift | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 3e9db501d..8ef636a77 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1053,7 +1053,9 @@ class LightningService: NodeLifecycleService { let mark = attemptMark if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { // LDK refuses its own repeats; Spark doesn't, so a Spark send checks both rails. - if let paid = try await settledEarlierAttempt(paymentHash: paymentHash, on: [.ldk, .channelFree]) { + if let paid = try await settledEarlierAttempt( + paymentHash: paymentHash, on: [.ldk, .channelFree], invoiceCreatedAt: invoice?.secondsSinceEpoch() + ) { return paid } guard beginRailSend(paymentHash: paymentHash) else { throw Self.pendingDuplicate(paymentHash: paymentHash) } diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 682bf4663..2bf7d87ce 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -1149,7 +1149,7 @@ extension ChannelFreeRailTests { private func assertRefusedUntilSparkLists(file: StaticString = #filePath, line: UInt = #line) async { do { _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) - XCTFail("A node send of an invoice made before the restore went out before Spark listed its sends", file: file, line: line) + XCTFail("A send of an invoice made before the restore went out before Spark listed its sends", file: file, line: line) } catch { XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused, "\(error)", file: file, line: line) } @@ -1213,6 +1213,15 @@ extension ChannelFreeRailTests { } } + func testAnInvoiceMadeBeforeARestoreIsRefusedOverAFundedSparkUntilSparkSyncs() async throws { + try restore(secondsAfterTheInvoice: 60) + // Spark is ready and funded, but no sync since the restore has listed its sends. + rail.syncedAfter = { _ in false } + rail.balanceSats = 100_000 + + await assertRefusedUntilSparkLists() + } + private func sendOverSpark() async throws { rail.balanceSats = 100_000 _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) From baa00f8afa7fdefa998a8a969ae095ad65c241cc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 15:47:04 +0100 Subject: [PATCH 314/378] test: wait for the send watch before emitting the rail failure The test emitted its events after a fixed 200 ms sleep, which only hoped the watch had started. It now waits until the watch holds its continuation. --- Bitkit/ViewModels/WalletViewModel.swift | 2 +- BitkitTests/ChannelFreeRailTests.swift | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index ddca5319c..d30e6d257 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -78,7 +78,7 @@ class WalletViewModel: ObservableObject { @Published var peers: [PeerDetails]? @Published var channels: [ChannelDetails]? private var eventHandlers: [String: (Event) -> Void] = [:] - private var watchSendContinuations: [String: CheckedContinuation] = [:] + private(set) var watchSendContinuations: [String: CheckedContinuation] = [:] private var probeOutcomes: [PaymentId: ProbeOutcome] = [:] @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 2bf7d87ce..92673375b 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -1382,7 +1382,13 @@ extension ChannelFreeRailTests { Bitkit.LightningService.shared.setEventCallback { wallet.notifyEventHandlers($0) } let wait = Task { try await wallet.waitForLightningPayment(hash: hash, timeoutSeconds: 5) } - try await Task.sleep(nanoseconds: 200_000_000) + // The event must reach the watcher, so it is emitted only once the watch has started. + var waited = 0 + while wallet.watchSendContinuations[hash] == nil, waited < 500 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertNotNil(wallet.watchSendContinuations[hash], "The send watch never started") Bitkit.LightningService.shared.emitChannelFreeRailEvent(.paymentFailed(paymentId: hash, paymentHash: hash, reason: .unexpectedError)) wallet.notifyEventHandlers(.paymentSuccessful(paymentId: hash, paymentHash: hash, paymentPreimage: "pp", feePaidMsat: 2000)) let settled = try await wait.value From aedeee5a41b55f571cd7cbaf02e2875240f6d102 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 16:26:59 +0100 Subject: [PATCH 315/378] fix: leave an open contact withdrawal off spark's transfers to savings --- Bitkit/Managers/SparkManager.swift | 6 ++++-- BitkitTests/PaykitRouteTests.swift | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 82cc48aac..8e0c087b7 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -1280,11 +1280,13 @@ final class SparkManager: ChannelFreeLightningRail { return unclaimed.filter { !claimedOutputs.contains("\($0.txid.lowercased()):\($0.vout)") } } - /// Spark's withdrawals to Savings, without the ones that paid contacts. + /// Spark's withdrawals to Savings, without the ones that paid contacts: those recorded as sent, and the open + /// journal's, which Spark keeps pending until its payout is accepted. nonisolated static func withdrawals(_ payments: [Payment]) -> [SparkWithdrawal] { let contactWithdrawals = contactWithdrawalTxIds + let journaledId = pendingContactWithdrawal.flatMap { matchingWithdrawal($0, in: payments)?.id } return payments.compactMap { payment in - guard payment.status != .failed, case let .withdraw(txId, _, _) = payment.details, + guard payment.status != .failed, payment.id != journaledId, case let .withdraw(txId, _, _) = payment.details, !contactWithdrawals.contains(txId) else { return nil } return SparkWithdrawal(txid: txId, amountSats: sats(payment.amount), feeSats: sats(payment.fees)) } diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 33754a3c6..fa8526590 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -1303,6 +1303,29 @@ final class PaykitRouteTests: XCTestCase { ) } + func testSparkScreenWithdrawalsLeaveOutAPendingContactPayment() { + snapshotAppDefaultsDomain() + SparkManager.contactWithdrawalTxIds = [] + var toContact = Self.withdrawal(id: "contact", to: onchain.value, amountSats: 20000, timestamp: 1) + toContact.status = .pending + let toSavings = Self.withdrawal(id: "savings", to: "bcrt1qsavings", amountSats: 5000, timestamp: 1) + var journal = SparkContactWithdrawal(address: onchain.value, amountSats: 20000, contact: "contact", startedAt: 1) + SparkManager.pendingContactWithdrawal = journal + + XCTAssertEqual( + SparkManager.withdrawals([toContact, toSavings]).map(\.txid), + ["tx-savings"], + "Before Spark returns it, the open journal's address and amount tell the contact payment apart" + ) + journal.paymentId = "contact" + SparkManager.pendingContactWithdrawal = journal + XCTAssertEqual( + SparkManager.withdrawals([toContact, toSavings]).map(\.txid), + ["tx-savings"], + "A contact payment Spark keeps pending is not a transfer to savings" + ) + } + private static func endpoint(_ methodId: PublicPaykitService.MethodId, _ value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint(methodId: methodId, value: value, min: nil, max: nil, rawPayload: value) } From 1a3a1799fc12b2280377865ec975c03f7b3f2104 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 16:36:03 +0100 Subject: [PATCH 316/378] fix: close a contact withdrawal spark refuses for a fee rise at once --- Bitkit/Managers/SparkManager.swift | 9 ++++++++- BitkitTests/PaykitRouteTests.swift | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 8e0c087b7..1fd15f324 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -807,7 +807,7 @@ final class SparkManager: ChannelFreeLightningRail { settleLater: () -> Void ) async -> Error { let failure = sendPaymentFailure(error) - if failure is ChannelFreeRailSendRefused { + if failure is ChannelFreeRailSendRefused || isWithdrawalFeeRise(error) { Logger.warn("Spark refused a withdrawal to a contact before sending it: \(error)", context: "SparkManager") await closeUnsent() return SparkWithdrawalNotSent(underlying: failure) @@ -817,6 +817,13 @@ final class SparkManager: ChannelFreeLightningRail { return error } + /// Spark refetches a withdrawal's quote near its expiry and refuses one whose fee rose past the prepared one at every + /// speed with this input error, before it withdraws (`affordable_speed` in the 0.28.0 SDK's `send/bitcoin_address.rs`). + nonisolated static func isWithdrawalFeeRise(_ error: Error) -> Bool { + guard case let SdkError.InvalidInput(message) = error else { return false } + return message.localizedCaseInsensitiveContains("onchain fee rose to") + } + /// Settles the open contact withdrawal from Spark's payments: its own payment, once Spark returned it, or else a /// withdrawal to its address and amount that wasn't listed before it started. With `allowClear` (after a sync that /// succeeded), a list without one closes it as never sent and releases the request's proof, but only once the diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index fa8526590..958c5dbc0 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -472,6 +472,17 @@ final class PaykitRouteTests: XCTestCase { XCTAssertTrue(refused is SparkWithdrawalNotSent, "Spark picks leaves before anything reaches the operators") XCTAssertEqual(steps, ["closed"], "A refusal releases the journal at once instead of after the expiry") + steps = [] + let feeRose = await SparkManager.withdrawalSendFailure( + SdkError.InvalidInput( + "The onchain fee rose to 900 sats at the slowest speed, above the 500 sats quoted for this payment. Please re-prepare the payment." + ), + closeUnsent: { steps.append("closed") }, + settleLater: { steps.append("settle later") } + ) + XCTAssertTrue(feeRose is SparkWithdrawalNotSent, "Spark checks a refreshed quote's fee before it withdraws") + XCTAssertEqual(steps, ["closed"]) + steps = [] let unknown = await SparkManager.withdrawalSendFailure( SdkError.SparkError("operators"), From 7ce626fe07473a1617c4bfe3c569f3a34f4cb522 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 16:38:18 +0100 Subject: [PATCH 317/378] fix: keep a full withdrawal's zero balance until a later sync shows funds A refresh that read the balance before a full withdrawal, and finished after it, cleared the withdrawal flag, so a stale balance could come back. The withdrawal's time is now kept, and the flag clears only on a read that shows funds from a sync that ended after it. --- Bitkit/Managers/SparkManager.swift | 20 ++++++++++++++------ BitkitTests/SparkTransferActivityTests.swift | 12 ++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 545189b4a..d663771d2 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -71,8 +71,9 @@ final class SparkManager: ChannelFreeLightningRail { /// When a withdrawal last failed, in seconds since 1970, until Spark reads a balance again or syncs after it /// (`keptBalanceSats`). @ObservationIgnored private var withdrawalFailedAt: UInt64? - /// Whether a withdrawal moved the whole balance, so a read of 0 is real until Spark reads a balance again. - @ObservationIgnored private var withdrewAll = false + /// When a withdrawal moved the whole balance, in seconds since 1970, so a read of 0 is real until a sync after it + /// reads a balance again (`withdrewAllAt(_:read:lastSyncedAt:)`). + @ObservationIgnored private var withdrewAllAt: UInt64? @ObservationIgnored private var listenerId: String? @ObservationIgnored private var connectTask: Task? /// Bumped by `wipe`, so a connect that started before it disconnects instead of keeping the wiped wallet. @@ -260,7 +261,7 @@ final class SparkManager: ChannelFreeLightningRail { balanceSats = 0 keptBalanceSats = nil withdrawalFailedAt = nil - withdrewAll = false + withdrewAllAt = nil pendingDepositSats = 0 deposits = [] withdrawals = [] @@ -512,7 +513,7 @@ final class SparkManager: ChannelFreeLightningRail { sentWithdrawalTxIds.insert(txId) } withdrawalFailedAt = response.payment.status == .failed ? Self.nowSecs() : nil - withdrewAll = response.payment.status != .failed + withdrewAllAt = response.payment.status == .failed ? nil : Self.nowSecs() try await Self.settleWithdrawal(status: response.payment.status) { try await self.refresh() } } catch { withdrawalFailedAt = Self.nowSecs() @@ -728,12 +729,12 @@ final class SparkManager: ChannelFreeLightningRail { withdrawalFailedAt = Self.withdrawalFailedAt( withdrawalFailedAt, read: info.balanceSats, lastSyncedAt: info.lastSyncedAt, syncFailed: info.lastSyncError != nil ) - withdrewAll = withdrewAll && info.balanceSats == 0 + withdrewAllAt = Self.withdrewAllAt(withdrewAllAt, read: info.balanceSats, lastSyncedAt: info.lastSyncedAt) let kept = Self.keptBalanceSats( read: info.balanceSats, lastShown: shownBalanceSats, isStale: withdrawalFailedAt != nil || info.lastSyncError != nil, - withdrewAll: withdrewAll + withdrewAll: withdrewAllAt != nil ) let changed = !isReady || Self.balancesChanged( from: SparkBalances(readSats: balanceSats, keptSats: keptBalanceSats, pendingSats: pendingDepositSats), @@ -779,6 +780,13 @@ final class SparkManager: ChannelFreeLightningRail { return failedAt } + /// A full withdrawal stops making a read of 0 real once a sync that ended after it reads a balance, so a refresh + /// that read the balance before the withdrawal can't bring it back. + nonisolated static func withdrewAllAt(_ withdrewAt: UInt64?, read: UInt64, lastSyncedAt: UInt64?) -> UInt64? { + guard let withdrewAt, read == 0 || (lastSyncedAt ?? 0) <= withdrewAt else { return nil } + return withdrewAt + } + private nonisolated static func nowSecs() -> UInt64 { UInt64(Date().timeIntervalSince1970) } diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift index 986f99182..e12ed5820 100644 --- a/BitkitTests/SparkTransferActivityTests.swift +++ b/BitkitTests/SparkTransferActivityTests.swift @@ -75,6 +75,18 @@ final class SparkTransferActivityTests: XCTestCase { ) } + func testABalanceReadBeforeAFullWithdrawalNeverBringsItBack() { + XCTAssertEqual( + SparkManager.withdrewAllAt(100, read: 9000, lastSyncedAt: 90), + 100, + "A refresh that read the balance before the withdrawal brought it back" + ) + XCTAssertEqual(SparkManager.withdrewAllAt(100, read: 9000, lastSyncedAt: nil), 100) + XCTAssertEqual(SparkManager.withdrewAllAt(100, read: 0, lastSyncedAt: 101), 100) + XCTAssertNil(SparkManager.withdrewAllAt(100, read: 2000, lastSyncedAt: 101), "New funds after the withdrawal stayed hidden") + XCTAssertNil(SparkManager.withdrewAllAt(nil, read: 0, lastSyncedAt: 101)) + } + func testASyncedZeroReadAfterAFailedWithdrawalClearsTheKeptBalance() { XCTAssertNil( SparkManager.withdrawalFailedAt(100, read: 0, lastSyncedAt: 101, syncFailed: false), From fb26d2273ed7f9f9c7028c6775d8ec8e8a792c30 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 16:39:25 +0100 Subject: [PATCH 318/378] fix: end a move's late payment watch on wipe --- Bitkit/ViewModels/WalletViewModel.swift | 12 +++++++++++ BitkitTests/MoveToSparkLateOutcomeTests.swift | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index b1f28bce3..621b58ed3 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -218,6 +218,10 @@ class WalletViewModel: ObservableObject { walletExists = try Keychain.exists(key: .bip39Mnemonic(index: 0)) } + /// The event streams of moves still waiting for their outcome, by listener id: `wipe` ends them, since the wiped + /// node reports no late outcome. + private var moveEventSinks: [String: AsyncStream.Continuation] = [:] + func addOnEvent(id: String, handler: @escaping (Event) -> Void) { eventHandlers[id] = handler } @@ -1189,11 +1193,13 @@ class WalletViewModel: ObservableObject { let listenerId = "move-to-spark-\(UUID().uuidString)" let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) addOnEvent(id: listenerId) { sink.yield($0) } + moveEventSinks[listenerId] = sink let id: String do { id = try await String(lightningService.send(bolt11: bolt11, sats: sats, params: params, channelsOnly: true)) } catch { removeOnEvent(id: listenerId) + moveEventSinks.removeValue(forKey: listenerId) sink.finish() throw error } @@ -1206,6 +1212,7 @@ class WalletViewModel: ObservableObject { onLateSuccess: onLateSuccess, onEnd: { [weak self] in self?.removeOnEvent(id: listenerId) + self?.moveEventSinks.removeValue(forKey: listenerId) sink.finish() } ) @@ -2200,6 +2207,11 @@ class WalletViewModel: ObservableObject { try await SparkManager.shared.wipe(walletIndex: 0) probeOutcomes.removeAll() + // A move's late outcome belongs to the wiped wallet, and its node is gone: stop waiting for it + for sink in moveEventSinks.values { + sink.finish() + } + moveEventSinks.removeAll() // Reset AppStorage display values totalBalanceSats = 0 diff --git a/BitkitTests/MoveToSparkLateOutcomeTests.swift b/BitkitTests/MoveToSparkLateOutcomeTests.swift index 7346231f9..d63fab83d 100644 --- a/BitkitTests/MoveToSparkLateOutcomeTests.swift +++ b/BitkitTests/MoveToSparkLateOutcomeTests.swift @@ -67,6 +67,27 @@ final class MoveToSparkLateOutcomeTests: XCTestCase { XCTAssertFalse(timedOut) } + @MainActor + func testEndedStreamStopsTheLateWatch() async throws { + let (events, sink) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + var lateOutcomes = 0 + let ended = expectation(description: "the watch ends with its stream") + let settled = try await WalletViewModel.settle( + paymentId: "move-hash", + events: events, + timeout: .milliseconds(10), + onTimeout: {}, + onLateFailure: { lateOutcomes += 1 }, + onLateSuccess: { _ in lateOutcomes += 1 }, + onEnd: { ended.fulfill() } + ) + XCTAssertNil(settled) + // A wipe ends the stream of a move still waiting for its late outcome. + sink.finish() + await fulfillment(of: [ended], timeout: 2) + XCTAssertEqual(lateOutcomes, 0) + } + @MainActor func testWipeForgetsAnUnsettledMove() { let manager = MoveToSparkManager() From e24e37cc774268e0026d3fad1942cc575e51144b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 16:36:04 +0100 Subject: [PATCH 319/378] fix: refuse a spark contact leg while an earlier withdrawal settles --- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Services/PaykitRouteService.swift | 28 +++++- BitkitTests/PaykitRouteSettlingTests.swift | 87 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 BitkitTests/PaykitRouteSettlingTests.swift diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 75355b37f..a5d98f18b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1313,6 +1313,7 @@ "wallet__send_swap_route" = "Route"; "wallet__send_paykit_no_route__title" = "Can't pay this contact"; "wallet__send_paykit_no_route__description" = "This contact has no payment method Bitkit can use right now."; +"wallet__send_paykit_withdrawal_settling__description" = "An earlier withdrawal from your spending balance is still settling. Try again once it completes."; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Paste Invoice"; "wallet__recipient_manual" = "Enter Manually"; diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 29d3bddc3..44525342c 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -93,7 +93,9 @@ enum PaykitRouteService { } /// Asks the router how to pay `amountSats` from `source`. The router pays directly before it weighs any leg, so a - /// Spark leg to each on-chain endpoint is quoted only when the route offers one and no balance pays. + /// Spark leg to each on-chain endpoint is quoted only when the route offers one and no balance pays. Spark takes one + /// contact withdrawal at a time, so while an earlier one's journal is open no leg is quoted and this throws + /// `SparkWithdrawalSettling`. static func route( _ route: PaykitRouteUi, amountSats: UInt64, @@ -117,6 +119,7 @@ enum PaykitRouteService { } let direct = try pick([]) guard case .none = direct, source == .spending, route.offersSparkLeg else { return direct } + guard SparkManager.pendingContactWithdrawal == nil else { throw SparkWithdrawalSettling() } let serviceLegs = try await quoteLegs(route.onchainEndpoints, amountSats) return serviceLegs.isEmpty ? direct : try pick(serviceLegs) } @@ -258,6 +261,9 @@ enum PaykitRouteService { } } +/// Only a Spark leg could pay, but an earlier contact withdrawal's journal is still open. +struct SparkWithdrawalSettling: Error {} + /// What asking the router did to a contact payment. enum PaykitRouteOutcome: Equatable { /// The send pays the endpoint the router picked. @@ -336,6 +342,26 @@ extension AppViewModel { Logger.warn("Found no Paykit route: '\(reason)'", context: "PaykitRoute") return refuse(sparkLegShort: false) } + } catch is SparkWithdrawalSettling { + Logger.warn("Found no Paykit route: an earlier Spark contact withdrawal is still settling", context: "PaykitRoute") + guard isCurrent() else { return .stale } + // A short balance and an LNURL limit name themselves first, as for any other refusal. + let lnurlRefuses = unrouted.lnurl.map { !$0.accepts(amountSats: amountSats) } ?? false + if lnurlRefuses || PaykitRouteService.isBalanceShort( + amountSats: amountSats, + lightningSpendableSats: lightningSpendableSats, + onchainSpendableSats: onchainSpendableSats + ) { + return refuse(sparkLegShort: false) + } + paykitRoute = unrouted + toast( + type: .error, + title: t("wallet__send_paykit_no_route__title"), + description: t("wallet__send_paykit_withdrawal_settling__description"), + accessibilityIdentifier: "PaykitRouteSettlingToast" + ) + return .refused } catch let error as SparkLegUnaffordable { Logger.warn("Found no Paykit route: \(error)", context: "PaykitRoute") guard isCurrent() else { return .stale } diff --git a/BitkitTests/PaykitRouteSettlingTests.swift b/BitkitTests/PaykitRouteSettlingTests.swift new file mode 100644 index 000000000..b6c6dc4e3 --- /dev/null +++ b/BitkitTests/PaykitRouteSettlingTests.swift @@ -0,0 +1,87 @@ +@testable import Bitkit +import BitkitCore +import Paykit +import XCTest + +final class PaykitRouteSettlingTests: XCTestCase { + private let onchain = PaykitEndpoint(methodId: "btc-regtest-p2wpkh", value: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x") + + @MainActor + func testASparkLegIsRefusedWhileAnEarlierContactWithdrawalSettles() async { + snapshotAppDefaultsDomain() + SparkManager.pendingContactWithdrawal = SparkContactWithdrawal( + address: "bcrt1qearlier", + amountSats: 5000, + contact: "earlier", + startedAt: 1 + ) + let app = AppViewModel() + defer { ToastWindowManager.shared.hideToast() } + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.selectedWalletToPayFrom = .lightning + let onchain = onchain + var quoted = false + + let outcome = await app.routeContactPayment( + PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true), + amountSats: 9000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil + ) { route, amountSats, source, lightningSats, onchainSats in + try await PaykitRouteService.route( + route, amountSats: amountSats, source: source, lightningSpendableSats: lightningSats, onchainSpendableSats: onchainSats + ) { _, amountSats in + quoted = true + return [ServiceRouteLeg( + service: SparkManager.routeService, + endpoint: onchain, + deliveredSat: amountSats, + debitSat: amountSats + 300, + atomicity: "trusted", + validUntilUnix: 0 + )] + } + } + + XCTAssertEqual(outcome, .refused, "Spark takes one contact withdrawal at a time, so Confirm must not offer a second") + XCTAssertFalse(quoted, "No Spark leg is quoted while the earlier withdrawal's journal is open") + XCTAssertEqual(ToastWindowManager.shared.currentToast?.accessibilityIdentifier, "PaykitRouteSettlingToast") + } + + @MainActor + func testAShortBalanceNamesItselfBeforeASettlingWithdrawal() async { + snapshotAppDefaultsDomain() + SparkManager.pendingContactWithdrawal = SparkContactWithdrawal( + address: "bcrt1qearlier", + amountSats: 5000, + contact: "earlier", + startedAt: 1 + ) + let app = AppViewModel() + defer { ToastWindowManager.shared.hideToast() } + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.selectedWalletToPayFrom = .lightning + + let outcome = await app.routeContactPayment( + PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true), + amountSats: 20000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil + ) { route, amountSats, source, lightningSats, onchainSats in + try await PaykitRouteService.route( + route, amountSats: amountSats, source: source, lightningSpendableSats: lightningSats, onchainSpendableSats: onchainSats + ) { _, _ in [] } + } + + XCTAssertEqual(outcome, .refused) + XCTAssertEqual( + ToastWindowManager.shared.currentToast?.accessibilityIdentifier, + "PaykitRouteBalanceToast", + "No balance covers 20,000, whatever an earlier withdrawal does, as on Android" + ) + } +} From da90a393d7e6d36fa16f37d3b27fa41fe71cfa26 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 16:40:58 +0100 Subject: [PATCH 320/378] fix: keep confirm's first route refusal and block its payment --- .../Wallets/Send/SendConfirmationView.swift | 17 +++++++++------- BitkitTests/SendConfirmationViewTests.swift | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 1ffe8834e..6831d797c 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -625,7 +625,8 @@ struct SendConfirmationView: View { shouldAutomaticallyPay: shouldAutomaticallyPay, hasStartedAutomaticPayment: hasStartedAutomaticPayment, isSetupPending: isSetupPending, - isPreparingWallet: walletSwitchContext != nil + isPreparingWallet: walletSwitchContext != nil, + isRouteRefused: refusedRouteSource == app.selectedWalletToPayFrom ) else { return } hasStartedAutomaticPayment = true do { @@ -1002,6 +1003,7 @@ struct SendConfirmationView: View { private func submitPayment(isAutomatic: Bool = false) async throws { guard !isSubmittingPayment else { return } guard !requiresAmountReview, preparingRequest == nil, !isRequestSetupPending, walletSwitchContext == nil, + refusedRouteSource != app.selectedWalletToPayFrom, isAutomatic || !prepareRequestCoinsIfNeeded() else { throw CancellationError() } // An automatic payment settles the fee itself below. if !isAutomatic, isPreparingOnchainSend { @@ -1118,14 +1120,16 @@ struct SendConfirmationView: View { return walletType == .lightning && !hasLightningPayment && route.offersSparkLeg } - /// The wallet switch chooses coins separately from send setup, so the automatic start waits for both. + /// The wallet switch chooses coins separately from send setup, so the automatic start waits for both. A source the + /// router refused has nothing to pay, so it never starts. static func canStartAutomaticPayment( shouldAutomaticallyPay: Bool, hasStartedAutomaticPayment: Bool, isSetupPending: Bool, - isPreparingWallet: Bool + isPreparingWallet: Bool, + isRouteRefused: Bool = false ) -> Bool { - shouldAutomaticallyPay && !hasStartedAutomaticPayment && !isSetupPending && !isPreparingWallet + shouldAutomaticallyPay && !hasStartedAutomaticPayment && !isSetupPending && !isPreparingWallet && !isRouteRefused } static func requiresManualConfirmation(isAutomatic: Bool, isHardwarePayment: Bool) -> Bool { @@ -1479,9 +1483,8 @@ struct SendConfirmationView: View { onchainSpendableSats: source == .savings ? UInt64(max(0, wallet.spendableOnchainBalanceSats)) : 0, request: request ) - if sourceChanged { - refusedRouteSource = Self.refusedRouteSource(after: outcome, on: walletType, previous: refusedRouteSource) - } + // The first call when Confirm opens keeps a refusal too, so a request with no route offers no swipe. + refusedRouteSource = Self.refusedRouteSource(after: outcome, on: walletType, previous: refusedRouteSource) } /// A source the router refused on Confirm keeps the swipe off while it's selected, so nothing consumes the contact's diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 8c938d5c5..a1aefad92 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -68,6 +68,26 @@ final class SendConfirmationViewTests: XCTestCase { )) } + func testARouteRefusedWhenConfirmOpensKeepsTheSwipeAndTheAutomaticPaymentOff() { + // Confirm's first routing call keeps a refusal the way a source switch does. + let refused = SendConfirmationView.refusedRouteSource(after: .refused, on: .lightning, previous: nil) + + XCTAssertFalse(SendConfirmationView.canStartAutomaticPayment( + shouldAutomaticallyPay: true, + hasStartedAutomaticPayment: false, + isSetupPending: false, + isPreparingWallet: false, + isRouteRefused: refused == .lightning + ), "an automatic attempt must not pay a source the router refused") + XCTAssertTrue(SendConfirmationView.isSwipeDisabled( + walletType: .lightning, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isRouteRefused: refused == .lightning + )) + } + func testAutomaticPaymentAwaitsFeeRateOnlyWhileItIsLoading() { XCTAssertTrue(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: true, feeRateLoadFailed: false)) XCTAssertFalse(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: true, feeRateLoadFailed: true)) From 0c4fe370e8e8b6f4dcb4baf5f53cc8e983df57e6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:14:36 +0100 Subject: [PATCH 321/378] fix: reject out-of-range fee estimates --- .../SparkExit/SparkExitBroadcaster.swift | 25 +++++++++++++++++-- BitkitTests/SparkExitSweepTests.swift | 11 ++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index d8afa7d84..ba5f8c659 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -45,6 +45,25 @@ enum SparkExitBroadcastOutcome: Equatable { /// regtest mempool does: a missing target falls back to the next one estimated, the nearest longer one first, and /// with none at all to the 1 sat/vB relay floor, so no step fails for want of an estimate. enum SparkExitFeeEstimates { + /// A host's answer with rates but none usable: the estimate is unavailable, as when the host can't be read. + struct Unusable: Error {} + + /// The estimates a host answered, less any rate that is not finite, not above 0 or above `maxRate`; throws when + /// it answered rates and none is usable. + static func validated(_ estimates: [String: Double]) throws -> [String: Double] { + let usable = estimates.filter { isUsable($0.value) } + guard usable.isEmpty, !estimates.isEmpty else { return usable } + Logger.warn("Spark exit fee estimates had no usable rate: \(estimates)", context: "SparkExitFeeEstimates") + throw Unusable() + } + + /// The most a rate may be, in sat/vB: anything above is a faulty host, never converted. + static let maxRate: Double = 10000 + + private static func isUsable(_ rate: Double) -> Bool { + rate.isFinite && rate > 0 && rate <= maxRate + } + static func rate(_ estimates: [String: Double], target: Int) -> UInt64 { let targets = parsed(estimates) guard let value = (targets.first { $0.target >= target } ?? targets.last)?.rate else { return SparkExitMath.minRelayFeeRate } @@ -57,7 +76,9 @@ enum SparkExitFeeEstimates { } private static func parsed(_ estimates: [String: Double]) -> [(target: Int, rate: Double)] { - estimates.compactMap { key, value in Int(key).map { (target: $0, rate: value) } }.sorted { $0.target < $1.target } + estimates.filter { isUsable($0.value) } + .compactMap { key, value in Int(key).map { (target: $0, rate: value) } } + .sorted { $0.target < $1.target } } private static func rounded(_ rate: Double) -> UInt64 { @@ -168,7 +189,7 @@ struct EsploraSparkExitChain: SparkExitChain { } private func estimates() async throws -> [String: Double] { - try await JSONDecoder().decode([String: Double].self, from: get("/fee-estimates")) + try await SparkExitFeeEstimates.validated(JSONDecoder().decode([String: Double].self, from: get("/fee-estimates"))) } /// esplora's targets of one and three blocks, mempool's `fastestFee` and `halfHourFee`. diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index a8ed10928..58b0f3818 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -652,6 +652,17 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(SparkExitFeeEstimates.rate(["1": 0.2], target: 1), 1) } + /// A host's rate out of range is never converted: such a rate is skipped, and an answer with nothing usable is + /// unavailable. + func testAnOutOfRangeFeeEstimateIsUnavailable() { + XCTAssertThrowsError(try SparkExitFeeEstimates.validated(["1": 1e30]), "an out-of-range rate was taken as an estimate") + XCTAssertThrowsError(try SparkExitFeeEstimates.validated(["1": .nan, "3": -2, "6": 0])) + XCTAssertEqual(try SparkExitFeeEstimates.validated(["1": 1e30, "6": 4]), ["6": 4]) + XCTAssertEqual(try SparkExitFeeEstimates.validated([:]), [:], "an empty answer still falls back to the floor") + XCTAssertEqual(SparkExitFeeEstimates.rate(["1": 1e30, "6": 4], target: 1), 4) + XCTAssertEqual(SparkExitFeeEstimates.economy(["1": 4, "144": .infinity]), 4) + } + func testAnEmptyEstimateMapStillPricesEveryStep() async throws { let http = SparkExitBroadcasterTests.FakeHTTP() http.responses = [("a.example/api/fee-estimates", 200, "{}"), ("a.example/api/fee-estimates", 200, "{}")] From 147a20ff4fbac07b2b64a85c4cecb337c8b7041c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:14:37 +0100 Subject: [PATCH 322/378] fix: pay the extra input fee when savings completes funding --- Bitkit/Managers/SparkExitManager.swift | 41 ++++++++++++++++--- .../Settings/Advanced/SparkExitViews.swift | 5 ++- BitkitTests/SparkExitReviewTests.swift | 22 ++++++++-- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 489dccb9b..f6f3aad64 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -106,12 +106,25 @@ final class SparkExitManager { /// What paying the fee funding from savings costs: that payment's own fee, and the total that leaves savings. Nil /// when another wallet pays, or there is nothing to fund. var savingsPayment: (feeSats: UInt64, totalSats: UInt64)? { - guard fundFromSavings, let quote else { return nil } - let sats = quote.fundingSats.saturatingSub(fundingSeenSats) + guard fundFromSavings else { return nil } + let sats = savingsSats guard sats > 0 else { return nil } return (savingsFeeSats, sats + savingsFeeSats) } + /// What savings pays for the quote on screen: what the funding address still lacks, with the input fee of the + /// savings payment when it completes a payment from another wallet. + var savingsSats: UInt64 { + guard let quote else { return 0 } + return Self.savingsFundingSats( + confirmed: quote, + fresh: quote, + seenSats: fundingSeenSats, + seenCount: fundingSeenCount, + feeRate: pendingFeeRate ?? 0 + ) + } + var isExiting: Bool { record?.isActive == true } @@ -907,7 +920,13 @@ final class SparkExitManager { await SparkManager.shared.reconnectForExit() Logger.info("Started Spark exit of \(exitQuote.exitSats) sats over \(exitQuote.leafIds.count) leaves", context: "SparkExitManager") - let savingsSats = Self.savingsFundingSats(confirmed: quote, fresh: exitQuote, seenSats: fundingSeenSats) + let savingsSats = Self.savingsFundingSats( + confirmed: quote, + fresh: exitQuote, + seenSats: fundingSeenSats, + seenCount: fundingSeenCount, + feeRate: feeRate + ) if fundFromSavings, savingsSats > 0 { let txid: String do { @@ -1028,8 +1047,20 @@ final class SparkExitManager { /// Savings pays at most the funding the user confirmed: a higher fresh amount goes through the shortfall flow on /// Progress, which asks for the rest. /// Funding another wallet already sent to the address (`seenSats`) is not paid again. - nonisolated static func savingsFundingSats(confirmed: SparkExitQuote, fresh: SparkExitQuote, seenSats: UInt64) -> UInt64 { - min(confirmed.fundingSats, fresh.fundingSats).saturatingSub(seenSats) + nonisolated static func savingsFundingSats( + confirmed: SparkExitQuote, + fresh: SparkExitQuote, + seenSats: UInt64, + seenCount: Int, + feeRate: UInt64 + ) -> UInt64 { + let total = SparkExitMath.prestartFunding( + fundingSats: min(confirmed.fundingSats, fresh.fundingSats), + payments: seenCount, + receivedSats: seenSats, + feeRate: feeRate + ).toSend + return total.saturatingSub(seenSats) } /// The exit a start began, still stored and active (not cancelled meanwhile); nil otherwise. The store keeps diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index d6bd4b1cc..d18c70073 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -172,9 +172,10 @@ struct SparkExitFundingView: View { exit.quote?.fundingSats ?? 0 } - /// What savings pays: the funding less what another wallet already sent to the address, as the start pays it. + /// What savings pays: the funding less what another wallet already sent to the address, plus the savings + /// payment's own input fee then, as the start pays it. private var savingsSats: UInt64 { - fundingSats.saturatingSub(exit.fundingSeenSats) + exit.savingsSats } /// What the funding address has to hold for the payments seen so far, and what the next one should bring it to. diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 44cc3ede7..89a937946 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -25,8 +25,22 @@ final class SparkExitReviewTests: XCTestCase { /// Funding sent from another wallet before the user picked savings is not paid twice. func testSavingsPaysOnlyWhatTheFundingAddressDoesNotHoldYet() { let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) - XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 8000), 12000) - XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 25000), 0) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 25000, seenCount: 1, feeRate: 2), 0) + } + + /// Savings completing a partial payment from another wallet is one more funding input: it pays that input's fee + /// too, so both payments together are adopted. A 20,000 quote at 2 sat/vB with 8,000 received needs 20,136. + func testSavingsCompletingPartialFundingPaysTheExtraInputsFee() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + let sats = SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 8000, seenCount: 1, feeRate: 2) + + XCTAssertEqual(sats, 12136, "savings left the exit short of the second input's fee") + let paid = [ + SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 8000), + SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: sats), + ] + XCTAssertNotNil(SparkExitMath.fundingTopUp(paid, requiredSats: 20000, inputFeeSats: SparkExitMath.inputVbytes * 2)) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 0, seenCount: 0, feeRate: 2), 20000) } /// A tapped notification for an exit whose result was seen already is let go once the record loaded, so a later @@ -996,8 +1010,8 @@ final class SparkExitReviewTests: XCTestCase { let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) let higher = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 35000, leaves: ["leaf-1", "leaf-2"])) let lower = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 15000, leaves: ["leaf-2"])) - XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: higher, seenSats: 0), 20000) - XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: lower, seenSats: 0), 15000) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: higher, seenSats: 0, seenCount: 0, feeRate: 2), 20000) + XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: lower, seenSats: 0, seenCount: 0, feeRate: 2), 15000) var record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: higher, sparkBalanceSats: 0)) record.fundingPaymentTxid = "paid" From c741a304341f2e62e5b10fc79bd50da1adc04129 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:14:38 +0100 Subject: [PATCH 323/378] fix: reset seen funding when the funding address changes --- Bitkit/Managers/SparkExitManager.swift | 8 ++++++++ BitkitTests/SparkExitReviewTests.swift | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index f6f3aad64..06a2053af 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -592,6 +592,9 @@ final class SparkExitManager { guard let address = record.funding.first?.address else { return } fundingAddress = address guard record.fundingShortfallSats != nil else { + // Adopted, or nothing to fund: what was seen is part of the exit now, never funding still to count. + fundingSeenSats = 0 + fundingSeenCount = 0 setFunding(nil, for: record) return } @@ -802,6 +805,11 @@ final class SparkExitManager { /// The quote the funding and confirm screens show, and `start()` starts from. func useQuote(_ quote: SparkExitQuote, destination: String, feeRate: UInt64, fundingIndex: UInt32, fundingAddress: String) { + // What reached another address, an earlier exit's, is not this exit's funding. + if self.fundingAddress != fundingAddress { + fundingSeenSats = 0 + fundingSeenCount = 0 + } pendingDestination = destination pendingFeeRate = feeRate pendingFundingIndex = fundingIndex diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 89a937946..4c9bab4f2 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -709,6 +709,24 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(refreshes, before) } + // MARK: Round 3 (Corey 5859257501) + + /// A quote for a new funding address starts with nothing seen there: an earlier exit's funding never lowers what + /// savings pays. + @MainActor func testAQuoteForANewFundingAddressStartsWithNothingSeen() { + let exit = SparkExitManager(walletIndex: 9999) + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared()) + exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 3, fundingAddress: "bcrt1qearlier") + exit.noteFundingSeen([SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 8000)]) + exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 3, fundingAddress: "bcrt1qearlier") + XCTAssertEqual(exit.fundingSeenSats, 8000, "a quote again for the same address keeps what reached it") + + exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 4, fundingAddress: "bcrt1qnext") + + XCTAssertEqual(exit.fundingSeenSats, 0, "the earlier exit's funding was taken as already sent") + XCTAssertEqual(exit.fundingSeenCount, 0) + } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats From da07aa60da6a1c78a30645da3ec7a6970db67698 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:14:38 +0100 Subject: [PATCH 324/378] fix: count left-behind leaves in what stays on spark --- Bitkit/Services/SparkExit/SparkExitFunding.swift | 3 ++- BitkitTests/SparkExitReviewTests.swift | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index a0ac0d525..93cb360e6 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -645,7 +645,8 @@ enum SparkExitMath { static func outsideExitSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? { guard record.isActive else { return nil } let live = staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) ?? 0 - let outside = max(live, record.outsideSats ?? 0) + // The start's snapshot predates any leaf the exit left behind, which stays on Spark too. + let outside = max(live, (record.outsideSats ?? 0) + record.leftBehindSats) return outside > 0 ? outside : nil } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 4c9bab4f2..fe70aa354 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -727,6 +727,18 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(exit.fundingSeenCount, 0) } + /// Quoted leaves A (65,536) and B (32,768) and C (1,696) outside; the first build leaves B behind. Once A is + /// published and leaves the balance (34,464), Spending still holds B and C. + func testALeafLeftBehindStaysInSpendingOnceThePublishedLeavesLeftTheBalance() { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["A", "B"] + record.leafTotalSats = 98304 + record.outsideSats = 1696 + record.leftBehindLeaves = [.init(leafId: "B", valueSats: 32768, reason: "its refund cannot pay")] + + XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 34464, record: record), 34464, "B was in neither total") + } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats From a13da47894565a615b457a35e8855edc3dd2bd8f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:14:39 +0100 Subject: [PATCH 325/378] fix: rebuild on redo while a top-up is awaited --- .../Services/SparkExit/SparkExitDriver.swift | 15 ++++++- BitkitTests/SparkExitReviewTests.swift | 40 +++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 0b53661d0..dde4fe401 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -120,8 +120,17 @@ struct SparkExitDriver { return try saveIfChanged(record, from: before) } // Nothing to fund when every leaf is recovered cooperatively. + var isRedoFromHeldFunding = false if let shortfall = record.fundingShortfallSats { - guard try await adoptFunding(&record, requiredSats: shortfall) else { + let isAdopted = try await adoptFunding(&record, requiredSats: shortfall) + // A Redo while a top-up is awaited rebuilds from the funding the exit has: a top-up that is dropped + // never leaves it waiting, and one the rebuild needs is asked for again. Paced like any rebuild. + isRedoFromHeldFunding = !isAdopted && record.isRebuilding && record.isRedoRebuild == true + && !record.isFundingWaitingLeaves && isRebuildDue(record) + if isRedoFromHeldFunding { + record.lastBuildAt = now() + } + guard isAdopted || isRedoFromHeldFunding else { await countShortOwnPayment(&record) // The steps already out keep their countdown while the top-up is awaited. await updateClaimableHeight(&record) @@ -137,6 +146,10 @@ struct SparkExitDriver { } } else { try await build(&record, recovering: recovering) + // Held funding that pays for less: the awaited top-up is still followed, as while waiting for it. + if isRedoFromHeldFunding, case .needsFunding = record.phase { + await countShortOwnPayment(&record) + } } } case .running where record.isRebuilding: diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index fe70aa354..9dce42549 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -165,16 +165,18 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertTrue(reminders.contains { $0.id.hasPrefix("1-") }, "the live steps keep their reminders") } - /// A Redo while a higher fee's top-up is awaited: the diverged generation is never sent again, and its rebuild - /// waits for the top-up as a Redo rebuild. + /// A Redo while a higher fee's top-up is awaited: the diverged generation is never sent again, and when the funding + /// held pays for less, its rebuild waits for the top-up as a Redo rebuild. func testARedoWhileAHigherFeesTopUpIsAwaitedStopsDrivingTheDivergedGeneration() async throws { var record = bumpedRecord() chain.mempoolTxids = ["bump"] sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 25680, docsUrl: "") record = try await driver().tick(record) - XCTAssertEqual(record.phase, .needsFunding(requiredSats: 25680)) + XCTAssertEqual(record.phase, .needsFunding(requiredSats: 46360)) + XCTAssertEqual(record.fundingPaymentTxid, "bump", "the awaited top-up was dropped") XCTAssertTrue(record.isRebuilding) XCTAssertEqual(record.isRedoRebuild, true) XCTAssertFalse(record.isDrivingWhileFunding, "the diverged generation would be sent again") @@ -739,6 +741,38 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 34464, record: record), 34464, "B was in neither total") } + /// A Redo while a top-up is awaited rebuilds from the funding the exit already has, so a top-up that is dropped + /// never leaves it waiting. + func testARedoWhileATopUpIsAwaitedRebuildsWithoutWaitingForADroppedTopUp() async throws { + var record = bumpedRecord() + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("m1")])] + + record = try await driver().tick(record) + + XCTAssertFalse(sdk.recoverRequests.isEmpty, "the rebuild waited for a top-up that was dropped") + XCTAssertEqual(record.phase, .running) + XCTAssertFalse(record.isRebuilding) + } + + /// A Redo rebuild the held funding pays too little for still follows the awaited top-up: one that confirmed short + /// asks for the rest, as while waiting for it. + func testARedoRebuildShortOfFundingAsksForTheRestOfAShortTopUp() async throws { + var record = bumpedRecord() + chain.utxosByAddress["bcrt1qfunding"] = [ + SparkExitFixtures.utxo("f0", vout: 1, value: 20000), + SparkExitFixtures.utxo("bump", value: 3000), + ] + sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 25680, docsUrl: "") + + record = try await driver().tick(record) + + XCTAssertFalse(sdk.recoverRequests.isEmpty) + XCTAssertNil(record.fundingPaymentTxid, "the short top-up was still awaited") + XCTAssertEqual(record.isFundingPaymentShort, true) + } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats From 198b0b9ee5e7c455ac96f487ed39100f894ea549 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:14:40 +0100 Subject: [PATCH 326/378] feat: keep earlier exit addresses reachable --- Bitkit/Constants/Env.swift | 6 + Bitkit/Managers/SparkExitManager.swift | 51 +++++++- Bitkit/Managers/SparkManager.swift | 1 + .../Localization/en.lproj/Localizable.strings | 3 + .../SparkExit/SparkExitEarlierAddresses.swift | 113 ++++++++++++++++++ .../Services/SparkExit/SparkExitRecord.swift | 8 +- .../Advanced/SparkConnectionViews.swift | 39 +++++- BitkitTests/SparkExitReviewTests.swift | 20 ++++ BitkitTests/SparkExitSweepTests.swift | 26 +++- 9 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 31dc8b32f..74a20bef9 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -304,6 +304,12 @@ enum Env { } /// The leaves an older state of the wallet claimed in an earlier Spark exit, kept out of every later one. + static func sparkExitEarlierAddresses(walletIndex: Int) -> URL { + appStorageUrl + .appendingPathComponent(networkName) + .appendingPathComponent("wallet\(walletIndex)/spark-exit-earlier-addresses.json") + } + static func sparkExitLostLeaves(walletIndex: Int) -> URL { appStorageUrl .appendingPathComponent(networkName) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 06a2053af..030bfd85e 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -30,6 +30,8 @@ final class SparkExitManager { } private(set) var quote: SparkExitQuote? + /// Funding addresses earlier exits left coins at, or may still receive them at: kept change and cancelled exits'. + private(set) var earlierAddresses: [SparkExitEarlierAddress] = [] private(set) var quoteError: String? private(set) var isQuoting = false private(set) var tipHeight: UInt32? @@ -139,6 +141,32 @@ final class SparkExitManager { record?.showsResult == true } + /// The earlier exits' addresses to show: one the current exit uses again is that exit's, shown on its progress. + var shownEarlierAddresses: [SparkExitEarlierAddress] { + let current = record?.funding.first?.address + return earlierAddresses.filter { $0.address != current } + } + + private var earlierAddressStore: SparkExitEarlierAddressStore { + SparkExitEarlierAddressStore(walletIndex: walletIndex) + } + + /// Reads the cancelled exits' addresses still watched for a late payment, and shows what each holds. + func refreshEarlierAddresses() async { + let store = earlierAddressStore + let stored = (try? store.load()) ?? earlierAddresses + earlierAddresses = stored + guard let chain, stored.contains(where: { $0.isWatched(now: Date()) }) else { return } + let refreshed = await SparkExitEarlierAddress.refreshed(stored, now: Date()) { try await chain.utxos(address: $0) } + guard refreshed != stored else { return } + do { + try store.save(refreshed) + } catch { + Logger.warn("Failed to save the earlier Spark exit addresses: \(error)", context: "SparkExitManager") + } + earlierAddresses = refreshed + } + private var store: SparkExitStore { SparkExitStore(walletIndex: walletIndex) } @@ -150,7 +178,7 @@ final class SparkExitManager { /// Saves the record, and keeps the leaves it lost to an older state for every later exit of the wallet. The lost-leaf /// list is best effort: it never fails the save the driver relies on. private var saveRecord: (SparkExitRecord) throws -> Void { - Self.gatedSave(store: store, lostLeafStore: lostLeafStore, gate: writeGate) + Self.gatedSave(store: store, lostLeafStore: lostLeafStore, earlierAddressStore: earlierAddressStore, gate: writeGate) } /// Writes from ticks and background runs, closed for good once the wallet is wiped: a tick still running then can @@ -160,6 +188,7 @@ final class SparkExitManager { nonisolated static func gatedSave( store: SparkExitStore, lostLeafStore: SparkExitLostLeafStore, + earlierAddressStore: SparkExitEarlierAddressStore? = nil, gate: SparkExitWriteGate ) -> (SparkExitRecord) throws -> Void { { @@ -170,6 +199,14 @@ final class SparkExitManager { } catch { Logger.warn("Failed to keep the Spark exit's lost leaves: \(error)", context: "SparkExitManager") } + // Kept change stays reachable after a later exit replaced this record. Best effort, as the lost leaves. + if let earlierAddressStore, let kept = SparkExitEarlierAddress.keptChange($0, network: Env.bitkitCoreNetwork, at: Date()) { + do { + try earlierAddressStore.add(kept) + } catch { + Logger.warn("Failed to keep the Spark exit's kept change address: \(error)", context: "SparkExitManager") + } + } } } @@ -184,6 +221,7 @@ final class SparkExitManager { self.walletIndex = walletIndex do { record = try Self.loadRecord(store: store, lostLeafStore: lostLeafStore) + earlierAddresses = (try? earlierAddressStore.load()) ?? [] hasLoadedRecord = true } catch is SparkExitStore.Unavailable { // Before the first unlock the file is there but cannot be read yet: it is read again on the next load. @@ -243,6 +281,8 @@ final class SparkExitManager { Logger.error("Failed to delete the wiped wallet's Spark exit record: \(error)", context: "SparkExitManager") } try? lostLeafStore.delete() + try? earlierAddressStore.delete() + earlierAddresses = [] record = nil hasLoadedRecord = false quote = nil @@ -458,6 +498,14 @@ final class SparkExitManager { case .forget: break } + // Its address stays listed, and is read for a late payment while the app is in the foreground for four weeks. + if let cancelled = SparkExitEarlierAddress.cancelledFunding(record, network: Env.bitkitCoreNetwork, at: Date()) { + do { + earlierAddresses = try earlierAddressStore.add(cancelled) + } catch { + Logger.warn("Failed to keep the cancelled Spark exit's address: \(error)", context: "SparkExitManager") + } + } try? store.delete() self.record = nil Self.clearReminders() @@ -474,6 +522,7 @@ final class SparkExitManager { /// Ticks every minute while the app is active and an exit runs. Each step of an exit needs Bitkit to publish it. func runWhileActive() async { + await refreshEarlierAddresses() while !Task.isCancelled, needsDriving { await tick() try? await Task.sleep(nanoseconds: Self.foregroundTickSeconds * 1_000_000_000) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index a473cbc3e..641da6e01 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -560,6 +560,7 @@ final class SparkManager: ChannelFreeLightningRail { // wallet's index even when the exit manager holds another one. try SparkExitStore(walletIndex: walletIndex).deleteWithCopies() try Self.removeIfPresent(Env.sparkExitLostLeaves(walletIndex: walletIndex)) + try Self.removeIfPresent(Env.sparkExitEarlierAddresses(walletIndex: walletIndex)) } nonisolated static func removeIfPresent(_ url: URL) throws { diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 6222a009d..d6bea9018 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -411,6 +411,9 @@ "lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}."; "lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings."; "lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings."; +"lightning__spark_exit__earlier_addresses" = "Earlier exit addresses"; +"lightning__spark_exit__earlier_addresses_text" = "Earlier emergency exits used these funding addresses. Coins left at one stay yours: spend them with your recovery phrase and its derivation path."; +"lightning__spark_exit__earlier_path" = "Derivation path"; "lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}."; "lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation."; "lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation."; diff --git a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift new file mode 100644 index 000000000..ad8f4745f --- /dev/null +++ b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift @@ -0,0 +1,113 @@ +import BitkitCore +import Foundation + +/// A funding address an earlier exit left coins at, or may still receive them at, with the path that spends it. Kept +/// apart from the record, so a later exit can start and replace the record while the address stays reachable. +struct SparkExitEarlierAddress: Codable, Equatable, Identifiable { + enum Kind: String, Codable { + /// Fee change too small to send, kept at the funding address for good. + case keptChange + /// The funding address of a cancelled exit that was forgotten: a late payment can still land there. + case cancelledFunding + } + + let address: String + let path: String + let kind: Kind + /// What the address holds, when known. + var sats: UInt64? + let date: Date + + var id: String { + address + } + + /// About four weeks: how long a cancelled exit's address is still read for a late payment. + static let watchSeconds: TimeInterval = 28 * 24 * 3600 + + /// A cancelled exit's address younger than `watchSeconds`, read in the foreground for a late payment. + func isWatched(now: Date) -> Bool { + kind == .cancelledFunding && now.timeIntervalSince(date) < Self.watchSeconds + } + + /// The kept fee change of an ended exit. + static func keptChange(_ record: SparkExitRecord, network: BitkitCore.Network, at date: Date) -> SparkExitEarlierAddress? { + guard record.isChangeKept, let address = record.funding.first?.address else { return nil } + return SparkExitEarlierAddress( + address: address, + path: SparkExitFundingKey.path(index: record.fundingIndex, network: network), + kind: .keptChange, + sats: record.leftover.reduce(0) { $0 + $1.valueSats }, + date: date + ) + } + + /// The funding address of a cancelled exit being forgotten. + static func cancelledFunding(_ record: SparkExitRecord, network: BitkitCore.Network, at date: Date) -> SparkExitEarlierAddress? { + guard let address = record.funding.first?.address else { return nil } + return SparkExitEarlierAddress( + address: address, + path: SparkExitFundingKey.path(index: record.fundingIndex, network: network), + kind: .cancelledFunding, + sats: nil, + date: date + ) + } + + /// The entries with what each watched address holds now; one that can't be read keeps what it showed. + static func refreshed( + _ entries: [SparkExitEarlierAddress], + now: Date, + utxos: (String) async throws -> [EsploraUtxo] + ) async -> [SparkExitEarlierAddress] { + var refreshed = entries + for i in refreshed.indices where refreshed[i].isWatched(now: now) { + guard let held = try? await utxos(refreshed[i].address) else { continue } + let sats = held.reduce(0) { $0 + $1.value } + refreshed[i].sats = sats > 0 ? sats : nil + } + return refreshed + } +} + +/// The earlier exits' addresses of a wallet, across exits. A wipe deletes it with the record. +struct SparkExitEarlierAddressStore { + let url: URL + + init(url: URL) { + self.url = url + } + + init(walletIndex: Int) { + url = Env.sparkExitEarlierAddresses(walletIndex: walletIndex) + } + + func load() throws -> [SparkExitEarlierAddress] { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return try decoder.decode([SparkExitEarlierAddress].self, from: Data(contentsOf: url)) + } + + /// Adds `entry` unless its address is listed already. + @discardableResult + func add(_ entry: SparkExitEarlierAddress) throws -> [SparkExitEarlierAddress] { + let stored = (try? load()) ?? [] + guard !stored.contains(where: { $0.address == entry.address }) else { return stored } + let entries = stored + [entry] + try save(entries) + return entries + } + + func save(_ entries: [SparkExitEarlierAddress]) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + try encoder.encode(entries).write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + func delete() throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index a4f9a504a..75117605d 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -313,11 +313,11 @@ struct SparkExitRecord: Codable, Equatable { } /// Whether Emergency exit opens this exit rather than a new one: it runs, its leftover is still on its way to - /// savings, the leftover waits at the funding address for good (the sweep was refused, or the change was kept), or - /// it failed or lost leaves and the user has not seen that yet. Coins left at the funding address keep the result, - /// with the address and the path that spends them, and no new exit replaces it. + /// savings, the leftover sweep was refused and the leftover waits at the funding address, or it failed, lost leaves + /// or kept change and the user has not seen that yet. Kept change's address and path stay in the earlier exit + /// addresses (`SparkExitEarlierAddress`), so a later exit may replace this record. var showsResult: Bool { - needsDriving || (!leftover.isEmpty && leftoverSweep?.rejection != nil) || isChangeKept || hasUnseenResult + needsDriving || (!leftover.isEmpty && leftoverSweep?.rejection != nil) || hasUnseenResult } /// The leaves builds select: the quote's, less those an older state claimed and those left behind. diff --git a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift index cabdc9986..8e1c11aa0 100644 --- a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift @@ -11,7 +11,8 @@ struct SparkConnectionRow: View { let spark = SparkManager.shared // An exit's progress and result stay reachable while Spark is not ready, such as with the operators down. let hasExit = spark.isExiting || spark.recoverableFundsSats > 0 - return SparkExitManager.shared.showsResult + // An earlier exit's address stays reachable once Spark holds nothing. + return SparkExitManager.shared.showsResult || !SparkExitManager.shared.shownEarlierAddresses.isEmpty || spark.isReady && (spark.shownBalanceSats > 0 || !spark.deposits.isEmpty || hasExit || Env.network == .regtest) } @@ -185,6 +186,26 @@ struct SparkConnectionDetailView: View { } } + let earlier = SparkExitManager.shared.shownEarlierAddresses + if !earlier.isEmpty { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__spark_exit__earlier_addresses")) + BodySText(t("lightning__spark_exit__earlier_addresses_text")) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 8) + ForEach(earlier) { entry in + copyRow(label: t("wallet__activity_address"), value: entry.address) + .accessibilityIdentifier("SparkExitEarlierAddress") + copyRow(label: t("lightning__spark_exit__earlier_path"), value: entry.path) + .accessibilityIdentifier("SparkExitEarlierPath") + if let sats = entry.sats { + amountRow(label: t("lightning__balance"), amount: sats) + } + } + .padding(.bottom, 8) + } + } + if !spark.withdrawals.isEmpty { VStack(alignment: .leading, spacing: 0) { SettingsSectionHeader(t("lightning__savings_confirm__label")) @@ -223,10 +244,14 @@ struct SparkConnectionDetailView: View { .bottomSafeAreaPadding() .refreshable { await spark.refreshState() + await SparkExitManager.shared.refreshEarlierAddresses() } .task { await loadDepositAddress() } + .task { + await SparkExitManager.shared.refreshEarlierAddresses() + } } /// On regtest, shows Spark's deposit address so testers can fund Spark from Lightspark's regtest faucet. @@ -253,6 +278,18 @@ struct SparkConnectionDetailView: View { } } + private func copyRow(label: String, value: String) -> some View { + Button { + UIPasteboard.general.string = value + Haptics.play(.copiedToClipboard) + app.toast(type: .success, title: t("common__copied"), description: value) + } label: { + detailRow(label: label, value: value) + .contentShape(.rect) + } + .buttonStyle(PlainButtonStyle()) + } + private func amountRow(label: String, amount: UInt64) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center, spacing: 0) { diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 9dce42549..526cd5b30 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -773,6 +773,26 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(record.isFundingPaymentShort, true) } + /// A cancelled exit's funding address stays listed, and for four weeks the foreground reads it: a late payment + /// shows there with its balance. + func testACancelledExitsAddressShowsALatePaymentForFourWeeks() async throws { + let record = SparkExitFixtures.record() + let cancelled = try XCTUnwrap(SparkExitEarlierAddress.cancelledFunding(record, network: .regtest, at: clock.addingTimeInterval(-86400))) + XCTAssertEqual(cancelled.address, record.funding[0].address) + XCTAssertEqual(cancelled.kind, .cancelledFunding) + let old = SparkExitEarlierAddress(address: "bcrt1qold", path: "m/84'/1'/7797'/0/1", kind: .cancelledFunding, sats: nil, + date: clock.addingTimeInterval(-29 * 86400)) + let kept = SparkExitEarlierAddress(address: "bcrt1qkept", path: "m/84'/1'/7797'/0/2", kind: .keptChange, sats: 600, + date: clock.addingTimeInterval(-86400)) + + let refreshed = await SparkExitEarlierAddress.refreshed([cancelled, old, kept], now: clock) { address in + address == cancelled.address ? [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5000, confirmed: false)] + : [SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: 9)] + } + + XCTAssertEqual(refreshed.map(\.sats), [5000, nil, 600], "a late payment at a cancelled exit's address showed nowhere") + } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index 58b0f3818..ee36a6435 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -676,9 +676,9 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(SparkExitFeeBumpOption.options(exitRate: 2, fees: fees).map(\.rate), [3], "a bump still offers a higher rate") } - /// Change kept at the funding address keeps its address and path reachable, even once the result was opened, so - /// the user can spend it and no new exit overwrites the record. - func testKeptChangeKeepsItsRecoveryDetailsReachable() async throws { + /// Change kept at the funding address shows its result once, then lets a later exit start: its address and path + /// stay in the earlier exit addresses, which no new exit replaces. + func testKeptChangeShowsOnceAndItsAddressStaysListed() async throws { var record = endedRecord() record.isResultSeen = true record.feeRateSatPerVbyte = 20 @@ -692,7 +692,25 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(record.leftoverLine, .kept(sats: 600)) XCTAssertTrue(record.showsResult, "the funding address and its path were out of reach") record.isResultSeen = true - XCTAssertTrue(record.showsResult, "the funding address and its path went out of reach once the result was opened") + XCTAssertFalse(record.showsResult, "the kept change blocked every later exit") + + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let earlier = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json")) + let save = SparkExitManager.gatedSave( + store: SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")), + lostLeafStore: SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")), + earlierAddressStore: earlier, + gate: SparkExitWriteGate() + ) + try save(record) + try save(record) + + let listed = try earlier.load() + XCTAssertEqual(listed.map(\.address), [key.address], "the kept change's address is in no list") + XCTAssertEqual(listed.first?.kind, .keptChange) + XCTAssertEqual(listed.first?.sats, 600) + XCTAssertEqual(listed.first?.path, SparkExitFundingKey.path(index: 0, network: Env.bitkitCoreNetwork)) } /// After about four weeks (4,032 blocks) the change stays at the funding address and the exit is let go. From 0c47124ed4cf35dcf279001803e9950f3cec1969 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:15:44 +0100 Subject: [PATCH 327/378] fix: keep the valid generation when funding an unverified rebuild --- .../Services/SparkExit/SparkExitDriver.swift | 34 ++++++++++--- .../Services/SparkExit/SparkExitRecord.swift | 4 ++ BitkitTests/SparkExitTests.swift | 49 +++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index dde4fe401..e4e2cbb88 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -144,6 +144,14 @@ struct SparkExitDriver { if try await !retryWaitingLeaves(&record, recovering: recovering, tip: tip, isFunded: true) { await finish(&record, phase: .done) } + } else if record.isUnverifiedAsk == true, !record.isRebuilding { + // Funding an unverified rebuild asked for resumes that rebuild, with its guard: the valid + // generation stays over a failed-only build, running again with the funding adopted. + var valid = record + valid.phase = .running + valid.isUnverifiedAsk = nil + try await build(&record, recovering: recovering) + keepValidGeneration(&record, over: valid) } else { try await build(&record, recovering: recovering) // Held funding that pays for less: the awaited top-up is still followed, as while waiting for it. @@ -295,11 +303,13 @@ struct SparkExitDriver { // Nothing is left to pay more for: back to running, whose next check finishes it (failed leaves included). // A payment still on its way is followed after the end and swept back with the leftover. record.phase = .running + record.isUnverifiedAsk = nil case .redo: // The diverged generation is never sent again: its rebuild, a Redo one, waits for the top-up. Logger.warn("Spark exit diverged on-chain while a top-up is awaited; rebuilding once it confirms", context: "SparkExitDriver") record.isRebuilding = true record.isRedoRebuild = true + record.isUnverifiedAsk = nil } } @@ -574,14 +584,9 @@ struct SparkExitDriver { try save(record) let valid = record try await build(&record, recovering: recovering) - // A rebuild of only failed cooperative leaves beside the leaf it could not read drops that leaf's signed - // steps, which a matured step needs sent within the operators' window: the valid generation stays, as - // it was before the build, and is what the tick saves. - if record.generation != valid.generation, record.recovery?.response.transactions.isEmpty == true, - !(record.waitingLeafIds ?? []).isEmpty - { - Logger.warn("Spark exit keeps its generation: the rebuild built only failed leaves", context: "SparkExitDriver") - record = valid + if !keepValidGeneration(&record, over: valid), case .needsFunding = record.phase { + // Like a fee bump's, the valid generation keeps going while the funding it asks for comes. + record.isUnverifiedAsk = true } return } @@ -900,6 +905,19 @@ struct SparkExitDriver { } } + /// A rebuild of only failed cooperative leaves beside the leaf it could not read drops that leaf's signed steps, + /// which a matured step needs sent within the operators' window: the valid generation stays, as `valid` holds it, + /// and is what the tick saves. True when it stayed. + @discardableResult + private func keepValidGeneration(_ record: inout SparkExitRecord, over valid: SparkExitRecord) -> Bool { + guard record.generation != valid.generation, record.recovery?.response.transactions.isEmpty == true, + !(record.waitingLeafIds ?? []).isEmpty + else { return false } + Logger.warn("Spark exit keeps its generation: the rebuild built only failed leaves", context: "SparkExitDriver") + record = valid + return true + } + private func isRebuildDue(_ record: SparkExitRecord) -> Bool { guard let last = record.lastBuildAt else { return true } return now().timeIntervalSince(last) >= Self.rebuildInterval diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index 75117605d..ae666f4eb 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -247,6 +247,9 @@ struct SparkExitRecord: Codable, Equatable { /// The pending rebuild answers a divergence the SDK reported (`Redo`). Only such a rebuild adds lost-leaf strikes, /// so rebuilds over a stalled chain or an unverified step never make a leaf lost. var isRedoRebuild: Bool? + /// The fee funding asked for came from a rebuild over a step the SDK could not read, while the generation out there + /// is valid: funding it resumes that rebuild with its guard, and the ask ends once every step reads again. + var isUnverifiedAsk: Bool? /// The tip of the last rebuild over a step the SDK could not read, so it rebuilds at most once a block. var unverifiedRebuiltAtHeight: UInt32? /// The tip at which each of the exit's leaves was first seen stopped (`exitChainState.stoppedLeafIds`). @@ -414,6 +417,7 @@ struct SparkExitRecord: Codable, Equatable { rebuiltFromChainConfirmedCount = SparkExitMath.confirmedCount(built) } isRedoRebuild = nil + isUnverifiedAsk = nil } /// Ends the rebuilt-from-chain note once the rebuilt exit moved: a step confirmed beyond those already in a block. diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 460088eac..8e57469cd 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1616,6 +1616,55 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") } + /// The valid generation beside a step the SDK could not read, whose rebuild of only failed cooperative leaves asks + /// for fee funding first. + private func unverifiedAsk() async throws -> (record: SparkExitRecord, readable: RecoverFundsResponse, failure: CooperativeRecoveryFailure) { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "coop-1"] + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down")) + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + ], failed: [failure]) + let readable = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"]), + ], failed: [failure]) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "") + + record = try await driver().tick(record) + guard case .needsFunding = record.phase else { + XCTFail("the unverified rebuild asked for no funding: \(record.phase)") + return (record, readable, failure) + } + XCTAssertEqual(record.generation, 1) + sdk.recoverError = nil + return (record, readable, failure) + } + + /// Confirmed funding for an unverified rebuild's fee ask resumes that rebuild with its guard: a build of only failed + /// cooperative leaves beside the waiting leaf is never adopted, and the exit runs on with the valid generation. + func testFundingAnUnverifiedRebuildsAskKeepsTheValidGenerationOverAFailedOnlyBuild() async throws { + var (record, _, failure) = try await unverifiedAsk() + XCTAssertEqual(record.isUnverifiedAsk, true) + let unread = try XCTUnwrap(record.recovery?.response) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 50000)] + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + chain.tip = 1001 + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "funding adopted a failed-only build without leaf-1's signed steps") + XCTAssertEqual(record.phase, .running) + XCTAssertNil(record.isUnverifiedAsk) + } + /// R2: while a unilateral step is ready, a rebuild would conflict with it, so nothing rebuilds; a cooperative step /// the SDK could not read is still sent. func testAReadyStepHoldsTheUnverifiedRebuildAndCooperativeStepsAreStillSent() async throws { From b0858643dd21faa46909a5108019d0207abbf748 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:15:45 +0100 Subject: [PATCH 328/378] fix: withdraw an unverified rebuild's fee ask once its steps read again --- Bitkit/Managers/SparkExitManager.swift | 19 +++++++++++++++++++ .../Services/SparkExit/SparkExitDriver.swift | 9 +++++++++ BitkitTests/SparkExitTests.swift | 15 +++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 030bfd85e..2c1e6c732 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -569,6 +569,10 @@ final class SparkExitManager { try? store.save(updated) } record = updated + if Self.isFundingAskWithdrawn(updated, previous: current) { + Self.dismissDeliveredFundingNotifications(record: updated) + } + earlierAddresses = (try? earlierAddressStore.load()) ?? earlierAddresses lastError = nil await afterTick(updated, wasActive: current.isActive) } catch { @@ -1429,6 +1433,21 @@ final class SparkExitManager { UserDefaults.standard.dictionary(forKey: remindersKey) as? [String: TimeInterval] ?? [:] } + /// An unverified rebuild's fee ask the tick from `previous` withdrew: the valid generation needs nothing more. + nonisolated static func isFundingAskWithdrawn(_ record: SparkExitRecord, previous: SparkExitRecord) -> Bool { + guard previous.isUnverifiedAsk == true, case .needsFunding = previous.phase else { return false } + return record.phase == .running + } + + /// Takes back the exit's delivered fee asks, which no longer hold. + private nonisolated static func dismissDeliveredFundingNotifications(record: SparkExitRecord) { + let prefix = notificationId(SparkExitReminder(id: "", fireAt: Date()), record: record) + UNUserNotificationCenter.current().getDeliveredNotifications { delivered in + let ids = delivered.map(\.request.identifier).filter { $0.hasPrefix(prefix) && $0.contains("funding-") } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + } + } + private nonisolated static func clearReminders() { UNUserNotificationCenter.current().getPendingNotificationRequests { requests in UNUserNotificationCenter.current() diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index e4e2cbb88..45364f6de 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -297,6 +297,15 @@ struct SparkExitDriver { record.noteProgressAfterRebuildFromChain(checked.recovery) switch checked.verdict { case .valid: + // An unverified rebuild's ask ends once every step reads again: the valid generation needs nothing more. + // Funding that reached the address meanwhile stays there, for a later rebuild or the leftover sweep. + if record.isUnverifiedAsk == true, record.fundingPaymentTxid == nil, + !checked.recovery.transactions.contains(where: { $0.status == .unverified }) + { + Logger.info("Spark exit steps read again: the unverified rebuild's fee ask is withdrawn", context: "SparkExitDriver") + record.phase = .running + record.isUnverifiedAsk = nil + } let tip = try? await chain.tipHeight() try await broadcastReady(&record, tip: tip) case .done: diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 8e57469cd..8eb1ef173 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1665,6 +1665,21 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertNil(record.isUnverifiedAsk) } + /// An unverified rebuild's fee ask ends once every step reads again and nothing was paid: the exit runs again. + func testAnUnverifiedRebuildsFeeAskEndsOnceItsStepsReadAgain() async throws { + var (record, readable, _) = try await unverifiedAsk() + let asked = record + sdk.checks = [CheckRecoverFundsResponse(recovery: readable, verdict: .valid)] + chain.tip = 1001 + + record = try await driver().tick(record) + + XCTAssertEqual(record.phase, .running, "the fee ask stayed after every step read again") + XCTAssertNil(record.isUnverifiedAsk) + XCTAssertTrue(SparkExitManager.isFundingAskWithdrawn(record, previous: asked)) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + } + /// R2: while a unilateral step is ready, a rebuild would conflict with it, so nothing rebuilds; a cooperative step /// the SDK could not read is still sent. func testAReadyStepHoldsTheUnverifiedRebuildAndCooperativeStepsAreStillSent() async throws { From 32ccd355faa94978502fe962c9ed5ce61c4ef55c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:15:45 +0100 Subject: [PATCH 329/378] fix: keep the valid generation when a rebuild leaves a recovered leaf waiting --- .../Services/SparkExit/SparkExitDriver.swift | 13 ++++--- BitkitTests/SparkExitTests.swift | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 45364f6de..c8b992fb3 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -914,15 +914,16 @@ struct SparkExitDriver { } } - /// A rebuild of only failed cooperative leaves beside the leaf it could not read drops that leaf's signed steps, - /// which a matured step needs sent within the operators' window: the valid generation stays, as `valid` holds it, - /// and is what the tick saves. True when it stayed. + /// A rebuild that leaves waiting a leaf the valid generation recovers drops that leaf's signed steps, which a + /// matured step needs sent within the operators' window, whether it built only failed cooperative leaves or other + /// leaves: the valid generation stays, as `valid` holds it, and is what the tick saves. While the leaf stays + /// unreadable the rebuild repeats once a block. True when it stayed. @discardableResult private func keepValidGeneration(_ record: inout SparkExitRecord, over valid: SparkExitRecord) -> Bool { - guard record.generation != valid.generation, record.recovery?.response.transactions.isEmpty == true, - !(record.waitingLeafIds ?? []).isEmpty + guard record.generation != valid.generation, let waiting = record.waitingLeafIds, + valid.recovery?.response.leaves.contains(where: { waiting.contains($0.leafId) }) == true else { return false } - Logger.warn("Spark exit keeps its generation: the rebuild built only failed leaves", context: "SparkExitDriver") + Logger.warn("Spark exit keeps its generation: the rebuild left waiting a leaf it recovers", context: "SparkExitDriver") record = valid return true } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 8eb1ef173..cb5d4600d 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1616,6 +1616,45 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") } + /// An unverified rebuild that skips the leaf it could not read but builds another unilateral leaf keeps the valid + /// generation: the unread leaf's signed steps are still sent once the chain reads again. + func testAnUnverifiedRebuildBesideAnotherUnilateralLeafKeepsTheValidGeneration() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)), + ]) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = Array(repeating: SparkExitFixtures.recovery([ + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2b", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)), + ]), count: 2) + + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1, "rebuild adopted a recovery without leaf-1's signed steps") + + // The chain reads again: whatever the record holds, its unverified steps now read as ready. + var reread = try XCTUnwrap(record.recovery?.response) + reread.transactions = reread.transactions.map { + var tx = $0 + if tx.status == .unverified { + tx.status = .ready + } + return tx + } + sdk.checks = [CheckRecoverFundsResponse(recovery: reread, verdict: .valid)] + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") + } + /// The valid generation beside a step the SDK could not read, whose rebuild of only failed cooperative leaves asks /// for fee funding first. private func unverifiedAsk() async throws -> (record: SparkExitRecord, readable: RecoverFundsResponse, failure: CooperativeRecoveryFailure) { From a78786d10425b09ed2b584e70a9782ddb150857a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:15:45 +0100 Subject: [PATCH 330/378] fix: keep the retry clock after an unverified rebuild that adopts nothing --- .../Services/SparkExit/SparkExitDriver.swift | 10 +++ BitkitTests/SparkExitTests.swift | 73 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index c8b992fb3..3316e0b3d 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -152,6 +152,7 @@ struct SparkExitDriver { valid.isUnverifiedAsk = nil try await build(&record, recovering: recovering) keepValidGeneration(&record, over: valid) + keepRetryClock(&record, from: valid) } else { try await build(&record, recovering: recovering) // Held funding that pays for less: the awaited top-up is still followed, as while waiting for it. @@ -593,6 +594,7 @@ struct SparkExitDriver { try save(record) let valid = record try await build(&record, recovering: recovering) + keepRetryClock(&record, from: valid) if !keepValidGeneration(&record, over: valid), case .needsFunding = record.phase { // Like a fee bump's, the valid generation keeps going while the funding it asks for comes. record.isUnverifiedAsk = true @@ -928,6 +930,14 @@ struct SparkExitDriver { return true } + /// An unverified rebuild that adopted nothing keeps the valid generation's retry clock: one it set while waiting + /// for the unread leaf would let that leaf go after about a week, ending the exit before its signed steps are sent. + private func keepRetryClock(_ record: inout SparkExitRecord, from valid: SparkExitRecord) { + guard record.generation == valid.generation, record.isActive else { return } + record.retrySince = valid.retrySince + record.retriedAt = valid.retriedAt + } + private func isRebuildDue(_ record: SparkExitRecord) -> Bool { guard let last = record.lastBuildAt else { return true } return now().timeIntervalSince(last) >= Self.rebuildInterval diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index cb5d4600d..f616dd5c6 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1719,6 +1719,79 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) } + /// The steps of leaf-1 as the chain reads them: n2 unread, waiting for its timelock, then ready. + private func leafOneSteps(_ status: ExitTransactionStatus) -> RecoverFundsResponse { + SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: status), + ]) + } + + /// A prepare that skips leaf-1, as the SDK does while it cannot read leaf-1's chain. + private var skipsUnreadLeaf: PrepareRecoverFundsResponse { + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: []) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + return prepared + } + + /// Funding an unverified rebuild's ask resumes a rebuild that waits for the unread leaf: the valid generation keeps + /// no retry clock from it, so a week on the exit still sends leaf-1's signed step once it matures. + func testAFundedUnverifiedRebuildThatWaitsForTheUnreadLeafLeavesNoStaleRetryClock() async throws { + let unread = leafOneSteps(.unverified) + var record = SparkExitFixtures.record(phase: .running) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 30000, docsUrl: "") + record = try await driver().tick(record) + guard case .needsFunding = record.phase else { return XCTFail("the unverified rebuild asked for no funding: \(record.phase)") } + XCTAssertEqual(record.isUnverifiedAsk, true) + + sdk.recoverError = nil + sdk.preparedResponse = skipsUnreadLeaf + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 100_000)] + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1) + XCTAssertNil(record.retrySince, "the funded unverified rebuild saved a retry clock at '\(record.retrySince.map(String.init) ?? "")'") + + try await runsUntilLeafOneMatures(&record) + } + + /// A check's unverified rebuild that waits for the unread leaf keeps the valid generation without a retry clock. + func testAnUnverifiedRebuildThatWaitsForTheUnreadLeafLeavesNoStaleRetryClock() async throws { + let unread = leafOneSteps(.unverified) + var record = SparkExitFixtures.record(phase: .running) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + sdk.preparedResponse = skipsUnreadLeaf + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1) + XCTAssertNil(record.retrySince, "the unverified rebuild saved a retry clock at '\(record.retrySince.map(String.init) ?? "")'") + + try await runsUntilLeafOneMatures(&record) + } + + /// Leaf-1 reads as waiting for its timelock, unread again a week later, then ready: n2 goes out. + private func runsUntilLeafOneMatures(_ record: inout SparkExitRecord) async throws { + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.waitingForTimelock(spendableAtHeight: 3000)), verdict: .valid)] + chain.tip = 1002 + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running) + XCTAssertNil(record.retrySince, "the exit runs with a stale retry clock '\(record.retrySince.map(String.init) ?? "")'") + + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.unverified), verdict: .valid)] + chain.tip = 2100 + record = try await driver().tick(record) + XCTAssertEqual(record.phase, .running, "the exit ended before leaf-1's signed step n2 was sent") + XCTAssertEqual(record.generation, 1) + + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)] + chain.tip = 3000 + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") + } + /// R2: while a unilateral step is ready, a rebuild would conflict with it, so nothing rebuilds; a cooperative step /// the SDK could not read is still sent. func testAReadyStepHoldsTheUnverifiedRebuildAndCooperativeStepsAreStillSent() async throws { From f0d06d9bc65d1dc33a66f3429d0e20629b299132 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:05:40 +0100 Subject: [PATCH 331/378] fix: guard an unverified rebuild before saving it The valid-generation guard now runs inside build, before the rebuild is adopted, saved or broadcast, so neither the store nor the chain ever sees a generation without the unread leaf's signed steps. --- .../Services/SparkExit/SparkExitDriver.swift | 17 ++++-- BitkitTests/SparkExitTests.swift | 58 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 3316e0b3d..04b4983e8 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -150,8 +150,7 @@ struct SparkExitDriver { var valid = record valid.phase = .running valid.isUnverifiedAsk = nil - try await build(&record, recovering: recovering) - keepValidGeneration(&record, over: valid) + try await build(&record, recovering: recovering, keeping: valid) keepRetryClock(&record, from: valid) } else { try await build(&record, recovering: recovering) @@ -347,7 +346,9 @@ struct SparkExitDriver { // MARK: Build - private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + /// With `valid`, an unverified rebuild's: the valid generation stays over a rebuild that leaves waiting a leaf it + /// recovers, before that rebuild is saved or any of it broadcast. + private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering, keeping valid: SparkExitRecord? = nil) async throws { let tip = try? await chain.tipHeight() // Waiting for a leaf that may still move: tried again at growing gaps of blocks, not every tick. if let since = record.retrySince { @@ -411,6 +412,9 @@ struct SparkExitDriver { if !built.failed.isEmpty { record.adopt(built, at: now()) record.waitingLeafIds = waitingLeafIds.isEmpty ? nil : waitingLeafIds + if let valid { + keepValidGeneration(&record, over: valid) + } return } await finish(&record, phase: .failed(reason: "Nothing to recover")) @@ -436,6 +440,9 @@ struct SparkExitDriver { } record.adopt(built, at: now()) record.waitingLeafIds = waitingLeafIds.isEmpty ? nil : waitingLeafIds + if let valid, keepValidGeneration(&record, over: valid) { + return + } record.spentStepStrikes = strikes.isEmpty ? nil : strikes // Saved before any of it is broadcast, so a crash never leaves broadcast transactions without their record. try save(record) @@ -593,9 +600,9 @@ struct SparkExitDriver { record.unverifiedRebuiltAtHeight = tip try save(record) let valid = record - try await build(&record, recovering: recovering) + try await build(&record, recovering: recovering, keeping: valid) keepRetryClock(&record, from: valid) - if !keepValidGeneration(&record, over: valid), case .needsFunding = record.phase { + if case .needsFunding = record.phase { // Like a fee bump's, the valid generation keeps going while the funding it asks for comes. record.isUnverifiedAsk = true } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index f616dd5c6..493474269 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1655,6 +1655,64 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent") } + /// The adopted rebuild without leaf-1 never reaches the store, even for the moment before the guard restores it: + /// a process killed in that window relaunches from what was saved. + func testAnUnverifiedRebuildBesideAnotherUnilateralLeafNeverSavesTheRebuild() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)), + ]) + record.adopt(unread, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2b", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)), + ])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1) + let rebuilt = saved.filter { $0.generation != 1 } + XCTAssertTrue( + rebuilt.isEmpty, + "saved generation \(rebuilt.map(\.generation)) without leaf-1's steps: \(rebuilt.first?.recovery?.response.transactions.map(\.txid) ?? [])" + ) + } + + /// Funding for an unverified rebuild's ask: a rebuild without leaf-1 is neither saved nor broadcast. + func testFundingAnUnverifiedRebuildsAskNeverSavesNorSendsARebuildWithoutTheUnreadLeaf() async throws { + var (record, _, _) = try await unverifiedAsk() + let unread = try XCTUnwrap(record.recovery?.response) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 50000)] + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2b", cpfp: "03c1", dependsOn: ["m1"]), + ])] + chain.tip = 1001 + saved = [] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "funding adopted a rebuild without leaf-1's signed steps") + let rebuilt = saved.filter { $0.generation != 1 } + XCTAssertTrue(rebuilt.isEmpty, "saved generation \(rebuilt.map(\.generation)) without leaf-1's steps") + XCTAssertFalse( + chain.broadcasts.map(\.txid).contains("m2b"), + "broadcast m2b of a generation the record does not keep: \(chain.broadcasts.map(\.txid))" + ) + } + /// The valid generation beside a step the SDK could not read, whose rebuild of only failed cooperative leaves asks /// for fee funding first. private func unverifiedAsk() async throws -> (record: SparkExitRecord, readable: RecoverFundsResponse, failure: CooperativeRecoveryFailure) { From 33766fb3e7bffb73e48d86a0a8554a86c3ad93f0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:08:11 +0100 Subject: [PATCH 332/378] fix: pay savings funding at least at the dust limit A savings payment that completes another wallet's partial funding is raised to the 294-sat dust limit, as the Progress top-up is, so the onchain wallet never refuses it. --- Bitkit/Managers/SparkExitManager.swift | 6 ++++-- BitkitTests/SparkExitReviewTests.swift | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 2c1e6c732..32a0700d2 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -1107,7 +1107,8 @@ final class SparkExitManager { /// Savings pays at most the funding the user confirmed: a higher fresh amount goes through the shortfall flow on /// Progress, which asks for the rest. - /// Funding another wallet already sent to the address (`seenSats`) is not paid again. + /// Funding another wallet already sent to the address (`seenSats`) is not paid again. What is still missing is + /// paid at least at the dust limit, as the Progress top-up asks for it: the onchain wallet refuses less. nonisolated static func savingsFundingSats( confirmed: SparkExitQuote, fresh: SparkExitQuote, @@ -1121,7 +1122,8 @@ final class SparkExitManager { receivedSats: seenSats, feeRate: feeRate ).toSend - return total.saturatingSub(seenSats) + let missing = total.saturatingSub(seenSats) + return missing > 0 ? max(missing, SparkExitMath.fundingDustSats) : 0 } /// The exit a start began, still stored and active (not cancelled meanwhile); nil otherwise. The store keeps diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 526cd5b30..478c3b20b 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -43,6 +43,14 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 0, seenCount: 0, feeRate: 2), 20000) } + /// Savings never pays under the 294-sat dust limit, which the onchain wallet refuses: a 20,000 quote at 2 sat/vB + /// with 19,900 received misses 236 sats and pays 294. + func testSavingsCompletingAlmostFullFundingPaysAtLeastTheDustLimit() { + let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000)) + let sats = SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 19900, seenCount: 1, feeRate: 2) + XCTAssertEqual(sats, SparkExitMath.fundingDustSats, "savings paid a dust amount") + } + /// A tapped notification for an exit whose result was seen already is let go once the record loaded, so a later /// exit's result never jumps to Progress. func testATappedNotificationIsLetGoOnceTheLoadedExitShowsNoResult() { From 693c45a4404dd7b3571b132a7a7e838293727423 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:08:11 +0100 Subject: [PATCH 333/378] fix: let a refused leftover sweep show once A leftover sweep refused for good now shows its result once, like kept change, then lets a later exit start. Its funding address and derivation path go onto Earlier exit addresses. --- .../Services/SparkExit/SparkExitDriver.swift | 2 ++ .../SparkExit/SparkExitEarlierAddresses.swift | 6 +++--- .../Services/SparkExit/SparkExitRecord.swift | 20 ++++++++++++------- BitkitTests/SparkExitSweepTests.swift | 16 +++++++++++++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 04b4983e8..c88e2c959 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -1110,6 +1110,8 @@ struct SparkExitDriver { } } else { record.leftoverSweep?.rejection = reason + // Final: shown once more with its address, even after the result was opened, as kept change is. + record.isResultSeen = nil } // Once accepted, a later refusal is mostly a node that already has it or a conflict with it confirming. A fee // refusal of one no host holds any more means it was evicted when the mempool's floor rose: it waits and is diff --git a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift index ad8f4745f..a39de9b26 100644 --- a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift +++ b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift @@ -5,7 +5,7 @@ import Foundation /// apart from the record, so a later exit can start and replace the record while the address stays reachable. struct SparkExitEarlierAddress: Codable, Equatable, Identifiable { enum Kind: String, Codable { - /// Fee change too small to send, kept at the funding address for good. + /// Fee funding left at the funding address for good: change too small to send, or a refused sweep's. case keptChange /// The funding address of a cancelled exit that was forgotten: a late payment can still land there. case cancelledFunding @@ -30,9 +30,9 @@ struct SparkExitEarlierAddress: Codable, Equatable, Identifiable { kind == .cancelledFunding && now.timeIntervalSince(date) < Self.watchSeconds } - /// The kept fee change of an ended exit. + /// The leftover an ended exit kept at its funding address. static func keptChange(_ record: SparkExitRecord, network: BitkitCore.Network, at date: Date) -> SparkExitEarlierAddress? { - guard record.isChangeKept, let address = record.funding.first?.address else { return nil } + guard record.isLeftoverKept, let address = record.funding.first?.address else { return nil } return SparkExitEarlierAddress( address: address, path: SparkExitFundingKey.path(index: record.fundingIndex, network: network), diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index ae666f4eb..cc87257c6 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -304,10 +304,10 @@ struct SparkExitRecord: Codable, Equatable { return false } - /// An ended exit that failed, lost leaves or kept change at the funding address, whose result the user has not - /// opened yet. + /// An ended exit that failed, lost leaves or kept its leftover at the funding address, whose result the user has + /// not opened yet. var hasUnseenResult: Bool { - !isActive && (isFailed || lostSats > 0 || isChangeKept) && isResultSeen != true + !isActive && (isFailed || lostSats > 0 || isLeftoverKept) && isResultSeen != true } /// The fee change stays at the funding address for good, shown with the path that spends it. @@ -315,12 +315,18 @@ struct SparkExitRecord: Codable, Equatable { !leftover.isEmpty && leftoverSweep?.isDustKept == true } + /// The leftover stays at the funding address for good: change too small to send, or a sweep refused for a reason + /// other than its fee, which nothing retries. + var isLeftoverKept: Bool { + isChangeKept || (!leftover.isEmpty && leftoverSweep?.rejection != nil && !isLeftoverSweepPending) + } + /// Whether Emergency exit opens this exit rather than a new one: it runs, its leftover is still on its way to - /// savings, the leftover sweep was refused and the leftover waits at the funding address, or it failed, lost leaves - /// or kept change and the user has not seen that yet. Kept change's address and path stay in the earlier exit - /// addresses (`SparkExitEarlierAddress`), so a later exit may replace this record. + /// savings, or it failed, lost leaves or kept its leftover at the funding address and the user has not seen that + /// yet. A kept leftover's address and path stay in the earlier exit addresses (`SparkExitEarlierAddress`), so a + /// later exit may replace this record. var showsResult: Bool { - needsDriving || (!leftover.isEmpty && leftoverSweep?.rejection != nil) || hasUnseenResult + needsDriving || hasUnseenResult } /// The leaves builds select: the quote's, less those an older state claimed and those left behind. diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift index ee36a6435..a48db3b05 100644 --- a/BitkitTests/SparkExitSweepTests.swift +++ b/BitkitTests/SparkExitSweepTests.swift @@ -713,6 +713,22 @@ final class SparkExitSweepTests: XCTestCase { XCTAssertEqual(listed.first?.path, SparkExitFundingKey.path(index: 0, network: Env.bitkitCoreNetwork)) } + /// A leftover sweep refused for good shows its result once, then lets a later exit start: the funding address and + /// its path stay in the earlier exit addresses. + func testARefusedLeftoverSweepShowsOnceAndItsAddressStaysListed() throws { + var record = endedRecord() + record.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "non-mandatory-script-verify-flag") + XCTAssertTrue(record.showsResult, "the leftover's address and path were out of reach") + XCTAssertTrue(record.hasUnseenResult, "opening the result never lets the refused sweep go") + record.isResultSeen = true + XCTAssertFalse(record.showsResult, "the refused sweep blocked every later exit") + + let listed = try XCTUnwrap(SparkExitEarlierAddress.keptChange(record, network: .regtest, at: Date()), "the leftover's address is in no list") + XCTAssertEqual(listed.address, key.address) + XCTAssertEqual(listed.kind, .keptChange) + XCTAssertEqual(listed.sats, 5035) + } + /// After about four weeks (4,032 blocks) the change stays at the funding address and the exit is let go. func testChangeTooSmallForFourWeeksIsKept() async throws { var record = endedRecord() From 6e978eae92cf8825a8ac0715aab7069539643ca2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:08:18 +0100 Subject: [PATCH 334/378] fix: read kept change at earlier exit addresses Kept change entries are read whenever the list is, as cancelled exits' addresses are, so the balance shown is current once the change was spent by hand. --- Bitkit/Managers/SparkExitManager.swift | 3 ++- Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift | 5 +++-- BitkitTests/SparkExitReviewTests.swift | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 32a0700d2..e62e490bf 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -151,7 +151,8 @@ final class SparkExitManager { SparkExitEarlierAddressStore(walletIndex: walletIndex) } - /// Reads the cancelled exits' addresses still watched for a late payment, and shows what each holds. + /// Reads the watched earlier exit addresses, kept change and cancelled exits' for a late payment, and shows what + /// each holds. func refreshEarlierAddresses() async { let store = earlierAddressStore let stored = (try? store.load()) ?? earlierAddresses diff --git a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift index a39de9b26..39da3b250 100644 --- a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift +++ b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift @@ -25,9 +25,10 @@ struct SparkExitEarlierAddress: Codable, Equatable, Identifiable { /// About four weeks: how long a cancelled exit's address is still read for a late payment. static let watchSeconds: TimeInterval = 28 * 24 * 3600 - /// A cancelled exit's address younger than `watchSeconds`, read in the foreground for a late payment. + /// Read in the foreground for what it holds: kept change always, so change spent by hand shows as spent, and a + /// cancelled exit's address younger than `watchSeconds`, for a late payment. func isWatched(now: Date) -> Bool { - kind == .cancelledFunding && now.timeIntervalSince(date) < Self.watchSeconds + kind == .keptChange || now.timeIntervalSince(date) < Self.watchSeconds } /// The leftover an ended exit kept at its funding address. diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 478c3b20b..c8ae0470e 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -782,7 +782,7 @@ final class SparkExitReviewTests: XCTestCase { } /// A cancelled exit's funding address stays listed, and for four weeks the foreground reads it: a late payment - /// shows there with its balance. + /// shows there with its balance. Kept change is read every time, so change the user spent by hand shows as spent. func testACancelledExitsAddressShowsALatePaymentForFourWeeks() async throws { let record = SparkExitFixtures.record() let cancelled = try XCTUnwrap(SparkExitEarlierAddress.cancelledFunding(record, network: .regtest, at: clock.addingTimeInterval(-86400))) @@ -798,7 +798,7 @@ final class SparkExitReviewTests: XCTestCase { : [SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: 9)] } - XCTAssertEqual(refreshed.map(\.sats), [5000, nil, 600], "a late payment at a cancelled exit's address showed nowhere") + XCTAssertEqual(refreshed.map(\.sats), [5000, nil, 9], "a late payment, or kept change spent by hand, showed nowhere") } // MARK: What stays on Spark (Android 124d66170, caa349d4b) From e728b6576c98143d55625f053e5da127ad434554 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:08:19 +0100 Subject: [PATCH 335/378] docs: fix earlier exit address comments --- Bitkit/Constants/Env.swift | 3 ++- Bitkit/Services/SparkExit/SparkExitDriver.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 74a20bef9..07bc428ef 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -303,13 +303,14 @@ enum Env { .appendingPathComponent("wallet\(walletIndex)/spark-exit.json") } - /// The leaves an older state of the wallet claimed in an earlier Spark exit, kept out of every later one. + /// Earlier Spark exits' funding addresses that hold, or may still receive, coins, kept past the exit record. static func sparkExitEarlierAddresses(walletIndex: Int) -> URL { appStorageUrl .appendingPathComponent(networkName) .appendingPathComponent("wallet\(walletIndex)/spark-exit-earlier-addresses.json") } + /// The leaves an older state of the wallet claimed in an earlier Spark exit, kept out of every later one. static func sparkExitLostLeaves(walletIndex: Int) -> URL { appStorageUrl .appendingPathComponent(networkName) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index c88e2c959..017a3f458 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -1206,7 +1206,8 @@ struct SparkExitDriver { if tip.saturatingSub(since) >= Self.sweepWaitBlocks { Logger.info("Spark exit keeps its fee change at the funding address after waiting for fees", context: "SparkExitDriver") record.leftoverSweep?.isDustKept = true - // Shown once more with its path, even after the result was opened: nothing else reaches that address. + // Shown once more with its path, even after the result was opened; the earlier exit addresses keep + // listing it after that. record.isResultSeen = nil try save(record) return From 199729d81b1b8e208ebcf82cc3f9d2161c65d330 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:23:29 +0100 Subject: [PATCH 336/378] fix: refuse pre-restore invoices spark hasn't listed until they expire Spark can report a sync while its payment history failed to sync, so its list can lack a send made before a restore. The restore time is now kept, and on either rail a still-valid invoice created before it that Spark doesn't list as pending or paid is refused until the invoice expires, also while Spark's list can't be read. An undecodable invoice counts as created before the restore and never expiring. The refusal is a send refusal, so Paykit and QuickPay treat it as nothing sent, and the send failure asks for a new invoice, also when the refusal is wrapped in another error. --- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Services/LightningService.swift | 70 +++++++++++- .../Views/Onboarding/RestoreWalletView.swift | 2 +- Bitkit/Views/Wallets/Send/SendFailure.swift | 21 ++++ BitkitTests/ChannelFreeRailTests.swift | 108 +++++++++++++++++- 5 files changed, 191 insertions(+), 11 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index f7a27dc64..c3414587d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1400,6 +1400,7 @@ "wallet__payment_recipient_rejected" = "The recipient rejected this Lightning payment. Please check the invoice and try again."; "wallet__payment_abandoned" = "The Lightning payment was stopped before it completed."; "wallet__payment_expired" = "This Lightning payment expired. Please request a new invoice."; +"wallet__payment_restored_invoice" = "Bitkit can't confirm this invoice wasn't paid before you restored your wallet. Please request a new invoice."; "wallet__payment_timeout" = "Payment timed out. Please try again."; "wallet__payment_route_not_found" = "Bitkit couldn't find a Lightning route for this payment."; "wallet__payment_retries_exhausted" = "Bitkit tried several Lightning routes, but the payment could not be completed."; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 8ef636a77..8888d7015 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -51,6 +51,12 @@ struct ChannelFreeRailUnavailable: Error {} struct ChannelFreeRailSendRefused: LocalizedError { let reason: String var underlying: Error? + /// A still-valid invoice created before the restore, as Spark can't show it didn't pay it then. + var isRestoredInvoiceUnverified = false + + static func restoredInvoiceUnverified() -> ChannelFreeRailSendRefused { + ChannelFreeRailSendRefused(reason: t("wallet__payment_restored_invoice"), isRestoredInvoiceUnverified: true) + } /// Always the user copy in `reason`; the underlying error is for logs. var errorDescription: String? { @@ -1054,7 +1060,10 @@ class LightningService: NodeLifecycleService { if !channelsOnly, let rail = channelFreeRail, await shouldPayWithRail(rail, bolt11: bolt11, sats: sats) { // LDK refuses its own repeats; Spark doesn't, so a Spark send checks both rails. if let paid = try await settledEarlierAttempt( - paymentHash: paymentHash, on: [.ldk, .channelFree], invoiceCreatedAt: invoice?.secondsSinceEpoch() + paymentHash: paymentHash, + on: [.ldk, .channelFree], + invoiceCreatedAt: invoice?.secondsSinceEpoch(), + invoiceExpiresAt: expiresAt ) { return paid } @@ -1115,7 +1124,10 @@ class LightningService: NodeLifecycleService { } } if let paid = try await settledEarlierAttempt( - paymentHash: paymentHash, on: [.channelFree], invoiceCreatedAt: invoice?.secondsSinceEpoch() + paymentHash: paymentHash, + on: [.channelFree], + invoiceCreatedAt: invoice?.secondsSinceEpoch(), + invoiceExpiresAt: expiresAt ) { return paid } @@ -1189,7 +1201,25 @@ class LightningService: NodeLifecycleService { /// Called as a wallet is restored, before Spark starts. func markSparkHistoryUnseeded() { let since = Int(now().timeIntervalSince1970) - eventCallbackLock.withLock { railAttemptsStore.set(since, forKey: Self.sparkHistoryUnseededSinceKey) } + eventCallbackLock.withLock { + railAttemptsStore.set(since, forKey: Self.sparkHistoryUnseededSinceKey) + railAttemptsStore.set(since, forKey: Self.sparkRestoredAtKey) + } + } + + /// When the wallet was restored, in seconds since 1970. Spark can report a sync while its payment history failed to + /// sync, so its list can lack a send it made before then, and a still-valid invoice created before it that Spark + /// hasn't listed as pending or paid is refused on either rail until it expires. + static let sparkRestoredAtKey = "sparkRestoredAt" + + var sparkRestoredAt: Int? { + eventCallbackLock.withLock { railAttemptsStore.object(forKey: Self.sparkRestoredAtKey) as? Int } + } + + /// Called when a restore fails before the wallet exists, so nothing stays held for it. + func clearSparkRestore() { + clearSparkHistoryUnseeded() + eventCallbackLock.withLock { railAttemptsStore.removeObject(forKey: Self.sparkRestoredAtKey) } } func clearSparkHistoryUnseeded() { @@ -1414,11 +1444,13 @@ class LightningService: NodeLifecycleService { /// An invoice must never go out on both rails: an earlier attempt still pending on `rails` refuses the payment as a /// duplicate, as LDK does its own, and a settled one returns its hash as paid. A failed or unknown one lets the /// payment go. While either rail can't list its payments, an invoice it was sent over is refused as pending too, and - /// while Spark hasn't listed its sends since a restore, one created (`invoiceCreatedAt`) before it is refused. + /// while Spark hasn't listed its sends since a restore, one created (`invoiceCreatedAt`) before it is refused, as + /// is one Spark's list doesn't show pending or paid until it expires (`refuseUnverifiedRestoredInvoice`). private func settledEarlierAttempt( paymentHash: String?, on rails: [LightningPaymentRail], - invoiceCreatedAt: UInt64? = nil + invoiceCreatedAt: UInt64?, + invoiceExpiresAt: Int? ) async throws -> PaymentHash? { guard let paymentHash else { return nil } var statuses: [PaymentStatus] = rails.contains(.channelFree) && isRailSendInFlight(paymentHash: paymentHash) ? [.pending] : [] @@ -1462,6 +1494,9 @@ class LightningService: NodeLifecycleService { statuses.append(.pending) } else { releaseRailAttempt(paymentHash: paymentHash) + try await refuseUnverifiedRestoredInvoice( + paymentHash: paymentHash, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt + ) } } else if payment?.status == .succeeded { // Only a settled row ends the unknown send: a pending one may be the synthetic row it is listed as. @@ -1473,7 +1508,11 @@ class LightningService: NodeLifecycleService { } else if let invoiceCreatedAt, let since = sparkHistoryUnseededSince, Int(clamping: invoiceCreatedAt) < since { // Nothing left the wallet, and an invoice made after the restore can't be one Spark paid before it. Logger.warn("Refused paying '\(paymentHash)' until Spark lists its sends since the restore", context: "LightningService") - throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) + throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() + } else { + try await refuseUnverifiedRestoredInvoice( + paymentHash: paymentHash, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt + ) } } if statuses.contains(.succeeded) { @@ -1487,6 +1526,25 @@ class LightningService: NodeLifecycleService { return nil } + /// After a restore, refuses a still-valid invoice created before it, whatever Spark's sync said, until it expires. + private func refuseUnverifiedRestoredInvoice(paymentHash: String, invoiceCreatedAt: UInt64?, invoiceExpiresAt: Int?) async throws { + guard let restoredAt = sparkRestoredAt, Self.holdsRestoredInvoice( + restoredAt: restoredAt, + invoiceCreatedAt: invoiceCreatedAt, + invoiceExpiresAt: invoiceExpiresAt, + now: Int(now().timeIntervalSince1970) + ) else { return } + let sparkRuns = channelFreeRail != nil ? true : await channelFreeRailSupported() + guard sparkRuns else { return } + Logger.warn("Refused paying '\(paymentHash)', a pre-restore invoice Spark hasn't listed", context: "LightningService") + throw ChannelFreeRailSendRefused.restoredInvoiceUnverified() + } + + /// An unknown time counts as before the restore and never expiring. + static func holdsRestoredInvoice(restoredAt: Int, invoiceCreatedAt: UInt64?, invoiceExpiresAt: Int?, now: Int) -> Bool { + Int(clamping: invoiceCreatedAt ?? 0) < restoredAt && invoiceExpiresAt.map { now < $0 } ?? true + } + /// Nil while the rail's payments can't be read: listing them failed, or the rail runs here but isn't connected. private func readableRailPayments() async -> [ChannelFreeRailPayment]? { try? await channelFreeRailPayments() diff --git a/Bitkit/Views/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 854f32bb2..b6b9e53cc 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -290,7 +290,7 @@ struct RestoreWalletView: View { // The node never started, so no sync will lift the hold. Left in place it would silence // every later on-chain receive, across retries and relaunches. #588 SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0 - LightningService.shared.clearSparkHistoryUnseeded() + LightningService.shared.clearSparkRestore() BackupService.shared.setRestoring(false) app.toast(error) } diff --git a/Bitkit/Views/Wallets/Send/SendFailure.swift b/Bitkit/Views/Wallets/Send/SendFailure.swift index 243eaf406..9d139f0d4 100644 --- a/Bitkit/Views/Wallets/Send/SendFailure.swift +++ b/Bitkit/Views/Wallets/Send/SendFailure.swift @@ -4,6 +4,10 @@ import SwiftUI func sendFailureMessage(for error: Error) -> String { let fallbackMessage = t("wallet__payment_failed_description") + if isRestoredInvoiceRefusal(error) { + return t("wallet__payment_restored_invoice") + } + if error is QuickPayConversionError { return t("wallet__send_quickpay__currency_conversion") } @@ -19,6 +23,23 @@ func sendFailureMessage(for error: Error) -> String { return fallbackMessage } +/// Also when the refusal is wrapped in another error. +func isRestoredInvoiceRefusal(_ error: Error) -> Bool { + var current: Error? = error + for _ in 0 ..< 8 { + guard let error = current else { return false } + if let refusal = error as? ChannelFreeRailSendRefused { + if refusal.isRestoredInvoiceUnverified { + return true + } + current = refusal.underlying + } else { + current = (error as? AppError)?.underlyingError + } + } + return false +} + func shouldResetRoutingCachesOnRetry(for error: Error) -> Bool { guard let reason = (error as? AppError)?.paymentFailureReason else { return false diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 92673375b..8c267b5db 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -25,7 +25,7 @@ final class ChannelFreeRailTests: XCTestCase { service.railAttemptsStore = UserDefaults(suiteName: "ChannelFreeRailTests")! service.forgetRailAttempts() service.forgetLdkAttempts() - service.clearSparkHistoryUnseeded() + service.clearSparkRestore() previousRailSupported = service.channelFreeRailSupported service.channelFreeRailSupported = { true } previousNow = service.now @@ -35,7 +35,7 @@ final class ChannelFreeRailTests: XCTestCase { let service = Bitkit.LightningService.shared service.forgetRailAttempts() service.forgetLdkAttempts() - service.clearSparkHistoryUnseeded() + service.clearSparkRestore() service.railAttemptsStore = previousAttemptsStore service.channelFreeRailSupported = previousRailSupported service.now = previousNow @@ -1151,11 +1151,111 @@ extension ChannelFreeRailTests { _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice) XCTFail("A send of an invoice made before the restore went out before Spark listed its sends", file: file, line: line) } catch { - XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused, "\(error)", file: file, line: line) + XCTAssertTrue((error as? Bitkit.ChannelFreeRailSendRefused)?.isRestoredInvoiceUnverified == true, "\(error)", file: file, line: line) } XCTAssertNil(rail.sentBolt11, file: file, line: line) } + func testARestoreKeepsWhenItHappened() throws { + let createdAt = try Int(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).secondsSinceEpoch()) + + try restore(secondsAfterTheInvoice: 60) + + XCTAssertEqual(Bitkit.LightningService.shared.sparkRestoredAt, createdAt + 60) + } + + func testAPreRestoreInvoiceSparksSyncedListLacksIsRefusedOverTheNodeUntilItExpires() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let createdAt = TimeInterval(invoice.secondsSinceEpoch()) + let service = Bitkit.LightningService.shared + try restore(secondsAfterTheInvoice: 60) + rail.balanceSats = 0 + service.now = { Date(timeIntervalSince1970: createdAt + 120) } + // Spark reports a sync while its payment history failed to sync, so its list lacks the pre-restore send. + rail.syncedAfter = { _ in true } + await service.listRailSendsIfUnseeded() + XCTAssertNil(service.sparkHistoryUnseededSince, "Spark's own list still ends the unseeded hold") + + await assertRefusedUntilSparkLists() + // A relaunch, before Spark syncs that run. + rail.syncedAfter = { _ in false } + await assertRefusedUntilSparkLists() + + service.now = { Date(timeIntervalSince1970: createdAt + TimeInterval(invoice.expiryTimeSeconds())) } + await assertReachesTheNode() + } + + func testASyncedSparkWhoseListLacksAPreRestoreInvoiceRefusesToSendItUntilItExpires() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let createdAt = TimeInterval(invoice.secondsSinceEpoch()) + let service = Bitkit.LightningService.shared + try restore(secondsAfterTheInvoice: 60) + service.now = { Date(timeIntervalSince1970: createdAt + 120) } + // Spark reports a sync while its payment history failed to sync, so its list lacks the pre-restore send. + rail.syncedAfter = { _ in true } + await service.listRailSendsIfUnseeded() + rail.balanceSats = 100_000 + + await assertRefusedUntilSparkLists() + + service.now = { Date(timeIntervalSince1970: createdAt + TimeInterval(invoice.expiryTimeSeconds())) } + _ = try await service.send(bolt11: Self.invoice) + XCTAssertEqual(rail.sentBolt11, Self.invoice, "Spark stayed held after the invoice expired") + } + + func testASparkThatCantListItsSendsAfterARestoreRefusesToSendAPreRestoreInvoice() async throws { + let createdAt = try TimeInterval(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).secondsSinceEpoch()) + let service = Bitkit.LightningService.shared + try restore(secondsAfterTheInvoice: 60) + service.now = { Date(timeIntervalSince1970: createdAt + 120) } + rail.syncedAfter = { _ in true } + await service.listRailSendsIfUnseeded() + XCTAssertNil(service.sparkHistoryUnseededSince) + rail.listError = NSError(domain: "spark", code: 1) + rail.balanceSats = 100_000 + + await assertRefusedUntilSparkLists() + } + + func testAPreRestoreInvoiceThatOutlastsAWeekIsStillHeldOnDayEight() { + let restoredAt = 1_000_000 + let day = 24 * 60 * 60 + let createdAt = UInt64(restoredAt - 60) + let dayEight = restoredAt + 8 * day + + XCTAssertTrue( + Bitkit.LightningService.holdsRestoredInvoice( + restoredAt: restoredAt, invoiceCreatedAt: createdAt, invoiceExpiresAt: restoredAt + 30 * day, now: dayEight + ), + "A pre-restore invoice valid for 30 days stopped being held after a week" + ) + XCTAssertFalse( + Bitkit.LightningService.holdsRestoredInvoice( + restoredAt: restoredAt, invoiceCreatedAt: createdAt, invoiceExpiresAt: restoredAt + 30 * day, now: restoredAt + 30 * day + ) + ) + XCTAssertTrue(Bitkit.LightningService.holdsRestoredInvoice( + restoredAt: restoredAt, + invoiceCreatedAt: nil, + invoiceExpiresAt: nil, + now: dayEight + )) + XCTAssertFalse( + Bitkit.LightningService.holdsRestoredInvoice( + restoredAt: restoredAt, invoiceCreatedAt: UInt64(restoredAt + 60), invoiceExpiresAt: nil, now: dayEight + ) + ) + } + + func testARefusedPreRestoreInvoiceAsksForANewInvoice() { + let refusal = Bitkit.ChannelFreeRailSendRefused.restoredInvoiceUnverified() + // The test target compiles its own `AppError`, so the app's is named. + let wrapped = Bitkit.AppError(message: "wallet__payment_failed_description", debugMessage: nil, underlyingError: refusal) + + XCTAssertEqual(sendFailureMessage(for: refusal), t("wallet__payment_restored_invoice")) + XCTAssertEqual(sendFailureMessage(for: wrapped), t("wallet__payment_restored_invoice")) + } + func testAnInvoiceMadeBeforeARestoreIsRefusedOverTheNodeUntilSparkSyncs() async throws { try restore(secondsAfterTheInvoice: 60) leaveSparkUnsyncedAndEmpty() @@ -1185,7 +1285,7 @@ extension ChannelFreeRailTests { // What Spark's synced event does once its sync reached it. rail.syncedAfter = { _ in true } await Bitkit.LightningService.shared.listRailSendsIfUnseeded() - // A later outage no longer holds the invoice, as Spark's list showed no send of it. + // A later outage no longer holds the invoice: Spark's list showed no send of it, and it has expired. rail.syncedAfter = { _ in false } XCTAssertNil(Bitkit.LightningService.shared.sparkHistoryUnseededSince) From 15b69255110a02cbe6bca9e3b435f23b1e982dda Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:30:06 +0100 Subject: [PATCH 337/378] fix: list kept change once its result was seen An ended exit's address is left out of Earlier exit addresses only while Emergency exit still opens that exit's result, so dismissing kept change no longer hides its address and derivation path. --- Bitkit/Managers/SparkExitManager.swift | 11 ++++++++--- BitkitTests/SparkExitReviewTests.swift | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index e62e490bf..90e8cd5f2 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -141,10 +141,15 @@ final class SparkExitManager { record?.showsResult == true } - /// The earlier exits' addresses to show: one the current exit uses again is that exit's, shown on its progress. + /// The earlier exits' addresses to show: one the current exit uses again is that exit's while Emergency exit opens + /// its progress or result, which shows it there. var shownEarlierAddresses: [SparkExitEarlierAddress] { - let current = record?.funding.first?.address - return earlierAddresses.filter { $0.address != current } + Self.shownEarlierAddresses(earlierAddresses, record: record) + } + + nonisolated static func shownEarlierAddresses(_ entries: [SparkExitEarlierAddress], record: SparkExitRecord?) -> [SparkExitEarlierAddress] { + guard let record, record.showsResult, let current = record.funding.first?.address else { return entries } + return entries.filter { $0.address != current } } private var earlierAddressStore: SparkExitEarlierAddressStore { diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index c8ae0470e..05c03bee4 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -801,6 +801,22 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(refreshed.map(\.sats), [5000, nil, 9], "a late payment, or kept change spent by hand, showed nowhere") } + /// Kept change whose result was seen stays listed with its address and path: the ended record still holding that + /// address hides it only while its result is what Emergency exit opens. + func testASeenKeptChangeResultStillListsItsAddress() throws { + var record = SparkExitFixtures.record(phase: .done) + record.leftover = [.init(txid: String(repeating: "a", count: 64), vout: 0, valueSats: 600)] + record.leftoverSweep = .init() + record.leftoverSweep?.isDustKept = true + record.isResultSeen = true + let kept = try XCTUnwrap(SparkExitEarlierAddress.keptChange(record, network: .regtest, at: clock)) + XCTAssertFalse(record.showsResult) + + XCTAssertEqual(SparkExitManager.shownEarlierAddresses([kept], record: record), [kept], "the kept change's address and path are out of reach") + record.isResultSeen = nil + XCTAssertEqual(SparkExitManager.shownEarlierAddresses([kept], record: record), [], "shown twice, on the result and in the list") + } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats From 63a2f491fd9f64ffbec0478ee0be99d7ce6e1623 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:30:07 +0100 Subject: [PATCH 338/378] fix: keep addresses added during an earlier address refresh The refresh writes the balances it read into the store as it is after the reads, so an exit's address archived meanwhile stays listed. --- Bitkit/Managers/SparkExitManager.swift | 31 +++++++++++++++---- .../SparkExit/SparkExitEarlierAddresses.swift | 30 ++++++++++++++---- BitkitTests/SparkExitReviewTests.swift | 20 ++++++++++++ 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 90e8cd5f2..72e340ece 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -160,17 +160,36 @@ final class SparkExitManager { /// each holds. func refreshEarlierAddresses() async { let store = earlierAddressStore - let stored = (try? store.load()) ?? earlierAddresses - earlierAddresses = stored - guard let chain, stored.contains(where: { $0.isWatched(now: Date()) }) else { return } - let refreshed = await SparkExitEarlierAddress.refreshed(stored, now: Date()) { try await chain.utxos(address: $0) } - guard refreshed != stored else { return } + earlierAddresses = (try? store.load()) ?? earlierAddresses + guard let chain else { return } + let refreshed = await Self.refreshEarlierAddresses(store: store, fallback: earlierAddresses, now: Date()) { + try await chain.utxos(address: $0) + } + if let refreshed { + earlierAddresses = refreshed + } + } + + /// The store's entries with what each watched address holds, saved; nil when nothing changed. The balances read + /// go into the store as it is once the reads are done, so an address added meanwhile stays. + nonisolated static func refreshEarlierAddresses( + store: SparkExitEarlierAddressStore, + fallback: [SparkExitEarlierAddress], + now: Date, + utxos: (String) async throws -> [EsploraUtxo] + ) async -> [SparkExitEarlierAddress]? { + let stored = (try? store.load()) ?? fallback + guard stored.contains(where: { $0.isWatched(now: now) }) else { return nil } + let balances = await SparkExitEarlierAddress.balances(stored, now: now, utxos: utxos) + let current = (try? store.load()) ?? stored + let refreshed = SparkExitEarlierAddress.updating(current, balances: balances) + guard refreshed != current else { return nil } do { try store.save(refreshed) } catch { Logger.warn("Failed to save the earlier Spark exit addresses: \(error)", context: "SparkExitManager") } - earlierAddresses = refreshed + return refreshed } private var store: SparkExitStore { diff --git a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift index 39da3b250..61665a3e7 100644 --- a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift +++ b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift @@ -61,13 +61,31 @@ struct SparkExitEarlierAddress: Codable, Equatable, Identifiable { now: Date, utxos: (String) async throws -> [EsploraUtxo] ) async -> [SparkExitEarlierAddress] { - var refreshed = entries - for i in refreshed.indices where refreshed[i].isWatched(now: now) { - guard let held = try? await utxos(refreshed[i].address) else { continue } - let sats = held.reduce(0) { $0 + $1.value } - refreshed[i].sats = sats > 0 ? sats : nil + await updating(entries, balances: balances(entries, now: now, utxos: utxos)) + } + + /// What each watched address that could be read holds now, by address. + static func balances( + _ entries: [SparkExitEarlierAddress], + now: Date, + utxos: (String) async throws -> [EsploraUtxo] + ) async -> [String: UInt64] { + var balances: [String: UInt64] = [:] + for entry in entries where entry.isWatched(now: now) { + guard let held = try? await utxos(entry.address) else { continue } + balances[entry.address] = held.reduce(0) { $0 + $1.value } + } + return balances + } + + /// The entries with the `balances` read; the others keep what they showed. + static func updating(_ entries: [SparkExitEarlierAddress], balances: [String: UInt64]) -> [SparkExitEarlierAddress] { + entries.map { entry in + guard let sats = balances[entry.address] else { return entry } + var updated = entry + updated.sats = sats > 0 ? sats : nil + return updated } - return refreshed } } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index 05c03bee4..b92bddcfe 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -817,6 +817,26 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(SparkExitManager.shownEarlierAddresses([kept], record: record), [], "shown twice, on the result and in the list") } + /// An address added while a refresh reads another stays listed: the refresh writes only the balances it read. + func testAnAddressRefreshKeepsAnAddressAddedDuringItsReads() async throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json")) + let now = Date() + let first = SparkExitEarlierAddress(address: "bcrt1qfirst", path: "m/84'/1'/7797'/0/1", kind: .cancelledFunding, sats: nil, date: now) + let added = SparkExitEarlierAddress(address: "bcrt1qadded", path: "m/84'/1'/7797'/0/2", kind: .cancelledFunding, sats: nil, date: now) + try store.add(first) + + let refreshed = await SparkExitManager.refreshEarlierAddresses(store: store, fallback: [], now: now) { _ in + try store.add(added) + return [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5000)] + } + + XCTAssertEqual(try store.load().map(\.address), [first.address, added.address], "the refresh erased the address added meanwhile") + XCTAssertEqual(try store.load().map(\.sats), [5000, nil]) + XCTAssertEqual(refreshed?.map(\.address), [first.address, added.address]) + } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats From d1efd4f3969b3d0e43f455e2714764e4d902ba5d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:32:40 +0100 Subject: [PATCH 339/378] fix: re-route when the source changes on confirm A switch to Spending on Confirm now always asks the router for a contact a Spark leg can reach, also when a decoded invoice or LNURL is kept from an earlier Savings pick, so an amount past Lightning's limit goes through an affordable Spark withdrawal. A Direct LNURL pick there continues on the LNURL's own confirm, since Confirm has no LNURL send. --- .../Wallets/Send/SendConfirmationView.swift | 30 +++++++++++++------ BitkitTests/PaykitRouteTests.swift | 26 ++++++++++++---- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 6831d797c..11760f5e9 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -542,11 +542,7 @@ struct SendConfirmationView: View { walletSwitchContext = sendContext if skipNextSourceReroute { skipNextSourceReroute = false - } else if Self.sourceSwitchNeedsReroute( - route: app.paykitRoute, - walletType: app.selectedWalletToPayFrom, - hasLightningPayment: app.scannedLightningInvoice != nil || app.lnurlPayData != nil - ) { + } else if Self.sourceSwitchNeedsReroute(route: app.paykitRoute, walletType: app.selectedWalletToPayFrom) { Task { await routeContactPaymentOnConfirm(sourceChanged: true) } } } @@ -1110,14 +1106,15 @@ struct SendConfirmationView: View { } } - /// A source switch on Confirm needs the router only when it moves a Spark leg's send to Savings, or a send that has no - /// Lightning payment to Spending, where a Spark leg can carry it. The app's own switch to a leg's source asks nothing. - static func sourceSwitchNeedsReroute(route: PaykitRouteUi?, walletType: WalletType, hasLightningPayment: Bool) -> Bool { + /// A source switch on Confirm needs the router only when it moves a Spark leg's send to Savings, or any send to + /// Spending, where a Spark leg can carry it: a mixed contact paid from Savings keeps its decoded Lightning payment, + /// which can't pay past Lightning's limit. The app's own switch to a leg's source asks nothing. + static func sourceSwitchNeedsReroute(route: PaykitRouteUi?, walletType: WalletType) -> Bool { guard let route else { return false } if route.serviceLeg != nil { return walletType != .lightning } - return walletType == .lightning && !hasLightningPayment && route.offersSparkLeg + return walletType == .lightning && route.offersSparkLeg } /// The wallet switch chooses coins separately from send setup, so the automatic start waits for both. A source the @@ -1485,6 +1482,20 @@ struct SendConfirmationView: View { ) // The first call when Confirm opens keeps a refusal too, so a request with no route offers no swipe. refusedRouteSource = Self.refusedRouteSource(after: outcome, on: walletType, previous: refusedRouteSource) + if Self.reroutePaysLnurl( + outcome: outcome, + walletType: app.selectedWalletToPayFrom, + hasInvoice: app.scannedLightningInvoice != nil, + hasLnurl: app.lnurlPayData != nil + ) { + navigationPath.append(.lnurlPayConfirm) + } + } + + /// Confirm has no LNURL send, so a Direct LNURL pick from Spending pays on the LNURL's own confirm, as one picked on + /// Continue does. + static func reroutePaysLnurl(outcome: PaykitRouteOutcome, walletType: WalletType, hasInvoice: Bool, hasLnurl: Bool) -> Bool { + outcome == .direct && walletType == .lightning && hasLnurl && !hasInvoice } /// A source the router refused on Confirm keeps the swipe off while it's selected, so nothing consumes the contact's @@ -2623,3 +2634,4 @@ struct SendConfirmationView: View { } } } + diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 958c5dbc0..6ba7bdbb0 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -1150,12 +1150,26 @@ final class PaykitRouteTests: XCTestCase { var routed = offered routed.serviceLeg = sparkLeg(amountSats: 20000, feeSats: 2190) - XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: routed, walletType: .onchain, hasLightningPayment: false)) - XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: routed, walletType: .lightning, hasLightningPayment: false)) - XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .lightning, hasLightningPayment: false)) - XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .lightning, hasLightningPayment: true)) - XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .onchain, hasLightningPayment: false)) - XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: nil, walletType: .onchain, hasLightningPayment: false)) + XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: routed, walletType: .onchain)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: routed, walletType: .lightning)) + XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .lightning)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: offered, walletType: .onchain)) + XCTAssertFalse(SendConfirmationView.sourceSwitchNeedsReroute(route: nil, walletType: .onchain)) + } + + func testADirectLnurlPickOnConfirmPaysOnTheLnurlConfirm() { + XCTAssertTrue(SendConfirmationView.reroutePaysLnurl(outcome: .direct, walletType: .lightning, hasInvoice: false, hasLnurl: true)) + XCTAssertFalse(SendConfirmationView.reroutePaysLnurl(outcome: .direct, walletType: .lightning, hasInvoice: true, hasLnurl: true)) + XCTAssertFalse(SendConfirmationView.reroutePaysLnurl(outcome: .leg, walletType: .lightning, hasInvoice: false, hasLnurl: true)) + XCTAssertFalse(SendConfirmationView.reroutePaysLnurl(outcome: .direct, walletType: .onchain, hasInvoice: false, hasLnurl: true)) + } + + /// A mixed contact paid directly from Savings keeps its decoded Lightning payment, so a switch to Spending must still + /// ask the router: over Lightning's limit only a Spark withdrawal pays it. + func testSourceSwitchToSpendingReroutesAMixedContactRoutedDirectFromSavings() { + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + + XCTAssertTrue(SendConfirmationView.sourceSwitchNeedsReroute(route: route, walletType: .lightning)) } func testASparkRoutedConfirmCanMoveToAnotherSource() throws { From 313c683ebb90fe6c8f2cf9839da339ee11c81cee Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:32:40 +0100 Subject: [PATCH 340/378] fix: confirm a spark withdrawal from the chain Spark lists a withdrawal completed once its provider accepts the payout, before the payout transaction is broadcast or mined (spark-sdk 0.28.0, crates/spark/src/ssp/graphql/models.rs:187-209 and crates/breez-sdk/core/src/models/adaptors.rs:339-347). A contact withdrawal's activity now stays unconfirmed until Electrum lists its payout output in a block. --- Bitkit/Managers/SparkManager.swift | 66 ++++++++++++++++++-- BitkitTests/SparkWithdrawalTests.swift | 83 ++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index b2799110c..2f0e3a03c 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -962,9 +962,7 @@ final class SparkManager: ChannelFreeLightningRail { return await (try? CoreService.shared.activity.appendTags(toActivity: txId, tags)) != nil } ) - if payment.status == .completed { - await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: txId) - } + Self.confirmMinedContactPayouts(Self.completedContactPayouts([payment], contactWithdrawals: [txId])) if isDurable { if let request = pending.request { await PaykitAllowanceExecutor.shared.settleManualPayment(requestId: request.id, outcome: .succeeded, transactionId: txId) @@ -1248,9 +1246,65 @@ final class SparkManager: ChannelFreeLightningRail { for txId in Self.savingsTransferTxIds(unclaimed: unclaimed, payments: payments, contactWithdrawals: contactWithdrawals) { await CoreService.shared.activity.markOnchainActivityAsTransfer(txId: txId) } - for payment in payments where payment.status == .completed { - if case let .withdraw(txId, _, _) = payment.details, contactWithdrawals.contains(txId) { - await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: txId) + Self.confirmMinedContactPayouts(Self.completedContactPayouts(payments, contactWithdrawals: contactWithdrawals)) + } + + /// The output of a contact withdrawal's transaction that pays the contact. + struct ContactPayout: Equatable { + let txId: String + let vout: UInt32 + let address: String + } + + /// The payouts of the contact withdrawals Spark lists as completed. + nonisolated static func completedContactPayouts(_ payments: [Payment], contactWithdrawals: Set) -> [ContactPayout] { + payments.compactMap { payment in + guard payment.status == .completed, case let .withdraw(txId, vout, address) = payment.details, + contactWithdrawals.contains(txId) + else { return nil } + return ContactPayout(txId: txId, vout: vout, address: address) + } + } + + /// Looks the payouts up on chain off the refresh, so a slow Electrum server never holds a send that waits for it. + private static func confirmMinedContactPayouts(_ payouts: [ContactPayout]) { + guard !payouts.isEmpty else { return } + Task { + await confirmMinedPayouts( + payouts, + needsConfirmation: { txId in + await (try? CoreService.shared.activity.getOnchainActivityByTxId(txid: txId))?.confirmed == false + }, + unspentOutputs: { address in + try await OnChainHwService.shared.getAddressInfo( + address: address, + electrumUrl: OnChainHwService.getElectrumUrl(), + network: OnChainHwService.appDefaultCoinType + ).utxos + }, + markConfirmed: { await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: $0) } + ) + } + } + + /// Spark lists a withdrawal completed once its provider took the payout on, which can be before the payout transaction + /// is broadcast or mined, so a payout's activity is confirmed only once the chain lists its output in a block. An + /// output the contact already spent drops out of that list, so its activity then stays unconfirmed. + nonisolated static func confirmMinedPayouts( + _ payouts: [ContactPayout], + needsConfirmation: (String) async -> Bool, + unspentOutputs: (String) async throws -> [AccountUtxo], + markConfirmed: (String) async -> Void + ) async { + for payout in payouts { + guard await needsConfirmation(payout.txId) else { continue } + do { + let outputs = try await unspentOutputs(payout.address) + if outputs.contains(where: { $0.txid == payout.txId && $0.vout == payout.vout && $0.confirmations > 0 }) { + await markConfirmed(payout.txId) + } + } catch { + Logger.warn("Failed to look up the payout of Spark withdrawal '\(payout.txId)': \(error)", context: "SparkManager") } } } diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index 4091cee0c..238a3250d 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import BreezSdkSpark import XCTest @@ -323,6 +324,80 @@ final class SparkWithdrawalTests: XCTestCase { ) } + // MARK: - Payout confirmation + + /// Spark lists a withdrawal completed once its provider took the payout on, before it is mined, so only a payout + /// output the chain lists in a block confirms its activity. + func testACompletedContactWithdrawalIsConfirmedOnlyOnceThePayoutIsMined() async { + let payouts = SparkManager.completedContactPayouts( + [ + withdrawal(txId: "mined", status: .completed), + withdrawal(txId: "mempool", status: .completed), + withdrawal(txId: "unlisted", status: .completed), + withdrawal(txId: "pending", status: .pending), + withdrawal(txId: "savings", status: .completed), + ], + contactWithdrawals: ["mined", "mempool", "unlisted", "pending"] + ) + let marked = TxIds() + + await SparkManager.confirmMinedPayouts( + payouts, + needsConfirmation: { _ in true }, + unspentOutputs: { _ in [Self.output(txId: "mined", confirmations: 1), Self.output(txId: "mempool", confirmations: 0)] }, + markConfirmed: { await marked.insert($0) } + ) + + let confirmed = await marked.ids + XCTAssertEqual(confirmed, ["mined"], "a payout still in the mempool or not yet broadcast stays unconfirmed") + } + + func testAConfirmedContactWithdrawalIsNotLookedUpAgain() async { + let lookups = TxIds() + + await SparkManager.confirmMinedPayouts( + [SparkManager.ContactPayout(txId: "mined", vout: 0, address: "bcrt1qcontact")], + needsConfirmation: { _ in false }, + unspentOutputs: { address in + await lookups.insert(address) + return [] + }, + markConfirmed: { _ in } + ) + + let looked = await lookups.ids + XCTAssertTrue(looked.isEmpty) + } + + private func withdrawal(txId: String, status: BreezSdkSpark.PaymentStatus) -> BreezSdkSpark.Payment { + Payment( + id: txId, + paymentType: .send, + status: status, + amount: 20000, + fees: 300, + timestamp: 1, + method: .withdraw, + details: .withdraw(txId: txId, vout: 1, destinationAddress: "bcrt1qcontact"), + conversionDetails: nil + ) + } + + private static func output(txId: String, confirmations: UInt32) -> AccountUtxo { + AccountUtxo( + txid: txId, + vout: 1, + amount: 20000, + blockHeight: confirmations > 0 ? 100 : 0, + address: "bcrt1qcontact", + path: "", + confirmations: confirmations, + coinbase: false, + own: false, + required: nil + ) + } + @MainActor private func waitUntilPriced(_ transfer: TransferViewModel) async { for _ in 0 ..< 100 where transfer.isPricingSparkWithdrawal { @@ -359,3 +434,11 @@ private actor PriceGate { private final class WithdrawnFee { var feeSats: UInt64? } + +private actor TxIds { + private(set) var ids: [String] = [] + + func insert(_ id: String) { + ids.append(id) + } +} From 46ccee93ac0dbf4587028e26d5738bbaa08e75ed Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:32:40 +0100 Subject: [PATCH 341/378] fix: resolve an lnurl the router picks A route built after the scan, once Spark became ready, can hold a contact's LNURL that was never decoded. The router can pick it, so the pick now decodes it instead of refusing the payment. --- Bitkit/Services/PaykitRouteService.swift | 31 +++++++++++++++------- BitkitTests/PaykitRouteTests.swift | 33 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 44525342c..79439a3a1 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -290,7 +290,8 @@ extension AppViewModel { request: PaykitPaymentRequest?, router: (PaykitRouteUi, UInt64, PaykitRouteService.Source, UInt64, UInt64) async throws -> PaykitRoute = { try await PaykitRouteService.route($0, amountSats: $1, source: $2, lightningSpendableSats: $3, onchainSpendableSats: $4) - } + }, + decodeEndpoint: (String) async throws -> BitkitCore.Scanner = { try await decode(invoice: $0) } ) async -> PaykitRouteOutcome { var unrouted = route unrouted.serviceLeg = nil @@ -327,7 +328,7 @@ extension AppViewModel { switch picked { case let .direct(endpoint): paykitRoute = unrouted - guard await payDirectly(endpoint, route: unrouted) else { return .refused } + guard await payDirectly(endpoint, route: unrouted, decode: decodeEndpoint) else { return .refused } if let request { paykitPaymentTerms = try request.payment(using: paykitPaymentMethod) } @@ -375,8 +376,12 @@ extension AppViewModel { } /// Switches the send to exactly the endpoint the router picked, clearing the other Lightning one. An earlier pick may - /// have cleared the picked one, so it comes back from `route`. - private func payDirectly(_ endpoint: PaykitEndpoint, route: PaykitRouteUi) async -> Bool { + /// have cleared the picked one, so it comes back from `route`; one never decoded is decoded here. + private func payDirectly( + _ endpoint: PaykitEndpoint, + route: PaykitRouteUi, + decode: (String) async throws -> BitkitCore.Scanner + ) async -> Bool { if endpoint.isOnchain { selectedWalletToPayFrom = .onchain if scannedOnchainInvoice?.address != endpoint.value { @@ -386,12 +391,18 @@ extension AppViewModel { } selectedWalletToPayFrom = .lightning if endpoint.isLnurl { - guard let lnurl = lnurlPayData ?? route.lnurl else { - Logger.warn("Found no LNURL for the endpoint the router picked", context: "PaykitRoute") - showNoPaykitRouteToast(isBalanceShort: false) - return false + if let lnurl = lnurlPayData ?? route.lnurl { + lnurlPayData = lnurl + } else { + // A route built once Spark became ready after the scan holds the contact's LNURL undecoded. + guard case let .lnurlPay(data) = try? await decode(endpoint.value) else { + Logger.warn("Failed to decode the LNURL endpoint the router picked", context: "PaykitRoute") + showNoPaykitRouteToast(isBalanceShort: false) + return false + } + lnurlPayData = data + paykitRoute?.lnurl = data } - lnurlPayData = lnurl scannedLightningInvoice = nil return true } @@ -400,7 +411,7 @@ extension AppViewModel { if let known { scannedLightningInvoice = known } else { - guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value) else { + guard case let .lightning(invoice) = try? await decode(endpoint.value) else { Logger.warn("Failed to decode the Lightning endpoint the router picked", context: "PaykitRoute") showNoPaykitRouteToast(isBalanceShort: false) return false diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 6ba7bdbb0..b2fd03187 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -565,6 +565,39 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(within.contactEndpoints, [lightning, onchain]) } + /// A route built after the scan, once Spark became ready, holds a contact's LNURL that was never decoded; the router + /// can still pick it, so the pick decodes it. + @MainActor + func testAnLnurlTheRouterPicksWithoutDecodedDataIsDecoded() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let route = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + let decoded = lnurlData(minSendableMsats: 1000) + app.contactPaymentContext = ContactPaymentContext(publicKey: "contact", privatePaymentContext: nil, endpoints: []) + app.selectedWalletToPayFrom = .lightning + let lightning = lightning + var decodedValues: [String] = [] + + let outcome = await app.routeContactPayment( + route, + amountSats: 2000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil, + router: { _, _, _, _, _ in .direct(endpoint: lightning) }, + decodeEndpoint: { value in + decodedValues.append(value) + return .lnurlPay(data: decoded) + } + ) + + XCTAssertEqual(outcome, .direct) + XCTAssertEqual(decodedValues, [lightning.value]) + XCTAssertEqual(app.lnurlPayData, decoded) + XCTAssertEqual(app.paykitRoute?.lnurl, decoded, "a later pick reuses the decoded LNURL") + } + @MainActor func testTheRouterIsAskedWithTheLnurlItsLimitsComeFrom() async { snapshotAppDefaultsDomain() From c8d16ff493ce43bbc41108e448b56547ca92123c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:32:41 +0100 Subject: [PATCH 342/378] fix: show the request note on a spark leg Confirm's expanded details for a Spark leg now show the payment request's note, as the on-chain and Lightning details do. --- .../Wallets/Send/SendConfirmationView.swift | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 11760f5e9..7e9f632d8 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -410,7 +410,7 @@ struct SendConfirmationView: View { if showDetails { if let route = app.paykitRoute, let totalFeeSat = route.totalFeeSat { - paykitRouteView(route, totalFeeSat: totalFeeSat) + paykitRouteView(route, totalFeeSat: totalFeeSat, note: oneOffPaymentRequestNote) } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { onchainView(invoice) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { @@ -769,7 +769,7 @@ struct SendConfirmationView: View { } } - func paykitRouteView(_ route: PaykitRouteUi, totalFeeSat: UInt64) -> some View { + func paykitRouteView(_ route: PaykitRouteUi, totalFeeSat: UInt64, note: String?) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { SendSectionView(t("wallet__send_from")) { @@ -826,6 +826,10 @@ struct SendConfirmationView: View { } } } + + if let note { + PaymentRequestInvoiceNote(note: note) + } } } @@ -2635,3 +2639,43 @@ struct SendConfirmationView: View { } } +#Preview("Spark leg with a request note") { + let onchain = PaykitEndpoint(methodId: "btc-regtest-p2wpkh", value: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x") + let leg = ServiceRouteLeg( + service: SparkManager.routeService, + endpoint: onchain, + deliveredSat: 20000, + debitSat: 22190, + atomicity: "trusted", + validUntilUnix: 0 + ) + ScrollView { + SendConfirmationView( + navigationPath: .constant([]), + isSubmittingPayment: .constant(false), + hwSend: HwSendCoordinator(walletId: nil), + requestPinCheck: { true }, + prepareIncomingPaymentRequest: {}, + routingCacheResetAttempted: false, + isAmountReviewRequired: .constant(false) + ) + .paykitRouteView( + PaykitRouteUi(endpoints: [onchain], offersSparkLeg: true, serviceLeg: leg), + totalFeeSat: 2190, + note: "Dinner on Friday" + ) + .padding(16) + } + .background(Color.black) + .environmentObject(AppViewModel()) + .environmentObject(ActivityListViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel()) + .environmentObject(FeeEstimatesManager()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) + .environmentObject(WalletViewModel()) + .environmentObject(TagManager()) + .environment(HwWalletManager()) + .preferredColorScheme(.dark) +} From 23e71bdf5473bec8364ef6bf7a98a77e018fb881 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:35:44 +0100 Subject: [PATCH 343/378] fix: withdraw spark only at the fee the swipe approved The swipe's recheck now refuses the commit when Spark's balance was priced again while it waited, and the commit carries the fee the recheck approved as the withdrawal's cap instead of the fee shown at commit time. --- Bitkit/ViewModels/TransferViewModel.swift | 30 +++++++++++++----- BitkitTests/SparkWithdrawalTests.swift | 37 ++++++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 7ae00b3d4..e62d34efe 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -66,6 +66,8 @@ class TransferViewModel: ObservableObject { private var sparkFeeTask: Task? /// The Spark balance the fee was priced for, so it is priced once per balance. private var sparkFeeBalanceSats: UInt64? + /// Counts each pricing of a new Spark balance, so a swipe's recheck can tell the fee shown changed under it. + private var sparkFeeGeneration = 0 /// The Spark withdrawal fee shown when the transfer was confirmed, the most `withdrawSparkToSavings` pays. private var confirmedSparkFeeSats: UInt64? /// One Savings address for Spark's withdrawal, priced on the confirm screen and then paid to, so the withdrawal @@ -292,6 +294,7 @@ class TransferViewModel: ObservableObject { } sparkFeeTask?.cancel() sparkFeeTask = nil + sparkFeeGeneration += 1 sparkFeeBalanceSats = balanceSats sparkWithdrawFeeSats = nil isSparkWithdrawTooSmall = false @@ -344,24 +347,29 @@ class TransferViewModel: ObservableObject { !withdrawsSpark || sparkWithdrawFeeSats != nil } - /// Prices the withdrawal again at swipe time. Throws `SparkWithdrawalFeeIncreased` after showing the higher fee, so - /// the user swipes again for it, and throws when it can't be priced. - func recheckSparkWithdrawFee() async throws { + /// Prices the withdrawal again at swipe time and returns the fee it approved. Throws `SparkWithdrawalFeeIncreased` + /// after showing the higher fee, so the user swipes again for it, `SavingsCommitRefused` when Spark's balance was + /// priced again meanwhile, and throws when it can't be priced. + @discardableResult + func recheckSparkWithdrawFee() async throws -> UInt64 { guard let shownFeeSats = sparkWithdrawFeeSats, let address = sparkWithdrawAddress else { throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced") } + let generation = sparkFeeGeneration let price = priceSparkWithdrawal isRecheckingSparkFee = true defer { isRecheckingSparkFee = false } guard let feeSats = await Self.withTimeout(seconds: sparkFeeTimeout, { try await price(address) }) else { throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal re-quote failed") } + guard sparkFeeGeneration == generation else { throw SavingsCommitRefused() } do { try SparkManager.checkWithdrawalFee(feeSats, shownFeeSats: shownFeeSats) } catch { sparkWithdrawFeeSats = feeSats throw error } + return shownFeeSats } /// The result of `operation`, or nil when it throws, takes longer than `seconds` or the caller is cancelled. Returns @@ -398,27 +406,33 @@ class TransferViewModel: ObservableObject { /// commit is refused, as when Spark's balance changed during the recheck, so the swipe resets instead of opening /// progress. func confirmSavingsSwipe(channels: [ChannelDetails], withdrawsSpark: Bool) async throws { + var approvedSparkFeeSats: UInt64? if withdrawsSpark { - try await recheckSparkWithdrawFee() + approvedSparkFeeSats = try await recheckSparkWithdrawFee() } - guard onTransferToSavingsConfirm(channels: channels, withdrawsSpark: withdrawsSpark) else { + guard onTransferToSavingsConfirm(channels: channels, withdrawsSpark: withdrawsSpark, approvedSparkFeeSats: approvedSparkFeeSats) else { throw SavingsCommitRefused() } } /// Commit the transfer and pick how it runs. A Spark withdrawal commits only at a fee shown and not being priced again, - /// returning false otherwise. A swap needs a priced quote, so without one + /// returning false otherwise, and pays at most `approvedSparkFeeSats` when the swipe rechecked one. 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. @discardableResult - func onTransferToSavingsConfirm(channels: [ChannelDetails], withdrawsSpark: Bool, mode: SavingsTransferMode? = nil) -> Bool { + func onTransferToSavingsConfirm( + channels: [ChannelDetails], + withdrawsSpark: Bool, + mode: SavingsTransferMode? = nil, + approvedSparkFeeSats: UInt64? = nil + ) -> Bool { guard !withdrawsSpark || (isSparkWithdrawalPriced(withdrawsSpark: true) && !isRecheckingSparkFee) else { return false } savingsTransferMode = mode ?? resolvedSavingsTransferMode selectAllForSavings() channelsToClose = channels self.withdrawsSpark = withdrawsSpark // Spark withdraws at the fee shown now, even if the screen prices it again before the withdrawal runs. - confirmedSparkFeeSats = withdrawsSpark ? sparkWithdrawFeeSats : nil + confirmedSparkFeeSats = withdrawsSpark ? approvedSparkFeeSats ?? sparkWithdrawFeeSats : nil // Each swipe commit is a new transfer; the previous run's memo must not satisfy it. savingsSwapRun = nil return true diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index ae2d77869..36f076e52 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -163,7 +163,7 @@ final class SparkWithdrawalTests: XCTestCase { try await Task.sleep(nanoseconds: 50_000_000) let committedDuringRecheck = transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true, mode: .close) await gate.open() - try await recheck.value + _ = try await recheck.value XCTAssertFalse(committedDuringRecheck, "Close instead committed while the swipe was repricing") XCTAssertTrue(transfer.onTransferToSavingsConfirm(channels: [], withdrawsSpark: true)) @@ -203,6 +203,41 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2300) } + @MainActor + func testTheSwipeResetsWhenSparksNewBalanceIsPricedBeforeItsRecheckReturns() async throws { + let transfer = TransferViewModel() + transfer.sparkReadsSavingsChain = { true } + transfer.priceSparkWithdrawal = { _ in 2190 } + transfer.loadSparkWithdrawFee(balanceSats: 99901, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + let recheckGate = PriceGate() + transfer.priceSparkWithdrawal = { _ in + await recheckGate.wait() + return 2190 + } + + let swipe = Task { try await transfer.confirmSavingsSwipe(channels: [], withdrawsSpark: true) } + try await Task.sleep(nanoseconds: 50_000_000) + // A receive lands during the recheck, and the confirm screen prices the new balance before the recheck returns. + transfer.priceSparkWithdrawal = { _ in 2300 } + transfer.loadSparkWithdrawFee(balanceSats: 120_000, savingsAddress: "bcrt1q-savings") + await waitUntilPriced(transfer) + XCTAssertEqual(transfer.sparkWithdrawFeeSats, 2300) + await recheckGate.open() + + do { + try await swipe.value + XCTFail("The swipe committed at a fee it did not recheck") + } catch { + XCTAssertTrue(error is SavingsCommitRefused, "Unexpected error: \(error)") + } + let withdrawn = WithdrawnFee() + transfer.sparkBalanceSats = { 120_000 } + transfer.withdrawSpark = { _, feeSats in withdrawn.feeSats = feeSats } + _ = try await transfer.closeSelectedChannels() + XCTAssertNil(withdrawn.feeSats, "Spark withdrew at a fee the swipe did not approve") + } + @MainActor func testTheSwipeWithdrawsSparkAtTheFeeItConfirmedWhenSparkIsPricedAgainBeforeTheWithdrawal() async throws { let transfer = await pricedTransfer() From 18946a0f0718df826937d8b07288739edf6469b1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:37:17 +0100 Subject: [PATCH 344/378] fix: return a node-paid invoice as paid after a restore A Spark send of an invoice the node already lists as paid now returns that payment instead of throwing the pre-restore refusal, whether Spark's list lacks the invoice or Spark can't list its sends. Nothing is sent again. --- Bitkit/Services/LightningService.swift | 12 +++++++++--- BitkitTests/ChannelFreeRailTests.swift | 27 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 8888d7015..119137588 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1478,6 +1478,8 @@ class LightningService: NodeLifecycleService { throw AppError(serviceError: .nodeNotStarted) } } + // The node's paid row is final, so no uncertainty about Spark's pre-restore sends refuses it. + let isPaidOverTheNode = statuses.contains(.succeeded) if rails.contains(.channelFree) { let mark = attemptMark if let payments = await readableRailPayments() { @@ -1494,9 +1496,11 @@ class LightningService: NodeLifecycleService { statuses.append(.pending) } else { releaseRailAttempt(paymentHash: paymentHash) - try await refuseUnverifiedRestoredInvoice( - paymentHash: paymentHash, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt - ) + if !isPaidOverTheNode { + try await refuseUnverifiedRestoredInvoice( + paymentHash: paymentHash, invoiceCreatedAt: invoiceCreatedAt, invoiceExpiresAt: invoiceExpiresAt + ) + } } } else if payment?.status == .succeeded { // Only a settled row ends the unknown send: a pending one may be the synthetic row it is listed as. @@ -1505,6 +1509,8 @@ class LightningService: NodeLifecycleService { } else if hasRailAttempt(paymentHash: paymentHash) { Logger.warn("Refused paying '\(paymentHash)', Spark can't list the earlier attempt now", context: "LightningService") throw Self.pendingDuplicate(paymentHash: paymentHash) + } else if isPaidOverTheNode { + Logger.info("Skipped Spark's restore checks of '\(paymentHash)', the node already paid it", context: "LightningService") } else if let invoiceCreatedAt, let since = sparkHistoryUnseededSince, Int(clamping: invoiceCreatedAt) < since { // Nothing left the wallet, and an invoice made after the restore can't be one Spark paid before it. Logger.warn("Refused paying '\(paymentHash)' until Spark lists its sends since the restore", context: "LightningService") diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift index 8c267b5db..d8731960b 100644 --- a/BitkitTests/ChannelFreeRailTests.swift +++ b/BitkitTests/ChannelFreeRailTests.swift @@ -1217,6 +1217,33 @@ extension ChannelFreeRailTests { await assertRefusedUntilSparkLists() } + func testAPreRestoreInvoiceTheNodeAlreadyPaidReadsAsPaidInsteadOfRefused() async throws { + let invoice = try Bolt11Invoice.fromStr(invoiceStr: Self.invoice) + let hash = String(invoice.paymentHash()) + let createdAt = TimeInterval(invoice.secondsSinceEpoch()) + let paid = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: Self.invoice), + amountMsat: 20_000_000, feePaidMsat: 0, direction: .outbound, status: .succeeded, latestUpdateTimestamp: 0 + ) + let service = Bitkit.LightningService.shared + let nodePayments = service.nodePayments + service.nodePayments = { [paid] } + defer { service.nodePayments = nodePayments } + try restore(secondsAfterTheInvoice: 60) + service.now = { Date(timeIntervalSince1970: createdAt + 120) } + rail.syncedAfter = { _ in true } + await service.listRailSendsIfUnseeded() + rail.balanceSats = 100_000 + + // Spark's list lacks the invoice, then Spark can't list at all: the node's paid row answers both. + for listError in [nil, NSError(domain: "spark", code: 1)] { + rail.listError = listError + let sent = try await service.send(bolt11: Self.invoice) + XCTAssertEqual(sent, hash) + XCTAssertNil(rail.sentBolt11, "Spark paid an invoice the node already paid") + } + } + func testAPreRestoreInvoiceThatOutlastsAWeekIsStillHeldOnDayEight() { let restoredAt = 1_000_000 let day = 24 * 60 * 60 From 437a979137a1a2d41bf2996bdcb901d62cf5b6d6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:37:59 +0100 Subject: [PATCH 345/378] fix: guard every rebuild over a valid exit generation --- .../Services/SparkExit/SparkExitDriver.swift | 21 ++++++-- BitkitTests/SparkExitTests.swift | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 017a3f458..fc32251f2 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -150,7 +150,7 @@ struct SparkExitDriver { var valid = record valid.phase = .running valid.isUnverifiedAsk = nil - try await build(&record, recovering: recovering, keeping: valid) + try await build(&record, recovering: recovering) keepRetryClock(&record, from: valid) } else { try await build(&record, recovering: recovering) @@ -346,9 +346,11 @@ struct SparkExitDriver { // MARK: Build - /// With `valid`, an unverified rebuild's: the valid generation stays over a rebuild that leaves waiting a leaf it + /// Over a generation that did not diverge, however the build was reached (an unverified rebuild, a fee bump, a + /// rebuild after a leaf was left behind), that generation stays over a rebuild that leaves waiting a leaf it /// recovers, before that rebuild is saved or any of it broadcast. - private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering, keeping valid: SparkExitRecord? = nil) async throws { + private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { + let valid = record.validGeneration let tip = try? await chain.tipHeight() // Waiting for a leaf that may still move: tried again at growing gaps of blocks, not every tick. if let since = record.retrySince { @@ -600,7 +602,7 @@ struct SparkExitDriver { record.unverifiedRebuiltAtHeight = tip try save(record) let valid = record - try await build(&record, recovering: recovering, keeping: valid) + try await build(&record, recovering: recovering) keepRetryClock(&record, from: valid) if case .needsFunding = record.phase { // Like a fee bump's, the valid generation keeps going while the funding it asks for comes. @@ -1462,6 +1464,17 @@ private extension RecoveryTransaction { } extension SparkExitRecord { + /// The exit running its current generation, when that generation did not diverge: what a rebuild that would drop a + /// waiting leaf's signed steps keeps. Funding adopted for the rebuild and a bumped rate stay the exit's. + var validGeneration: SparkExitRecord? { + guard recovery != nil, isRedoRebuild != true else { return nil } + var valid = self + valid.phase = .running + valid.isRebuilding = false + valid.isUnverifiedAsk = nil + return valid + } + /// What keeps the exit from moving, once rebuilding over its stuck steps stopped helping. var surfacedRejection: String? { isStalled ? stuckStep?.lastOutcome : nil diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 493474269..2bf0c4037 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1830,6 +1830,56 @@ final class SparkExitDriverTests: XCTestCase { try await runsUntilLeafOneMatures(&record) } + /// A paid fee bump whose build skips leaf-1 as unread and builds only failed cooperative leaves keeps the valid + /// generation: adopted, the build would drop leaf-1's signed refund n2. + func testAPaidFeeBumpKeepsTheValidGenerationOverABuildThatDropsLeafOnesRefund() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "coop-1"] + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(leafOneSteps(.unverified), at: clock) + record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump") + chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("bump", value: 5680)] + let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down")) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "the paid bump adopted a recovery without leaf-1's refund n2") + XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1") + XCTAssertEqual(record.phase, .running) + XCTAssertFalse(record.isRebuilding) + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"]) + } + + /// A fee bump with nothing to pay whose build skips leaf-1 as unread and builds leaf-2 keeps the valid generation, + /// which goes on being checked and sends leaf-1's refund n2 once the chain reads it. + func testAFeeBumpWithNothingToPayKeepsTheValidGenerationOverABuildThatDropsLeafOnesRefund() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(leafOneSteps(.unverified), at: clock) + record.applyFeeBump(rate: 10, extraSats: 0, paymentTxid: nil) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")])] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "the bump adopted a recovery without leaf-1's refund n2") + XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1") + XCTAssertEqual(record.phase, .running) + XCTAssertFalse(record.isRebuilding, "the valid generation is no longer checked or sent") + + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)] + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's refund n2 was not sent") + } + /// Leaf-1 reads as waiting for its timelock, unread again a week later, then ready: n2 goes out. private func runsUntilLeafOneMatures(_ record: inout SparkExitRecord) async throws { sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.waitingForTimelock(spendableAtHeight: 3000)), verdict: .valid)] From fd9c8879a218cdd72ce32f3b7ffb3e9eae56fe5c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:38:00 +0100 Subject: [PATCH 346/378] test: keep the valid generation after a leaf is left behind --- BitkitTests/SparkExitTests.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 2bf0c4037..788b5762c 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -192,6 +192,8 @@ final class FakeSparkExitRecovering: SparkExitRecovering, @unchecked Sendable { var builds: [RecoverFundsResponse] = [] var checks: [CheckRecoverFundsResponse] = [] var recoverError: Error? + /// Errors the next recovers throw, in order, before `recoverError`. + var recoverErrors: [Error] = [] /// Thrown by the next prepares, in order. var prepareErrors: [Error] = [] var preparedByRequest: ((PrepareRecoverFundsRequest) -> PrepareRecoverFundsResponse)? @@ -209,6 +211,9 @@ final class FakeSparkExitRecovering: SparkExitRecovering, @unchecked Sendable { func recover(_ request: RecoverFundsRequest, signer _: CpfpSigner?) async throws -> RecoverFundsResponse { recoverRequests.append(request) + if !recoverErrors.isEmpty { + throw recoverErrors.removeFirst() + } if let recoverError { throw recoverError } @@ -1880,6 +1885,29 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's refund n2 was not sent") } + /// An unverified rebuild that cannot exit leaf-3 leaves it behind and rebuilds on the next tick: with leaf-1 still + /// unread, that rebuild keeps the valid generation and leaf-1's refund n2. + func testARebuildAfterAnUnverifiedRebuildLeftALeafBehindKeepsLeafOnesRefund() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2", "leaf-3"] + record.adopt(leafOneSteps(.unverified), at: clock) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2", "leaf-3"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.recoverErrors = [SdkError.SparkError("Leaf leaf-3 cannot be exited: its refund cannot pay its own fee at this fee rate")] + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")])] + + record = try await driver().tick(record) + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-3"]) + chain.tip = 1001 + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1, "the rebuild adopted a recovery without leaf-1's refund n2") + XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1") + XCTAssertFalse(record.isRebuilding, "the valid generation is no longer checked or sent") + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"]) + } + /// Leaf-1 reads as waiting for its timelock, unread again a week later, then ready: n2 goes out. private func runsUntilLeafOneMatures(_ record: inout SparkExitRecord) async throws { sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.waitingForTimelock(spendableAtHeight: 3000)), verdict: .valid)] From 1af56fc9d1e5035a33034fb5eeebcd6543aace6a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:38:00 +0100 Subject: [PATCH 347/378] fix: pace a funded unverified resume once a block --- .../Services/SparkExit/SparkExitDriver.swift | 17 ++++++++----- BitkitTests/SparkExitTests.swift | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index fc32251f2..819cb03f5 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -146,12 +146,17 @@ struct SparkExitDriver { } } else if record.isUnverifiedAsk == true, !record.isRebuilding { // Funding an unverified rebuild asked for resumes that rebuild, with its guard: the valid - // generation stays over a failed-only build, running again with the funding adopted. - var valid = record - valid.phase = .running - valid.isUnverifiedAsk = nil - try await build(&record, recovering: recovering) - keepRetryClock(&record, from: valid) + // generation stays over a failed-only build, running again with the funding adopted. Once a + // block, like the check's rebuild: the valid generation's retry clock does not pace it. + let tip = try? await chain.tipHeight() + if tip == nil || record.unverifiedRebuiltAtHeight != tip { + record.unverifiedRebuiltAtHeight = tip + var valid = record + valid.phase = .running + valid.isUnverifiedAsk = nil + try await build(&record, recovering: recovering) + keepRetryClock(&record, from: valid) + } } else { try await build(&record, recovering: recovering) // Held funding that pays for less: the awaited top-up is still followed, as while waiting for it. diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 788b5762c..48834409d 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1908,6 +1908,31 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"]) } + /// Funding an unverified rebuild's ask resumes a rebuild that waits for the unread leaf once a block, as the + /// check's rebuild does: a second tick at the same tip prepares nothing. + func testAFundedUnverifiedRebuildWaitingForTheUnreadLeafPreparesOnceABlock() async throws { + let unread = leafOneSteps(.unverified) + var record = SparkExitFixtures.record(phase: .running) + record.adopt(unread, at: clock) + sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 30000, docsUrl: "") + record = try await driver().tick(record) + XCTAssertEqual(record.isUnverifiedAsk, true) + sdk.recoverError = nil + sdk.preparedResponse = skipsUnreadLeaf + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 100_000)] + + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 2) + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 2, "the funded resume prepared again at the same tip") + + chain.tip = 1002 + record = try await driver().tick(record) + XCTAssertEqual(sdk.prepareRequests.count, 3, "the funded resume stopped trying at the next block") + XCTAssertEqual(record.isUnverifiedAsk, true) + } + /// Leaf-1 reads as waiting for its timelock, unread again a week later, then ready: n2 goes out. private func runsUntilLeafOneMatures(_ record: inout SparkExitRecord) async throws { sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.waitingForTimelock(spendableAtHeight: 3000)), verdict: .valid)] From d8a058c2332e2110e950ce16d800ceebc32c4e77 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:38:00 +0100 Subject: [PATCH 348/378] fix: show a final sweep refusal again after a foreground tick --- Bitkit/Managers/SparkExitManager.swift | 16 ++++++++++---- BitkitTests/SparkExitReviewTests.swift | 29 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 72e340ece..672cd85db 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -91,9 +91,13 @@ final class SparkExitManager { /// Recomputes the wallet's totals, through Spark's state hook, when what they read from the exit changed. @ObservationIgnored private let balanceWatch = SparkExitBalanceWatch { SparkManager.shared.onStateChanged?() } - /// The app uses `shared`; tests make their own, on a wallet index no wallet uses. - init(walletIndex: Int = 0) { + /// The chain a test drives the exit on, in place of the network's hosts. + @ObservationIgnored private let testChain: SparkExitChain? + + /// The app uses `shared`; tests make their own, on a wallet index no wallet uses, optionally on their own chain. + init(walletIndex: Int = 0, chain: SparkExitChain? = nil) { self.walletIndex = walletIndex + testChain = chain } private func noteBalanceInputs() { @@ -236,6 +240,9 @@ final class SparkExitManager { } private var chain: SparkExitChain? { + if let testChain { + return testChain + } guard let network = SparkManager.exitNetwork else { return nil } return EsploraSparkExitChain.network(network) } @@ -588,8 +595,9 @@ final class SparkExitManager { if updated.phase != current.phase { await refreshFundingSeen(chain: chain, record: updated) } - // The result may have been opened while this tick ran. - if record?.isResultSeen == true, updated.isResultSeen != true { + // The result may have been opened while this tick ran; a reset the driver made, such as for change kept + // after the result was opened, stays. + if record?.isResultSeen == true, current.isResultSeen != true, updated.isResultSeen != true { updated.isResultSeen = true try? store.save(updated) } diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index b92bddcfe..aee682725 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -837,6 +837,35 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(refreshed?.map(\.address), [first.address, added.address]) } + /// The foreground tick keeps the driver's reset of a seen result: change kept after the result was opened shows + /// that result again with its path. + @MainActor func testTheForegroundTickKeepsTheDriversResetOfASeenResult() async throws { + let walletIndex = 9996 + let store = SparkExitStore(walletIndex: walletIndex) + defer { + try? store.delete() + try? SparkExitEarlierAddressStore(walletIndex: walletIndex).delete() + try? SparkExitLostLeafStore(walletIndex: walletIndex).delete() + } + var record = SparkExitFixtures.record(phase: .failed(reason: "Nothing could be recovered")) + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 9000) + record.leftover = [.init(txid: String(repeating: "a", count: 64), vout: 0, valueSats: 600)] + record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: 1000, checkedAtHeight: 1000) + record.isResultSeen = true + XCTAssertTrue(record.needsDriving) + XCTAssertFalse(record.hasUnseenResult) + try store.save(record) + chain.tip = 1000 + SparkExitDriver.sweepWaitBlocks + let exit = SparkExitManager(walletIndex: walletIndex, chain: chain) + exit.load(walletIndex: walletIndex) + + await exit.tick() + + XCTAssertEqual(exit.record?.isChangeKept, true) + XCTAssertEqual(exit.record?.hasUnseenResult, true, "the tick hid the kept change's result again") + XCTAssertEqual(try store.load()?.hasUnseenResult, true, "the stored record hid the kept change's result again") + } + // MARK: What stays on Spark (Android 124d66170, caa349d4b) /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats From 78cacc07e119f2883150458e278f037a6d99889a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:38:01 +0100 Subject: [PATCH 349/378] fix: keep reading kept change at a reused address --- .../SparkExit/SparkExitEarlierAddresses.swift | 11 ++++++++-- BitkitTests/SparkExitReviewTests.swift | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift index 61665a3e7..616499a2a 100644 --- a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift +++ b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift @@ -108,11 +108,18 @@ struct SparkExitEarlierAddressStore { return try decoder.decode([SparkExitEarlierAddress].self, from: Data(contentsOf: url)) } - /// Adds `entry` unless its address is listed already. + /// Adds `entry` unless its address is listed already. Kept change at a cancelled exit's address, which a later exit + /// reused, replaces that entry, so it is read for good. @discardableResult func add(_ entry: SparkExitEarlierAddress) throws -> [SparkExitEarlierAddress] { let stored = (try? load()) ?? [] - guard !stored.contains(where: { $0.address == entry.address }) else { return stored } + if let index = stored.firstIndex(where: { $0.address == entry.address }) { + guard entry.kind == .keptChange, stored[index].kind != .keptChange else { return stored } + var entries = stored + entries[index] = entry + try save(entries) + return entries + } let entries = stored + [entry] try save(entries) return entries diff --git a/BitkitTests/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift index aee682725..b05084581 100644 --- a/BitkitTests/SparkExitReviewTests.swift +++ b/BitkitTests/SparkExitReviewTests.swift @@ -837,6 +837,27 @@ final class SparkExitReviewTests: XCTestCase { XCTAssertEqual(refreshed?.map(\.address), [first.address, added.address]) } + /// Kept change at a cancelled exit's address, which a later exit reused, is read for good: it replaces the + /// cancel's entry, which is read only for about four weeks. + func testKeptChangeAtACancelledExitsReusedAddressIsReadPastTheCancelsFourWeeks() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let store = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json")) + let cancelledAt = Date(timeIntervalSince1970: 1_800_000_000) + let keptAt = cancelledAt.addingTimeInterval(20 * 24 * 3600) + let path = "m/84'/1'/7797'/0/0" + try store.add(SparkExitEarlierAddress(address: "bcrt1qreused", path: path, kind: .cancelledFunding, sats: nil, date: cancelledAt)) + + try store.add(SparkExitEarlierAddress(address: "bcrt1qreused", path: path, kind: .keptChange, sats: 600, date: keptAt)) + + let listed = try XCTUnwrap(store.load().first) + XCTAssertEqual(try store.load().count, 1) + XCTAssertEqual(listed.kind, .keptChange, "the kept change stays a cancel's entry") + XCTAssertEqual(listed.date, keptAt) + XCTAssertEqual(listed.sats, 600) + XCTAssertTrue(listed.isWatched(now: cancelledAt.addingTimeInterval(60 * 24 * 3600)), "the kept change is no longer read") + } + /// The foreground tick keeps the driver's reset of a seen result: change kept after the result was opened shows /// that result again with its path. @MainActor func testTheForegroundTickKeepsTheDriversResetOfASeenResult() async throws { From 5176fa0c1aac9f106d49b920591fec2ab4c4d9fc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:56:32 +0100 Subject: [PATCH 350/378] fix: keep the valid generation over an unexitable leaf --- .../Services/SparkExit/SparkExitDriver.swift | 7 +++++++ BitkitTests/SparkExitTests.swift | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 819cb03f5..0dc74cdf2 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -406,6 +406,13 @@ struct SparkExitDriver { if let leafId = SparkExitMath.unexitableLeafId(message), record.exitLeafIds.contains(leafId) { leaveBehind([leafId], reason: message, in: &record) } + // Over a valid generation that recovers a leaf this build left waiting, that generation stays, rebuilt over + // again under its guard once a block. + if var valid, valid.recovery?.response.leaves.contains(where: { waitingLeafIds.contains($0.leafId) }) == true { + valid.leftBehindLeaves = record.leftBehindLeaves + record = valid + return + } record.isRebuilding = record.recovery != nil record.lastBuildAt = nil return diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 48834409d..4f166d43a 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1908,6 +1908,26 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"]) } + /// An unverified rebuild that cannot exit leaf-3 leaves it behind and keeps the valid generation running, with + /// leaf-1 still unread: leaf-1's refund n2 stays and nothing rebuilds without the guard. + func testAnUnverifiedRebuildThatCannotExitALeafKeepsTheValidGenerationRunning() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2", "leaf-3"] + record.adopt(leafOneSteps(.unverified), at: clock) + var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2", "leaf-3"]) + prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = prepared + sdk.recoverErrors = [SdkError.SparkError("Leaf leaf-3 cannot be exited: its refund cannot pay its own fee at this fee rate")] + + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 1) + XCTAssertFalse(record.isRebuilding, "the next tick rebuilds without the valid generation's guard") + XCTAssertEqual(record.phase, .running) + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"], "leaf-1's refund n2 is gone") + XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-3"]) + } + /// Funding an unverified rebuild's ask resumes a rebuild that waits for the unread leaf once a block, as the /// check's rebuild does: a second tick at the same tip prepares nothing. func testAFundedUnverifiedRebuildWaitingForTheUnreadLeafPreparesOnceABlock() async throws { From 754725c84bb14413f9ec12dd5585022ee3cf1401 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:56:32 +0100 Subject: [PATCH 351/378] fix: read the configured regtest chain in the exit --- .../Services/SparkExit/SparkExitBroadcaster.swift | 11 ++++++++--- BitkitTests/SparkExitTests.swift | 13 ++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index ba5f8c659..f43068543 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -147,8 +147,12 @@ struct EsploraSparkExitChain: SparkExitChain { /// On mainnet the chain service Spark connects with (`Env.sparkChainServiceUrl`) answers every read and takes /// broadcasts first, on its `/txs/package` (no `/v1`); the SDK's default hosts stay as broadcast fallbacks. On - /// regtest the SDK's default host (breez spark-sdk `default_rest_chain_services`) does both. - static func network(_ network: BreezSdkSpark.Network) -> EsploraSparkExitChain { + /// regtest the chain service Spark connects with, `regtestChainServiceUrl` (`SPARK_CHAIN_SERVICE_URL`), does both, + /// else the SDK's default host (breez spark-sdk `default_rest_chain_services`). + static func network( + _ network: BreezSdkSpark.Network, + regtestChainServiceUrl: String? = Env.sparkChainServiceUrl(for: .regtest) + ) -> EsploraSparkExitChain { switch network { case .mainnet: let electrs = URL(string: Env.sparkChainServiceUrl(for: .bitcoin)!)! @@ -161,7 +165,8 @@ struct EsploraSparkExitChain: SparkExitChain { ] ) case .regtest: - return EsploraSparkExitChain(hosts: [URL(string: "https://regtest-mempool.us-west-2.sparkinfra.net/api")!]) + let configured = regtestChainServiceUrl.flatMap(URL.init(string:)) + return EsploraSparkExitChain(hosts: [configured ?? URL(string: "https://regtest-mempool.us-west-2.sparkinfra.net/api")!]) default: return EsploraSparkExitChain(hosts: []) } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 4f166d43a..82fd783b0 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -3260,9 +3260,20 @@ final class SparkExitBroadcasterTests: XCTestCase { .init(url: mempool, packagePaths: ["/v1/txs/package", "/txs/package"]), .init(url: blockstream, packagePaths: ["/v1/txs/package", "/txs/package"]), ]) - XCTAssertEqual(EsploraSparkExitChain.network(.regtest).readHosts.map(\.host), ["regtest-mempool.us-west-2.sparkinfra.net"]) + XCTAssertEqual( + EsploraSparkExitChain.network(.regtest, regtestChainServiceUrl: nil).readHosts.map(\.host), + ["regtest-mempool.us-west-2.sparkinfra.net"] + ) } + /// On regtest the exit reads and broadcasts on the chain Spark connects with when `SPARK_CHAIN_SERVICE_URL` names one. + func testTheRegtestExitUsesTheConfiguredSparkChain() { + let chain = EsploraSparkExitChain.network(.regtest, regtestChainServiceUrl: "http://localhost:3002") + XCTAssertEqual(chain.readHosts.map(\.absoluteString), ["http://localhost:3002"]) + XCTAssertEqual(chain.broadcastHosts.map(\.url.absoluteString), ["http://localhost:3002"]) + } + + func testSendsAPackageToBitkitElectrsWithoutTheV1Path() async { let http = FakeHTTP() http.responses = [("bitkit.to/electrs/txs/package", 200, #"{"package_msg":"success"}"#)] From 15f76ba5a4063e62fe16372a64d5ef9a6f8e529f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:56:33 +0100 Subject: [PATCH 352/378] fix: fund the exit from savings only on spark's chain --- Bitkit/Services/SparkExit/SparkExitFunding.swift | 7 ++++--- Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 6 ++++-- BitkitTests/SparkExitTests.swift | 13 +++++++++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 93cb360e6..a0d43be6f 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -673,8 +673,9 @@ enum SparkExitMath { network == .regtest ? 10 * 60 : 4 * 60 * 60 } - /// Savings can pay the fee funding when its spendable balance covers the funding and the payment's own fee. - static func canFundFromSavings(spendableSats: UInt64, fundingSats: UInt64, paymentFeeSats: UInt64) -> Bool { - fundingSats > 0 && spendableSats >= fundingSats.saturatingAdd(paymentFeeSats) + /// Savings can pay the fee funding when Spark reads Bitkit's chain, which the payment goes out on + /// (`readsBitkitChain`), and its spendable balance covers the funding and the payment's own fee. + static func canFundFromSavings(spendableSats: UInt64, fundingSats: UInt64, paymentFeeSats: UInt64, readsBitkitChain: Bool) -> Bool { + readsBitkitChain && fundingSats > 0 && spendableSats >= fundingSats.saturatingAdd(paymentFeeSats) } } diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index d18c70073..a6a95e581 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -193,7 +193,8 @@ struct SparkExitFundingView: View { return SparkExitMath.canFundFromSavings( spendableSats: UInt64(max(0, wallet.spendableOnchainBalanceSats)), fundingSats: savingsSats, - paymentFeeSats: savingsFeeSats + paymentFeeSats: savingsFeeSats, + readsBitkitChain: SparkManager.shared.canFundFromChain ) } @@ -242,7 +243,8 @@ struct SparkExitFundingView: View { .disabled(exit.fundingSeenSats < prestartFunding.covered) .accessibilityIdentifier("SparkExitFundingContinue") } else { - if hasSavingsFeeFailed || (savingsFeeSats != nil && !canFundFromSavings) { + // Savings on a chain Spark does not read is off without a balance line, as on the transfer screens. + if SparkManager.shared.canFundFromChain, hasSavingsFeeFailed || (savingsFeeSats != nil && !canFundFromSavings) { CaptionText(t("lightning__spark_exit__fund_insufficient")) .padding(.bottom, 16) } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 82fd783b0..a1a09403e 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -2966,9 +2966,15 @@ final class SparkExitMathTests: XCTestCase { } func testSavingsMustCoverTheFundingAndItsFee() { - XCTAssertTrue(SparkExitMath.canFundFromSavings(spendableSats: 10500, fundingSats: 10000, paymentFeeSats: 500)) - XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10499, fundingSats: 10000, paymentFeeSats: 500)) - XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10000, fundingSats: 0, paymentFeeSats: 0)) + XCTAssertTrue(SparkExitMath.canFundFromSavings(spendableSats: 10500, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: true)) + XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10499, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: true)) + XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10000, fundingSats: 0, paymentFeeSats: 0, readsBitkitChain: true)) + } + + /// A Savings payment goes out on Bitkit's chain: while Spark reads another chain, such as its hosted regtest, it + /// would never fund the exit. + func testSavingsCannotFundAnExitOnAChainSparkDoesNotRead() { + XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10500, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: false)) } func testEtaUsesTheNetworksBlockTime() { @@ -3273,7 +3279,6 @@ final class SparkExitBroadcasterTests: XCTestCase { XCTAssertEqual(chain.broadcastHosts.map(\.url.absoluteString), ["http://localhost:3002"]) } - func testSendsAPackageToBitkitElectrsWithoutTheV1Path() async { let http = FakeHTTP() http.responses = [("bitkit.to/electrs/txs/package", 200, #"{"package_msg":"success"}"#)] From c2346f340d2e8de0194f9cc7a4935a0c9519a592 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:58:20 +0100 Subject: [PATCH 353/378] fix: match a spark withdrawal by its idempotency key --- Bitkit/Managers/SparkManager.swift | 27 ++++++++++++++++++--------- BitkitTests/PaykitRouteTests.swift | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 2f0e3a03c..29386d23b 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -716,7 +716,8 @@ final class SparkManager: ChannelFreeLightningRail { startedAt: UInt64(Date().timeIntervalSince1970), request: request, tags: tags, - earlierPaymentIds: Self.withdrawalIds(to: address, amountSats: amountSats, in: earlierPayments) + earlierPaymentIds: Self.withdrawalIds(to: address, amountSats: amountSats, in: earlierPayments), + idempotencyKey: UUID().uuidString.lowercased() ) let journaled = pending try await Self.startJournaledWithdrawal( @@ -734,7 +735,8 @@ final class SparkManager: ChannelFreeLightningRail { do { response = try await sdk.sendPayment(request: SendPaymentRequest( prepareResponse: prepared, - options: .bitcoinAddress(confirmationSpeed: .medium) + options: .bitcoinAddress(confirmationSpeed: .medium), + idempotencyKey: pending.idempotencyKey )) } catch { throw await Self.withdrawalSendFailure( @@ -834,9 +836,10 @@ final class SparkManager: ChannelFreeLightningRail { guard let pending = Self.pendingContactWithdrawal, !isWithdrawingToContact, let payments = try? await requireSdk().listPayments(request: ListPaymentsRequest(sortAscending: false)).payments else { return } - if let payment = Self.matchingWithdrawal(pending, in: payments), case let .withdraw(txId, _, _) = payment.details { + let payment = Self.matchingWithdrawal(pending, in: payments) + if let payment, case let .withdraw(txId, _, _) = payment.details { await recordContactWithdrawal(payment, txId: txId, pending: pending) - } else if allowClear, Self.canCloseUnlisted(pending, now: now) { + } else if allowClear, Self.canCloseUnlisted(pending, listed: payment, now: now) { Logger.info("Closed a Spark contact withdrawal Spark never listed", context: "SparkManager") await Self.closeUnsentWithdrawal(pending, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) } @@ -888,15 +891,19 @@ final class SparkManager: ChannelFreeLightningRail { nonisolated static let contactWithdrawalExpirySecs: UInt64 = 600 - /// An attempt Spark never returned nor listed is taken as unsent only after the expiry. - nonisolated static func canCloseUnlisted(_ pending: SparkContactWithdrawal, now: UInt64) -> Bool { - pending.paymentId == nil && now >= pending.startedAt + contactWithdrawalExpirySecs + /// An attempt Spark never returned nor listed is taken as unsent only after the expiry. Spark can list its payment + /// without withdrawal details while the payout may still go out, so only one it failed is unsent too. + nonisolated static func canCloseUnlisted(_ pending: SparkContactWithdrawal, listed: Payment? = nil, now: UInt64) -> Bool { + pending.paymentId == nil && (listed?.status ?? .failed) == .failed && now >= pending.startedAt + contactWithdrawalExpirySecs } + /// The attempt's own payment, by the id Spark returned or the idempotency key it keeps as the id, also while Spark + /// lists it without withdrawal details; else, for one Spark never returned, a new withdrawal to its address and amount. nonisolated static func matchingWithdrawal(_ pending: SparkContactWithdrawal, in payments: [Payment]) -> Payment? { - if let paymentId = pending.paymentId { - return payments.first { $0.id == paymentId } + if let ownId = pending.paymentId ?? pending.idempotencyKey, let own = payments.first(where: { $0.id == ownId }) { + return own } + guard pending.paymentId == nil else { return nil } let earlier = Set(pending.earlierPaymentIds ?? []) return payments.first { payment in !earlier.contains(payment.id) && withdrawalIds(to: pending.address, amountSats: pending.amountSats, in: [payment]) == [payment.id] @@ -1778,6 +1785,8 @@ struct SparkContactWithdrawal: Codable, Equatable { var earlierPaymentIds: [String]? /// This attempt's Spark payment, once Spark returned it. var paymentId: String? + /// The UUID the send passed as its idempotency key, which Spark keeps as the payment's id. + var idempotencyKey: String? } /// One contact withdrawal, which a pending send screen follows until its journal closes. diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index b2fd03187..39d557b91 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -213,6 +213,33 @@ final class PaykitRouteTests: XCTestCase { ) } + func testAWithdrawalSparkListsWithoutDetailsIsMatchedByItsKeyAndKeptOpen() { + let pending = SparkContactWithdrawal( + address: onchain.value, + amountSats: 20000, + contact: "contact", + startedAt: 1000, + idempotencyKey: "key" + ) + var listed = Self.withdrawal(id: "key", to: onchain.value, amountSats: 20000, timestamp: 1001) + listed.status = .pending + listed.details = nil + let past = 1000 + SparkManager.contactWithdrawalExpirySecs * 10 + + let matched = SparkManager.matchingWithdrawal(pending, in: [listed]) + XCTAssertEqual(matched?.id, "key", "Spark keeps the send's idempotency key as the payment's id") + XCTAssertFalse( + SparkManager.canCloseUnlisted(pending, listed: matched, now: past), + "A withdrawal Spark lists without its details may still pay out, so its journal stays open" + ) + + listed.status = .failed + XCTAssertTrue( + SparkManager.canCloseUnlisted(pending, listed: SparkManager.matchingWithdrawal(pending, in: [listed]), now: past), + "One Spark failed before it had withdrawal details never pays out" + ) + } + func testAFailedWithdrawalStaysOpenAfterASyncThatNeverReachedSpark() async { var allowedClears: [Bool] = [] // The sync call returns, but Spark keeps its failed sync in `lastSyncError`, so it never synced after the failure. From 7622b775bac06de9e092c6d7ed18ddfedd1e6862 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 23:00:19 +0100 Subject: [PATCH 354/378] fix: back up the spark exit funding index The next exit funding index of each wallet now rides the metadata backup's cache as sparkExitNextFundingIndexes, keyed /wallet like bitkit-android, so a backup restores on either app. A restore keeps the higher of the backed-up and the device's index, and a backup without the field restores none. --- Bitkit/Managers/SparkExitManager.swift | 5 ++- Bitkit/Models/BackupPayloads.swift | 9 ++++- .../Services/SparkExit/SparkExitFunding.swift | 33 +++++++++++++++ Bitkit/ViewModels/SettingsViewModel.swift | 4 +- BitkitTests/SparkExitFundingIndexTests.swift | 40 +++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 BitkitTests/SparkExitFundingIndexTests.swift diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 672cd85db..4e33f6080 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -544,6 +544,8 @@ final class SparkExitManager { // A payment that lands at the forgotten address late is then seen by the next exit, which uses it again. let counter = UserDefaults.standard.integer(forKey: fundingIndexKey) UserDefaults.standard.set(Self.fundingCounterAfterForget(counter, index: record.fundingIndex), forKey: fundingIndexKey) + // The counter rides the metadata backup, which its dynamic key does not mark by itself. + SettingsViewModel.shared.notifyAppStateChanged() if let transferId = record.transferId { try? await TransferService(lightningService: .shared).markSettled(id: transferId) } @@ -962,6 +964,7 @@ final class SparkExitManager { ) UserDefaults.standard.set(walletIndex, forKey: Self.walletIndexKey) UserDefaults.standard.set(Int(fundingIndex) + 1, forKey: fundingIndexKey) + SettingsViewModel.shared.notifyAppStateChanged() pendingFundingIndex = nil startingCreatedAt = record.createdAt defer { startingCreatedAt = nil } @@ -1176,7 +1179,7 @@ final class SparkExitManager { } private var fundingIndexKey: String { - "sparkExitNextFundingIndex-\(Env.network)-\(walletIndex)" + SparkExitFundingIndexes.defaultsKey(network: "\(Env.network)", walletIndex: walletIndex) } /// The fast rate of Bitkit's own fee source, the ceiling of a leftover sweep with the exit's own rate. diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index a083b111c..e59786a76 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -84,6 +84,9 @@ struct AppCacheData: Codable { let detachedActivityContacts: Set /// Imported cache addresses may have been public; local snapshots do not export the live receive address. let onchainAddress: String? + /// The funding key index each wallet's next Spark exit takes, keyed `/wallet` as bitkit-android keys + /// its own. Nil in envelopes written before this field. + let sparkExitNextFundingIndexes: [String: Int]? init( hasSeenContactsIntro: Bool, @@ -103,7 +106,8 @@ struct AppCacheData: Codable { dismissedSuggestions: [String], lastUsedTags: [String], quickPayLedger: QuickPayLedger? = nil, - detachedActivityContacts: Set = [] + detachedActivityContacts: Set = [], + sparkExitNextFundingIndexes: [String: Int]? = nil ) { self.hasSeenContactsIntro = hasSeenContactsIntro self.hasSeenProfileIntro = hasSeenProfileIntro @@ -123,6 +127,7 @@ struct AppCacheData: Codable { self.lastUsedTags = lastUsedTags self.quickPayLedger = quickPayLedger self.detachedActivityContacts = detachedActivityContacts + self.sparkExitNextFundingIndexes = sparkExitNextFundingIndexes onchainAddress = nil } @@ -147,6 +152,7 @@ struct AppCacheData: Codable { quickPayLedger = try c.decodeIfPresent(QuickPayLedger.self, forKey: .quickPayLedger) detachedActivityContacts = try c.decodeIfPresent(Set.self, forKey: .detachedActivityContacts) ?? [] onchainAddress = try c.decodeIfPresent(String.self, forKey: .onchainAddress) + sparkExitNextFundingIndexes = try c.decodeIfPresent([String: Int].self, forKey: .sparkExitNextFundingIndexes) } private enum CodingKeys: String, CodingKey { @@ -158,6 +164,7 @@ struct AppCacheData: Codable { case quickPayLedger case detachedActivityContacts case onchainAddress + case sparkExitNextFundingIndexes } } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index a0d43be6f..fd40b1e64 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -679,3 +679,36 @@ enum SparkExitMath { readsBitkitChain && fundingSats > 0 && spendableSats >= fundingSats.saturatingAdd(paymentFeeSats) } } + +/// Each wallet's next exit funding index in the metadata backup, keyed `/wallet` as bitkit-android keys its +/// `sparkExitNextFundingIndexes`, so a backup from either app restores on the other. +enum SparkExitFundingIndexes { + private static let defaultsPrefix = "sparkExitNextFundingIndex-" + + /// The counter the last exit start left, per network and wallet. + static func defaultsKey(network: String, walletIndex: Int) -> String { + "\(defaultsPrefix)\(network)-\(walletIndex)" + } + + /// The counters kept in `defaults`, nil with none. + static func backup(_ defaults: UserDefaults) -> [String: Int]? { + let kept = defaults.dictionaryRepresentation().reduce(into: [String: Int]()) { kept, entry in + guard entry.key.hasPrefix(defaultsPrefix), let index = entry.value as? Int else { return } + let rest = entry.key.dropFirst(defaultsPrefix.count) + guard let dash = rest.lastIndex(of: "-"), let wallet = Int(rest[rest.index(after: dash)...]) else { return } + kept["\(rest[.. Date: Wed, 11 Nov 2026 23:00:19 +0100 Subject: [PATCH 355/378] fix: find the spark exit funding index after a restore without one A wallet restored from the phrase, or from a backup older than the counter, started its next exit at funding index 0. With no counter kept and no record, the quote now reads the funding addresses from index 0 until 20 unused ones follow the last used (at most 1000), takes the index past it, and keeps it. A scan the chain fails fails the quote and keeps nothing. --- Bitkit/Managers/SparkExitManager.swift | 41 +++++- .../SparkExit/SparkExitBackgroundRun.swift | 4 + .../SparkExit/SparkExitBroadcaster.swift | 23 ++++ .../Services/SparkExit/SparkExitFunding.swift | 19 +++ BitkitTests/SparkExitFundingIndexTests.swift | 125 ++++++++++++++++++ BitkitTests/SparkExitTests.swift | 17 +++ 6 files changed, 224 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 4e33f6080..3a2c53afb 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -881,7 +881,11 @@ final class SparkExitManager { destination: destination, lostLeafIds: (try? lostLeafStore.load()) ?? [] ) - let fundingIndex = pendingFundingIndex ?? nextFundingIndex() + let fundingIndex = if let pendingFundingIndex { + pendingFundingIndex + } else { + try await nextFundingIndex() + } let fundingAddress = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex).address useQuote(newQuote, destination: destination, feeRate: feeRate, fundingIndex: fundingIndex, fundingAddress: fundingAddress) } catch { @@ -1172,10 +1176,37 @@ final class SparkExitManager { /// Keeps the savings payment in the record, so the exit waits for it instead of asking for the funding again. /// Written between ticks, so a tick in flight never saves over it. - /// The funding key index of the next exit: one no exit of this wallet used, from the counter the last start - /// left and the stored record, so leftover of an earlier exit never mixes into a new one. - private func nextFundingIndex() -> UInt32 { - SparkExitMath.nextFundingIndex(counter: UserDefaults.standard.integer(forKey: fundingIndexKey), record: record) + /// The funding key index of the next exit: one no exit of this wallet used, so leftover of an earlier exit never + /// mixes into a new one. A scan's result rides the next metadata backup. + private func nextFundingIndex() async throws -> UInt32 { + let wasKept = UserDefaults.standard.object(forKey: fundingIndexKey) != nil + let walletIndex = walletIndex + let index = try await Self.nextFundingIndex(defaults: .standard, key: fundingIndexKey, record: record, chain: chain) { + try Self.fundingKey(walletIndex: walletIndex, index: $0).address + } + if !wasKept { + SettingsViewModel.shared.notifyAppStateChanged() + } + return index + } + + /// From the counter the last start left at `key` and the stored record. With neither, as after a restore from the + /// phrase or from a backup older than the counter, from a scan of the funding addresses at `address` on `chain`, + /// kept as the counter so it runs once. A scan the chain fails keeps nothing. + static func nextFundingIndex( + defaults: UserDefaults, + key: String, + record: SparkExitRecord?, + chain: SparkExitChain?, + address: (UInt32) throws -> String + ) async throws -> UInt32 { + if defaults.object(forKey: key) != nil || record != nil { + return SparkExitMath.nextFundingIndex(counter: defaults.integer(forKey: key), record: record) + } + guard let chain else { return 0 } + let next = try await SparkExitMath.scanNextFundingIndex { try await chain.isAddressUsed(address: address($0)) } + defaults.set(Int(next), forKey: key) + return next } private var fundingIndexKey: String { diff --git a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift index 7ea2038c4..9e12a6ae9 100644 --- a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift +++ b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift @@ -158,6 +158,10 @@ struct TimedSparkExitChain: SparkExitChain { try await base.utxos(address: address) } + func isAddressUsed(address: String) async throws -> Bool { + try await base.isAddressUsed(address: address) + } + func txStatus(txid: String) async throws -> EsploraTxStatus { try await base.txStatus(txid: txid) } diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift index f43068543..bc5ae86b6 100644 --- a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift +++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift @@ -90,6 +90,8 @@ enum SparkExitFeeEstimates { protocol SparkExitChain: Sendable { func tipHeight() async throws -> UInt32 func utxos(address: String) async throws -> [EsploraUtxo] + /// Whether the address has any transaction, in a block or the mempool, from `/address/{address}`. + func isAddressUsed(address: String) async throws -> Bool func txStatus(txid: String) async throws -> EsploraTxStatus /// Whether the transaction is in a block or in the mempool; false once every host answers it has no such /// transaction. @@ -184,6 +186,27 @@ struct EsploraSparkExitChain: SparkExitChain { try await JSONDecoder().decode([EsploraUtxo].self, from: get("/address/\(address)/utxo")) } + func isAddressUsed(address: String) async throws -> Bool { + struct Stats: Decodable { + let txCount: Int + + enum CodingKeys: String, CodingKey { + case txCount = "tx_count" + } + } + struct Address: Decodable { + let chainStats: Stats + let mempoolStats: Stats + + enum CodingKeys: String, CodingKey { + case chainStats = "chain_stats" + case mempoolStats = "mempool_stats" + } + } + let stats = try await JSONDecoder().decode(Address.self, from: get("/address/\(address)")) + return stats.chainStats.txCount + stats.mempoolStats.txCount > 0 + } + func txStatus(txid: String) async throws -> EsploraTxStatus { try await JSONDecoder().decode(EsploraTxStatus.self, from: get("/tx/\(txid)/status")) } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index fd40b1e64..75e526ecd 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -580,6 +580,25 @@ enum SparkExitMath { receivedSats > 0 && receivedSats < requiredSats ? max(requiredSats - receivedSats, fundingDustSats) : nil } + /// Unused funding addresses a scan reads past the last used one before it stops. + static let fundingScanGapLimit: UInt32 = 20 + /// The most funding addresses one scan reads, even when a host reports every one used. + static let fundingScanMaxAddresses: UInt32 = 1000 + + /// The funding index past the last one `isUsed` reports used, read from 0 until `fundingScanGapLimit` unused ones + /// follow it, for a wallet that kept no index, such as one restored from the phrase or an older backup. + static func scanNextFundingIndex(isUsed: (UInt32) async throws -> Bool) async rethrows -> UInt32 { + var next: UInt32 = 0 + var index: UInt32 = 0 + while index < next + fundingScanGapLimit, index < fundingScanMaxAddresses { + if try await isUsed(index) { + next = index + 1 + } + index += 1 + } + return next + } + /// The funding key index of a new exit: past the counter the last start left and every index the stored record /// used. Records from before per-exit indexes used 0. static func nextFundingIndex(counter: Int, record: SparkExitRecord?) -> UInt32 { diff --git a/BitkitTests/SparkExitFundingIndexTests.swift b/BitkitTests/SparkExitFundingIndexTests.swift index 3e02a4cf4..be4643adc 100644 --- a/BitkitTests/SparkExitFundingIndexTests.swift +++ b/BitkitTests/SparkExitFundingIndexTests.swift @@ -7,6 +7,7 @@ import XCTest final class SparkExitFundingIndexTests: XCTestCase { private let suiteName = "SparkExitFundingIndexTests" private var defaults: UserDefaults! + private let chain = FakeSparkExitChain() private let key = SparkExitFundingIndexes.defaultsKey(network: "regtest", walletIndex: 0) override func setUp() { @@ -20,6 +21,74 @@ final class SparkExitFundingIndexTests: XCTestCase { super.tearDown() } + private func address(_ index: UInt32) -> String { + "bcrt1qfunding\(index)" + } + + private func nextFundingIndex() async throws -> UInt32 { + try await SparkExitManager.nextFundingIndex(defaults: defaults, key: key, record: nil, chain: chain) { self.address($0) } + } + + /// What a new device holds after a restore: the app's data is gone, and only the backed-up cache comes back. + private func restore(_ cache: AppCacheData) { + UserDefaults().removePersistentDomain(forName: suiteName) + defaults = UserDefaults(suiteName: suiteName) + SparkExitFundingIndexes.restore(cache.sparkExitNextFundingIndexes, into: defaults) + } + + private func roundTrip(_ cache: AppCacheData) throws -> AppCacheData { + try JSONDecoder().decode(AppCacheData.self, from: JSONEncoder().encode(cache)) + } + + private func cache(sparkExitNextFundingIndexes: [String: Int]?) -> AppCacheData { + AppCacheData( + hasSeenContactsIntro: false, + hasSeenProfileIntro: false, + hasSeenNotificationsIntro: false, + hasSeenQuickpayIntro: false, + hasSeenShopIntro: false, + hasSeenTransferToSavingsIntro: false, + hasSeenWidgetsIntro: false, + hasDismissedWidgetsOnboardingHint: false, + appUpdateIgnoreTimestamp: 0, + backupIgnoreTimestamp: 0, + highBalanceIgnoreCount: 0, + highBalanceIgnoreTimestamp: 0, + dismissedSuggestions: [], + lastUsedTags: [], + sparkExitNextFundingIndexes: sparkExitNextFundingIndexes + ) + } + + /// Funding indexes 0 to 2 were used before the restore; index 2 still holds what a lost exit received. + func testARestoreFromABackupWithoutTheIndexScansPastTheFundingItsLastExitUsed() async throws { + defaults.set(3, forKey: key) + let oldBackup = try JSONDecoder().decode(AppCacheData.self, from: Data("{}".utf8)) + restore(oldBackup) + chain.usedAddresses = [address(0), address(1)] + chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] + + let next = try await nextFundingIndex() + + XCTAssertEqual(next, 3) + XCTAssertTrue(chain.addressReads.contains(address(2)), "the funding at index 2 goes unseen") + XCTAssertEqual(chain.addressReads, (0 ... 2 + SparkExitMath.fundingScanGapLimit).map(address)) + XCTAssertEqual(defaults.object(forKey: key) as? Int, 3, "the next quote scans again") + } + + func testAFundingIndexRestoredFromTheBackupIsUsedWithoutAScan() async throws { + defaults.set(3, forKey: key) + let backup = try roundTrip(cache(sparkExitNextFundingIndexes: SparkExitFundingIndexes.backup(defaults))) + restore(backup) + chain.usedAddresses = [address(0), address(1), address(2)] + + let next = try await nextFundingIndex() + + XCTAssertEqual(backup.sparkExitNextFundingIndexes, ["regtest/wallet0": 3], "keyed as bitkit-android keys it") + XCTAssertEqual(next, 3) + XCTAssertTrue(chain.addressReads.isEmpty, "a backed-up index needs no scan") + } + func testARestoreKeepsTheHigherIndexOfTheDeviceAndTheBackup() { defaults.set(5, forKey: key) @@ -37,4 +106,60 @@ final class SparkExitFundingIndexTests: XCTestCase { XCTAssertTrue(backup.cache.hasSeenContactsIntro) XCTAssertNil(backup.cache.sparkExitNextFundingIndexes) } + + func testAFundingScanTheChainFailsKeepsNoIndex() async { + chain.isAddressReadUnreadable = true + + do { + _ = try await nextFundingIndex() + XCTFail("an exit could fund from an address an earlier exit used") + } catch {} + + XCTAssertNil(defaults.object(forKey: key)) + } + + func testAFundingScanReadsTheGapLimitPastTheLastUsedAddress() async { + var reads: [UInt32] = [] + + let next = await SparkExitMath.scanNextFundingIndex { index in + reads.append(index) + return [0, 2, 5].contains(index) + } + + XCTAssertEqual(next, 6) + XCTAssertEqual(reads, Array(0 ... 5 + SparkExitMath.fundingScanGapLimit)) + } + + func testAFundingScanStopsAtItsCeilingWhenEveryAddressReadsAsUsed() async { + var reads: UInt32 = 0 + + let next = await SparkExitMath.scanNextFundingIndex { _ in + reads += 1 + return true + } + + XCTAssertEqual(next, SparkExitMath.fundingScanMaxAddresses) + XCTAssertEqual(reads, SparkExitMath.fundingScanMaxAddresses) + } + + func testAnAddressWithATransactionInABlockOrTheMempoolReadsAsUsed() async throws { + let http = SparkExitBroadcasterTests.FakeHTTP() + let stats = { (chain: Int, mempool: Int) in + #"{"chain_stats":{"tx_count":\#(chain)},"mempool_stats":{"tx_count":\#(mempool)}}"# + } + http.responses = [ + ("a.example/api/address/bcrt1qmempool", 200, stats(0, 1)), + ("a.example/api/address/bcrt1qspent", 200, stats(2, 0)), + ("a.example/api/address/bcrt1qfresh", 200, stats(0, 0)), + ] + let esplora = try EsploraSparkExitChain(hosts: [XCTUnwrap(URL(string: "https://a.example/api"))], http: http) + + let isMempoolUsed = try await esplora.isAddressUsed(address: "bcrt1qmempool") + let isSpentUsed = try await esplora.isAddressUsed(address: "bcrt1qspent") + let isFreshUsed = try await esplora.isAddressUsed(address: "bcrt1qfresh") + + XCTAssertTrue(isMempoolUsed) + XCTAssertTrue(isSpentUsed) + XCTAssertFalse(isFreshUsed) + } } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index a1a09403e..0c6dee1eb 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -144,6 +144,23 @@ final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable { return lock.withLock { utxosByAddress[address] ?? [] } } + /// Addresses with a transaction though they hold nothing now. + var usedAddresses: Set = [] + /// Every address read for its transactions, in order. + private(set) var addressReads: [String] = [] + /// The address read fails, as on a host timeout. + var isAddressReadUnreadable = false + + func isAddressUsed(address: String) async throws -> Bool { + if isAddressReadUnreadable { + throw URLError(.timedOut) + } + return lock.withLock { + addressReads.append(address) + return usedAddresses.contains(address) || !(utxosByAddress[address] ?? []).isEmpty + } + } + /// Transactions every host answers not found for, as for one a node dropped. var droppedTxids: Set = [] /// Block heights of confirmed transactions; 900 for any other. From 9659b9558fe2573a15ae05c005b7ab60ad790a6f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 23:31:24 +0100 Subject: [PATCH 356/378] fix: back up the earlier spark exit addresses The earlier exit addresses each wallet watches, kept change and cancelled exits' funding, lived only on the device. The metadata backup now carries them as sparkExitEarlierAddresses, keyed /wallet with the kind and time in milliseconds as bitkit-android writes them. A restore lists them next to the device's own entries and skips a kind it does not know, and a list it can't read never fails the rest of the envelope. --- Bitkit/Models/BackupPayloads.swift | 34 ++++++++++- Bitkit/Services/BackupService.swift | 18 +++++- .../SparkExit/SparkExitEarlierAddresses.swift | 59 +++++++++++++++++++ .../Services/SparkExit/SparkExitFunding.swift | 5 ++ BitkitTests/SparkExitFundingIndexTests.swift | 54 +++++++++++++++++ 5 files changed, 168 insertions(+), 2 deletions(-) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index e59786a76..628345dcd 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -27,6 +27,9 @@ struct MetadataBackupV1: Codable { /// before this field, and in envelopes from an app that knows no names. Shared with /// bitkit-android, which spells the key the same way — see its `MetadataBackupV1`. let hwWalletNames: [String: String]? + /// The earlier Spark exit addresses each wallet watches, keyed `/wallet` as bitkit-android keys them. + /// Nil in envelopes written before this field. + var sparkExitEarlierAddresses: [String: [SparkExitEarlierAddressBackupV1]]? init( version: Int, @@ -35,7 +38,8 @@ struct MetadataBackupV1: Codable { cache: AppCacheData, pubkySession: PubkySessionBackupV1?, pubkyContactProfileOverrides: [String: PubkyProfileData]?, - hwWalletNames: [String: String]? = nil + hwWalletNames: [String: String]? = nil, + sparkExitEarlierAddresses: [String: [SparkExitEarlierAddressBackupV1]]? = nil ) { self.version = version self.createdAt = createdAt @@ -44,9 +48,37 @@ struct MetadataBackupV1: Codable { self.pubkySession = pubkySession self.pubkyContactProfileOverrides = pubkyContactProfileOverrides self.hwWalletNames = hwWalletNames + self.sparkExitEarlierAddresses = sparkExitEarlierAddresses } } +extension MetadataBackupV1 { + /// An earlier exit address list this app can't read is left out, so it never fails the rest of the envelope. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(Int.self, forKey: .version) + createdAt = try c.decode(UInt64.self, forKey: .createdAt) + tagMetadata = try c.decode([PreActivityMetadata].self, forKey: .tagMetadata) + cache = try c.decode(AppCacheData.self, forKey: .cache) + pubkySession = try c.decodeIfPresent(PubkySessionBackupV1.self, forKey: .pubkySession) + pubkyContactProfileOverrides = try c.decodeIfPresent([String: PubkyProfileData].self, forKey: .pubkyContactProfileOverrides) + hwWalletNames = try c.decodeIfPresent([String: String].self, forKey: .hwWalletNames) + sparkExitEarlierAddresses = try? c.decodeIfPresent( + [String: [SparkExitEarlierAddressBackupV1]].self, + forKey: .sparkExitEarlierAddresses + ) + } +} + +/// An earlier Spark exit address as both apps back it up. `kind` is `keptChange` or `cancelledFunding`; a reader skips +/// one it does not know. What the address holds is read again from the chain. +struct SparkExitEarlierAddressBackupV1: Codable, Equatable { + let address: String + let path: String + let kind: String + let atMillis: Int64 +} + struct PubkySessionBackupV1: Codable, Equatable { enum Kind: String, Codable { case localSeed diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 0c040f574..536de26ed 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -792,6 +792,9 @@ class BackupService { // App-owned, so it takes no part in the core field migration above and never sets // needsRewrite. Restored names wait as pending ones until each wallet is paired again. HwKnownDeviceStorage.restoreNames(payload.hwWalletNames ?? [:]) + SparkExitEarlierAddressBackup.restore(payload.sparkExitEarlierAddresses, network: "\(Env.network)") { + SparkExitEarlierAddressStore(walletIndex: $0) + } // Force address rotation by clearing onchain address UserDefaults.standard.set("", forKey: "onchainAddress") @@ -956,6 +959,15 @@ class BackupService { } .store(in: cancellables) + // METADATA (earlier Spark exit addresses) + SparkExitEarlierAddressStore.savedPublisher + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { @Sendable [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .metadata) + } + .store(in: cancellables) + // APP STATE (UserDefaults changes, etc.) SettingsViewModel.shared.appStatePublisher .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) @@ -1383,7 +1395,11 @@ class BackupService { cache: cache, pubkySession: pubkySession, pubkyContactProfileOverrides: pubkyContactProfileOverrides, - hwWalletNames: hwWalletNames.isEmpty ? nil : hwWalletNames + hwWalletNames: hwWalletNames.isEmpty ? nil : hwWalletNames, + sparkExitEarlierAddresses: SparkExitEarlierAddressBackup.backup( + SparkExitEarlierAddressStore(walletIndex: LightningService.shared.currentWalletIndex), + walletKey: SparkExitFundingIndexes.walletKey(network: "\(Env.network)", walletIndex: LightningService.shared.currentWalletIndex) + ) ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift index 616499a2a..e28f6b374 100644 --- a/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift +++ b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift @@ -1,4 +1,5 @@ import BitkitCore +import Combine import Foundation /// A funding address an earlier exit left coins at, or may still receive them at, with the path that spends it. Kept @@ -91,6 +92,10 @@ struct SparkExitEarlierAddress: Codable, Equatable, Identifiable { /// The earlier exits' addresses of a wallet, across exits. A wipe deletes it with the record. struct SparkExitEarlierAddressStore { + /// Sends on every write of any wallet's list, so the metadata backup that carries them runs again. + static let savedPublisher = savedSubject.eraseToAnyPublisher() + private static let savedSubject = PassthroughSubject() + let url: URL init(url: URL) { @@ -130,6 +135,7 @@ struct SparkExitEarlierAddressStore { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .millisecondsSince1970 try encoder.encode(entries).write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + Self.savedSubject.send() } func delete() throws { @@ -137,3 +143,56 @@ struct SparkExitEarlierAddressStore { try FileManager.default.removeItem(at: url) } } + +/// The earlier exit addresses in the metadata backup. +enum SparkExitEarlierAddressBackup { + /// The entries of `store`, keyed `walletKey`; nil with none, or when the list can't be read, which then never + /// blocks the rest of the metadata. + static func backup(_ store: SparkExitEarlierAddressStore, walletKey: String) -> [String: [SparkExitEarlierAddressBackupV1]]? { + guard let entries = try? store.load(), !entries.isEmpty else { return nil } + return [walletKey: entries.map(\.backup)] + } + + /// Lists each backed-up address of `network` in its wallet's store, next to the entries already there. + static func restore( + _ backedUp: [String: [SparkExitEarlierAddressBackupV1]]?, + network: String, + store: (Int) -> SparkExitEarlierAddressStore + ) { + for (key, entries) in backedUp ?? [:] { + let prefix = "\(network)/wallet" + guard key.hasPrefix(prefix), let walletIndex = Int(key.dropFirst(prefix.count)) else { continue } + let walletStore = store(walletIndex) + for entry in entries.compactMap(SparkExitEarlierAddress.init(backup:)) { + do { + try walletStore.add(entry) + } catch { + Logger.warn("Failed to restore the earlier Spark exit address: \(error)", context: "SparkExitEarlierAddressBackup") + } + } + } + } +} + +extension SparkExitEarlierAddress { + var backup: SparkExitEarlierAddressBackupV1 { + SparkExitEarlierAddressBackupV1( + address: address, + path: path, + kind: kind.rawValue, + atMillis: Int64((date.timeIntervalSince1970 * 1000).rounded()) + ) + } + + /// The backed-up entry, with what it holds read again later; nil for a kind this app does not know. + init?(backup: SparkExitEarlierAddressBackupV1) { + guard let kind = Kind(rawValue: backup.kind) else { return nil } + self.init( + address: backup.address, + path: backup.path, + kind: kind, + sats: nil, + date: Date(timeIntervalSince1970: Double(backup.atMillis) / 1000) + ) + } +} diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 75e526ecd..1f03c3799 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -704,6 +704,11 @@ enum SparkExitMath { enum SparkExitFundingIndexes { private static let defaultsPrefix = "sparkExitNextFundingIndex-" + /// How the metadata backup keys a wallet's exit state, as bitkit-android does. + static func walletKey(network: String, walletIndex: Int) -> String { + "\(network)/wallet\(walletIndex)" + } + /// The counter the last exit start left, per network and wallet. static func defaultsKey(network: String, walletIndex: Int) -> String { "\(defaultsPrefix)\(network)-\(walletIndex)" diff --git a/BitkitTests/SparkExitFundingIndexTests.swift b/BitkitTests/SparkExitFundingIndexTests.swift index be4643adc..0cd765437 100644 --- a/BitkitTests/SparkExitFundingIndexTests.swift +++ b/BitkitTests/SparkExitFundingIndexTests.swift @@ -9,15 +9,20 @@ final class SparkExitFundingIndexTests: XCTestCase { private var defaults: UserDefaults! private let chain = FakeSparkExitChain() private let key = SparkExitFundingIndexes.defaultsKey(network: "regtest", walletIndex: 0) + private var earlierAddressStore: SparkExitEarlierAddressStore! + private let now = Date(timeIntervalSince1970: 1_800_000_000) override func setUp() { super.setUp() UserDefaults().removePersistentDomain(forName: suiteName) defaults = UserDefaults(suiteName: suiteName) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("SparkExitFundingIndexTests-\(UUID().uuidString).json") + earlierAddressStore = SparkExitEarlierAddressStore(url: url) } override func tearDown() { UserDefaults().removePersistentDomain(forName: suiteName) + try? earlierAddressStore.delete() super.tearDown() } @@ -25,10 +30,18 @@ final class SparkExitFundingIndexTests: XCTestCase { "bcrt1qfunding\(index)" } + private func path(_ index: UInt32) -> String { + "m/84'/1'/7797'/0/\(index)" + } + private func nextFundingIndex() async throws -> UInt32 { try await SparkExitManager.nextFundingIndex(defaults: defaults, key: key, record: nil, chain: chain) { self.address($0) } } + private func keptCoins(_ index: UInt32, sats: UInt64) -> SparkExitEarlierAddress { + SparkExitEarlierAddress(address: address(index), path: path(index), kind: .keptChange, sats: sats, date: now) + } + /// What a new device holds after a restore: the app's data is gone, and only the backed-up cache comes back. private func restore(_ cache: AppCacheData) { UserDefaults().removePersistentDomain(forName: suiteName) @@ -162,4 +175,45 @@ final class SparkExitFundingIndexTests: XCTestCase { XCTAssertTrue(isSpentUsed) XCTAssertFalse(isFreshUsed) } + + // MARK: Earlier exit addresses in the backup + + func testTheMetadataBackupCarriesTheEarlierExitAddresses() throws { + try earlierAddressStore.add(keptCoins(2, sats: 600)) + try earlierAddressStore.add(SparkExitEarlierAddress(address: address(0), path: path(0), kind: .cancelledFunding, sats: nil, date: now)) + + let backup = SparkExitEarlierAddressBackup.backup(earlierAddressStore, walletKey: "regtest/wallet0") + + let millis: Int64 = 1_800_000_000_000 + XCTAssertEqual(backup, ["regtest/wallet0": [ + SparkExitEarlierAddressBackupV1(address: address(2), path: path(2), kind: "keptChange", atMillis: millis), + SparkExitEarlierAddressBackupV1(address: address(0), path: path(0), kind: "cancelledFunding", atMillis: millis), + ]]) + } + + func testARestoreListsTheBackedUpEarlierAddressesAndKeepsTheDevices() throws { + let onDevice = keptCoins(1, sats: 600) + try earlierAddressStore.add(onDevice) + let backedUp = [ + SparkExitEarlierAddressBackupV1(address: address(1), path: path(1), kind: "cancelledFunding", atMillis: 1000), + SparkExitEarlierAddressBackupV1(address: address(2), path: path(2), kind: "keptChange", atMillis: 2000), + SparkExitEarlierAddressBackupV1(address: address(3), path: path(3), kind: "someLaterKind", atMillis: 3000), + ] + + SparkExitEarlierAddressBackup.restore(["regtest/wallet0": backedUp, "bitcoin/wallet0": backedUp], network: "regtest") { _ in + self.earlierAddressStore + } + + let restored = SparkExitEarlierAddress(address: address(2), path: path(2), kind: .keptChange, sats: nil, date: Date(timeIntervalSince1970: 2)) + XCTAssertEqual(try earlierAddressStore.load(), [onDevice, restored]) + } + + func testAMetadataBackupWithAnUnreadableEarlierAddressListKeepsTheRest() throws { + let json = #"{"version":1,"createdAt":1,"tagMetadata":[],"cache":{"hasSeenContactsIntro":true},"sparkExitEarlierAddresses":{"regtest/wallet0":[{"address":1}]}}"# + + let backup = try JSONDecoder().decode(MetadataBackupV1.self, from: Data(json.utf8)) + + XCTAssertTrue(backup.cache.hasSeenContactsIntro, "one bad entry fails the whole metadata restore") + XCTAssertNil(backup.sparkExitEarlierAddresses) + } } From 31be1fea9c72a530bf617275ea4c5c4efa101dc2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 23:31:25 +0100 Subject: [PATCH 357/378] fix: watch earlier exits' funding that still holds coins after a restore Funding an exit received at an address above index 0 went unwatched once the wallet was restored, as its record is not backed up. On each start after a restore (with no funding counter kept, or once after a restore brought one back), the exit reads its funding addresses, and lists each one that still holds coins and nothing watches as kept change, so it is watched for good and shown with what it holds. A read that fails keeps the restore pending, so the next start reads again. --- Bitkit/Managers/SparkExitManager.swift | 112 ++++++++++++++---- .../Services/SparkExit/SparkExitFunding.swift | 7 ++ BitkitTests/SparkExitFundingIndexTests.swift | 61 +++++++++- 3 files changed, 157 insertions(+), 23 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 3a2c53afb..e11827766 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -163,6 +163,11 @@ final class SparkExitManager { /// Reads the watched earlier exit addresses, kept change and cancelled exits' for a late payment, and shows what /// each holds. func refreshEarlierAddresses() async { + do { + try await Self.recoverFunding(fundingRecovery, now: Date()) + } catch { + Logger.warn("Failed to look for earlier exits' funding after a restore: \(error)", context: "SparkExitManager") + } let store = earlierAddressStore earlierAddresses = (try? store.load()) ?? earlierAddresses guard let chain else { return } @@ -1180,33 +1185,98 @@ final class SparkExitManager { /// mixes into a new one. A scan's result rides the next metadata backup. private func nextFundingIndex() async throws -> UInt32 { let wasKept = UserDefaults.standard.object(forKey: fundingIndexKey) != nil - let walletIndex = walletIndex - let index = try await Self.nextFundingIndex(defaults: .standard, key: fundingIndexKey, record: record, chain: chain) { - try Self.fundingKey(walletIndex: walletIndex, index: $0).address - } + let index = try await Self.nextFundingIndex(fundingRecovery, now: Date()) if !wasKept { SettingsViewModel.shared.notifyAppStateChanged() } return index } - /// From the counter the last start left at `key` and the stored record. With neither, as after a restore from the - /// phrase or from a backup older than the counter, from a scan of the funding addresses at `address` on `chain`, - /// kept as the counter so it runs once. A scan the chain fails keeps nothing. - static func nextFundingIndex( - defaults: UserDefaults, - key: String, - record: SparkExitRecord?, - chain: SparkExitChain?, - address: (UInt32) throws -> String - ) async throws -> UInt32 { - if defaults.object(forKey: key) != nil || record != nil { - return SparkExitMath.nextFundingIndex(counter: defaults.integer(forKey: key), record: record) - } - guard let chain else { return 0 } - let next = try await SparkExitMath.scanNextFundingIndex { try await chain.isAddressUsed(address: address($0)) } - defaults.set(Int(next), forKey: key) - return next + /// One wallet's exit funding as a restore leaves it: the counter at `key`, the stored record, the earlier exit + /// addresses, and each funding index's address and path. + struct FundingRecovery { + let defaults: UserDefaults + let key: String + let pendingKey: String + let record: SparkExitRecord? + let chain: SparkExitChain? + let earlierAddressStore: SparkExitEarlierAddressStore + let funding: (UInt32) throws -> (address: String, path: String) + } + + private var fundingRecovery: FundingRecovery { + let walletIndex = walletIndex + return FundingRecovery( + defaults: .standard, + key: fundingIndexKey, + pendingKey: SparkExitFundingIndexes.recoveryPendingKey(network: "\(Env.network)", walletIndex: walletIndex), + record: record, + chain: chain, + earlierAddressStore: earlierAddressStore, + funding: { index in + let key = try Self.fundingKey(walletIndex: walletIndex, index: index) + return (key.address, SparkExitFundingKey.path(index: index, network: Env.bitkitCoreNetwork)) + } + ) + } + + /// From the counter the last start left and the stored record. With neither, as after a restore from the phrase or + /// from a backup older than the counter, from a scan of the funding addresses first. A scan the chain fails keeps + /// nothing. + static func nextFundingIndex(_ recovery: FundingRecovery, now: Date) async throws -> UInt32 { + let defaults = recovery.defaults + if defaults.object(forKey: recovery.key) != nil || recovery.record != nil { + return SparkExitMath.nextFundingIndex(counter: defaults.integer(forKey: recovery.key), record: recovery.record) + } + return try await recoverFunding(recovery, now: now) ?? 0 + } + + /// Finds earlier exits' funding after a restore. With no counter kept and no record, scans for the next index and + /// keeps it; with a counter a restore brought back, reads each funding address below it, once. Each funding address + /// that still holds coins and nothing watches is listed as kept change, so it is watched for good. Throws when a + /// read fails, keeping no counter and the restore pending, so it runs again on the next start. + /// + /// Returns the index a scan found, nil when there was nothing to scan. + @discardableResult + static func recoverFunding(_ recovery: FundingRecovery, now: Date) async throws -> UInt32? { + let defaults = recovery.defaults + let kept = defaults.object(forKey: recovery.key) as? Int + let isPending = kept == nil ? recovery.record == nil : defaults.bool(forKey: recovery.pendingKey) + guard isPending, let chain = recovery.chain else { return nil } + var derived: [UInt32: (address: String, path: String)] = [:] + func funding(_ index: UInt32) throws -> (address: String, path: String) { + if let known = derived[index] { + return known + } + let found = try recovery.funding(index) + derived[index] = found + return found + } + var used: [UInt32] = [] + var scanned: UInt32? + if kept == nil { + scanned = try await SparkExitMath.scanNextFundingIndex { index in + let isUsed = try await chain.isAddressUsed(address: funding(index).address) + if isUsed { + used.append(index) + } + return isUsed + } + } + let candidates = kept.map { Array(0 ..< UInt32(clamping: max(0, $0))) } ?? used + let watched = Set(((try? recovery.earlierAddressStore.load()) ?? []).map(\.address) + (recovery.record?.funding.map(\.address) ?? [])) + for index in candidates { + let (address, path) = try funding(index) + guard !watched.contains(address) else { continue } + let sats = try await chain.utxos(address: address).reduce(0) { $0 + $1.value } + guard sats > 0 else { continue } + try recovery.earlierAddressStore.add(SparkExitEarlierAddress(address: address, path: path, kind: .keptChange, sats: sats, date: now)) + } + if let scanned { + defaults.set(max(defaults.integer(forKey: recovery.key), Int(scanned)), forKey: recovery.key) + } + defaults.removeObject(forKey: recovery.pendingKey) + return scanned } private var fundingIndexKey: String { diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 1f03c3799..29623dbe1 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -709,6 +709,11 @@ enum SparkExitFundingIndexes { "\(network)/wallet\(walletIndex)" } + /// Set when a restore brought the counter back, so the exit looks once for funding addresses that still hold coins. + static func recoveryPendingKey(network: String, walletIndex: Int) -> String { + "sparkExitFundingRecoveryPending-\(network)-\(walletIndex)" + } + /// The counter the last exit start left, per network and wallet. static func defaultsKey(network: String, walletIndex: Int) -> String { "\(defaultsPrefix)\(network)-\(walletIndex)" @@ -733,6 +738,8 @@ enum SparkExitFundingIndexes { let defaultsKey = defaultsKey(network: String(parts[0]), walletIndex: wallet) let local = defaults.object(forKey: defaultsKey) as? Int ?? 0 defaults.set(max(local, index), forKey: defaultsKey) + // The exit then looks once for funding addresses below it that still hold coins and nothing watches. + defaults.set(true, forKey: recoveryPendingKey(network: String(parts[0]), walletIndex: wallet)) } } } diff --git a/BitkitTests/SparkExitFundingIndexTests.swift b/BitkitTests/SparkExitFundingIndexTests.swift index 0cd765437..a77c690cb 100644 --- a/BitkitTests/SparkExitFundingIndexTests.swift +++ b/BitkitTests/SparkExitFundingIndexTests.swift @@ -9,6 +9,7 @@ final class SparkExitFundingIndexTests: XCTestCase { private var defaults: UserDefaults! private let chain = FakeSparkExitChain() private let key = SparkExitFundingIndexes.defaultsKey(network: "regtest", walletIndex: 0) + private let pendingKey = SparkExitFundingIndexes.recoveryPendingKey(network: "regtest", walletIndex: 0) private var earlierAddressStore: SparkExitEarlierAddressStore! private let now = Date(timeIntervalSince1970: 1_800_000_000) @@ -34,8 +35,20 @@ final class SparkExitFundingIndexTests: XCTestCase { "m/84'/1'/7797'/0/\(index)" } + private var recovery: SparkExitManager.FundingRecovery { + SparkExitManager.FundingRecovery( + defaults: defaults, + key: key, + pendingKey: pendingKey, + record: nil, + chain: chain, + earlierAddressStore: earlierAddressStore, + funding: { [unowned self] in (address($0), path($0)) } + ) + } + private func nextFundingIndex() async throws -> UInt32 { - try await SparkExitManager.nextFundingIndex(defaults: defaults, key: key, record: nil, chain: chain) { self.address($0) } + try await SparkExitManager.nextFundingIndex(recovery, now: now) } private func keptCoins(_ index: UInt32, sats: UInt64) -> SparkExitEarlierAddress { @@ -176,7 +189,51 @@ final class SparkExitFundingIndexTests: XCTestCase { XCTAssertFalse(isFreshUsed) } - // MARK: Earlier exit addresses in the backup + // MARK: Earlier exit addresses after a restore + + func testARestoredWalletWithNoCounterWatchesTheFundingAddressesThatStillHoldCoins() async throws { + // Index 2 still holds what a lost exit received; 0 and 1 were spent by earlier exits. + chain.usedAddresses = [address(0), address(1)] + chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] + + let next = try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(next, 3) + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(2, sats: 5000)], "the lost funding goes unwatched") + XCTAssertEqual(defaults.object(forKey: key) as? Int, 3) + } + + func testAFundingAddressWithHistoryButNoCoinsIsNotWatched() async throws { + chain.usedAddresses = [address(0)] + chain.utxosByAddress[address(1)] = [SparkExitFixtures.utxo("lost", value: 700)] + + try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(1, sats: 700)]) + } + + func testARestoredCounterBringsBackTheFundingAddressesThatStillHoldCoins() async throws { + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.usedAddresses = [address(0), address(1)] + chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] + + try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(2, sats: 5000)], "the lost funding goes unwatched") + XCTAssertTrue(chain.addressReads.isEmpty, "a restored counter needs no scan for the next index") + XCTAssertNil(defaults.object(forKey: pendingKey), "every start reads the funding addresses again") + XCTAssertEqual(defaults.integer(forKey: key), 3) + } + + func testAKeptCounterWithNoRestorePendingReadsNoFundingAddress() async throws { + defaults.set(3, forKey: key) + chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] + + try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertTrue(chain.addressReads.isEmpty) + XCTAssertEqual(try earlierAddressStore.load(), []) + } func testTheMetadataBackupCarriesTheEarlierExitAddresses() throws { try earlierAddressStore.add(keptCoins(2, sats: 600)) From 9fab00f9f48d99ba561bd385b855684dd95b4aad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 21:46:36 +0100 Subject: [PATCH 358/378] fix: refuse a contact withdrawal while an exit runs --- Bitkit/Managers/SparkManager.swift | 75 +++++++++++++++++++----------- BitkitTests/PaykitRouteTests.swift | 19 ++++++++ 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index bd2ff2773..35215b91f 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -980,6 +980,10 @@ final class SparkManager: ChannelFreeLightningRail { let prepared: PrepareSendPaymentResponse let earlierPayments: [Payment] do { + // An exit spends every leaf, so no contact withdrawal starts while one runs. + guard !isExiting else { + throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description")) + } try claimContactWithdrawal() } catch { throw SparkWithdrawalNotSent(underlying: error) @@ -1023,35 +1027,34 @@ final class SparkManager: ChannelFreeLightningRail { }, paymentDeadline: paymentDeadline ) - let response: SendPaymentResponse - do { - response = try await sdk.sendPayment(request: SendPaymentRequest( - prepareResponse: prepared, - options: .bitcoinAddress(confirmationSpeed: .medium), - idempotencyKey: pending.idempotencyKey - )) - } catch { - throw await Self.withdrawalSendFailure( - error, - closeUnsent: { - await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) - }, - settleLater: { - let failedAt = UInt64(Date().timeIntervalSince1970) - Task { - await Self.settleAfterFailedWithdrawal( - failedAt: failedAt, - syncWallet: { - _ = try await self.requireSdk().syncWallet(request: SyncWalletRequest()) - self.syncedThisRun = true - }, - hasSynced: { await self.hasSynced(after: $0) }, - settle: { await self.settleContactWithdrawal(allowClear: $0) } - ) - } + let idempotencyKey = pending.idempotencyKey + let response = try await Self.sendJournaledWithdrawal( + spends: spends, + send: { + try await sdk.sendPayment(request: SendPaymentRequest( + prepareResponse: prepared, + options: .bitcoinAddress(confirmationSpeed: .medium), + idempotencyKey: idempotencyKey + )) + }, + closeUnsent: { + await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance) + }, + settleLater: { + let failedAt = UInt64(Date().timeIntervalSince1970) + Task { + await Self.settleAfterFailedWithdrawal( + failedAt: failedAt, + syncWallet: { + _ = try await self.requireSdk().syncWallet(request: SyncWalletRequest()) + self.syncedThisRun = true + }, + hasSynced: { await self.hasSynced(after: $0) }, + settle: { await self.settleContactWithdrawal(allowClear: $0) } + ) } - ) - } + } + ) Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager") pending.paymentId = response.payment.id Self.pendingContactWithdrawal = pending @@ -1093,6 +1096,22 @@ final class SparkManager: ChannelFreeLightningRail { return txId } + /// Sends a contact withdrawal whose journal is open, counted in `spends` so an exit starting meanwhile refuses it or + /// waits for it. A send refused before it left closes the journal and ends as `SparkWithdrawalNotSent`; any other + /// failure settles later from Spark's payment list. + static func sendJournaledWithdrawal( + spends: SparkSpends, + send: () async throws -> T, + closeUnsent: () async -> Void, + settleLater: () -> Void + ) async throws -> T { + do { + return try await spends.run(send) + } catch { + throw await withdrawalSendFailure(error, closeUnsent: closeUnsent, settleLater: settleLater) + } + } + /// What a failed `sendPayment` of a journaled withdrawal means. A refusal Spark makes before anything reaches the /// operators, as on the Lightning rail, closes the journal at once; an outcome that may have sent it stays journaled /// and settles from the sync after it. diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index 39d557b91..be77164c1 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -520,6 +520,25 @@ final class PaykitRouteTests: XCTestCase { XCTAssertEqual(steps, ["settle later"], "An outcome that may have sent it settles from the sync after it") } + @MainActor + func testAContactWithdrawalIsRefusedOnceAnExitHoldsSpark() async { + let spends = SparkSpends { true } + var steps: [String] = [] + do { + _ = try await SparkManager.sendJournaledWithdrawal( + spends: spends, + send: { steps.append("sent") }, + closeUnsent: { steps.append("closed") }, + settleLater: { steps.append("settle later") } + ) + XCTFail("A contact withdrawal went out while an exit held Spark") + } catch { + XCTAssertTrue(error is SparkWithdrawalNotSent, "Nothing left, so the allowance attempt fails instead of staying unknown") + } + XCTAssertEqual(steps, ["closed"], "The exit's leaves stay unspent and the journal closes at once") + XCTAssertEqual(spends.inFlight, 0) + } + @MainActor func testASecondContactWithdrawalIsRefusedWhileTheFirstPrepares() throws { snapshotAppDefaultsDomain() From e1dbb0b9b54270293c1acaac613c9b7e8686bd48 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 00:46:06 +0100 Subject: [PATCH 359/378] fix: build a kept fee bump at its rate A fee bump whose build would leave a leaf waiting kept the valid generation with the bumped rate on the record but the old rate in its signed steps, and nothing built at the new rate afterwards. The bump now stays pending until a build at its rate is adopted: the check builds at that rate again once a block, whatever the leaf waits for, and the kept generation is still sent meanwhile. A dead fee chain beside a waiting leaf keeps the valid generation too, so the leaf's signed steps stay; its rebuilds stop once stalled and it rebuilds once the leaf's step confirms. A test pins that. --- .../Services/SparkExit/SparkExitDriver.swift | 19 +++- .../Services/SparkExit/SparkExitRecord.swift | 10 ++ BitkitTests/SparkExitTests.swift | 92 ++++++++++++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 0dc74cdf2..3f07dfdea 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -355,8 +355,11 @@ struct SparkExitDriver { /// rebuild after a leaf was left behind), that generation stays over a rebuild that leaves waiting a leaf it /// recovers, before that rebuild is saved or any of it broadcast. private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws { - let valid = record.validGeneration let tip = try? await chain.tipHeight() + if record.isFeeBumpPending == true { + record.feeBumpBuiltAtHeight = tip + } + let valid = record.validGeneration // Waiting for a leaf that may still move: tried again at growing gaps of blocks, not every tick. if let since = record.retrySince { guard let tip, Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) else { return } @@ -607,6 +610,20 @@ struct SparkExitDriver { } switch checked.verdict { case .valid: + // A fee bump whose build kept this generation over a waiting leaf is built at its rate again once a block, + // whatever the leaf waits for, until a build at it is adopted. That build also settles any unread step. + // A generation it keeps is still sent. + if record.isFeeBumpPending == true, let tip, record.feeBumpBuiltAtHeight != tip { + Logger.info("Spark exit builds again at its higher fee rate \(record.feeRateSatPerVbyte) sat/vB", context: "SparkExitDriver") + record.unverifiedRebuiltAtHeight = tip + let valid = record + try await build(&record, recovering: recovering) + keepRetryClock(&record, from: valid) + if record.generation == valid.generation, record.phase == .running, !record.isRebuilding { + try await broadcastReady(&record, tip: tip) + } + return + } // A step the SDK could not read is settled by a fresh build, once a block, unless a step is still to send: // a build now would conflict with it at the same fee. if let tip, isUnverifiedRebuildDue(checked.recovery, record: record, tip: tip) { diff --git a/Bitkit/Services/SparkExit/SparkExitRecord.swift b/Bitkit/Services/SparkExit/SparkExitRecord.swift index cc87257c6..0e8147da4 100644 --- a/Bitkit/Services/SparkExit/SparkExitRecord.swift +++ b/Bitkit/Services/SparkExit/SparkExitRecord.swift @@ -252,6 +252,11 @@ struct SparkExitRecord: Codable, Equatable { var isUnverifiedAsk: Bool? /// The tip of the last rebuild over a step the SDK could not read, so it rebuilds at most once a block. var unverifiedRebuiltAtHeight: UInt32? + /// A higher fee rate the steps out there are not built at yet: a fee bump whose build kept the valid generation + /// over a leaf it would leave waiting. Built at that rate again until a build at it is adopted. + var isFeeBumpPending: Bool? + /// The tip of the last build at a pending fee bump's rate, so it builds at most once a block. + var feeBumpBuiltAtHeight: UInt32? /// The tip at which each of the exit's leaves was first seen stopped (`exitChainState.stoppedLeafIds`). var stoppedLeafSince: [String: UInt32]? /// The tip at which a build first found nothing to build while a leaf may still move, and of its last try. @@ -424,6 +429,8 @@ struct SparkExitRecord: Codable, Equatable { } isRedoRebuild = nil isUnverifiedAsk = nil + isFeeBumpPending = nil + feeBumpBuiltAtHeight = nil } /// Ends the rebuilt-from-chain note once the rebuilt exit moved: a step confirmed beyond those already in a block. @@ -476,9 +483,12 @@ struct SparkExitRecord: Codable, Equatable { /// Takes a higher fee rate for the steps still to confirm. With `extraSats` to come at the funding address the /// exit waits for it as for a top-up; with none it rebuilds at once at the new rate, replacing what is unconfirmed. + /// The bump stays pending until a build at the new rate is adopted. mutating func applyFeeBump(rate: UInt64, extraSats: UInt64, paymentTxid: String?) { feeRateSatPerVbyte = rate isRedoRebuild = nil + isFeeBumpPending = true + feeBumpBuiltAtHeight = nil fundingPaymentSeenAtHeight = nil fundingPaymentMissingSince = nil if extraSats > 0 { diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 0c6dee1eb..e19e078e1 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1887,7 +1887,8 @@ final class SparkExitDriverTests: XCTestCase { var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] sdk.preparedResponse = prepared - sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")])] + // The second build is the bump's again, at the next block. + sdk.builds = Array(repeating: SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")]), count: 2) record = try await driver().tick(record) @@ -1902,6 +1903,95 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's refund n2 was not sent") } + /// A fee bump kept over a waiting leaf stays to be built: once a block it rebuilds at the bumped rate, the valid + /// generation sending leaf-1's refund n2 meanwhile, and once leaf-1 reads that rebuild is adopted. + func testAFeeBumpKeptOverAWaitingLeafBuildsAtTheBumpedRateOnceTheLeafReads() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000) + record.adopt(leafOneSteps(.unverified), at: clock) + record.applyFeeBump(rate: 10, extraSats: 0, paymentTxid: nil) + var skipsLeafOne = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + skipsLeafOne.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = skipsLeafOne + sdk.builds = Array(repeating: SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")]), count: 2) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1) + + sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)] + chain.tip = 1001 + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1, "the rebuild that drops leaf-1's refund n2 was adopted") + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's refund n2 was not sent while the bump waits") + + sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-1", "leaf-2"]) + sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("b1"), SparkExitFixtures.tx("b2")])] + let preparesBefore = sdk.prepareRequests.count + chain.tip = 1002 + record = try await driver().tick(record) + + XCTAssertEqual(record.generation, 2, "the bump was never built at its rate") + XCTAssertEqual(sdk.prepareRequests.dropFirst(preparesBefore).map(\.feeRateSatPerVbyte), [10]) + XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["b1", "b2"]) + } + + /// A fee chain that lost a race beside a leaf the rebuild leaves waiting: the valid generation stays, so leaf-1's + /// signed steps are not dropped, its rebuilds stop once stalled, and once leaf-1's step confirms the exit rebuilds + /// without the dead chain. + func testADeadFeeChainBesideAWaitingLeafRebuildsOnceTheLeafsStepConfirms() async throws { + let funding = String(repeating: "f0", count: 32) + let ourChild = SparkExitFixtures.rawTx(spending: [funding]) + let chained = try SparkExitFixtures.rawTx(spending: [XCTUnwrap(SparkExitTxid.txid(hex: ourChild))]) + let bothLeaves = [ + RecoverFundsLeaf(leafId: "leaf-1", valueSats: 100_000, method: .unilateral), + RecoverFundsLeaf(leafId: "leaf-2", valueSats: 100_000, method: .unilateral), + ] + func steps(_ l1: ExitTransactionStatus, n1: ExitTransactionStatus = .confirmed(blockHeight: 10)) -> RecoverFundsResponse { + var recovery = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", cpfp: ourChild, status: n1), + SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"]), + SparkExitFixtures.tx("l1", status: l1), + ]) + recovery.leaves = bothLeaves + return recovery + } + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: funding, vout: 0, valueSats: 20000) + record.adopt(steps(.unverified, n1: .ready), at: Date(timeIntervalSince1970: 1_700_000_050)) + chain.confirmedTxids = ["n1"] + var skipsLeafOne = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + skipsLeafOne.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)] + sdk.preparedResponse = skipsLeafOne + let fresh = SparkExitFixtures.rawTx(spending: [funding]) + var rebuilt = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: fresh, dependsOn: ["n1"]), + ]) + rebuilt.leaves = [bothLeaves[1]] + sdk.builds = Array(repeating: rebuilt, count: SparkExitDriver.stallRebuildsToSurface + 1) + + for i in 0 ... SparkExitDriver.stallRebuildsToSurface { + sdk.checks = [CheckRecoverFundsResponse(recovery: steps(.unverified), verdict: .valid)] + chain.tip = 1000 + UInt32(i) + record = try await driver().tick(record) + XCTAssertEqual(record.generation, 1, "a rebuild dropped leaf-1's signed step l1") + } + XCTAssertTrue(record.isStalled) + let rebuildsWhileStalled = sdk.recoverRequests.count + + // leaf-1 reads again and its step confirms: the exit moves, so the dead chain is rebuilt over. + sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"]) + chain.confirmedTxids.insert("l1") + sdk.checks = [CheckRecoverFundsResponse(recovery: steps(.confirmed(blockHeight: 1004)), verdict: .valid)] + chain.tip = 1005 + record = try await driver().tick(record) + + XCTAssertEqual(sdk.recoverRequests.count, rebuildsWhileStalled + 1) + XCTAssertEqual(record.generation, 2, "the dead fee chain was never rebuilt over") + XCTAssertEqual(chain.broadcasts.last?.cpfpTxHex, fresh) + } + /// An unverified rebuild that cannot exit leaf-3 leaves it behind and rebuilds on the next tick: with leaf-1 still /// unread, that rebuild keeps the valid generation and leaf-1's refund n2. func testARebuildAfterAnUnverifiedRebuildLeftALeafBehindKeepsLeafOnesRefund() async throws { From 16b7c66a31037fd6a11132dca12721b69bd401fd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 00:46:07 +0100 Subject: [PATCH 360/378] fix: pay a higher fee from savings only on spark's chain The Pay a higher fee screen and its payment checked only the spendable balance. On a chain Spark does not read, such as its hosted regtest, the extra funding went out on Bitkit's chain and the exit never saw it. Both now use the same chain check as the start's funding screen; a rate with nothing to pay is still offered. --- Bitkit/Managers/SparkExitManager.swift | 4 ++++ Bitkit/Services/SparkExit/SparkExitFunding.swift | 7 +++++++ Bitkit/Views/Settings/Advanced/SparkExitViews.swift | 11 ++++++++--- BitkitTests/SparkExitTests.swift | 10 ++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index e11827766..a9bf5c3c0 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -834,6 +834,10 @@ final class SparkExitManager { } var txid: String? if extraSats > 0 { + // The payment goes out on Bitkit's chain: while Spark reads another, the exit would never see it. + guard SparkManager.shared.canFundFromChain else { + throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "Spark does not read Bitkit's chain") + } guard let paymentFeeSats = option.paymentFeeSats else { throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "Fee bump payment not priced") } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 29623dbe1..a287b0c44 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -697,6 +697,13 @@ enum SparkExitMath { static func canFundFromSavings(spendableSats: UInt64, fundingSats: UInt64, paymentFeeSats: UInt64, readsBitkitChain: Bool) -> Bool { readsBitkitChain && fundingSats > 0 && spendableSats >= fundingSats.saturatingAdd(paymentFeeSats) } + + /// Savings can pay a higher fee's `totalSats` (its extra funding and that payment's fee) on the same terms: Spark + /// reads Bitkit's chain and the spendable balance covers it. A rate with nothing to pay needs no payment. + static func canPayFeeBump(totalSats: UInt64?, spendableSats: UInt64, readsBitkitChain: Bool) -> Bool { + guard let totalSats else { return false } + return totalSats == 0 || (readsBitkitChain && spendableSats >= totalSats) + } } /// Each wallet's next exit funding index in the metadata backup, keyed `/wallet` as bitkit-android keys its diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift index a6a95e581..e5800f5c3 100644 --- a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift +++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift @@ -910,12 +910,17 @@ struct SparkExitFeeBumpView: View { } private func canPay(_ option: SparkExitFeeBumpOption) -> Bool { - guard let total = option.totalSats else { return false } - return total == 0 || UInt64(max(0, wallet.spendableOnchainBalanceSats)) >= total + SparkExitMath.canPayFeeBump( + totalSats: option.totalSats, + spendableSats: UInt64(max(0, wallet.spendableOnchainBalanceSats)), + readsBitkitChain: SparkManager.shared.canFundFromChain + ) } - /// Savings can't pay the selected rate, or, with none selected, any rate priced so far. + /// Savings can't pay the selected rate, or, with none selected, any rate priced so far. Savings on a chain Spark + /// does not read is off without a balance line, as on the start's funding screen. private var isInsufficient: Bool { + guard SparkManager.shared.canFundFromChain else { return false } if let selected { return selected.totalSats != nil && !canPay(selected) } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index e19e078e1..bff9dcb48 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -3084,6 +3084,16 @@ final class SparkExitMathTests: XCTestCase { XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10500, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: false)) } + /// A higher fee's extra funding goes out from savings on Bitkit's chain too: off while Spark reads another chain. + /// A rate with nothing to pay needs no payment. + func testSavingsPaysAHigherFeeOnlyOnAChainSparkReads() { + XCTAssertTrue(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5980, readsBitkitChain: true)) + XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5979, readsBitkitChain: true)) + XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: nil, spendableSats: 5980, readsBitkitChain: true), "not priced") + XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5980, readsBitkitChain: false)) + XCTAssertTrue(SparkExitMath.canPayFeeBump(totalSats: 0, spendableSats: 0, readsBitkitChain: false)) + } + func testEtaUsesTheNetworksBlockTime() { XCTAssertEqual(SparkExitMath.secondsUntil(height: 110, tip: 100, network: .bitcoin), 6000) XCTAssertEqual(SparkExitMath.secondsUntil(height: 110, tip: 100, network: .regtest), 300) From 5ec8ec44484810796544aaf8de4d7fdd045031a6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 02:56:41 +0100 Subject: [PATCH 361/378] fix: lightning connection reads using spark only outside an exit --- Bitkit/Utilities/AppStatusHelper.swift | 9 +++++++-- BitkitTests/AppStatusHelperTests.swift | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index f32bb9953..a1cd58861 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -67,7 +67,7 @@ struct AppStatusHelper { isOnline: network.isConnected, hasUsableChannels: wallet.hasUsableChannels, hasChannels: wallet.channelCount > 0, - isSparkReady: SparkManager.shared.isReady + isSparkReady: canSparkPay(isReady: SparkManager.shared.isReady, isExiting: SparkManager.shared.isExiting) ) } @@ -75,7 +75,7 @@ struct AppStatusHelper { isLightningViaSpark( isOnline: network.isConnected, hasUsableChannels: wallet.hasUsableChannels, - isSparkReady: SparkManager.shared.isReady + isSparkReady: canSparkPay(isReady: SparkManager.shared.isReady, isExiting: SparkManager.shared.isExiting) ) } @@ -110,6 +110,11 @@ struct AppStatusHelper { } } + /// Spark carries Lightning only while ready and out of an emergency exit, which takes it off the Lightning rail. + nonisolated static func canSparkPay(isReady: Bool, isExiting: Bool) -> Bool { + isReady && !isExiting + } + /// No channel is usable, so Spark carries Lightning payments. nonisolated static func isLightningViaSpark(isOnline: Bool, hasUsableChannels: Bool, isSparkReady: Bool) -> Bool { isOnline && !hasUsableChannels && isSparkReady diff --git a/BitkitTests/AppStatusHelperTests.swift b/BitkitTests/AppStatusHelperTests.swift index e3c9a62c9..03f99ecff 100644 --- a/BitkitTests/AppStatusHelperTests.swift +++ b/BitkitTests/AppStatusHelperTests.swift @@ -39,4 +39,10 @@ final class AppStatusHelperTests: XCTestCase { XCTAssertEqual(status, .error) XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: false, hasUsableChannels: false, isSparkReady: true)) } + + func testSparkCannotPayDuringAnEmergencyExit() { + XCTAssertTrue(AppStatusHelper.canSparkPay(isReady: true, isExiting: false)) + XCTAssertFalse(AppStatusHelper.canSparkPay(isReady: true, isExiting: true)) + XCTAssertFalse(AppStatusHelper.canSparkPay(isReady: false, isExiting: false)) + } } From 5a4459f56d5b7bfafaeef36858e38386d6f95733 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 03:47:46 +0100 Subject: [PATCH 362/378] refactor: use the existing spark usable check in app status --- Bitkit/Utilities/AppStatusHelper.swift | 9 ++------- BitkitTests/AppStatusHelperTests.swift | 6 ------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index e32f1fad1..f5f910b57 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -83,7 +83,7 @@ struct AppStatusHelper { isOnline: network.isConnected, hasUsableChannels: wallet.hasUsableChannels, hasChannels: wallet.channelCount > 0, - isSparkReady: canSparkPay(isReady: SparkManager.shared.isReady, isExiting: SparkManager.shared.isExiting) + isSparkReady: MoveToSparkMath.isSparkUsable(SparkManager.shared) ) } @@ -91,7 +91,7 @@ struct AppStatusHelper { isLightningViaSpark( isOnline: network.isConnected, hasUsableChannels: wallet.hasUsableChannels, - isSparkReady: canSparkPay(isReady: SparkManager.shared.isReady, isExiting: SparkManager.shared.isExiting) + isSparkReady: MoveToSparkMath.isSparkUsable(SparkManager.shared) ) } @@ -126,11 +126,6 @@ struct AppStatusHelper { } } - /// Spark carries Lightning only while ready and out of an emergency exit, which takes it off the Lightning rail. - nonisolated static func canSparkPay(isReady: Bool, isExiting: Bool) -> Bool { - isReady && !isExiting - } - /// No channel is usable, so Spark carries Lightning payments. nonisolated static func isLightningViaSpark(isOnline: Bool, hasUsableChannels: Bool, isSparkReady: Bool) -> Bool { isOnline && !hasUsableChannels && isSparkReady diff --git a/BitkitTests/AppStatusHelperTests.swift b/BitkitTests/AppStatusHelperTests.swift index 03f99ecff..e3c9a62c9 100644 --- a/BitkitTests/AppStatusHelperTests.swift +++ b/BitkitTests/AppStatusHelperTests.swift @@ -39,10 +39,4 @@ final class AppStatusHelperTests: XCTestCase { XCTAssertEqual(status, .error) XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: false, hasUsableChannels: false, isSparkReady: true)) } - - func testSparkCannotPayDuringAnEmergencyExit() { - XCTAssertTrue(AppStatusHelper.canSparkPay(isReady: true, isExiting: false)) - XCTAssertFalse(AppStatusHelper.canSparkPay(isReady: true, isExiting: true)) - XCTAssertFalse(AppStatusHelper.canSparkPay(isReady: false, isExiting: false)) - } } From 09d865499e22b4af10b62356c55a1c0c3df12ec6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 04:09:50 +0100 Subject: [PATCH 363/378] refactor: name the app status spark input isSparkUsable --- Bitkit/Utilities/AppStatusHelper.swift | 12 ++++++------ BitkitTests/AppStatusHelperTests.swift | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift index f5f910b57..acce738fc 100644 --- a/Bitkit/Utilities/AppStatusHelper.swift +++ b/Bitkit/Utilities/AppStatusHelper.swift @@ -83,7 +83,7 @@ struct AppStatusHelper { isOnline: network.isConnected, hasUsableChannels: wallet.hasUsableChannels, hasChannels: wallet.channelCount > 0, - isSparkReady: MoveToSparkMath.isSparkUsable(SparkManager.shared) + isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared) ) } @@ -91,7 +91,7 @@ struct AppStatusHelper { isLightningViaSpark( isOnline: network.isConnected, hasUsableChannels: wallet.hasUsableChannels, - isSparkReady: MoveToSparkMath.isSparkUsable(SparkManager.shared) + isSparkUsable: MoveToSparkMath.isSparkUsable(SparkManager.shared) ) } @@ -111,13 +111,13 @@ struct AppStatusHelper { isOnline: Bool, hasUsableChannels: Bool, hasChannels: Bool, - isSparkReady: Bool + isSparkUsable: Bool ) -> HealthStatus { if !isOnline { return .error } - if hasUsableChannels || isSparkReady { + if hasUsableChannels || isSparkUsable { return .ready } else if !hasChannels { return .error @@ -127,8 +127,8 @@ struct AppStatusHelper { } /// No channel is usable, so Spark carries Lightning payments. - nonisolated static func isLightningViaSpark(isOnline: Bool, hasUsableChannels: Bool, isSparkReady: Bool) -> Bool { - isOnline && !hasUsableChannels && isSparkReady + nonisolated static func isLightningViaSpark(isOnline: Bool, hasUsableChannels: Bool, isSparkUsable: Bool) -> Bool { + isOnline && !hasUsableChannels && isSparkUsable } nonisolated static func sparkStatus(isOnline: Bool, isSupported: Bool, isReady: Bool, hasStartFailed: Bool) -> HealthStatus? { diff --git a/BitkitTests/AppStatusHelperTests.swift b/BitkitTests/AppStatusHelperTests.swift index e3c9a62c9..989d358c2 100644 --- a/BitkitTests/AppStatusHelperTests.swift +++ b/BitkitTests/AppStatusHelperTests.swift @@ -17,26 +17,26 @@ final class AppStatusHelperTests: XCTestCase { } func testChannelsStatusIsReadyThroughSparkWhenNoChannelIsUsable() { - let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: false, hasChannels: true, isSparkReady: true) + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: false, hasChannels: true, isSparkUsable: true) XCTAssertEqual(status, .ready) - XCTAssertTrue(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: false, isSparkReady: true)) + XCTAssertTrue(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: false, isSparkUsable: true)) } func testChannelsStatusIsOpenWhenAChannelIsUsableBesideSpark() { - let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: true, hasChannels: true, isSparkReady: true) + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: true, hasChannels: true, isSparkUsable: true) XCTAssertEqual(status, .ready) - XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: true, isSparkReady: true)) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: true, isSparkUsable: true)) } func testChannelsStatusIsErrorWhenNeitherAChannelNorSparkCanPay() { - let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: false, hasChannels: false, isSparkReady: false) + let status = AppStatusHelper.channelsStatus(isOnline: true, hasUsableChannels: false, hasChannels: false, isSparkUsable: false) XCTAssertEqual(status, .error) - XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: false, isSparkReady: false)) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: true, hasUsableChannels: false, isSparkUsable: false)) } func testChannelsStatusIsErrorOfflineEvenWithSpark() { - let status = AppStatusHelper.channelsStatus(isOnline: false, hasUsableChannels: false, hasChannels: false, isSparkReady: true) + let status = AppStatusHelper.channelsStatus(isOnline: false, hasUsableChannels: false, hasChannels: false, isSparkUsable: true) XCTAssertEqual(status, .error) - XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: false, hasUsableChannels: false, isSparkReady: true)) + XCTAssertFalse(AppStatusHelper.isLightningViaSpark(isOnline: false, hasUsableChannels: false, isSparkUsable: true)) } } From c07e4ce38d17abf85f1a05dee7f952dbd5f367a7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 04:50:32 +0100 Subject: [PATCH 364/378] docs: exit journey checks lightning connection stops reading using spark --- journeys/settings/app-status-emergency-exit.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/journeys/settings/app-status-emergency-exit.xml b/journeys/settings/app-status-emergency-exit.xml index 49716f192..9b443c538 100644 --- a/journeys/settings/app-status-emergency-exit.xml +++ b/journeys/settings/app-status-emergency-exit.xml @@ -5,16 +5,18 @@ while a payment that covers the funding waits to confirm. The row opens the exit's progress screen. With no active exit the row is absent. An exit paid from savings cannot be cancelled and runs for days, so the row's removal once an exit ends is covered by unit tests, not here. On iOS, OK on the progress - screen returns to the wallet home screen from any entry point, App Status included. + screen returns to the wallet home screen from any entry point, App Status included. An exit takes Spark off the + Lightning rail, so with no usable channel Lightning Connection stops reading "Using Spark" while it runs. Precondition: Debug build on regtest with Spark connected, a Spark balance and savings that can pay the - exit's fee funding. Start an emergency exit from Spark (spark emergency exit journey), pick "Pay from + exit's fee funding. The wallet has no Lightning channel. Start an emergency exit from Spark (spark emergency exit journey), pick "Pay from savings" (id "SparkExitFundSavings") and swipe to start it. Starting an exit takes the Spark balance out of Spending for days, so use a throwaway wallet. Start on the wallet home screen. Tap the menu button (id "HeaderMenu") and tap App Status (id "DrawerAppStatus") Verify a row titled "Emergency Exit" (id "Status-spark_exit") sits under Lightning Connection (id "Status-lightning_connection") and reads "In progress", since savings paid the funding + Verify Lightning Connection (id "Status-lightning_connection") reads "No open connections", not "Using Spark" Tap the row and verify the exit's progress screen opens Tap "OK" (id "SparkExitProgressOk") and verify the wallet home screen shows Open App Status again from the menu and verify the "Emergency Exit" row is still there From 26a7b62e90012c487e5c92883c8714f3ed3ca87d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 08:46:52 +0100 Subject: [PATCH 365/378] fix: confirm a spark payout the contact already spent --- Bitkit/Managers/SparkManager.swift | 22 +++++++++++++-- BitkitTests/SparkWithdrawalTests.swift | 39 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 657799a4c..7671fd555 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -1608,25 +1608,41 @@ final class SparkManager: ChannelFreeLightningRail { network: OnChainHwService.appDefaultCoinType ).utxos }, + isMined: { txId in + guard let network = Self.exitNetwork else { return false } + do { + return try await EsploraSparkExitChain.network(network).txStatus(txid: txId).confirmed + } catch let error as URLError where error.code == .fileDoesNotExist { + // Spark can list a payout before it is broadcast, and the chain then knows no such transaction. + return false + } + }, markConfirmed: { await CoreService.shared.activity.markOnchainActivityAsConfirmed(txId: $0) } ) } } /// Spark lists a withdrawal completed once its provider took the payout on, which can be before the payout transaction - /// is broadcast or mined, so a payout's activity is confirmed only once the chain lists its output in a block. An - /// output the contact already spent drops out of that list, so its activity then stays unconfirmed. + /// is broadcast or mined, so a payout's activity is confirmed only once the payout is in a block. The payout address's + /// unspent outputs show that first; an output missing there, such as one the contact spent before Bitkit saw it mined, + /// is looked up by its transaction (`isMined`) instead. nonisolated static func confirmMinedPayouts( _ payouts: [ContactPayout], needsConfirmation: (String) async -> Bool, unspentOutputs: (String) async throws -> [AccountUtxo], + isMined: (String) async throws -> Bool, markConfirmed: (String) async -> Void ) async { for payout in payouts { guard await needsConfirmation(payout.txId) else { continue } do { let outputs = try await unspentOutputs(payout.address) - if outputs.contains(where: { $0.txid == payout.txId && $0.vout == payout.vout && $0.confirmations > 0 }) { + let isPayoutMined: Bool = if let output = outputs.first(where: { $0.txid == payout.txId && $0.vout == payout.vout }) { + output.confirmations > 0 + } else { + try await isMined(payout.txId) + } + if isPayoutMined { await markConfirmed(payout.txId) } } catch { diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index 05594c352..a7766cec9 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -380,6 +380,7 @@ final class SparkWithdrawalTests: XCTestCase { payouts, needsConfirmation: { _ in true }, unspentOutputs: { _ in [Self.output(txId: "mined", confirmations: 1), Self.output(txId: "mempool", confirmations: 0)] }, + isMined: { _ in false }, markConfirmed: { await marked.insert($0) } ) @@ -387,6 +388,40 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertEqual(confirmed, ["mined"], "a payout still in the mempool or not yet broadcast stays unconfirmed") } + /// A payout the contact spent before Bitkit saw it mined no longer lists as unspent, so its transaction is asked. + func testAPayoutTheContactAlreadySpentIsConfirmedFromItsTransaction() async { + let marked = TxIds() + + await SparkManager.confirmMinedPayouts( + [SparkManager.ContactPayout(txId: "spent", vout: 1, address: "bcrt1qcontact")], + needsConfirmation: { _ in true }, + unspentOutputs: { _ in [] }, + isMined: { $0 == "spent" }, + markConfirmed: { await marked.insert($0) } + ) + + let confirmed = await marked.ids + XCTAssertEqual(confirmed, ["spent"]) + } + + func testAPayoutListedUnspentIsNotAskedAboutByItsTransaction() async { + let asked = TxIds() + + await SparkManager.confirmMinedPayouts( + [SparkManager.ContactPayout(txId: "mempool", vout: 1, address: "bcrt1qcontact")], + needsConfirmation: { _ in true }, + unspentOutputs: { _ in [Self.output(txId: "mempool", confirmations: 0)] }, + isMined: { txId in + await asked.insert(txId) + return true + }, + markConfirmed: { _ in } + ) + + let lookedUp = await asked.ids + XCTAssertTrue(lookedUp.isEmpty, "the unspent list already says the payout is in the mempool") + } + func testAConfirmedContactWithdrawalIsNotLookedUpAgain() async { let lookups = TxIds() @@ -397,6 +432,10 @@ final class SparkWithdrawalTests: XCTestCase { await lookups.insert(address) return [] }, + isMined: { txId in + await lookups.insert(txId) + return true + }, markConfirmed: { _ in } ) From e467a2ff14eda092ac83b247fac817a38febdf41 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 10:48:40 +0100 Subject: [PATCH 366/378] fix: route contact payments on bitkit-core rc15 --- Bitkit/Services/PaykitRouteService.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 79439a3a1..88a7763e8 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -79,7 +79,10 @@ enum PaykitRouteService { contactEndpoints: route.endpoints.filter { !$0.isLnurl || route.lnurl?.accepts(amountSats: amountSats) != false }, lightningSpendableSat: lightningSpendableSats, onchainSpendableSat: 0, - serviceLegs: serviceLegs + serviceLegs: serviceLegs, + // Only bitkit-core's Pubky Swaps route reads these, and the app never asks it, so no swap comes back. + providers: [], + feeRateSatVb: 0 ) case .savings: PaykitRouteRequest( @@ -87,7 +90,9 @@ enum PaykitRouteService { contactEndpoints: route.onchainEndpoints, lightningSpendableSat: 0, onchainSpendableSat: onchainSpendableSats, - serviceLegs: [] + serviceLegs: [], + providers: [], + feeRateSatVb: 0 ) } } @@ -342,6 +347,10 @@ extension AppViewModel { case let .none(reason): Logger.warn("Found no Paykit route: '\(reason)'", context: "PaykitRoute") return refuse(sparkLegShort: false) + case .swap: + // The app never offers Pubky Swaps providers, so a swap route can't come back; it pays nothing either. + Logger.warn("Found no Paykit route: 'Swap routes are unsupported'", context: "PaykitRoute") + return refuse(sparkLegShort: false) } } catch is SparkWithdrawalSettling { Logger.warn("Found no Paykit route: an earlier Spark contact withdrawal is still settling", context: "PaykitRoute") From 9bfe85fb3a6eb866f5b3874dad768f35435bdf8f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 10:57:22 +0100 Subject: [PATCH 367/378] fix: confirm a spark payout from its transaction when electrum fails --- Bitkit/Managers/SparkManager.swift | 11 +++++++++-- BitkitTests/SparkWithdrawalTests.swift | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 7671fd555..bb0aeff7a 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -1625,7 +1625,7 @@ final class SparkManager: ChannelFreeLightningRail { /// Spark lists a withdrawal completed once its provider took the payout on, which can be before the payout transaction /// is broadcast or mined, so a payout's activity is confirmed only once the payout is in a block. The payout address's /// unspent outputs show that first; an output missing there, such as one the contact spent before Bitkit saw it mined, - /// is looked up by its transaction (`isMined`) instead. + /// or a failed read of them, is looked up by its transaction (`isMined`) instead. nonisolated static func confirmMinedPayouts( _ payouts: [ContactPayout], needsConfirmation: (String) async -> Bool, @@ -1636,7 +1636,14 @@ final class SparkManager: ChannelFreeLightningRail { for payout in payouts { guard await needsConfirmation(payout.txId) else { continue } do { - let outputs = try await unspentOutputs(payout.address) + let outputs: [AccountUtxo] + do { + outputs = try await unspentOutputs(payout.address) + } catch { + // A failed Electrum lookup falls through to the transaction's status, as a missing output does. + Logger.warn("Failed to read the payout address of Spark withdrawal '\(payout.txId)': \(error)", context: "SparkManager") + outputs = [] + } let isPayoutMined: Bool = if let output = outputs.first(where: { $0.txid == payout.txId && $0.vout == payout.vout }) { output.confirmations > 0 } else { diff --git a/BitkitTests/SparkWithdrawalTests.swift b/BitkitTests/SparkWithdrawalTests.swift index a7766cec9..74a7ed0b3 100644 --- a/BitkitTests/SparkWithdrawalTests.swift +++ b/BitkitTests/SparkWithdrawalTests.swift @@ -404,6 +404,21 @@ final class SparkWithdrawalTests: XCTestCase { XCTAssertEqual(confirmed, ["spent"]) } + func testAPayoutIsConfirmedFromItsTransactionWhenElectrumFails() async { + let marked = TxIds() + + await SparkManager.confirmMinedPayouts( + [SparkManager.ContactPayout(txId: "mined", vout: 1, address: "bcrt1qcontact")], + needsConfirmation: { _ in true }, + unspentOutputs: { _ in throw URLError(.timedOut) }, + isMined: { $0 == "mined" }, + markConfirmed: { await marked.insert($0) } + ) + + let confirmed = await marked.ids + XCTAssertEqual(confirmed, ["mined"], "a failed Electrum read should fall back to the payout transaction's status") + } + func testAPayoutListedUnspentIsNotAskedAboutByItsTransaction() async { let asked = TxIds() From f53eb9dd9211b889b98de96596b5745dbcb90275 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 10:58:03 +0100 Subject: [PATCH 368/378] fix: find a restored wallet's exit funding past its backed-up index --- Bitkit/Managers/SparkExitManager.swift | 32 +++++++++---------- .../Services/SparkExit/SparkExitFunding.swift | 11 ++++--- BitkitTests/SparkExitFundingIndexTests.swift | 15 ++++++++- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index a9bf5c3c0..5f944853c 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -1235,12 +1235,13 @@ final class SparkExitManager { return try await recoverFunding(recovery, now: now) ?? 0 } - /// Finds earlier exits' funding after a restore. With no counter kept and no record, scans for the next index and - /// keeps it; with a counter a restore brought back, reads each funding address below it, once. Each funding address - /// that still holds coins and nothing watches is listed as kept change, so it is watched for good. Throws when a - /// read fails, keeping no counter and the restore pending, so it runs again on the next start. + /// Finds earlier exits' funding after a restore. Scans for the next index and keeps it: from 0 with no counter kept + /// and no record, else from the counter a restore brought back, reading each funding address below it once too. + /// Each funding address that still holds coins and nothing watches is listed as kept change, so it is watched for + /// good. Throws when a read or that listing fails, keeping no counter and the restore pending, so it runs again on + /// the next start. /// - /// Returns the index a scan found, nil when there was nothing to scan. + /// Returns the index the scan found, nil when no restore was pending. @discardableResult static func recoverFunding(_ recovery: FundingRecovery, now: Date) async throws -> UInt32? { let defaults = recovery.defaults @@ -1257,17 +1258,16 @@ final class SparkExitManager { return found } var used: [UInt32] = [] - var scanned: UInt32? - if kept == nil { - scanned = try await SparkExitMath.scanNextFundingIndex { index in - let isUsed = try await chain.isAddressUsed(address: funding(index).address) - if isUsed { - used.append(index) - } - return isUsed + let restored = kept.map { UInt32(clamping: max(0, $0)) } + // A restored index can predate the last exit's funding, when its backup didn't land, so the scan goes on from it. + let scanned = try await SparkExitMath.scanNextFundingIndex(from: restored ?? 0) { index in + let isUsed = try await chain.isAddressUsed(address: funding(index).address) + if isUsed { + used.append(index) } + return isUsed } - let candidates = kept.map { Array(0 ..< UInt32(clamping: max(0, $0))) } ?? used + let candidates = restored.map { Array(0 ..< $0) + used } ?? used let watched = Set(((try? recovery.earlierAddressStore.load()) ?? []).map(\.address) + (recovery.record?.funding.map(\.address) ?? [])) for index in candidates { let (address, path) = try funding(index) @@ -1276,9 +1276,7 @@ final class SparkExitManager { guard sats > 0 else { continue } try recovery.earlierAddressStore.add(SparkExitEarlierAddress(address: address, path: path, kind: .keptChange, sats: sats, date: now)) } - if let scanned { - defaults.set(max(defaults.integer(forKey: recovery.key), Int(scanned)), forKey: recovery.key) - } + defaults.set(max(defaults.integer(forKey: recovery.key), Int(scanned)), forKey: recovery.key) defaults.removeObject(forKey: recovery.pendingKey) return scanned } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index a287b0c44..ad72c6b23 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -585,11 +585,12 @@ enum SparkExitMath { /// The most funding addresses one scan reads, even when a host reports every one used. static let fundingScanMaxAddresses: UInt32 = 1000 - /// The funding index past the last one `isUsed` reports used, read from 0 until `fundingScanGapLimit` unused ones - /// follow it, for a wallet that kept no index, such as one restored from the phrase or an older backup. - static func scanNextFundingIndex(isUsed: (UInt32) async throws -> Bool) async rethrows -> UInt32 { - var next: UInt32 = 0 - var index: UInt32 = 0 + /// The funding index past the last one `isUsed` reports used, read from `start` until `fundingScanGapLimit` unused + /// ones follow it: from 0 for a wallet that kept no index, such as one restored from the phrase or an older backup, + /// and from a restored index, which can predate the last exit's funding. + static func scanNextFundingIndex(from start: UInt32 = 0, isUsed: (UInt32) async throws -> Bool) async rethrows -> UInt32 { + var next = start + var index = start while index < next + fundingScanGapLimit, index < fundingScanMaxAddresses { if try await isUsed(index) { next = index + 1 diff --git a/BitkitTests/SparkExitFundingIndexTests.swift b/BitkitTests/SparkExitFundingIndexTests.swift index a77c690cb..0e96bd12b 100644 --- a/BitkitTests/SparkExitFundingIndexTests.swift +++ b/BitkitTests/SparkExitFundingIndexTests.swift @@ -220,11 +220,24 @@ final class SparkExitFundingIndexTests: XCTestCase { try await SparkExitManager.recoverFunding(recovery, now: now) XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(2, sats: 5000)], "the lost funding goes unwatched") - XCTAssertTrue(chain.addressReads.isEmpty, "a restored counter needs no scan for the next index") + XCTAssertEqual(chain.addressReads, (3 ..< 23).map(address), "a restored counter is scanned forward from, once") XCTAssertNil(defaults.object(forKey: pendingKey), "every start reads the funding addresses again") XCTAssertEqual(defaults.integer(forKey: key), 3) } + func testARestoredCounterThatPredatesTheLastExitsFundingFindsItsCoins() async throws { + // The backup carrying counter 4 never landed: the exit that funded address 3 is lost with it. + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.usedAddresses = [address(0), address(1), address(2), address(3)] + chain.utxosByAddress[address(3)] = [SparkExitFixtures.utxo("lost", value: 7000)] + + let next = try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(next, 4) + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(3, sats: 7000)], "address 3's coins stay hidden") + XCTAssertEqual(defaults.integer(forKey: key), 4, "the next exit reuses address 3") + } + func testAKeptCounterWithNoRestorePendingReadsNoFundingAddress() async throws { defaults.set(3, forKey: key) chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] From 82ec5993808f421c3c4328e8e74c1e40c73bd670 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 11:04:02 +0100 Subject: [PATCH 369/378] chore: bump breez sdk to 0.28.2 --- 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 c34b63eb8..91f26a99f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1262,7 +1262,7 @@ repositoryURL = "https://github.com/breez/breez-sdk-spark-swift"; requirement = { kind = exactVersion; - version = 0.28.0; + version = 0.28.2; }; }; 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e951aa16e..d0fcdd4a9 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/breez/breez-sdk-spark-swift", "state" : { - "revision" : "09081e9f2862a0359df154aa6813242d36a56275", - "version" : "0.28.0" + "revision" : "063f6ad1184674b77400c3a8f8e83bd9cd35dfd0", + "version" : "0.28.2" } }, { From 8150b14807207aa0ce080a289c531eb109ad9e23 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 12:26:16 +0100 Subject: [PATCH 370/378] fix: wait a check before rebuilding over a step one read left unverified --- .../Services/SparkExit/SparkExitDriver.swift | 24 +++++++++---- BitkitTests/SparkExitTests.swift | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 3f07dfdea..a15b39c8a 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -625,8 +625,10 @@ struct SparkExitDriver { return } // A step the SDK could not read is settled by a fresh build, once a block, unless a step is still to send: - // a build now would conflict with it at the same fee. - if let tip, isUnverifiedRebuildDue(checked.recovery, record: record, tip: tip) { + // a build now would conflict with it at the same fee. A check marks a step unverified when one chain read + // fails and clears it on the next clean check, so only a step that was already unverified before this check + // rebuilds; one this check just turned unverified waits for the next check, as it may be in flight. + if let tip, isUnverifiedRebuildDue(checked.recovery, stored: stored, record: record, tip: tip) { Logger.warn("Spark exit has a step the SDK could not read, rebuilding over it", context: "SparkExitDriver") record.unverifiedRebuiltAtHeight = tip try save(record) @@ -867,10 +869,18 @@ struct SparkExitDriver { try await rebuild(&record, recovering: recovering) } - /// A unilateral step the SDK could not read, nothing unilateral ready to send, and no such rebuild at this tip yet. - private func isUnverifiedRebuildDue(_ recovery: RecoverFundsResponse, record: SparkExitRecord, tip: UInt32) -> Bool { + /// A unilateral step the SDK could not read, at this check and the one before, nothing unilateral ready to send, + /// and no such rebuild at this tip yet. + private func isUnverifiedRebuildDue( + _ recovery: RecoverFundsResponse, + stored: RecoverFundsResponse, + record: SparkExitRecord, + tip: UInt32 + ) -> Bool { let unilateral = recovery.transactions.filter { $0.kind != .cooperative } - return unilateral.contains { $0.status == .unverified } && !unilateral.contains { $0.status == .ready } + let unreadBefore = Set(stored.transactions.filter { $0.status == .unverified }.map(\.txid)) + return unilateral.contains { $0.status == .unverified && unreadBefore.contains($0.txid) } + && !unilateral.contains { $0.status == .ready } && record.unverifiedRebuiltAtHeight != tip } @@ -1316,8 +1326,8 @@ struct SparkExitDriver { // MARK: Broadcast - /// A unilateral step the SDK could not read is never sent: a rebuild settles it. A cooperative one is, since nodes - /// refuse it if its funds already moved. + /// A unilateral step the SDK could not read is never sent: the next clean check, or a rebuild once it stays unread, + /// settles it. A cooperative one is, since nodes refuse it if its funds already moved. private func broadcastReady(_ record: inout SparkExitRecord, tip: UInt32? = nil) async throws { guard let recovery = record.recovery?.response else { return } let confirmed = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid)) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index bff9dcb48..25babe38b 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1606,6 +1606,42 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2b"], "the unverified step is still not sent") } + /// A ready step that one failed chain read turns unverified may already be sent, so it is not rebuilt over: the + /// exit waits for the next check, which reads it again, and rebuilds only if it stays unread. + func testAStepAFailedReadTurnsUnverifiedWaitsForTheNextCheckInsteadOfRebuilding() async throws { + var record = SparkExitFixtures.record(phase: .running) + let ready = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"]), + ]) + let unread = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2", cpfp: "03c", dependsOn: ["n1"], status: .unverified), + ]) + record.adopt(ready, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertTrue(sdk.recoverRequests.isEmpty, "rebuilt over a step one failed read turned unverified") + XCTAssertNil(record.unverifiedRebuiltAtHeight) + XCTAssertNil(record.isUnverifiedAsk) + XCTAssertEqual(record.generation, 1) + XCTAssertTrue(chain.broadcasts.isEmpty, "the unverified step was sent") + + // Still unread at the next check: now it rebuilds. + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + sdk.builds = [SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)), + SparkExitFixtures.tx("n2b", cpfp: "03d", dependsOn: ["n1"]), + ])] + record = try await driver().tick(record) + + XCTAssertEqual(sdk.recoverRequests.count, 1) + XCTAssertEqual(record.unverifiedRebuiltAtHeight, 1000) + XCTAssertEqual(record.generation, 2) + } + /// An unverified rebuild that builds only failed cooperative leaves, beside the leaf it could not read, keeps the /// valid generation: its signed steps are still sent once the chain reads again. func testAnUnverifiedRebuildOfOnlyFailedLeavesKeepsTheValidGeneration() async throws { From 38447fb021865bac2031a5afa31b5976c6911483 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 12:33:03 +0100 Subject: [PATCH 371/378] fix: scan a pending restore before quoting and past a forgotten exit's address --- Bitkit/Managers/SparkExitManager.swift | 26 +++++---- .../Services/SparkExit/SparkExitFunding.swift | 9 ++- BitkitTests/SparkExitFundingIndexTests.swift | 55 ++++++++++++++++++- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift index 5f944853c..b4dcd3d7a 100644 --- a/Bitkit/Managers/SparkExitManager.swift +++ b/Bitkit/Managers/SparkExitManager.swift @@ -976,7 +976,8 @@ final class SparkExitManager { markSettled: { try? await transfers.markSettled(id: $0) } ) UserDefaults.standard.set(walletIndex, forKey: Self.walletIndexKey) - UserDefaults.standard.set(Int(fundingIndex) + 1, forKey: fundingIndexKey) + // Never lowers a counter a restore's scan moved past this quote's index. + UserDefaults.standard.set(max(UserDefaults.standard.integer(forKey: fundingIndexKey), Int(fundingIndex) + 1), forKey: fundingIndexKey) SettingsViewModel.shared.notifyAppStateChanged() pendingFundingIndex = nil startingCreatedAt = record.createdAt @@ -1188,8 +1189,9 @@ final class SparkExitManager { /// The funding key index of the next exit: one no exit of this wallet used, so leftover of an earlier exit never /// mixes into a new one. A scan's result rides the next metadata backup. private func nextFundingIndex() async throws -> UInt32 { - let wasKept = UserDefaults.standard.object(forKey: fundingIndexKey) != nil - let index = try await Self.nextFundingIndex(fundingRecovery, now: Date()) + let recovery = fundingRecovery + let wasKept = recovery.defaults.object(forKey: recovery.key) != nil && !recovery.defaults.bool(forKey: recovery.pendingKey) + let index = try await Self.nextFundingIndex(recovery, now: Date()) if !wasKept { SettingsViewModel.shared.notifyAppStateChanged() } @@ -1224,15 +1226,12 @@ final class SparkExitManager { ) } - /// From the counter the last start left and the stored record. With neither, as after a restore from the phrase or - /// from a backup older than the counter, from a scan of the funding addresses first. A scan the chain fails keeps - /// nothing. + /// From the counter the last start left and the stored record. While a restore is pending, as after a restore from + /// the phrase or from a backup, from a scan of the funding addresses first: a restored counter can predate the last + /// exit's funding. A scan the chain fails throws and keeps nothing, so no quote reuses that funding address. static func nextFundingIndex(_ recovery: FundingRecovery, now: Date) async throws -> UInt32 { - let defaults = recovery.defaults - if defaults.object(forKey: recovery.key) != nil || recovery.record != nil { - return SparkExitMath.nextFundingIndex(counter: defaults.integer(forKey: recovery.key), record: recovery.record) - } - return try await recoverFunding(recovery, now: now) ?? 0 + try await recoverFunding(recovery, now: now) + return SparkExitMath.nextFundingIndex(counter: recovery.defaults.integer(forKey: recovery.key), record: recovery.record) } /// Finds earlier exits' funding after a restore. Scans for the next index and keeps it: from 0 with no counter kept @@ -1268,7 +1267,10 @@ final class SparkExitManager { return isUsed } let candidates = restored.map { Array(0 ..< $0) + used } ?? used - let watched = Set(((try? recovery.earlierAddressStore.load()) ?? []).map(\.address) + (recovery.record?.funding.map(\.address) ?? [])) + // A forgotten exit's funding address is no longer watched once the scan moves the counter past it, so one that + // holds coins is listed again, as kept change. + let listed = ((try? recovery.earlierAddressStore.load()) ?? []).filter { $0.kind != .cancelledFunding } + let watched = Set(listed.map(\.address) + (recovery.record?.funding.map(\.address) ?? [])) for index in candidates { let (address, path) = try funding(index) guard !watched.contains(address) else { continue } diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index ad72c6b23..709e5bf0b 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -582,7 +582,7 @@ enum SparkExitMath { /// Unused funding addresses a scan reads past the last used one before it stops. static let fundingScanGapLimit: UInt32 = 20 - /// The most funding addresses one scan reads, even when a host reports every one used. + /// The most funding addresses one scan reads from where it starts, even when a host reports every one used. static let fundingScanMaxAddresses: UInt32 = 1000 /// The funding index past the last one `isUsed` reports used, read from `start` until `fundingScanGapLimit` unused @@ -591,7 +591,9 @@ enum SparkExitMath { static func scanNextFundingIndex(from start: UInt32 = 0, isUsed: (UInt32) async throws -> Bool) async rethrows -> UInt32 { var next = start var index = start - while index < next + fundingScanGapLimit, index < fundingScanMaxAddresses { + let end = start.addingReportingOverflow(fundingScanMaxAddresses) + let limit = end.overflow ? UInt32.max : end.partialValue + while index < next + fundingScanGapLimit, index < limit { if try await isUsed(index) { next = index + 1 } @@ -746,7 +748,8 @@ enum SparkExitFundingIndexes { let defaultsKey = defaultsKey(network: String(parts[0]), walletIndex: wallet) let local = defaults.object(forKey: defaultsKey) as? Int ?? 0 defaults.set(max(local, index), forKey: defaultsKey) - // The exit then looks once for funding addresses below it that still hold coins and nothing watches. + // The exit then looks once for funding addresses that still hold coins and nothing watches: each one below + // it, and those a scan forward from it finds used, which also moves the counter past them. defaults.set(true, forKey: recoveryPendingKey(network: String(parts[0]), walletIndex: wallet)) } } diff --git a/BitkitTests/SparkExitFundingIndexTests.swift b/BitkitTests/SparkExitFundingIndexTests.swift index 0e96bd12b..7bfe609e6 100644 --- a/BitkitTests/SparkExitFundingIndexTests.swift +++ b/BitkitTests/SparkExitFundingIndexTests.swift @@ -102,7 +102,7 @@ final class SparkExitFundingIndexTests: XCTestCase { XCTAssertEqual(defaults.object(forKey: key) as? Int, 3, "the next quote scans again") } - func testAFundingIndexRestoredFromTheBackupIsUsedWithoutAScan() async throws { + func testAFundingIndexRestoredFromTheBackupIsOnlyScannedForwardFrom() async throws { defaults.set(3, forKey: key) let backup = try roundTrip(cache(sparkExitNextFundingIndexes: SparkExitFundingIndexes.backup(defaults))) restore(backup) @@ -112,7 +112,33 @@ final class SparkExitFundingIndexTests: XCTestCase { XCTAssertEqual(backup.sparkExitNextFundingIndexes, ["regtest/wallet0": 3], "keyed as bitkit-android keys it") XCTAssertEqual(next, 3) - XCTAssertTrue(chain.addressReads.isEmpty, "a backed-up index needs no scan") + XCTAssertEqual(chain.addressReads, (3 ..< 3 + SparkExitMath.fundingScanGapLimit).map(address), "a backed-up index needs no scan below it") + } + + func testAQuoteWhileARestoreIsPendingScansPastTheRestoredCounterFirst() async throws { + // The backup carrying counter 4 never landed: address 3 still holds the lost exit's coins. + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.usedAddresses = [address(0), address(1), address(2), address(3)] + chain.utxosByAddress[address(3)] = [SparkExitFixtures.utxo("lost", value: 7000)] + + let next = try await nextFundingIndex() + + XCTAssertEqual(next, 4, "the next exit funds from the lost exit's address") + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(3, sats: 7000)]) + XCTAssertFalse(defaults.bool(forKey: pendingKey)) + } + + func testAQuoteWhileARestoreIsPendingFailsWhenTheScanFails() async { + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.isAddressReadUnreadable = true + + do { + _ = try await nextFundingIndex() + XCTFail("a quote could fund from the address a lost exit used") + } catch {} + + XCTAssertEqual(defaults.integer(forKey: key), 3) + XCTAssertTrue(defaults.bool(forKey: pendingKey), "the restore no longer runs again") } func testARestoreKeepsTheHigherIndexOfTheDeviceAndTheBackup() { @@ -238,6 +264,31 @@ final class SparkExitFundingIndexTests: XCTestCase { XCTAssertEqual(defaults.integer(forKey: key), 4, "the next exit reuses address 3") } + func testARestoredCounterAtAForgottenExitsAddressListsItsLatePaymentAsKeptChange() async throws { + // The exit at address 3 was forgotten before funding, so the counter went back to 3; a payment came late. + try earlierAddressStore.add(SparkExitEarlierAddress(address: address(3), path: path(3), kind: .cancelledFunding, sats: nil, date: now)) + SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults) + chain.usedAddresses = [address(0), address(1), address(2), address(3)] + chain.utxosByAddress[address(3)] = [SparkExitFixtures.utxo("late", value: 4000)] + + let next = try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(next, 4) + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(3, sats: 4000)], "the late payment is watched only until its cancel expires") + } + + func testARestoredCounterPastTheScanCeilingStillScansForward() async throws { + let ceiling = Int(SparkExitMath.fundingScanMaxAddresses) + SparkExitFundingIndexes.restore(["regtest/wallet0": ceiling], into: defaults) + chain.usedAddresses = [address(UInt32(ceiling))] + chain.utxosByAddress[address(UInt32(ceiling))] = [SparkExitFixtures.utxo("lost", value: 7000)] + + let next = try await SparkExitManager.recoverFunding(recovery, now: now) + + XCTAssertEqual(next, UInt32(ceiling) + 1) + XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(UInt32(ceiling), sats: 7000)]) + } + func testAKeptCounterWithNoRestorePendingReadsNoFundingAddress() async throws { defaults.set(3, forKey: key) chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)] From 71f103fca08f0080f32219ada82cb958641b0ef7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 12:40:19 +0100 Subject: [PATCH 372/378] fix: keep a late contact endpoint decode out of the next send --- Bitkit/Services/PaykitRouteService.swift | 22 +++++++++---- BitkitTests/PaykitRouteTests.swift | 41 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitRouteService.swift b/Bitkit/Services/PaykitRouteService.swift index 88a7763e8..8638c664f 100644 --- a/Bitkit/Services/PaykitRouteService.swift +++ b/Bitkit/Services/PaykitRouteService.swift @@ -333,7 +333,11 @@ extension AppViewModel { switch picked { case let .direct(endpoint): paykitRoute = unrouted - guard await payDirectly(endpoint, route: unrouted, decode: decodeEndpoint) else { return .refused } + // A decode is a network fetch for an LNURL: the send it was for can change while it runs. + guard let paid = await payDirectly(endpoint, route: unrouted, decode: decodeEndpoint, isCurrent: isCurrent) else { + return .stale + } + guard paid else { return .refused } if let request { paykitPaymentTerms = try request.payment(using: paykitPaymentMethod) } @@ -385,12 +389,14 @@ extension AppViewModel { } /// Switches the send to exactly the endpoint the router picked, clearing the other Lightning one. An earlier pick may - /// have cleared the picked one, so it comes back from `route`; one never decoded is decoded here. + /// have cleared the picked one, so it comes back from `route`; one never decoded is decoded here. Returns false when + /// it cannot pay the endpoint, and nil when the send changed during a decode, writing nothing. private func payDirectly( _ endpoint: PaykitEndpoint, route: PaykitRouteUi, - decode: (String) async throws -> BitkitCore.Scanner - ) async -> Bool { + decode: (String) async throws -> BitkitCore.Scanner, + isCurrent: () -> Bool + ) async -> Bool? { if endpoint.isOnchain { selectedWalletToPayFrom = .onchain if scannedOnchainInvoice?.address != endpoint.value { @@ -404,7 +410,9 @@ extension AppViewModel { lnurlPayData = lnurl } else { // A route built once Spark became ready after the scan holds the contact's LNURL undecoded. - guard case let .lnurlPay(data) = try? await decode(endpoint.value) else { + let decoded = try? await decode(endpoint.value) + guard isCurrent() else { return nil } + guard case let .lnurlPay(data) = decoded else { Logger.warn("Failed to decode the LNURL endpoint the router picked", context: "PaykitRoute") showNoPaykitRouteToast(isBalanceShort: false) return false @@ -420,7 +428,9 @@ extension AppViewModel { if let known { scannedLightningInvoice = known } else { - guard case let .lightning(invoice) = try? await decode(endpoint.value) else { + let decoded = try? await decode(endpoint.value) + guard isCurrent() else { return nil } + guard case let .lightning(invoice) = decoded else { Logger.warn("Failed to decode the Lightning endpoint the router picked", context: "PaykitRoute") showNoPaykitRouteToast(isBalanceShort: false) return false diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift index be77164c1..e5664cba6 100644 --- a/BitkitTests/PaykitRouteTests.swift +++ b/BitkitTests/PaykitRouteTests.swift @@ -891,6 +891,47 @@ final class PaykitRouteTests: XCTestCase { ) } + /// Contact A's LNURL decode (a network fetch) is slow; the user dismisses A and opens contact B before it returns. + @MainActor + func testAnLnurlDecodeThatReturnsAfterThePaymentChangedNeverLandsOnTheNextOne() async { + snapshotAppDefaultsDomain() + let app = AppViewModel() + let contactA = ContactPaymentContext(publicKey: "contacta", privatePaymentContext: nil, endpoints: []) + let contactB = ContactPaymentContext(publicKey: "contactb", privatePaymentContext: nil, endpoints: []) + let routeA = PaykitRouteUi(endpoints: [lightning, onchain], offersSparkLeg: true) + let routeB = PaykitRouteUi(endpoints: [lightning], offersSparkLeg: false) + var lnurlA = lnurlData(minSendableMsats: 1000) + lnurlA.callback = "https://contact-a.example/pay" + var lnurlB = lnurlData(minSendableMsats: 1000) + lnurlB.callback = "https://contact-b.example/pay" + app.contactPaymentContext = contactA + app.selectedWalletToPayFrom = .lightning + let lightning = lightning + + let outcome = await app.routeContactPayment( + routeA, + amountSats: 2000, + source: .spending, + lightningSpendableSats: 10000, + onchainSpendableSats: 0, + request: nil, + router: { _, _, _, _, _ in .direct(endpoint: lightning) }, + decodeEndpoint: { _ in + // While A's LNURL is fetched, A's sheet is dismissed and contact B's send opens with B's LNURL. + app.resetSendState() + app.contactPaymentContext = contactB + app.paykitRoute = routeB + app.selectedWalletToPayFrom = .lightning + app.lnurlPayData = lnurlB + return .lnurlPay(data: lnurlA) + } + ) + + XCTAssertEqual(outcome, .stale) + XCTAssertEqual(app.lnurlPayData?.callback, lnurlB.callback, "contact B's send must never pay contact A's LNURL") + XCTAssertNil(app.paykitRoute?.lnurl, "contact A's LNURL must not land on contact B's route") + } + @MainActor func testARouteThatReturnsAfterItsPaymentChangedNeverLandsOnTheNextOne() async { snapshotAppDefaultsDomain() From bfa6268a2f79ab42f753aacc193eaa2231aeb153 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 12:53:03 +0100 Subject: [PATCH 373/378] fix: hold an unverified exit rebuild while any step just turned unverified --- .../Services/SparkExit/SparkExitDriver.swift | 10 ++++--- BitkitTests/SparkExitTests.swift | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index a15b39c8a..a2b23af77 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -627,7 +627,7 @@ struct SparkExitDriver { // A step the SDK could not read is settled by a fresh build, once a block, unless a step is still to send: // a build now would conflict with it at the same fee. A check marks a step unverified when one chain read // fails and clears it on the next clean check, so only a step that was already unverified before this check - // rebuilds; one this check just turned unverified waits for the next check, as it may be in flight. + // rebuilds, and one this check just turned unverified holds it until the next check, as it may be in flight. if let tip, isUnverifiedRebuildDue(checked.recovery, stored: stored, record: record, tip: tip) { Logger.warn("Spark exit has a step the SDK could not read, rebuilding over it", context: "SparkExitDriver") record.unverifiedRebuiltAtHeight = tip @@ -869,8 +869,9 @@ struct SparkExitDriver { try await rebuild(&record, recovering: recovering) } - /// A unilateral step the SDK could not read, at this check and the one before, nothing unilateral ready to send, - /// and no such rebuild at this tip yet. + /// Unilateral steps the SDK could not read, each one at this check and the one before, nothing unilateral ready to + /// send, and no such rebuild at this tip yet. A step this check just turned unverified may be in flight, so it holds + /// the rebuild as a ready one does. private func isUnverifiedRebuildDue( _ recovery: RecoverFundsResponse, stored: RecoverFundsResponse, @@ -879,7 +880,8 @@ struct SparkExitDriver { ) -> Bool { let unilateral = recovery.transactions.filter { $0.kind != .cooperative } let unreadBefore = Set(stored.transactions.filter { $0.status == .unverified }.map(\.txid)) - return unilateral.contains { $0.status == .unverified && unreadBefore.contains($0.txid) } + let unread = unilateral.filter { $0.status == .unverified } + return !unread.isEmpty && unread.allSatisfy { unreadBefore.contains($0.txid) } && !unilateral.contains { $0.status == .ready } && record.unverifiedRebuiltAtHeight != tip } diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 25babe38b..f5e4c262c 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1642,6 +1642,33 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.generation, 2) } + /// A step one failed read turns unverified holds the rebuild over another leaf's step that was already unread: the + /// rebuild would replace it while it may be in flight. + func testAStepAFailedReadTurnsUnverifiedHoldsTheRebuildOverAnotherUnreadLeaf() async throws { + var record = SparkExitFixtures.record(phase: .running) + record.leafIds = ["leaf-1", "leaf-2"] + let stored = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2", dependsOn: ["m1"]), + ]) + let checked = SparkExitFixtures.recovery([ + SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified), + SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)), + SparkExitFixtures.tx("m2", dependsOn: ["m1"], status: .unverified), + ]) + record.adopt(stored, at: clock) + sdk.checks = [CheckRecoverFundsResponse(recovery: checked, verdict: .valid)] + + record = try await driver().tick(record) + + XCTAssertTrue(sdk.recoverRequests.isEmpty, "rebuilt over m2, which one failed read turned unverified") + XCTAssertNil(record.unverifiedRebuiltAtHeight) + XCTAssertEqual(record.generation, 1) + } + /// An unverified rebuild that builds only failed cooperative leaves, beside the leaf it could not read, keeps the /// valid generation: its signed steps are still sent once the chain reads again. func testAnUnverifiedRebuildOfOnlyFailedLeavesKeepsTheValidGeneration() async throws { From 0f9f29c1821ba16d5b9b1a9ccd32d1e704cd0253 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 13:01:25 +0100 Subject: [PATCH 374/378] fix: end the exit funding scan at the top of the index range --- Bitkit/Services/SparkExit/SparkExitFunding.swift | 6 +++--- BitkitTests/SparkExitFundingIndexTests.swift | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift index 709e5bf0b..7657d0139 100644 --- a/Bitkit/Services/SparkExit/SparkExitFunding.swift +++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift @@ -591,9 +591,9 @@ enum SparkExitMath { static func scanNextFundingIndex(from start: UInt32 = 0, isUsed: (UInt32) async throws -> Bool) async rethrows -> UInt32 { var next = start var index = start - let end = start.addingReportingOverflow(fundingScanMaxAddresses) - let limit = end.overflow ? UInt32.max : end.partialValue - while index < next + fundingScanGapLimit, index < limit { + // In 64 bits, so a counter near the top of the index range ends the scan instead of overflowing. + let limit = min(UInt64(start) + UInt64(fundingScanMaxAddresses), UInt64(UInt32.max)) + while UInt64(index) < UInt64(next) + UInt64(fundingScanGapLimit), UInt64(index) < limit { if try await isUsed(index) { next = index + 1 } diff --git a/BitkitTests/SparkExitFundingIndexTests.swift b/BitkitTests/SparkExitFundingIndexTests.swift index 7bfe609e6..a3535d4f6 100644 --- a/BitkitTests/SparkExitFundingIndexTests.swift +++ b/BitkitTests/SparkExitFundingIndexTests.swift @@ -182,6 +182,14 @@ final class SparkExitFundingIndexTests: XCTestCase { XCTAssertEqual(reads, Array(0 ... 5 + SparkExitMath.fundingScanGapLimit)) } + func testAFundingScanFromTheTopOfTheIndexRangeEndsInsteadOfOverflowing() async { + let start = UInt32.max - 5 + + let next = await SparkExitMath.scanNextFundingIndex(from: start) { _ in true } + + XCTAssertEqual(next, UInt32.max) + } + func testAFundingScanStopsAtItsCeilingWhenEveryAddressReadsAsUsed() async { var reads: UInt32 = 0 From 0869a9e34f655ead317c3d63d8a99c7f3e761150 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 13:25:06 +0100 Subject: [PATCH 375/378] fix: skip a funded unverified exit rebuild once its step reads again --- .../Services/SparkExit/SparkExitDriver.swift | 10 +++++++++- BitkitTests/SparkExitTests.swift | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index a2b23af77..5a409de13 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -149,7 +149,15 @@ struct SparkExitDriver { // generation stays over a failed-only build, running again with the funding adopted. Once a // block, like the check's rebuild: the valid generation's retry clock does not pace it. let tip = try? await chain.tipHeight() - if tip == nil || record.unverifiedRebuiltAtHeight != tip { + let unilateral = (record.recovery?.response.transactions ?? []).filter { $0.kind != .cooperative } + if unilateral.contains(where: { $0.status == .ready }) || !unilateral.contains(where: { $0.status == .unverified }) { + // A clean check while the payment came can read the unread step again, and it is then sent: + // a rebuild now would conflict with it at the same fee. The ask ends, and the funding it + // adopted stays for a later rebuild or the leftover sweep. + Logger.info("Spark exit steps read again: the funded unverified rebuild is not needed", context: "SparkExitDriver") + record.phase = .running + record.isUnverifiedAsk = nil + } else if tip == nil || record.unverifiedRebuiltAtHeight != tip { record.unverifiedRebuiltAtHeight = tip var valid = record valid.phase = .running diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index f5e4c262c..6d0a1bfa1 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1829,6 +1829,26 @@ final class SparkExitDriverTests: XCTestCase { return (record, readable, failure) } + /// A savings payment keeps an unverified rebuild's ask open; a clean check while it confirms reads the unread step + /// as ready, and it is sent. The funded rebuild then never runs: it would conflict with that step at the same fee. + func testAFundedUnverifiedAskWhoseStepReadsAgainEndsWithoutARebuild() async throws { + var (record, readable, _) = try await unverifiedAsk() + let paid = String(repeating: "f", count: 64) + record.fundingPaymentTxid = paid + let builds = sdk.recoverRequests.count + sdk.checks = [CheckRecoverFundsResponse(recovery: readable, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(paid, value: 50000)] + chain.tip = 1001 + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.recoverRequests.count, builds, "rebuilt over n2, which the clean check made ready and sent") + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(record.phase, .running) + XCTAssertNil(record.isUnverifiedAsk) + XCTAssertEqual(record.generation, 1) + } + /// Confirmed funding for an unverified rebuild's fee ask resumes that rebuild with its guard: a build of only failed /// cooperative leaves beside the waiting leaf is never adopted, and the exit runs on with the valid generation. func testFundingAnUnverifiedRebuildsAskKeepsTheValidGenerationOverAFailedOnlyBuild() async throws { From cf14f5e77d2466f87ae11bec01b33e6bef991146 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 13:28:07 +0100 Subject: [PATCH 376/378] docs: say when an unread exit step's rebuild runs --- Bitkit/Services/SparkExit/SparkExitDriver.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 5a409de13..3ed9724ea 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -635,7 +635,8 @@ struct SparkExitDriver { // A step the SDK could not read is settled by a fresh build, once a block, unless a step is still to send: // a build now would conflict with it at the same fee. A check marks a step unverified when one chain read // fails and clears it on the next clean check, so only a step that was already unverified before this check - // rebuilds, and one this check just turned unverified holds it until the next check, as it may be in flight. + // rebuilds, and one this check just turned unverified holds it, as it may be in flight. A step that stays + // unread rebuilds at the first check, once a block at most, where no other step just turned unverified. if let tip, isUnverifiedRebuildDue(checked.recovery, stored: stored, record: record, tip: tip) { Logger.warn("Spark exit has a step the SDK could not read, rebuilding over it", context: "SparkExitDriver") record.unverifiedRebuiltAtHeight = tip From d5940383c10f398177582ab4489c4dce669459f5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 13:55:31 +0100 Subject: [PATCH 377/378] fix: hold a funded spark exit rebuild over a step sent on an earlier check --- .../Services/SparkExit/SparkExitDriver.swift | 4 +++ BitkitTests/SparkExitTests.swift | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Bitkit/Services/SparkExit/SparkExitDriver.swift b/Bitkit/Services/SparkExit/SparkExitDriver.swift index 3ed9724ea..7273b88f9 100644 --- a/Bitkit/Services/SparkExit/SparkExitDriver.swift +++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift @@ -150,6 +150,7 @@ struct SparkExitDriver { // block, like the check's rebuild: the valid generation's retry clock does not pace it. let tip = try? await chain.tipHeight() let unilateral = (record.recovery?.response.transactions ?? []).filter { $0.kind != .cooperative } + let unreadBefore = Set((before.recovery?.response.transactions ?? []).filter { $0.status == .unverified }.map(\.txid)) if unilateral.contains(where: { $0.status == .ready }) || !unilateral.contains(where: { $0.status == .unverified }) { // A clean check while the payment came can read the unread step again, and it is then sent: // a rebuild now would conflict with it at the same fee. The ask ends, and the funding it @@ -157,6 +158,9 @@ struct SparkExitDriver { Logger.info("Spark exit steps read again: the funded unverified rebuild is not needed", context: "SparkExitDriver") record.phase = .running record.isUnverifiedAsk = nil + } else if unilateral.contains(where: { step in step.status == .unverified && !unreadBefore.contains(step.txid) }) { + // A step this check just turned unverified may have been sent on an earlier clean check: the + // resume waits for the next check, as the check's own rebuild does. } else if tip == nil || record.unverifiedRebuiltAtHeight != tip { record.unverifiedRebuiltAtHeight = tip var valid = record diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index 6d0a1bfa1..b311a56f9 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1849,6 +1849,32 @@ final class SparkExitDriverTests: XCTestCase { XCTAssertEqual(record.generation, 1) } + /// A step that a clean check sent while the payment was unconfirmed holds the funded rebuild when the tick that adopts + /// the payment fails to read it again: the resume waits for the next check. + func testAFundedUnverifiedAskHoldsItsRebuildOverAStepSentOnAnEarlierCleanCheck() async throws { + var (record, readable, _) = try await unverifiedAsk() + let unread = try XCTUnwrap(record.recovery?.response) + let paid = String(repeating: "f", count: 64) + record.fundingPaymentTxid = paid + let builds = sdk.recoverRequests.count + sdk.checks = [CheckRecoverFundsResponse(recovery: readable, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(paid, value: 50000, confirmed: false)] + + record = try await driver().tick(record) + XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"]) + XCTAssertEqual(record.isUnverifiedAsk, true) + + sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)] + chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(paid, value: 50000)] + chain.tip = 1001 + + record = try await driver().tick(record) + + XCTAssertEqual(sdk.recoverRequests.count, builds, "rebuilt over n2, sent on the previous tick") + XCTAssertEqual(record.isUnverifiedAsk, true) + XCTAssertEqual(record.generation, 1) + } + /// Confirmed funding for an unverified rebuild's fee ask resumes that rebuild with its guard: a build of only failed /// cooperative leaves beside the waiting leaf is never adopted, and the exit runs on with the valid generation. func testFundingAnUnverifiedRebuildsAskKeepsTheValidGenerationOverAFailedOnlyBuild() async throws { From 31f88487116070647bde156cf6907a55efdb5d9a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 12 Nov 2026 14:23:07 +0100 Subject: [PATCH 378/378] test: check the held spark exit resume adopted its payment --- BitkitTests/SparkExitTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift index b311a56f9..d21315235 100644 --- a/BitkitTests/SparkExitTests.swift +++ b/BitkitTests/SparkExitTests.swift @@ -1870,6 +1870,7 @@ final class SparkExitDriverTests: XCTestCase { record = try await driver().tick(record) + XCTAssertTrue(record.funding.contains { $0.txid == paid }, "the payment was not adopted, so the hold never ran") XCTAssertEqual(sdk.recoverRequests.count, builds, "rebuilt over n2, sent on the previous tick") XCTAssertEqual(record.isUnverifiedAsk, true) XCTAssertEqual(record.generation, 1)