From 03a771bba90f43c90fbe4d0c9f0d52a97f7584c9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 02:40:03 +0100 Subject: [PATCH 001/134] 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 46ae75561cd6e178b7aa0a7c39029d7f6b477c7c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 04:03:19 +0100 Subject: [PATCH 002/134] 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 003/134] 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 004/134] 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 7220fd00c054b97285aced125c494e5ef9928fa2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 12:38:01 +0100 Subject: [PATCH 005/134] 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 006/134] 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 d6d74631afa3ff468e4f2bf4b500b380d7109154 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 16:26:50 +0100 Subject: [PATCH 007/134] 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 008/134] 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 009/134] 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 010/134] 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 1a5d3dd8b28af23c64df4f4ceb10b0858123c1c2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 22:40:02 +0100 Subject: [PATCH 011/134] 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 8eac1b98a22aacfce9320e614309fafdd8c68858 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 22:53:42 +0100 Subject: [PATCH 012/134] 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 013/134] 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 014/134] 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 015/134] 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 016/134] 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 017/134] 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 018/134] 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 019/134] 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 61a7d7a6f62ddf0626c19e667e24fc3d00c5ff84 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 18:41:33 +0100 Subject: [PATCH 020/134] 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 4b2feb53b0e7bb3d3143353db24e5a63ad273020 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:02:23 +0100 Subject: [PATCH 021/134] 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 28170b98e0237ad896bae51b9c924c0d2f36a0f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:26:42 +0100 Subject: [PATCH 022/134] 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 3331a5aba0c51c189beb8344ae1a64a573af1680 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:30:56 +0100 Subject: [PATCH 023/134] 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 d4df811fbfea9168604f95b8dc27a67f26884311 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 01:13:04 +0100 Subject: [PATCH 024/134] 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 6f0cdee463cf28efbcc87dc280fadf61910ba6a1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 02:02:48 +0100 Subject: [PATCH 025/134] 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 026/134] 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 027/134] 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 a56c28fe225d13b7415d01af5d64ba5d0d57efb5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 11:49:19 +0100 Subject: [PATCH 028/134] 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 029/134] 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 030/134] 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 031/134] 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 032/134] 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 033/134] 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 034/134] 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 035/134] 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 036/134] 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 f0ee36d86980c54f500c1c7b1c9d62e17b48f3c7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 14:43:13 +0100 Subject: [PATCH 037/134] 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 038/134] 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 736e552e89729adddc86dfdbc2c374a26dfa9547 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:00:34 +0100 Subject: [PATCH 039/134] 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 ce4f86be4047e5d822c51614a2ebefd50ba755b9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 15:36:06 +0100 Subject: [PATCH 040/134] 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 041/134] 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 042/134] 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 043/134] 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 118b5e1ed027b4e55638b59384942a51cd23ac75 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:24:19 +0100 Subject: [PATCH 044/134] 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 f4ca8e67a6e39f3d19b95a0f1e9cf03a7b19fb90 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 16:33:58 +0100 Subject: [PATCH 045/134] 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 046/134] 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 5dfc2ca015bbe897d6a786473097c2584d084e3b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 17:25:21 +0100 Subject: [PATCH 047/134] 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 66cbf307a6dcb8aee2ac20daee221bc4fad0f78e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 18:32:34 +0100 Subject: [PATCH 048/134] 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 049/134] 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 050/134] 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 b4f65559bd723a20fec0b9f7841a515a0c6f7440 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:17:09 +0100 Subject: [PATCH 051/134] 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 611c9be5dfec972ce08e1ccbc197f4886d8f7bbf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:44:39 +0100 Subject: [PATCH 052/134] 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 b62ed541d040d94ded456de30687dbabd6b92973 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:56:19 +0100 Subject: [PATCH 053/134] 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 054/134] 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 055/134] 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 056/134] 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 b3006724a0d573f8b21bad0ae6db0db85eec6470 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 21:47:46 +0100 Subject: [PATCH 057/134] 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 058/134] 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 059/134] 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 0abf0abc20e7018098ef218fadc467c03c070d6b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 10:56:52 +0100 Subject: [PATCH 060/134] 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 86432fad3d5cf16885af43792ca905fdd588dda4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:07:07 +0100 Subject: [PATCH 061/134] 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 4b9d3d006e6880f0776f005c8381a918d9171bc9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 11:28:29 +0100 Subject: [PATCH 062/134] 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 063/134] 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 8fcf7c55505b6e5aa189ffe3fc9d1c65cdf2aaa3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 12:38:49 +0100 Subject: [PATCH 064/134] 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 065/134] 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 5ebff50778a36098ab94a3946855e15c54283f8f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 13:25:53 +0100 Subject: [PATCH 066/134] 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 4b4b975ef1c3db583958248285f1e5d1c19cb2b2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:20:01 +0100 Subject: [PATCH 067/134] 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 681818953f65191b435711a4893915d304d99c90 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:50:36 +0100 Subject: [PATCH 068/134] 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 172e564faebfb5f34f502cd10450385ab681d744 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 19:17:58 +0100 Subject: [PATCH 069/134] 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 070/134] 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 071/134] 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 48b2252f9e588a7ce5ed5df690db0bc614167046 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 20:03:06 +0100 Subject: [PATCH 072/134] 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 073/134] 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 074/134] 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 075/134] 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 536144ca719c2ebcc41888ff084aebd679105358 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 21:43:39 +0100 Subject: [PATCH 076/134] 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 077/134] 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 078/134] 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 01bfbd8078e7204b7cf3ee523f50e518610b9a42 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:27:06 +0100 Subject: [PATCH 079/134] 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 080/134] 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 6429e7be6011a32b75dbf345c05d255881f018c8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:00:51 +0100 Subject: [PATCH 081/134] 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 082/134] 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 083/134] 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 084/134] 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 085/134] 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 086/134] 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 087/134] 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 088/134] 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 089/134] 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 090/134] 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 091/134] 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 092/134] 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 093/134] 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 094/134] 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 095/134] 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 096/134] 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 097/134] 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 f51344357d3f63fa053470fcf52329cada595700 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:31:32 +0100 Subject: [PATCH 098/134] 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 099/134] 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 100/134] 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 101/134] 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 f8c896b9b735acb2db829dca65febe5d61c784ab Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 14:27:27 +0100 Subject: [PATCH 102/134] 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 0c4fe370e8e8b6f4dcb4baf5f53cc8e983df57e6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:14:36 +0100 Subject: [PATCH 103/134] 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 104/134] 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 105/134] 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 106/134] 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 107/134] 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 108/134] 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 109/134] 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 110/134] 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 111/134] 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 112/134] 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 113/134] 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 114/134] 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 115/134] 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 116/134] 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 117/134] 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 15b69255110a02cbe6bca9e3b435f23b1e982dda Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 18:30:06 +0100 Subject: [PATCH 118/134] 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 119/134] 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 437a979137a1a2d41bf2996bdcb901d62cf5b6d6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:37:59 +0100 Subject: [PATCH 120/134] 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 121/134] 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 122/134] 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 123/134] 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 124/134] 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 125/134] 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 126/134] 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 127/134] 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 7622b775bac06de9e092c6d7ed18ddfedd1e6862 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 23:00:19 +0100 Subject: [PATCH 128/134] 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 129/134] 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 130/134] 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 131/134] 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 132/134] 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 133/134] 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 134/134] 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)