From 03a771bba90f43c90fbe4d0c9f0d52a97f7584c9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 02:40:03 +0100 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 16/17] 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 17/17] 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 = [